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' +```