diff --git a/README.md b/README.md
index cd68fab2..5390e19f 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,26 @@ brew install --cask cardinal-search
You can also grab the latest packaged builds from [GitHub Releases](https://github.com/cardisoft/cardinal/releases/).
+### macOS integration
+
+Cardinal registers the `cardinal://` URL scheme on macOS. The supported deep link is:
+
+```text
+cardinal://search?scope=/path/to/folder&q=keyword
+```
+
+- `scope` is optional and opens Cardinal's folder scope with that directory.
+- `q` is optional and fills the main search query.
+- At least one of `scope` or `q` must be present.
+
+Cardinal also ships a Finder Quick Action workflow at:
+
+```text
+Cardinal.app/Contents/Resources/share/batch/macOS service menus/Cardinal, search in folder.workflow
+```
+
+Double-click the workflow to install it into `~/Library/Services`. After installation, Finder can show `Search in Cardinal` in Quick Actions or Services for selected files and folders.
+
### i18n support
Need a different language? Click the ⚙️ button in the status bar to switch instantly.
@@ -87,3 +107,11 @@ npm run tauri dev -- --release --features dev
cd cardinal
npm run tauri build
```
+
+To create a macOS DMG that exposes the Finder Quick Action installer in the DMG
+root, run:
+
+```bash
+cd cardinal
+npm run package:macos-dmg
+```
diff --git a/cardinal/package-lock.json b/cardinal/package-lock.json
index 597030bf..d2af43fa 100644
--- a/cardinal/package-lock.json
+++ b/cardinal/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@crabnebula/tauri-plugin-drag": "^2.1.0",
- "@tauri-apps/api": "^2",
+ "@tauri-apps/api": "2.10.1",
+ "@tauri-apps/plugin-deep-link": "2.4.8",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-window-state": "^2.4.1",
@@ -1351,7 +1352,6 @@
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
- "license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
@@ -1574,6 +1574,14 @@
"node": ">= 10"
}
},
+ "node_modules/@tauri-apps/plugin-deep-link": {
+ "version": "2.4.8",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.8.tgz",
+ "integrity": "sha512-Cd2Cs960MGuGONeIwxOPx9wqwedetAHOGlwK5boJ/SMTfAtAyfErpfVPEn+EJzgXsJun8EKzsEumHjr+64V4fw==",
+ "dependencies": {
+ "@tauri-apps/api": "^2.10.1"
+ }
+ },
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
diff --git a/cardinal/package.json b/cardinal/package.json
index e5fe8f2d..48d1587c 100644
--- a/cardinal/package.json
+++ b/cardinal/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
+ "package:macos-dmg": "bash scripts/package-macos-dmg.sh",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest --watch=false",
@@ -16,7 +17,8 @@
},
"dependencies": {
"@crabnebula/tauri-plugin-drag": "^2.1.0",
- "@tauri-apps/api": "^2",
+ "@tauri-apps/api": "2.10.1",
+ "@tauri-apps/plugin-deep-link": "2.4.8",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-window-state": "^2.4.1",
diff --git a/cardinal/scripts/package-macos-dmg.sh b/cardinal/scripts/package-macos-dmg.sh
new file mode 100755
index 00000000..a4a7b5b6
--- /dev/null
+++ b/cardinal/scripts/package-macos-dmg.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SRC_TAURI="$APP_ROOT/src-tauri"
+TARGET_DIR="$SRC_TAURI/target/release"
+APP_BUNDLE="$TARGET_DIR/bundle/macos/Cardinal.app"
+SERVICE_MENUS="$APP_BUNDLE/Contents/Resources/share/batch/macOS service menus"
+VERSION="$(node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")).version)')"
+ARCH="$(uname -m)"
+
+if [[ "$ARCH" == "arm64" ]]; then
+ ARCH="aarch64"
+fi
+
+DMG_PATH="$TARGET_DIR/Cardinal_${VERSION}_${ARCH}.dmg"
+STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cardinal-dmg-stage.XXXXXX")"
+
+cleanup() {
+ rm -rf "$STAGE_DIR"
+}
+trap cleanup EXIT
+
+cd "$APP_ROOT"
+npm run tauri build -- --bundles app
+
+if [[ ! -d "$APP_BUNDLE" ]]; then
+ echo "Missing app bundle: $APP_BUNDLE" >&2
+ exit 1
+fi
+
+if [[ ! -d "$SERVICE_MENUS" ]]; then
+ echo "Missing macOS service menus: $SERVICE_MENUS" >&2
+ exit 1
+fi
+
+ditto "$APP_BUNDLE" "$STAGE_DIR/Cardinal.app"
+ln -s /Applications "$STAGE_DIR/Applications"
+ditto "$SERVICE_MENUS" "$STAGE_DIR/macOS service menus"
+
+cat > "$STAGE_DIR/README - Finder Quick Action.txt" <<'EOF'
+Install Cardinal first by dragging Cardinal.app to Applications.
+
+To install the Finder Quick Action, open the "macOS service menus" folder and
+double-click "Cardinal, search in folder.workflow".
+
+After installation, Finder shows "Search in Cardinal" under Quick Actions or
+Services for selected files and folders.
+EOF
+
+hdiutil create \
+ -volname "Cardinal ${VERSION}" \
+ -srcfolder "$STAGE_DIR" \
+ -ov \
+ -format UDZO \
+ "$DMG_PATH"
+
+echo "Created $DMG_PATH"
diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock
index e19f1ca0..cef8657c 100644
--- a/cardinal/src-tauri/Cargo.lock
+++ b/cardinal/src-tauri/Cargo.lock
@@ -404,6 +404,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
+ "tauri-plugin-deep-link",
"tauri-plugin-drag",
"tauri-plugin-global-shortcut",
"tauri-plugin-macos-permissions",
@@ -578,6 +579,26 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -709,6 +730,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -924,6 +951,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "dlv-list"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
+dependencies = [
+ "const-random",
+]
+
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -1689,6 +1725,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2884,6 +2926,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-multimap"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
+dependencies = [
+ "dlv-list",
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3589,6 +3641,16 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rust-ini"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
+dependencies = [
+ "cfg-if",
+ "ordered-multimap",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4404,6 +4466,27 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "tauri-plugin-deep-link"
+version = "2.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
+dependencies = [
+ "dunce",
+ "plist",
+ "rust-ini",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tracing",
+ "url",
+ "windows-registry",
+ "windows-result 0.3.4",
+]
+
[[package]]
name = "tauri-plugin-drag"
version = "2.1.0"
@@ -4723,6 +4806,15 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -5681,6 +5773,17 @@ dependencies = [
"windows-link 0.1.3",
]
+[[package]]
+name = "windows-registry"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+dependencies = [
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
[[package]]
name = "windows-result"
version = "0.2.0"
diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml
index 386b1944..a27c10ac 100644
--- a/cardinal/src-tauri/Cargo.toml
+++ b/cardinal/src-tauri/Cargo.toml
@@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
+tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-drag = "2"
diff --git a/cardinal/src-tauri/capabilities/default.json b/cardinal/src-tauri/capabilities/default.json
index ce8f857a..50e35ebe 100644
--- a/cardinal/src-tauri/capabilities/default.json
+++ b/cardinal/src-tauri/capabilities/default.json
@@ -9,6 +9,7 @@
"core:window:allow-set-focus",
"opener:default",
"macos-permissions:default",
+ "deep-link:default",
"drag:default",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
diff --git a/cardinal/src-tauri/resources/share/batch/macOS service menus/Cardinal, search in folder.workflow/Contents/Info.plist b/cardinal/src-tauri/resources/share/batch/macOS service menus/Cardinal, search in folder.workflow/Contents/Info.plist
new file mode 100644
index 00000000..c072dfac
--- /dev/null
+++ b/cardinal/src-tauri/resources/share/batch/macOS service menus/Cardinal, search in folder.workflow/Contents/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ NSServices
+
+
+ NSBackgroundColorName
+ background
+ NSIconName
+ NSTouchBarSearchTemplate
+ NSMenuItem
+
+ default
+ Search in Cardinal
+
+ NSMessage
+ runWorkflowAsService
+ NSRequiredContext
+
+ NSApplicationIdentifier
+ com.apple.finder
+
+ NSSendFileTypes
+
+ public.item
+
+
+
+
+
diff --git a/cardinal/src-tauri/resources/share/batch/macOS service menus/Cardinal, search in folder.workflow/Contents/document.wflow b/cardinal/src-tauri/resources/share/batch/macOS service menus/Cardinal, search in folder.workflow/Contents/document.wflow
new file mode 100644
index 00000000..4e3a41f9
--- /dev/null
+++ b/cardinal/src-tauri/resources/share/batch/macOS service menus/Cardinal, search in folder.workflow/Contents/document.wflow
@@ -0,0 +1,235 @@
+
+
+
+
+ AMApplicationBuild
+ 521.1
+ AMApplicationVersion
+ 2.10
+ AMDocumentVersion
+ 2
+ actions
+
+
+ action
+
+ AMAccepts
+
+ Container
+ List
+ Optional
+
+ Types
+
+ com.apple.cocoa.string
+
+
+ AMActionVersion
+ 2.0.3
+ AMApplication
+
+ Automator
+
+ AMParameterProperties
+
+ COMMAND_STRING
+
+ CheckedForUserDefaultShell
+
+ inputMethod
+
+ shell
+
+ source
+
+
+ AMProvides
+
+ Container
+ List
+ Types
+
+ com.apple.cocoa.string
+
+
+ ActionBundlePath
+ /System/Library/Automator/Run Shell Script.action
+ ActionName
+ Run Shell Script
+ ActionParameters
+
+ COMMAND_STRING
+ for item in "$@"; do
+ if [ -d "$item" ]; then
+ scope="$item"
+ else
+ scope="$(/usr/bin/dirname "$item")"
+ fi
+
+ encoded="$(/usr/bin/osascript -l JavaScript -e 'const args=$.NSProcessInfo.processInfo.arguments; encodeURIComponent(ObjC.unwrap(args.objectAtIndex(args.count - 1)))' "$scope")"
+ /usr/bin/open "cardinal://search?scope=${encoded}"
+done
+ CheckedForUserDefaultShell
+
+ inputMethod
+ 1
+ shell
+ /bin/zsh
+ source
+
+
+ BundleIdentifier
+ com.apple.RunShellScript
+ CFBundleVersion
+ 2.0.3
+ CanShowSelectedItemsWhenRun
+
+ CanShowWhenRun
+
+ Category
+
+ AMCategoryUtilities
+
+ Class Name
+ RunShellScriptAction
+ InputUUID
+ DD2DB29D-02A8-49A4-9114-3799231D61DA
+ Keywords
+
+ Shell
+ Script
+ Command
+ Run
+ Unix
+
+ OutputUUID
+ 42597D45-3C71-4B2C-8FD8-9DA62C211D1D
+ UUID
+ 2041F26C-10D3-4DA4-8465-14FA753B3A48
+ UnlocalizedApplications
+
+ Automator
+
+ arguments
+
+ 0
+
+ default value
+ 0
+ name
+ inputMethod
+ required
+ 0
+ type
+ 0
+ uuid
+ 0
+
+ 1
+
+ default value
+
+ name
+ CheckedForUserDefaultShell
+ required
+ 0
+ type
+ 0
+ uuid
+ 1
+
+ 2
+
+ default value
+
+ name
+ source
+ required
+ 0
+ type
+ 0
+ uuid
+ 2
+
+ 3
+
+ default value
+
+ name
+ COMMAND_STRING
+ required
+ 0
+ type
+ 0
+ uuid
+ 3
+
+ 4
+
+ default value
+ /bin/sh
+ name
+ shell
+ required
+ 0
+ type
+ 0
+ uuid
+ 4
+
+
+ isViewVisible
+ 1
+ location
+ 448.000000:305.000000
+ nibPath
+ /System/Library/Automator/Run Shell Script.action/Contents/Resources/Base.lproj/main.nib
+
+ isViewVisible
+ 1
+
+
+ connectors
+
+ workflowMetaData
+
+ applicationBundleID
+ com.apple.finder
+ applicationBundleIDsByPath
+
+ /System/Library/CoreServices/Finder.app
+ com.apple.finder
+
+ applicationPath
+ /System/Library/CoreServices/Finder.app
+ applicationPaths
+
+ /System/Library/CoreServices/Finder.app
+
+ inputTypeIdentifier
+ com.apple.Automator.fileSystemObject
+ outputTypeIdentifier
+ com.apple.Automator.nothing
+ presentationMode
+ 15
+ processesInput
+
+ serviceApplicationBundleID
+ com.apple.finder
+ serviceApplicationPath
+ /System/Library/CoreServices/Finder.app
+ serviceInputTypeIdentifier
+ com.apple.Automator.fileSystemObject
+ serviceOutputTypeIdentifier
+ com.apple.Automator.nothing
+ serviceProcessesInput
+
+ systemImageName
+ NSTouchBarSearchTemplate
+ useAutomaticInputType
+
+ workflowTypeIdentifier
+ com.apple.Automator.servicesMenu
+
+
+
diff --git a/cardinal/src-tauri/resources/share/batch/readme-batch.txt b/cardinal/src-tauri/resources/share/batch/readme-batch.txt
new file mode 100644
index 00000000..53ccfeca
--- /dev/null
+++ b/cardinal/src-tauri/resources/share/batch/readme-batch.txt
@@ -0,0 +1,12 @@
+macOS service menus contains Automator workflows to integrate Cardinal with Finder.
+
+To install a workflow, double-click the .workflow item from this folder.
+macOS installs it into the user's ~/Library/Services directory and manages Finder
+Quick Action visibility through the standard system UI.
+
+The "Cardinal, search in folder.workflow" item opens:
+
+cardinal://search?scope=/path/to/folder
+
+When Finder sends a file, the workflow uses the file's parent directory as the
+scope. When Finder sends a folder, the workflow uses that folder as the scope.
diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs
index 920840c0..24f2b9f6 100644
--- a/cardinal/src-tauri/src/lib.rs
+++ b/cardinal/src-tauri/src/lib.rs
@@ -78,6 +78,7 @@ pub fn run() -> Result<()> {
let update_window_state_tx_for_window = update_window_state_tx.clone();
builder = builder
.plugin(tauri_plugin_opener::init())
+ .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_drag::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_macos_permissions::init())
diff --git a/cardinal/src-tauri/tauri.conf.json b/cardinal/src-tauri/tauri.conf.json
index 6b674bef..f8e22825 100644
--- a/cardinal/src-tauri/tauri.conf.json
+++ b/cardinal/src-tauri/tauri.conf.json
@@ -21,10 +21,20 @@
"csp": null
}
},
+ "plugins": {
+ "deep-link": {
+ "desktop": {
+ "schemes": ["cardinal"]
+ }
+ }
+ },
"bundle": {
"active": true,
"targets": "all",
"category": "Utilities",
+ "resources": {
+ "resources/share/": "share/"
+ },
"macOS": {
"signingIdentity": "-",
"minimumSystemVersion": "12.0"
diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx
index 6a49e9a1..f616a276 100644
--- a/cardinal/src/App.tsx
+++ b/cardinal/src/App.tsx
@@ -23,6 +23,7 @@ import type { FSEventsPanelHandle } from './components/FSEventsPanel';
import { useTranslation } from 'react-i18next';
import { useFullDiskAccessPermission } from './hooks/useFullDiskAccessPermission';
import type { DisplayState } from './components/StateDisplay';
+import type { DeepLinkSearchAction } from './runtime/deepLink';
import { openResultPath } from './utils/openResultPath';
import { useStableEvent } from './hooks/useStableEvent';
import { useAppHotkeys } from './hooks/useAppHotkeys';
@@ -108,6 +109,7 @@ function App() {
const {
activeTab,
+ setActiveTab,
isSearchFocused,
handleSearchFocus,
handleSearchBlur,
@@ -128,6 +130,15 @@ function App() {
queueDirectorySearch,
onNavigateFromSearchToResults: navigateFromSearchToResults,
});
+
+ const submitDeepLinkSearch = useCallback(
+ ({ query, directoryQuery, directoryScopeOpen }: DeepLinkSearchAction) => {
+ setActiveTab('files');
+ updateSearchParams({ query, directoryQuery, directoryScopeOpen });
+ queueSearch(query, { immediate: true });
+ },
+ [queueSearch, setActiveTab, updateSearchParams],
+ );
const { filteredEvents } = useRecentFSEvents({
caseSensitive,
isActive: activeTab === 'events',
@@ -212,6 +223,7 @@ function App() {
handleStatusUpdate,
setLifecycleState,
submitFilesQuery,
+ submitDeepLinkSearch,
setEventFilterQuery,
});
diff --git a/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts b/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts
index 3fc7f82a..68819110 100644
--- a/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts
+++ b/cardinal/src/hooks/__tests__/useAppWindowListeners.test.ts
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import type { RefObject } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
+ subscribeDeepLinkSearchAction,
subscribeLifecycleState,
subscribeQuickLaunch,
subscribeStatusBarUpdate,
@@ -12,12 +13,14 @@ import { useAppWindowListeners } from '../useAppWindowListeners';
vi.mock('../../runtime/tauriEventRuntime', () => ({
subscribeStatusBarUpdate: vi.fn(),
subscribeLifecycleState: vi.fn(),
+ subscribeDeepLinkSearchAction: vi.fn(),
subscribeQuickLaunch: vi.fn(),
subscribeWindowDragDrop: vi.fn(),
}));
const mockedSubscribeStatusBarUpdate = vi.mocked(subscribeStatusBarUpdate);
const mockedSubscribeLifecycleState = vi.mocked(subscribeLifecycleState);
+const mockedSubscribeDeepLinkSearchAction = vi.mocked(subscribeDeepLinkSearchAction);
const mockedSubscribeQuickLaunch = vi.mocked(subscribeQuickLaunch);
const mockedSubscribeWindowDragDrop = vi.mocked(subscribeWindowDragDrop);
@@ -28,12 +31,18 @@ type HookProps = {
handleStatusUpdate: (scannedFiles: number, processedEvents: number, rescanErrors: number) => void;
setLifecycleState: (status: 'Initializing' | 'Updating' | 'Ready') => void;
submitFilesQuery: (query: string, options?: { immediate?: boolean }) => void;
+ submitDeepLinkSearch: (payload: {
+ query: string;
+ directoryQuery: string;
+ directoryScopeOpen: boolean;
+ }) => void;
setEventFilterQuery: (query: string) => void;
};
describe('useAppWindowListeners', () => {
const statusUnlisten = vi.fn();
const lifecycleUnlisten = vi.fn();
+ const deepLinkUnlisten = vi.fn();
const quickLaunchUnlisten = vi.fn();
const dragDropUnlisten = vi.fn();
@@ -41,6 +50,7 @@ describe('useAppWindowListeners', () => {
const handleStatusUpdate = vi.fn();
const setLifecycleState = vi.fn();
const submitFilesQuery = vi.fn();
+ const submitDeepLinkSearch = vi.fn();
const setEventFilterQuery = vi.fn();
const searchInputRef: { current: HTMLInputElement | null } = { current: null };
let searchInputElement: HTMLInputElement;
@@ -50,6 +60,9 @@ describe('useAppWindowListeners', () => {
| ((payload: { scannedFiles: number; processedEvents: number; rescanErrors: number }) => void)
| null;
let lifecycleCallback: ((status: 'Initializing' | 'Updating' | 'Ready') => void) | null;
+ let deepLinkCallback:
+ | ((payload: { query: string; directoryQuery: string; directoryScopeOpen: boolean }) => void)
+ | null;
let quickLaunchCallback: (() => void) | null;
let dragDropCallback: ((event: any) => void) | null;
@@ -62,6 +75,7 @@ describe('useAppWindowListeners', () => {
handleStatusUpdate,
setLifecycleState,
submitFilesQuery,
+ submitDeepLinkSearch,
setEventFilterQuery,
...overrides,
},
@@ -71,6 +85,7 @@ describe('useAppWindowListeners', () => {
vi.clearAllMocks();
statusCallback = null;
lifecycleCallback = null;
+ deepLinkCallback = null;
quickLaunchCallback = null;
dragDropCallback = null;
document.documentElement.removeAttribute('data-window-focused');
@@ -90,6 +105,10 @@ describe('useAppWindowListeners', () => {
lifecycleCallback = listener;
return lifecycleUnlisten;
});
+ mockedSubscribeDeepLinkSearchAction.mockImplementation((listener) => {
+ deepLinkCallback = listener;
+ return deepLinkUnlisten;
+ });
mockedSubscribeQuickLaunch.mockImplementation((listener) => {
quickLaunchCallback = listener;
return quickLaunchUnlisten;
@@ -106,6 +125,7 @@ describe('useAppWindowListeners', () => {
await waitFor(() => {
expect(mockedSubscribeStatusBarUpdate).toHaveBeenCalledTimes(1);
expect(mockedSubscribeLifecycleState).toHaveBeenCalledTimes(1);
+ expect(mockedSubscribeDeepLinkSearchAction).toHaveBeenCalledTimes(1);
expect(mockedSubscribeQuickLaunch).toHaveBeenCalledTimes(1);
expect(mockedSubscribeWindowDragDrop).toHaveBeenCalledTimes(1);
});
@@ -124,6 +144,17 @@ describe('useAppWindowListeners', () => {
quickLaunchCallback?.();
});
expect(focusAndSelectSearchInput).toHaveBeenCalledTimes(1);
+
+ const payload = {
+ query: 'report',
+ directoryQuery: '/tmp/work',
+ directoryScopeOpen: true,
+ };
+ act(() => {
+ deepLinkCallback?.(payload);
+ });
+ expect(submitDeepLinkSearch).toHaveBeenCalledWith(payload);
+ expect(focusAndSelectSearchInput).toHaveBeenCalledTimes(2);
});
it('handles drag-drop search routing for files and events tabs', async () => {
@@ -150,6 +181,7 @@ describe('useAppWindowListeners', () => {
handleStatusUpdate,
setLifecycleState,
submitFilesQuery,
+ submitDeepLinkSearch,
setEventFilterQuery,
});
@@ -201,6 +233,7 @@ describe('useAppWindowListeners', () => {
expect(statusUnlisten).toHaveBeenCalledTimes(1);
expect(lifecycleUnlisten).toHaveBeenCalledTimes(1);
+ expect(deepLinkUnlisten).toHaveBeenCalledTimes(1);
expect(quickLaunchUnlisten).toHaveBeenCalledTimes(1);
expect(dragDropUnlisten).toHaveBeenCalledTimes(1);
});
diff --git a/cardinal/src/hooks/useAppWindowListeners.ts b/cardinal/src/hooks/useAppWindowListeners.ts
index d6ffb1fc..6620a667 100644
--- a/cardinal/src/hooks/useAppWindowListeners.ts
+++ b/cardinal/src/hooks/useAppWindowListeners.ts
@@ -3,11 +3,13 @@ import type { RefObject } from 'react';
import type { StatusTabKey } from '../components/StatusBar';
import {
subscribeLifecycleState,
+ subscribeDeepLinkSearchAction,
subscribeQuickLaunch,
subscribeStatusBarUpdate,
subscribeWindowDragDrop,
type WindowDragDropEvent,
} from '../runtime/tauriEventRuntime';
+import type { DeepLinkSearchAction } from '../runtime/deepLink';
import type { AppLifecycleStatus, StatusBarUpdatePayload } from '../types/ipc';
import { useStableEvent } from './useStableEvent';
@@ -22,6 +24,7 @@ type UseAppWindowListenersOptions = {
handleStatusUpdate: (scannedFiles: number, processedEvents: number, rescanErrors: number) => void;
setLifecycleState: (status: AppLifecycleStatus) => void;
submitFilesQuery: (query: string, options?: QueueSearchOptions) => void;
+ submitDeepLinkSearch: (payload: DeepLinkSearchAction) => void;
setEventFilterQuery: (value: string) => void;
};
@@ -40,6 +43,7 @@ export function useAppWindowListeners({
handleStatusUpdate,
setLifecycleState,
submitFilesQuery,
+ submitDeepLinkSearch,
setEventFilterQuery,
}: UseAppWindowListenersOptions): UseAppWindowListenersResult {
const [isWindowFocused, setIsWindowFocused] = useState(() => {
@@ -70,6 +74,14 @@ export function useAppWindowListeners({
return unlistenQuickLaunch;
}, [focusAndSelectSearchInput]);
+ useEffect(() => {
+ const unlistenDeepLinkSearch = subscribeDeepLinkSearchAction((payload) => {
+ submitDeepLinkSearch(payload);
+ focusAndSelectSearchInput();
+ });
+ return unlistenDeepLinkSearch;
+ }, [focusAndSelectSearchInput, submitDeepLinkSearch]);
+
useEffect(() => {
if (typeof window === 'undefined') {
return;
diff --git a/cardinal/src/runtime/__tests__/deepLink.test.ts b/cardinal/src/runtime/__tests__/deepLink.test.ts
new file mode 100644
index 00000000..b271690e
--- /dev/null
+++ b/cardinal/src/runtime/__tests__/deepLink.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { parseDeepLinkSearchUrl, parseDeepLinkSearchUrls } from '../deepLink';
+
+describe('deepLink runtime parsing', () => {
+ it('parses search scope links', () => {
+ expect(
+ parseDeepLinkSearchUrl('cardinal://search?scope=%2FUsers%2Fzc%2FProjects&q=report'),
+ ).toEqual({
+ query: 'report',
+ directoryQuery: '/Users/zc/Projects',
+ directoryScopeOpen: true,
+ });
+ });
+
+ it('supports scope-only folder searches', () => {
+ expect(parseDeepLinkSearchUrl('cardinal://search?scope=/tmp/work')).toEqual({
+ query: '',
+ directoryQuery: '/tmp/work',
+ directoryScopeOpen: true,
+ });
+ });
+
+ it('rejects unsupported links', () => {
+ expect(parseDeepLinkSearchUrl('https://search?scope=/tmp')).toBeNull();
+ expect(parseDeepLinkSearchUrl('cardinal://open?scope=/tmp')).toBeNull();
+ expect(parseDeepLinkSearchUrl('cardinal://search')).toBeNull();
+ expect(parseDeepLinkSearchUrl('cardinal://search?query=report')).toBeNull();
+ expect(parseDeepLinkSearchUrl('cardinal://search?folder=/tmp')).toBeNull();
+ expect(parseDeepLinkSearchUrl('cardinal://search?directoryQuery=/tmp')).toBeNull();
+ expect(parseDeepLinkSearchUrl('not a url')).toBeNull();
+ });
+
+ it('filters URL batches to supported search actions', () => {
+ expect(
+ parseDeepLinkSearchUrls([
+ 'cardinal://search?q=report',
+ 'cardinal://open?scope=/tmp',
+ 'cardinal://search?directoryQuery=/tmp/work',
+ 'cardinal://search?scope=/tmp/work',
+ ]),
+ ).toEqual([
+ { query: 'report', directoryQuery: '', directoryScopeOpen: false },
+ { query: '', directoryQuery: '/tmp/work', directoryScopeOpen: true },
+ ]);
+ });
+});
diff --git a/cardinal/src/runtime/deepLink.ts b/cardinal/src/runtime/deepLink.ts
new file mode 100644
index 00000000..1d4fb5b5
--- /dev/null
+++ b/cardinal/src/runtime/deepLink.ts
@@ -0,0 +1,44 @@
+const CARDINAL_SCHEME = 'cardinal:';
+const SEARCH_HOST = 'search';
+
+export type DeepLinkSearchAction = {
+ query: string;
+ directoryQuery: string;
+ directoryScopeOpen: boolean;
+};
+
+export const parseDeepLinkSearchUrl = (rawUrl: string): DeepLinkSearchAction | null => {
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ return null;
+ }
+
+ if (url.protocol !== CARDINAL_SCHEME || url.hostname !== SEARCH_HOST) {
+ return null;
+ }
+
+ // Public URL form: cardinal://search?scope=/path/to/folder&q=keyword
+ const query = (url.searchParams.get('q') ?? '').trim();
+ const directoryQuery = (url.searchParams.get('scope') ?? '').trim();
+ const directoryScopeOpen = directoryQuery.length > 0;
+
+ if (!query && !directoryScopeOpen) {
+ return null;
+ }
+
+ return { query, directoryQuery, directoryScopeOpen };
+};
+
+export const parseDeepLinkSearchUrls = (
+ urls: readonly string[] | null | undefined,
+): DeepLinkSearchAction[] => {
+ if (!urls) {
+ return [];
+ }
+ return urls.flatMap((url) => {
+ const payload = parseDeepLinkSearchUrl(url);
+ return payload ? [payload] : [];
+ });
+};
diff --git a/cardinal/src/runtime/tauriEventRuntime.ts b/cardinal/src/runtime/tauriEventRuntime.ts
index 350f7c55..6d0fc637 100644
--- a/cardinal/src/runtime/tauriEventRuntime.ts
+++ b/cardinal/src/runtime/tauriEventRuntime.ts
@@ -1,7 +1,9 @@
+import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import type { DragDropEvent } from '@tauri-apps/api/window';
+import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import type {
AppLifecycleStatus,
IconUpdatePayload,
@@ -10,6 +12,8 @@ import type {
StatusBarUpdatePayload,
} from '../types/ipc';
import type { SlabIndex } from '../types/slab';
+import { parseDeepLinkSearchUrls } from './deepLink';
+import type { DeepLinkSearchAction } from './deepLink';
export type QuickLookKeydownPayload = {
keyCode: number;
@@ -28,6 +32,7 @@ export type WindowDragDropEvent = TauriEvent;
const statusBarUpdateListeners = new Set>();
const lifecycleStateListeners = new Set>();
const quickLaunchListeners = new Set>();
+const deepLinkSearchActionListeners = new Set>();
const fsEventsBatchListeners = new Set>();
const iconUpdateListeners = new Set>();
const quickLookKeydownListeners = new Set>();
@@ -90,6 +95,17 @@ const normalizeIconUpdates = (payload: unknown): IconUpdatePayload[] => {
.map((item) => ({ slabIndex: item.slabIndex as SlabIndex, icon: item.icon }));
};
+const emitDeepLinkSearchUrls = (urls: readonly string[] | null | undefined): void => {
+ const payloads = parseDeepLinkSearchUrls(urls);
+ if (payloads.length === 0) {
+ return;
+ }
+ void invoke('activate_main_window').catch((error) => {
+ console.error('Failed to activate window for deep link', error);
+ });
+ payloads.forEach((payload) => emit(deepLinkSearchActionListeners, payload));
+};
+
export const initializeTauriEventRuntime = (): Promise => {
if (initPromise) {
return initPromise;
@@ -116,6 +132,18 @@ export const initializeTauriEventRuntime = (): Promise => {
}).catch((error) => {
console.error('Failed to register quick_launch listener', error);
}),
+ getCurrent()
+ .then((urls) => {
+ emitDeepLinkSearchUrls(urls);
+ })
+ .catch((error) => {
+ console.error('Failed to fetch current deep links', error);
+ }),
+ onOpenUrl((urls) => {
+ emitDeepLinkSearchUrls(urls);
+ }).catch((error) => {
+ console.error('Failed to register deep link listener', error);
+ }),
listen('fs_events_batch', (event) => {
const payload = normalizeRecentEvents(event.payload);
if (payload.length === 0) return;
@@ -169,6 +197,13 @@ export const subscribeQuickLaunch = (listener: () => void): UnlistenFn => {
return subscribe(quickLaunchListeners, listener);
};
+export const subscribeDeepLinkSearchAction = (
+ listener: Listener,
+): UnlistenFn => {
+ void initializeTauriEventRuntime();
+ return subscribe(deepLinkSearchActionListeners, listener);
+};
+
export const subscribeFSEventsBatch = (listener: Listener): UnlistenFn => {
void initializeTauriEventRuntime();
return subscribe(fsEventsBatchListeners, listener);
diff --git a/doc/macos-finder-quick-action-verification.md b/doc/macos-finder-quick-action-verification.md
new file mode 100644
index 00000000..5cdd6228
--- /dev/null
+++ b/doc/macos-finder-quick-action-verification.md
@@ -0,0 +1,201 @@
+# macOS Finder Quick Action Verification
+
+This document verifies that Cardinal's macOS packaging exposes and installs the
+Finder Quick Action through both the DMG and Homebrew Cask paths.
+
+## Scope
+
+The Finder integration is an Automator Quick Action, not a Finder Sync
+extension. The workflow opens Cardinal through the public URL scheme:
+
+```text
+cardinal://search?scope=/path/to/folder&q=keyword
+```
+
+The Finder workflow uses `scope` only. When Finder sends a folder, the folder is
+used as the search scope. When Finder sends a file, the file's parent directory
+is used as the search scope.
+
+## Build the DMG
+
+From the repository root:
+
+```bash
+cd cardinal
+npm run package:macos-dmg
+```
+
+Expected artifact:
+
+```text
+cardinal/src-tauri/target/release/Cardinal_0.1.23_aarch64.dmg
+```
+
+The generated DMG root should contain:
+
+```text
+Cardinal.app
+Applications
+macOS service menus/
+README - Finder Quick Action.txt
+```
+
+## Reset Local State
+
+Before testing either install path, remove previous app and workflow installs:
+
+```bash
+brew uninstall --cask zc/cardinal-local/cardinal-local --force || true
+brew uninstall --cask cardinal-search --force || true
+rm -rf /Applications/Cardinal.app
+rm -rf "$HOME/Library/Services/Cardinal, search in folder.workflow"
+killall Finder
+```
+
+## DMG Install Path
+
+Open the generated DMG:
+
+```bash
+open cardinal/src-tauri/target/release/Cardinal_0.1.23_aarch64.dmg
+```
+
+In Finder:
+
+1. Drag `Cardinal.app` to `Applications`.
+2. Open `macOS service menus`.
+3. Double-click `Cardinal, search in folder.workflow`.
+4. Confirm the macOS workflow installation prompt.
+
+Verify the installed app and workflow:
+
+```bash
+test -d /Applications/Cardinal.app
+test -d "$HOME/Library/Services/Cardinal, search in folder.workflow"
+plutil -lint "$HOME/Library/Services/Cardinal, search in folder.workflow/Contents/Info.plist"
+```
+
+Verify the URL scheme:
+
+```bash
+open -a Cardinal
+open 'cardinal://search?scope=%2Ftmp&q=report'
+```
+
+Refresh Finder, then verify the context menu manually:
+
+```bash
+killall Finder
+```
+
+Right-click a file or folder in Finder. `Search in Cardinal` should appear under
+Quick Actions or Services.
+
+## Local Homebrew Cask Path
+
+Create a local tap if it does not already exist:
+
+```bash
+brew tap-new zc/cardinal-local || true
+mkdir -p "$(brew --repository zc/cardinal-local)/Casks"
+```
+
+Create the local cask:
+
+```bash
+cat > "$(brew --repository zc/cardinal-local)/Casks/cardinal-local.rb" <<'RUBY'
+cask "cardinal-local" do
+ version "0.1.23"
+ sha256 :no_check
+
+ url "file:///Users/zc/codespace/rust/cardinal/cardinal/src-tauri/target/release/Cardinal_0.1.23_aarch64.dmg"
+ name "Cardinal"
+ desc "Fast file search for macOS"
+ homepage "https://github.com/cardisoft/cardinal"
+
+ app "Cardinal.app"
+ service "macOS service menus/Cardinal, search in folder.workflow"
+end
+RUBY
+```
+
+Check that Homebrew recognizes both artifacts:
+
+```bash
+brew info --cask zc/cardinal-local/cardinal-local
+```
+
+Expected artifact output:
+
+```text
+Cardinal.app (App)
+macOS service menus/Cardinal, search in folder.workflow (Service)
+```
+
+Install through the local cask:
+
+```bash
+brew install --cask zc/cardinal-local/cardinal-local
+```
+
+Verify the installed app and workflow:
+
+```bash
+test -d /Applications/Cardinal.app
+test -d "$HOME/Library/Services/Cardinal, search in folder.workflow"
+plutil -lint "$HOME/Library/Services/Cardinal, search in folder.workflow/Contents/Info.plist"
+```
+
+Verify the URL scheme:
+
+```bash
+open -a Cardinal
+open 'cardinal://search?scope=%2Ftmp&q=report'
+```
+
+Refresh Finder, then verify the context menu manually:
+
+```bash
+killall Finder
+```
+
+Right-click a file or folder in Finder. `Search in Cardinal` should appear under
+Quick Actions or Services.
+
+## Homebrew Uninstall Verification
+
+Uninstall the local cask:
+
+```bash
+brew uninstall --cask zc/cardinal-local/cardinal-local --force
+```
+
+Verify Homebrew removed both artifacts:
+
+```bash
+test ! -d /Applications/Cardinal.app
+test ! -d "$HOME/Library/Services/Cardinal, search in folder.workflow"
+```
+
+## Troubleshooting
+
+If the workflow exists but Finder does not show the Quick Action:
+
+```bash
+killall Finder
+```
+
+If it still does not appear, check macOS Services settings:
+
+```text
+System Settings -> Keyboard -> Keyboard Shortcuts -> Services
+```
+
+The item should be named `Search in Cardinal`.
+
+If `cardinal://` does not open Cardinal, launch the app once and retry:
+
+```bash
+open -a Cardinal
+open 'cardinal://search?scope=%2Ftmp&q=report'
+```