From 2f42c6712f5e7e5b9937f121bcb72f5c3a6d17b6 Mon Sep 17 00:00:00 2001 From: theg Date: Mon, 20 Oct 2025 19:36:18 +0530 Subject: [PATCH 1/7] feat: implemented native link opening --- README.md | 4 +- apps/desktop/bun.lock | 110 +++++- apps/desktop/package.json | 4 + apps/desktop/src-tauri/Cargo.lock | 222 +++++++++++- apps/desktop/src-tauri/Cargo.toml | 13 + .../src-tauri/capabilities/default.json | 3 +- apps/desktop/src-tauri/src/browser_details.rs | 101 +++--- apps/desktop/src-tauri/src/commands.rs | 223 +++++++++++- apps/desktop/src-tauri/src/domain/mod.rs | 1 - apps/desktop/src-tauri/src/domain/models.rs | 7 +- apps/desktop/src-tauri/src/lib.rs | 76 ++++- apps/desktop/src-tauri/src/link.rs | 165 +++++++++ apps/desktop/src-tauri/src/platform/linux.rs | 66 ++++ apps/desktop/src-tauri/src/platform/macos.rs | 38 +++ apps/desktop/src-tauri/src/platform/mod.rs | 21 ++ .../desktop/src-tauri/src/platform/windows.rs | 117 +++++++ apps/desktop/src-tauri/src/preferences.rs | 75 ++++ apps/desktop/src-tauri/src/routing.rs | 294 ++++++++++++++++ apps/desktop/src/App.tsx | 188 ++++++---- apps/desktop/src/Layout.tsx | 20 +- apps/desktop/src/OpenWithDialog.tsx | 33 +- apps/desktop/src/lib/models.ts | 2 +- apps/desktop/src/lib/preferences.ts | 24 ++ apps/desktop/src/lib/routing.ts | 180 ++++++++++ apps/desktop/src/pages/Dashboard.tsx | 249 +++++++++++--- apps/desktop/src/pages/Settings.tsx | 320 +++++++++++++++++- 26 files changed, 2322 insertions(+), 234 deletions(-) create mode 100644 apps/desktop/src-tauri/src/link.rs create mode 100644 apps/desktop/src-tauri/src/platform/linux.rs create mode 100644 apps/desktop/src-tauri/src/platform/macos.rs create mode 100644 apps/desktop/src-tauri/src/platform/mod.rs create mode 100644 apps/desktop/src-tauri/src/platform/windows.rs create mode 100644 apps/desktop/src-tauri/src/preferences.rs create mode 100644 apps/desktop/src-tauri/src/routing.rs create mode 100644 apps/desktop/src/lib/preferences.ts create mode 100644 apps/desktop/src/lib/routing.ts diff --git a/README.md b/README.md index 038a19b..872e71c 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,10 @@ bun run tauri build --- -## Usage (Planned) +## Usage 1) Launch the app. -2) In Settings, register Open With Browser as your default handler for http/https. +2) In Settings, use **Open default-app settings** to trigger OS registration (the app writes the required manifests before opening the system panel). 3) Create a few rules (e.g., host: github.com → Chrome [Coding]). 4) Click a link anywhere—matching rules will route it automatically. 5) If unmatched, the “Open With” dialog appears; choose a browser/profile: diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index c8e6564..004a11b 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -4,12 +4,16 @@ "": { "name": "desktop", "dependencies": { + "@tailwindcss/vite": "^4.1.14", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.1", + "@tauri-apps/plugin-store": "^2.4.0", "motion": "^12.23.24", "react": "^19.1.0", "react-dom": "^19.1.0", - "zustand": "^5.0.8" + "tailwindcss": "^4.1.14", + "zustand": "^5.0.8", }, "devDependencies": { "@tauri-apps/cli": "^2", @@ -27,9 +31,9 @@ "prettier": "^3.6.2", "rollup": "^4.52.5", "typescript": "~5.8.3", - "vite": "^7.0.4" - } - } + "vite": "^7.0.4", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, ""], @@ -98,6 +102,8 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, ""], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, ""], @@ -162,6 +168,36 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="], + "@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, ""], "@tauri-apps/cli": ["@tauri-apps/cli@2.8.4", "", { "optionalDependencies": { "@tauri-apps/cli-win32-x64-msvc": "2.8.4" }, "bin": { "tauri": "tauri.js" } }, ""], @@ -170,6 +206,10 @@ "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, ""], + "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="], + + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, ""], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, ""], @@ -258,6 +298,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -284,12 +326,16 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.237", "", {}, ""], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -392,6 +438,8 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -476,6 +524,8 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, ""], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -496,6 +546,28 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -504,6 +576,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, ""], + "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -512,6 +586,10 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="], "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], @@ -648,6 +726,12 @@ "synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="], + "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -688,7 +772,7 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "yallist": ["yallist@3.1.1", "", {}, ""], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -702,6 +786,18 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -710,6 +806,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "lru-cache/yallist": ["yallist@3.1.1", "", {}, ""], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "vite/rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4" }, "bin": "dist/bin/rollup" }, ""], @@ -718,6 +816,6 @@ "vite/rollup/@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, ""], - "vite/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, ""] + "vite/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, ""], } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ee78eef..98496aa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,11 +14,15 @@ "format:check": "prettier --check ." }, "dependencies": { + "@tailwindcss/vite": "^4.1.14", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.1", + "@tauri-apps/plugin-store": "^2.4.0", "motion": "^12.23.24", "react": "^19.1.0", "react-dom": "^19.1.0", + "tailwindcss": "^4.1.14", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index db481a2..48c0972 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -457,8 +457,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -503,6 +505,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -526,7 +538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -539,7 +551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.9.4", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -593,7 +605,7 @@ dependencies = [ "signal-hook", "tiny_http", "tungstenite", - "which", + "which 6.0.3", ] [[package]] @@ -841,7 +853,7 @@ dependencies = [ "rustc_version", "toml 0.9.8", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1233,6 +1245,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2496,14 +2518,24 @@ dependencies = [ name = "open-with-browser" version = "0.1.0" dependencies = [ + "chrono", + "core-foundation 0.9.4", "crowser", "dirs", + "percent-encoding", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tokio", + "url", "uuid", + "which 5.0.0", + "winreg 0.52.0", ] [[package]] @@ -2522,6 +2554,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +dependencies = [ + "log", + "plist", + "serde", + "windows-sys 0.52.0", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3687,6 +3731,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3708,7 +3761,7 @@ checksum = "6121216ff67fe4bcfe64508ea1700bc15f74937d835a07b4a209cc00a8926a84" dependencies = [ "bitflags 2.9.4", "block2 0.6.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3911,6 +3964,55 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb9cac815bf11c4a80fb498666bcdad66d65b89e3ae24669e47806febb76389c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85dd80d60a76ee2c2fdce09e9ef30877b239c2a6bb76e6d7d03708aa5f13a19" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.8.0" @@ -4139,9 +4241,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -4750,6 +4864,19 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "which" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf3ea8596f3a0dd5980b46430f2058dfe2c36a27ccfbb1845d6fbfcd9ba6e14" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + [[package]] name = "which" version = "6.0.3" @@ -4956,6 +5083,24 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4998,6 +5143,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5055,6 +5215,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5073,6 +5239,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5091,6 +5263,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5121,6 +5299,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5139,6 +5323,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5157,6 +5347,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5175,6 +5371,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5205,6 +5407,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index f2dfd24..50a170b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,9 +20,22 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" crowser = "0.4.1" dirs = "6.0.0" uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock"] } +tokio = { version = "1", features = ["time"] } +which = "5" +url = "2" +percent-encoding = "2" +tauri-plugin-os = "2.3.1" +tauri-plugin-store = "2.4.0" +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 4cdbf49..3532012 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "os:default" ] } diff --git a/apps/desktop/src-tauri/src/browser_details.rs b/apps/desktop/src-tauri/src/browser_details.rs index 5a4d89f..4e9cb71 100644 --- a/apps/desktop/src-tauri/src/browser_details.rs +++ b/apps/desktop/src-tauri/src/browser_details.rs @@ -1,10 +1,11 @@ use crowser::browser; +use dirs::{config_dir, data_local_dir}; +use serde_json::Value; use std::{ fs, io::{self, Read}, }; -use dirs::{config_dir, data_local_dir}; -use serde_json::Value; +use tauri_plugin_os::OsType; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Browsers { @@ -23,11 +24,7 @@ pub fn get_browsers() -> Vec { } pub fn parse_browser_kind>(value: S) -> Option { - let normalized = value - .as_ref() - .trim() - .to_lowercase() - .replace([' ', '-'], ""); + let normalized = value.as_ref().trim().to_lowercase().replace([' ', '-'], ""); match normalized.as_str() { "chrome" | "googlechrome" => Some(Browsers::Chrome), @@ -39,27 +36,26 @@ pub fn parse_browser_kind>(value: S) -> Option { } } -pub fn get_chrome_based_profiles(os_paths: Vec<&str>) -> Result, Box> { - - let base_dir = if cfg!(target_os = "windows") || cfg!(target_os = "macos") { - data_local_dir() - } else { - config_dir() +pub fn get_chrome_based_profiles( + os_paths: [&str; 3], +) -> Result, Box> { + let os_type = tauri_plugin_os::type_(); + let base_dir = match os_type { + OsType::Windows | OsType::Macos => data_local_dir(), + OsType::Linux => config_dir(), + _ => None, }; if let Some(mut path) = base_dir { - - #[cfg(target_os = "windows")] - path.push(os_paths[0]); - - #[cfg(target_os = "macos")] - path.push(os_paths[1]); - - #[cfg(target_os = "linux")] - path.push(os_paths[2]); + let suffix = match os_type { + OsType::Windows => os_paths[0], + OsType::Macos => os_paths[1], + OsType::Linux => os_paths[2], + _ => return Ok(Vec::new()), + }; + path.push(suffix); if path.exists() { - let mut file = fs::File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; @@ -70,7 +66,12 @@ pub fn get_chrome_based_profiles(os_paths: Vec<&str>) -> Result, Box .get("profile") .and_then(|p| p.get("info_cache")) .and_then(|ic| ic.as_object()) - .ok_or_else(|| {Box::new(io::Error::new(io::ErrorKind::InvalidData, "Could not find 'profile' or 'info_cache' in JSON.")) as Box})?; + .ok_or_else(|| { + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "Could not find 'profile' or 'info_cache' in JSON.", + )) as Box + })?; let mut profile_names: Vec = Vec::new(); @@ -83,7 +84,6 @@ pub fn get_chrome_based_profiles(os_paths: Vec<&str>) -> Result, Box } return Ok(profile_names); - } } @@ -91,19 +91,18 @@ pub fn get_chrome_based_profiles(os_paths: Vec<&str>) -> Result, Box } pub fn get_chrome_profiles(kind: Browsers) -> Result, Box> { - - let paths: Vec<&str> = match kind { - Browsers::Chrome => vec![ + let paths: [&str; 3] = match kind { + Browsers::Chrome => [ "Google\\Chrome\\User Data\\Local State", "Google/Chrome/Local State", "google-chrome/Local State", ], - Browsers::Edge => vec![ - "Microsoft\\Edge\\User Data\\Local State", + Browsers::Edge => [ + "Microsoft\\Edge\\User Data\\Local State", "Microsoft/Edge/Local State", "microsoft-edge/Local State", ], - Browsers::Brave => vec![ + Browsers::Brave => [ "BraveSoftware\\Brave-Browser\\User Data\\Local State", "BraveSoftware/Brave-Browser/Local State", "brave/Local State", @@ -111,40 +110,35 @@ pub fn get_chrome_profiles(kind: Browsers) -> Result, Box return Ok(Vec::new()), }; - return get_chrome_based_profiles(paths); + get_chrome_based_profiles(paths) } pub fn get_firefox_profiles() -> Result, Box> { - - let base_dir = if cfg!(target_os = "windows") || cfg!(target_os = "macos") { - data_local_dir() - } else { - dirs::home_dir() + let os_type = tauri_plugin_os::type_(); + let base_dir = match os_type { + OsType::Windows | OsType::Macos => data_local_dir(), + OsType::Linux => dirs::home_dir(), + _ => None, }; if let Some(mut path) = base_dir { - - #[cfg(target_os = "windows")] - path.push("Mozilla\\Firefox\\Profiles"); - - #[cfg(target_os = "macos")] - path.push("Firefox/Profiles"); - - #[cfg(target_os = "linux")] - path.push("~/.mozilla/firefox"); + match os_type { + OsType::Windows => path.push("Mozilla\\Firefox\\Profiles"), + OsType::Macos => path.push("Firefox/Profiles"), + OsType::Linux => path.push(".mozilla/firefox"), + _ => return Ok(Vec::new()), + } if path.exists() { match fs::read_dir(path) { Ok(entries) => { let profile_names: Vec = entries .filter_map(Result::ok) - .filter_map(|entry| { - match entry.file_type() { - Ok(file_type) if file_type.is_dir() => { - Some(entry.file_name().to_string_lossy().into_owned()) - } - _ => None, + .filter_map(|entry| match entry.file_type() { + Ok(file_type) if file_type.is_dir() => { + Some(entry.file_name().to_string_lossy().into_owned()) } + _ => None, }) .collect(); return Ok(profile_names); @@ -158,5 +152,4 @@ pub fn get_firefox_profiles() -> Result, Box> } return Ok(Vec::new()); - -} \ No newline at end of file +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index fda31bb..6b10f85 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,15 +1,79 @@ -use crate::browser_details::{ - get_browsers, - get_chrome_profiles, - get_firefox_profiles, - parse_browser_kind, - Browsers, +use crate::{ + browser_details::{ + get_browsers, get_chrome_profiles, get_firefox_profiles, parse_browser_kind, Browsers, + }, + platform, + preferences::{FallbackPreference, PreferencesState}, + routing::{ + simulate_link_payload, IncomingLink, LaunchDecision, RoutingSnapshot, RoutingStateHandle, + }, }; +use serde::Serialize; +use std::process::Command; +use tauri::{AppHandle, Manager}; +use tauri_plugin_os::OsType; fn map_error(err: Box) -> String { err.to_string() } +#[cfg(target_os = "windows")] +fn current_http_handler_windows() -> Result { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + const USER_CHOICE_KEY: &str = + "Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice"; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let key = hkcu + .open_subkey(USER_CHOICE_KEY) + .map_err(|e| e.to_string())?; + key.get_value::("ProgId") + .map_err(|e| e.to_string()) +} + +#[cfg(target_os = "macos")] +fn current_http_handler_macos() -> Result { + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + + #[link(name = "CoreServices", kind = "framework")] + extern "C" { + fn LSCopyDefaultHandlerForURLScheme( + scheme: core_foundation::sys::string::CFStringRef, + ) -> core_foundation::sys::string::CFStringRef; + } + + let scheme = CFString::new("http"); + let result = unsafe { LSCopyDefaultHandlerForURLScheme(scheme.as_concrete_TypeRef()) }; + if result.is_null() { + return Err("LaunchServices did not return a handler".to_string()); + } + + let handler = unsafe { CFString::wrap_under_create_rule(result) }; + Ok(handler.to_string()) +} + +#[cfg(target_os = "linux")] +fn current_http_handler_linux() -> Result { + let output = Command::new("xdg-settings") + .args(["get", "default-web-browser"]) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + return Err(format!("xdg-settings exited with status {}", output.status)); + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + return Err("xdg-settings returned an empty value".to_string()); + } + + Ok(value) +} + #[tauri::command] pub fn get_available_browsers() -> Vec { get_browsers() @@ -27,4 +91,149 @@ pub fn get_profiles(browser_kind: String) -> Result, String> { Browsers::FireFox => get_firefox_profiles().map_err(map_error), Browsers::Safari => Ok(Vec::new()), } -} \ No newline at end of file +} + +#[tauri::command] +pub async fn routing_snapshot(state: RoutingStateHandle<'_>) -> Result { + Ok(state.snapshot().await) +} + +#[tauri::command] +pub async fn register_incoming_link( + app_handle: AppHandle, + state: RoutingStateHandle<'_>, + link: IncomingLink, +) -> Result { + state.register_incoming(&app_handle, link).await +} + +#[tauri::command] +pub async fn resolve_incoming_link( + app_handle: AppHandle, + state: RoutingStateHandle<'_>, + decision: LaunchDecision, +) -> Result { + state.resolve(&app_handle, decision).await +} + +#[tauri::command] +pub async fn simulate_incoming_link( + app_handle: AppHandle, + state: RoutingStateHandle<'_>, + payload: Option, +) -> Result { + let link = simulate_link_payload(payload).await; + state.register_incoming(&app_handle, link).await +} + +#[tauri::command] +pub async fn is_default_browser(app_handle: AppHandle) -> Result { + match tauri_plugin_os::type_() { + #[cfg(target_os = "windows")] + OsType::Windows => { + let handler = current_http_handler_windows()?; + Ok(handler.eq_ignore_ascii_case("OpenWithBrowserURL")) + } + #[cfg(target_os = "macos")] + OsType::Macos => { + let handler = current_http_handler_macos()?; + let bundle_id = app_handle.config().identifier.clone(); + Ok(handler == bundle_id) + } + #[cfg(target_os = "linux")] + OsType::Linux => { + let handler = current_http_handler_linux()?; + let app_identifier = app_handle.config().identifier.clone(); + let expected = format!("{}.desktop", app_identifier.replace('-', "_")); + Ok(handler == expected) + } + _ => Ok(false), + } +} + +#[tauri::command] +pub async fn open_default_browser_settings(_app_handle: AppHandle) -> Result<(), String> { + match tauri_plugin_os::type_() { + #[cfg(target_os = "windows")] + OsType::Windows => Command::new("explorer.exe") + .arg("ms-settings:defaultapps") + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()), + #[cfg(target_os = "macos")] + OsType::Macos => Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.general?DefaultWebBrowser") + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()), + #[cfg(target_os = "linux")] + OsType::Linux => { + let attempts = [ + ( + "xdg-settings", + vec!["set", "default-web-browser", "open-with-browser.desktop"], + ), + ("gnome-control-center", vec!["default-applications"]), + ("xdg-open", vec!["about:preferences"]), + ]; + + for (cmd, args) in attempts { + if which::which(cmd).is_ok() + && std::process::Command::new(cmd).args(&args).spawn().is_ok() + { + return Ok(()); + } + } + + Command::new("xdg-open") + .arg("https://wiki.archlinux.org/title/Default_applications") + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) + } + _ => Ok(()), + } +} + +#[tauri::command] +pub async fn register_browser_handlers(app_handle: AppHandle) -> Result<(), String> { + platform::register_as_browser(&app_handle) +} + +#[derive(Debug, Serialize)] +pub struct PreferencesSnapshot { + pub fallback: Option, +} + +#[tauri::command] +pub async fn get_preferences(app_handle: AppHandle) -> Result { + if let Some(state) = app_handle.try_state::() { + let fallback = state.fallback().await; + Ok(PreferencesSnapshot { fallback }) + } else { + Ok(PreferencesSnapshot { fallback: None }) + } +} + +#[tauri::command] +pub async fn set_fallback_browser( + app_handle: AppHandle, + browser: Option, + profile: Option, +) -> Result<(), String> { + let state = app_handle + .try_state::() + .ok_or_else(|| "Preferences state not initialised".to_string())?; + + match browser { + Some(name) if !name.is_empty() => { + state + .set_fallback(Some(FallbackPreference { + browser: name, + profile: profile.filter(|p| !p.is_empty()), + })) + .await + } + _ => state.set_fallback(None).await, + } +} diff --git a/apps/desktop/src-tauri/src/domain/mod.rs b/apps/desktop/src-tauri/src/domain/mod.rs index 11e5179..c446ac8 100644 --- a/apps/desktop/src-tauri/src/domain/mod.rs +++ b/apps/desktop/src-tauri/src/domain/mod.rs @@ -1,2 +1 @@ pub mod models; -pub use models::*; \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/domain/models.rs b/apps/desktop/src-tauri/src/domain/models.rs index 2fa3626..fed52bf 100644 --- a/apps/desktop/src-tauri/src/domain/models.rs +++ b/apps/desktop/src-tauri/src/domain/models.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Serialize, Deserialize, Debug)] @@ -15,13 +15,12 @@ pub struct Rule { pub struct Condition { pub fact: String, pub operator: String, - pub value: String + pub value: String, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Action { pub profile: String, pub browser: String, - pub url: String + pub url: String, } - diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a3cc8a1..5b4624b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,8 +1,23 @@ mod browser_details; mod commands; mod domain; +mod link; +mod platform; +mod preferences; +mod routing; -use commands::{get_available_browsers, get_profiles}; +use commands::{ + get_available_browsers, get_preferences, get_profiles, is_default_browser, + open_default_browser_settings, register_browser_handlers, register_incoming_link, + resolve_incoming_link, routing_snapshot, set_fallback_browser, simulate_incoming_link, +}; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use link::handle_open_urls; +use link::{handle_cli_arguments, LinkSource}; +use routing::RoutingService; +use tauri::Manager; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use tauri::RunEvent; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] @@ -12,13 +27,62 @@ fn greet(name: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() + .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + let args = argv.into_iter().skip(1).collect::>(); + handle_cli_arguments(&app.app_handle(), &args, LinkSource::SecondaryInstance); + })) + .manage(RoutingService::new()) + .setup(|app| { + let args = std::env::args().skip(1).collect::>(); + handle_cli_arguments(&app.handle(), &args, LinkSource::InitialLaunch); + + if let Err(err) = platform::register_as_browser(&app.handle()) { + eprintln!("failed to register platform browser hooks: {err}"); + } + + match preferences::PreferencesState::load(&app.handle()) { + Ok(state) => { + let _ = app.manage(state); + } + Err(err) => eprintln!("failed to load preferences: {err}"), + } + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ greet, get_available_browsers, - get_profiles - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + get_profiles, + routing_snapshot, + register_incoming_link, + resolve_incoming_link, + simulate_incoming_link, + is_default_browser, + open_default_browser_settings, + register_browser_handlers, + get_preferences, + set_fallback_browser + ]); + + let app = builder + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|app_handle, event| { + #[cfg(any(target_os = "macos", target_os = "ios"))] + { + if let RunEvent::Opened { urls } = event { + let urls = urls.iter().map(|u| u.to_string()).collect::>(); + handle_open_urls(app_handle, &urls, LinkSource::OsEvent); + } + } + #[cfg(not(any(target_os = "macos", target_os = "ios")))] + { + let _ = app_handle; + let _ = event; + } + }); } diff --git a/apps/desktop/src-tauri/src/link.rs b/apps/desktop/src-tauri/src/link.rs new file mode 100644 index 0000000..be4e7b3 --- /dev/null +++ b/apps/desktop/src-tauri/src/link.rs @@ -0,0 +1,165 @@ +use crate::routing::{IncomingLink, RoutingService}; +use chrono::Utc; +use std::borrow::Cow; +use tauri::{AppHandle, Manager}; +use url::Url; + +#[derive(Debug, Clone, Copy)] +pub enum LinkSource { + InitialLaunch, + SecondaryInstance, + #[cfg(any(target_os = "macos", target_os = "ios"))] + OsEvent, +} + +impl LinkSource { + fn source_app(self) -> &'static str { + match self { + LinkSource::InitialLaunch => "System", + LinkSource::SecondaryInstance => "System (handoff)", + #[cfg(any(target_os = "macos", target_os = "ios"))] + LinkSource::OsEvent => "Operating System", + } + } + + fn source_context(self) -> &'static str { + match self { + LinkSource::InitialLaunch => "App launch arguments", + LinkSource::SecondaryInstance => "Secondary instance activation", + #[cfg(any(target_os = "macos", target_os = "ios"))] + LinkSource::OsEvent => "OS open-url event", + } + } +} + +pub fn handle_cli_arguments(app: &AppHandle, args: &[String], origin: LinkSource) { + let urls = extract_urls(args); + dispatch_urls(app, urls, origin); +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub fn handle_open_urls(app: &AppHandle, urls: &[String], origin: LinkSource) { + let cleaned = urls + .iter() + .filter_map(|s| parse_candidate(s)) + .collect::>(); + dispatch_urls(app, cleaned, origin); +} + +fn dispatch_urls(app: &AppHandle, urls: Vec, origin: LinkSource) { + if urls.is_empty() { + return; + } + + let handle = app.clone(); + + tauri::async_runtime::spawn(async move { + let routing = handle.state::().clone(); + + for url in urls { + let mut link = IncomingLink { + id: String::new(), + url: url.clone(), + source_app: origin.source_app().to_string(), + source_context: Some(origin.source_context().to_string()), + contact_name: None, + preview: None, + recommended_browser: None, + arrived_at: Some(Utc::now().to_rfc3339()), + }; + + if link.source_context.as_deref() == Some("") { + link.source_context = None; + } + + if let Err(err) = routing.register_incoming(&handle, link).await { + eprintln!("failed to register incoming link '{url}': {err}"); + } + } + }); +} + +fn extract_urls(args: &[String]) -> Vec { + let mut collected = Vec::new(); + let mut after_delimiter = false; + + for raw in args { + if raw == "--" { + after_delimiter = true; + continue; + } + + if let Some(parsed) = parse_argument(raw) { + push_unique(&mut collected, parsed); + continue; + } + + if after_delimiter { + if let Some(parsed) = parse_candidate(raw) { + push_unique(&mut collected, parsed); + } + } else if let Some(idx) = raw.find('=') { + let value = &raw[idx + 1..]; + if let Some(parsed) = parse_candidate(value) { + push_unique(&mut collected, parsed); + } + } + } + + collected +} + +fn parse_argument(arg: &str) -> Option { + if let Some(candidate) = parse_candidate(arg) { + return Some(candidate); + } + + // Handle flags such as --url=https://example.com + if let Some(stripped) = arg.strip_prefix("--url=") { + return parse_candidate(stripped); + } + + if let Some(stripped) = arg.strip_prefix("url=") { + return parse_candidate(stripped); + } + + None +} + +fn parse_candidate(input: &str) -> Option { + let trimmed = input.trim_matches(|c| matches!(c, '"' | '\'')); + if trimmed.is_empty() { + return None; + } + + // Handle surrounding angle brackets that some launchers add. + let trimmed = trimmed + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + .unwrap_or(trimmed); + + let decoded = percent_decode_if_needed(trimmed); + + if let Ok(url) = Url::parse(&decoded) { + if matches!(url.scheme(), "http" | "https") { + return Some(url.to_string()); + } + } + + None +} + +fn percent_decode_if_needed(input: &str) -> Cow<'_, str> { + if input.contains("%3A") || input.contains("%2F") { + if let Ok(decoded) = percent_encoding::percent_decode_str(input).decode_utf8() { + return Cow::Owned(decoded.into_owned()); + } + } + Cow::Borrowed(input) +} + +fn push_unique(list: &mut Vec, value: String) { + if !list.iter().any(|existing| existing == &value) { + list.push(value); + } +} diff --git a/apps/desktop/src-tauri/src/platform/linux.rs b/apps/desktop/src-tauri/src/platform/linux.rs new file mode 100644 index 0000000..db44468 --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/linux.rs @@ -0,0 +1,66 @@ +use std::fs; +use std::io; +use std::path::PathBuf; +use std::process::Command; +use tauri::AppHandle; + +const DESKTOP_FILE_NAME: &str = "open-with-browser.desktop"; + +pub fn register(_app: &AppHandle) -> Result<(), String> { + let exe_path = std::env::current_exe().map_err(|e| e.to_string())?; + let exe_str = exe_path + .to_str() + .ok_or_else(|| "Executable path contains invalid UTF-8 characters".to_string())?; + + let applications_dir = resolve_applications_dir()?; + fs::create_dir_all(&applications_dir).map_err(map_fs_error)?; + + let desktop_path = applications_dir.join(DESKTOP_FILE_NAME); + let desktop_entry = format!( + "[Desktop Entry]\n\ +Version=1.0\n\ +Type=Application\n\ +Name=Open With Browser\n\ +Comment=Route http and https links through Open With Browser\n\ +Exec=\"{exe_str}\" %u\n\ +Terminal=false\n\ +Categories=Network;WebBrowser;\n\ +MimeType=x-scheme-handler/http;x-scheme-handler/https;\n\ +", + ); + + fs::write(&desktop_path, desktop_entry).map_err(map_fs_error)?; + + if which::which("xdg-mime").is_ok() { + let _ = Command::new("xdg-mime") + .args(["default", DESKTOP_FILE_NAME, "x-scheme-handler/http"]) + .status(); + let _ = Command::new("xdg-mime") + .args(["default", DESKTOP_FILE_NAME, "x-scheme-handler/https"]) + .status(); + } + + if which::which("update-desktop-database").is_ok() { + let _ = Command::new("update-desktop-database") + .arg(applications_dir) + .status(); + } + + Ok(()) +} + +fn resolve_applications_dir() -> Result { + if let Some(xdg_home) = std::env::var_os("XDG_DATA_HOME") { + if !xdg_home.is_empty() { + return Ok(PathBuf::from(xdg_home).join("applications")); + } + } + + dirs::data_dir() + .map(|p| p.join("applications")) + .ok_or_else(|| "Unable to determine XDG data directory".to_string()) +} + +fn map_fs_error(err: io::Error) -> String { + err.to_string() +} diff --git a/apps/desktop/src-tauri/src/platform/macos.rs b/apps/desktop/src-tauri/src/platform/macos.rs new file mode 100644 index 0000000..7b637a1 --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/macos.rs @@ -0,0 +1,38 @@ +use core_foundation::base::TCFType; +use core_foundation::string::CFString; +use core_foundation::url::{kCFURLPOSIXPathStyle, CFURL}; +use std::path::Path; +use tauri::AppHandle; + +#[link(name = "CoreServices", kind = "framework")] +extern "C" { + fn LSRegisterURL(in_url: core_foundation::sys::url::CFURLRef, in_update: bool) -> i32; +} + +pub fn register(app: &AppHandle) -> Result<(), String> { + if let Some(bundle_path) = app.path_resolver().app_bundle_path() { + try_register_path(&bundle_path)?; + } else if let Ok(exe_path) = std::env::current_exe() { + try_register_path(&exe_path)?; + } + + Ok(()) +} + +fn try_register_path(path: &Path) -> Result<(), String> { + let is_directory = path.is_dir(); + let path_str = path.to_str().ok_or_else(|| { + "Failed to resolve application path for LaunchServices registration".to_string() + })?; + let cf_path = CFString::new(path_str); + let url = CFURL::from_file_system_path(&cf_path, kCFURLPOSIXPathStyle, is_directory); + let status = unsafe { LSRegisterURL(url.as_concrete_TypeRef(), true) }; + + if status != 0 { + return Err(format!( + "LaunchServices registration returned status code {status}" + )); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..6dee9a3 --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/mod.rs @@ -0,0 +1,21 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +use tauri::AppHandle; +use tauri_plugin_os::OsType; + +pub fn register_as_browser(app: &AppHandle) -> Result<(), String> { + match tauri_plugin_os::type_() { + #[cfg(target_os = "windows")] + OsType::Windows => windows::register(app), + #[cfg(target_os = "macos")] + OsType::Macos => macos::register(app), + #[cfg(target_os = "linux")] + OsType::Linux => linux::register(app), + _ => Ok(()), + } +} diff --git a/apps/desktop/src-tauri/src/platform/windows.rs b/apps/desktop/src-tauri/src/platform/windows.rs new file mode 100644 index 0000000..74ba8dc --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/windows.rs @@ -0,0 +1,117 @@ +use std::io; +use tauri::AppHandle; +use winreg::enums::HKEY_CURRENT_USER; +use winreg::RegKey; + +const APP_REGISTRATION_NAME: &str = "Open With Browser"; +const CLIENT_KEY_PATH: &str = "Software\\Clients\\StartMenuInternet\\OpenWithBrowser"; +const REGISTERED_APPLICATIONS_KEY: &str = "Software\\RegisteredApplications"; +const PROTOCOL_CLASS_KEY: &str = "Software\\Classes\\OpenWithBrowserURL"; + +pub fn register(_app: &AppHandle) -> Result<(), String> { + let exe_path = std::env::current_exe().map_err(|e| e.to_string())?; + let exe_str = exe_path + .to_str() + .ok_or_else(|| "Executable path contains invalid UTF-8 characters".to_string())?; + let command_value = format!("\"{exe_str}\" \"%1\""); + let icon_value = format!("\"{exe_str}\",0"); + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + + let (client_key, _) = hkcu + .create_subkey(CLIENT_KEY_PATH) + .map_err(map_registry_error)?; + client_key + .set_value("", &APP_REGISTRATION_NAME) + .map_err(map_registry_error)?; + client_key + .set_value("LocalizedString", &APP_REGISTRATION_NAME) + .map_err(map_registry_error)?; + + let (default_icon, _) = client_key + .create_subkey("DefaultIcon") + .map_err(map_registry_error)?; + default_icon + .set_value("", &icon_value) + .map_err(map_registry_error)?; + + let (shell_command, _) = client_key + .create_subkey("shell\\open\\command") + .map_err(map_registry_error)?; + shell_command + .set_value("", &command_value) + .map_err(map_registry_error)?; + + let (capabilities, _) = client_key + .create_subkey("Capabilities") + .map_err(map_registry_error)?; + capabilities + .set_value("ApplicationName", &APP_REGISTRATION_NAME) + .map_err(map_registry_error)?; + capabilities + .set_value( + "ApplicationDescription", + &"Route http and https links through Open With Browser", + ) + .map_err(map_registry_error)?; + let (start_menu, _) = capabilities + .create_subkey("StartMenu") + .map_err(map_registry_error)?; + start_menu + .set_value("StartMenuInternet", &"OpenWithBrowser") + .map_err(map_registry_error)?; + + let (url_associations, _) = capabilities + .create_subkey("URLAssociations") + .map_err(map_registry_error)?; + url_associations + .set_value("http", &"OpenWithBrowserURL") + .map_err(map_registry_error)?; + url_associations + .set_value("https", &"OpenWithBrowserURL") + .map_err(map_registry_error)?; + + // Register application capabilities for Settings UI + let (registered_apps, _) = hkcu + .create_subkey(REGISTERED_APPLICATIONS_KEY) + .map_err(map_registry_error)?; + registered_apps + .set_value( + APP_REGISTRATION_NAME, + &format!("{CLIENT_KEY_PATH}\\Capabilities"), + ) + .map_err(map_registry_error)?; + + let (class_key, _) = hkcu + .create_subkey(PROTOCOL_CLASS_KEY) + .map_err(map_registry_error)?; + class_key + .set_value("", &"Open With Browser URL") + .map_err(map_registry_error)?; + class_key + .set_value("URL Protocol", &"") + .map_err(map_registry_error)?; + class_key + .set_value("FriendlyTypeName", &"Open With Browser URL") + .map_err(map_registry_error)?; + + let (class_icon, _) = class_key + .create_subkey("DefaultIcon") + .map_err(map_registry_error)?; + class_icon + .set_value("", &icon_value) + .map_err(map_registry_error)?; + + let (class_command, _) = class_key + .create_subkey("shell\\open\\command") + .map_err(map_registry_error)?; + class_command + .set_value("", &command_value) + .map_err(map_registry_error)?; + + Ok(()) +} + +fn map_registry_error(err: io::Error) -> String { + err.to_string() +} diff --git a/apps/desktop/src-tauri/src/preferences.rs b/apps/desktop/src-tauri/src/preferences.rs new file mode 100644 index 0000000..8132516 --- /dev/null +++ b/apps/desktop/src-tauri/src/preferences.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{async_runtime::RwLock, AppHandle, Manager}; + +const PREFERENCES_FILE: &str = "preferences.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Preferences { + #[serde(default)] + pub fallback: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FallbackPreference { + pub browser: String, + #[serde(default)] + pub profile: Option, +} + +pub struct PreferencesState { + path: PathBuf, + inner: RwLock, +} + +impl PreferencesState { + pub fn load(app: &AppHandle) -> Result { + let base_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + + if !base_dir.exists() { + fs::create_dir_all(&base_dir).map_err(|e| e.to_string())?; + } + + let path = base_dir.join(PREFERENCES_FILE); + let prefs = read_preferences(&path)?; + + Ok(Self { + path, + inner: RwLock::new(prefs), + }) + } + + pub async fn fallback(&self) -> Option { + let guard = self.inner.read().await; + guard.fallback.clone() + } + + pub async fn set_fallback(&self, fallback: Option) -> Result<(), String> { + { + let mut guard = self.inner.write().await; + guard.fallback = fallback.clone(); + } + + persist_preferences(&self.path, &self.inner).await + } +} + +fn read_preferences(path: &Path) -> Result { + if !path.exists() { + return Ok(Preferences::default()); + } + + let contents = fs::read_to_string(path).map_err(|e| e.to_string())?; + serde_json::from_str(&contents).map_err(|e| e.to_string()) +} + +async fn persist_preferences(path: &Path, data: &RwLock) -> Result<(), String> { + let snapshot = { + let guard = data.read().await; + guard.clone() + }; + + let serialized = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())?; + fs::write(path, serialized).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/routing.rs b/apps/desktop/src-tauri/src/routing.rs new file mode 100644 index 0000000..26aa640 --- /dev/null +++ b/apps/desktop/src-tauri/src/routing.rs @@ -0,0 +1,294 @@ +use crate::preferences::PreferencesState; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::async_runtime::{self, RwLock}; +use tauri::{Emitter, Manager, State}; +use tauri_plugin_opener::OpenerExt; +use tokio::time::{sleep, Duration}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserDescriptor { + pub name: String, + #[serde(default)] + pub profile: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomingLink { + pub id: String, + pub url: String, + pub source_app: String, + #[serde(default)] + pub source_context: Option, + #[serde(default)] + pub contact_name: Option, + #[serde(default)] + pub preview: Option, + #[serde(default)] + pub recommended_browser: Option, + #[serde(default)] + pub arrived_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LaunchDecision { + pub id: String, + pub url: String, + pub browser: String, + #[serde(default)] + pub profile: Option, + pub persist: PersistChoice, + #[serde(default)] + pub decided_at: Option, + pub source_app: String, + #[serde(default)] + pub contact_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PersistChoice { + JustOnce, + Always, +} + +impl Default for PersistChoice { + fn default() -> Self { + PersistChoice::JustOnce + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RoutingSnapshot { + pub active: Option, + pub history: Vec, +} + +#[derive(Clone)] +pub struct RoutingService { + inner: Arc>, +} + +#[derive(Default)] +struct RoutingState { + active: Option, + history: Vec, +} + +impl RoutingService { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(RoutingState::default())), + } + } + + pub async fn snapshot(&self) -> RoutingSnapshot { + let guard = self.inner.read().await; + RoutingSnapshot { + active: guard.active.clone(), + history: guard.history.clone(), + } + } + + pub async fn register_incoming( + &self, + app_handle: &tauri::AppHandle, + mut link: IncomingLink, + ) -> Result { + if link.id.is_empty() { + link.id = Uuid::new_v4().to_string(); + } + { + let mut guard = self.inner.write().await; + guard.active = Some(link.clone()); + } + app_handle + .emit("routing://incoming", link.clone()) + .map_err(|e| e.to_string())?; + + if let Some(prefs) = app_handle.try_state::() { + if let Some(fallback) = prefs.fallback().await { + let decision = LaunchDecision { + id: link.id.clone(), + url: link.url.clone(), + browser: fallback.browser.clone(), + profile: fallback.profile.clone(), + persist: PersistChoice::Always, + decided_at: None, + source_app: link.source_app.clone(), + contact_name: link.contact_name.clone(), + }; + + if let Err(err) = self.resolve(app_handle, decision).await { + eprintln!("automatic fallback failed: {err}"); + } + } + } + + Ok(link) + } + + pub async fn resolve( + &self, + app_handle: &tauri::AppHandle, + mut decision: LaunchDecision, + ) -> Result { + if decision.decided_at.is_none() { + decision.decided_at = Some(current_timestamp()); + } + + { + let mut guard = self.inner.write().await; + guard.active = None; + guard.history.insert(0, decision.clone()); + guard.history.truncate(50); + } + + app_handle + .emit("routing://decision", decision.clone()) + .map_err(|e| e.to_string())?; + + let app = app_handle.clone(); + let launch_event = decision.clone(); + async_runtime::spawn(async move { + let _ = app.emit( + "routing://status", + RoutingStatus { + id: launch_event.id.clone(), + browser: launch_event.browser.clone(), + status: LaunchState::Launching, + }, + ); + + let open_result = app + .opener() + .open_url(launch_event.url.clone(), None::<&str>); + + let status = match open_result { + Ok(_) => LaunchState::Launched, + Err(err) => { + let _ = app.emit( + "routing://error", + RoutingError { + id: launch_event.id.clone(), + browser: launch_event.browser.clone(), + message: err.to_string(), + }, + ); + LaunchState::Failed + } + }; + + sleep(Duration::from_millis(200)).await; + + let _ = app.emit( + "routing://status", + RoutingStatus { + id: launch_event.id.clone(), + browser: launch_event.browser.clone(), + status, + }, + ); + }); + + Ok(decision) + } + + pub async fn clear_active(&self) { + let mut guard = self.inner.write().await; + guard.active = None; + } +} + +impl Default for RoutingService { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RoutingStatus { + pub id: String, + pub browser: String, + pub status: LaunchState, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LaunchState { + Launching, + Launched, + Failed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RoutingError { + pub id: String, + pub browser: String, + pub message: String, +} + +fn current_timestamp() -> String { + use chrono::Utc; + Utc::now().to_rfc3339() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulatedLinkPayload { + #[serde(default)] + pub url: Option, + #[serde(default)] + pub source_app: Option, + #[serde(default)] + pub contact_name: Option, + #[serde(default)] + pub source_context: Option, + #[serde(default)] + pub preview: Option, +} + +pub async fn simulate_link_payload(payload: Option) -> IncomingLink { + let data = payload.unwrap_or_default(); + let url = data + .url + .unwrap_or_else(|| "https://example.com/background-launch".to_string()); + let source_app = data + .source_app + .unwrap_or_else(|| "WhatsApp Desktop".to_string()); + let contact_name = data + .contact_name + .unwrap_or_else(|| "Automation Bot".to_string()); + + IncomingLink { + id: Uuid::new_v4().to_string(), + url: url.clone(), + source_app, + source_context: data + .source_context + .or_else(|| Some("Auto-generated hand-off".to_string())), + contact_name: Some(contact_name), + preview: data + .preview + .or_else(|| Some("Shared link detected.".to_string())), + recommended_browser: Some(BrowserDescriptor { + name: "Arc".to_string(), + profile: Some("Workspace".to_string()), + }), + arrived_at: Some(current_timestamp()), + } +} + +impl Default for SimulatedLinkPayload { + fn default() -> Self { + Self { + url: None, + source_app: None, + contact_name: None, + source_context: None, + preview: None, + } + } +} + +pub type RoutingStateHandle<'a> = State<'a, RoutingService>; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4f2af35..1f764f1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,81 +1,131 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Layout from './Layout'; import Dashboard from './pages/Dashboard'; import Rules from './pages/Rules'; import Settings from './pages/Settings'; import { ActiveLink, LaunchHistoryItem } from './lib/models'; +import { + fetchRoutingSnapshot, + listenIncomingLink, + listenLaunchDecision, + listenRoutingStatus, + listenRoutingError, + resolveIncomingLink, + simulateIncomingLink, + type RoutingStatusWire, +} from './lib/routing'; +import type { BrowserProfile } from './OpenWithDialog'; type PageKey = 'dashboard' | 'rules' | 'settings'; - -const incomingLinks: ActiveLink[] = [ - { - id: 'inc-401', - url: 'https://calendar.app/google/invite/team-sync', - sourceApp: 'WhatsApp Desktop', - sourceContext: 'Marketing · Standup thread', - contactName: 'Maya Singh', - preview: 'Quick reminder: standup notes are here.', - recommendedBrowser: { name: 'Arc', profile: 'Workspace' }, - arrivedAt: new Date(Date.now() - 45 * 1000).toISOString(), - }, - { - id: 'inc-402', - url: 'https://miro.com/app/board/strategy-review', - sourceApp: 'Slack', - sourceContext: '#strategy-room', - contactName: 'Strategy Ops', - preview: 'Please open this before the call.', - recommendedBrowser: { name: 'Chrome', profile: 'Finance' }, - arrivedAt: new Date(Date.now() - 15 * 1000).toISOString(), - }, - { - id: 'inc-403', - url: 'https://docs.google.com/spreadsheets/d/Q4-benchmark', - sourceApp: 'Microsoft Teams', - sourceContext: 'Product Council', - contactName: 'Jordan', - preview: 'Latest benchmark numbers.', - recommendedBrowser: { name: 'Chrome', profile: 'Workspace' }, - arrivedAt: new Date(Date.now() - 5 * 1000).toISOString(), - }, -]; +type StatusMap = Record; +type ErrorMap = Record; export default function App() { const [currentPage, setCurrentPage] = useState('dashboard'); - const [incomingPointer, setIncomingPointer] = useState(1); - const [activeLink, setActiveLink] = useState(incomingLinks[0]); + const [activeLink, setActiveLink] = useState(null); const [history, setHistory] = useState([]); + const [statusById, setStatusById] = useState({}); + const [errorsById, setErrorsById] = useState({}); + const [ready, setReady] = useState(false); + const [initError, setInitError] = useState(null); + + useEffect(() => { + let unlisten: Array<() => void> = []; + + const setup = async () => { + try { + const snapshot = await fetchRoutingSnapshot(); + setActiveLink(snapshot.active); + setHistory(snapshot.history); + setReady(true); + + const removeIncoming = await listenIncomingLink(link => { + setActiveLink(link); + }); + const removeDecision = await listenLaunchDecision(decision => { + setHistory(prev => [ + decision, + ...prev.filter(item => item.id !== decision.id), + ]); + }); + const removeStatus = await listenRoutingStatus(status => { + setStatusById(prev => ({ + ...prev, + [status.id]: status.status, + })); + if (status.status !== 'failed') { + setErrorsById(prev => { + const next = { ...prev }; + delete next[status.id]; + return next; + }); + } + }); + + const removeError = await listenRoutingError(error => { + setErrorsById(prev => ({ + ...prev, + [error.id]: error.message, + })); + setStatusById(prev => ({ + ...prev, + [error.id]: 'failed', + })); + }); + + unlisten = [removeIncoming, removeDecision, removeStatus, removeError]; + } catch (err) { + const message = + err instanceof Error + ? `Failed to connect to routing service: ${err.message}` + : 'Failed to connect to routing service.'; + setInitError(message); + setReady(true); + } + }; + + setup(); + return () => { + unlisten.forEach(fn => fn()); + }; + }, []); const recentHistory = useMemo(() => history.slice(0, 5), [history]); - const cycleIncomingLink = () => { - const nextIndex = incomingPointer % incomingLinks.length; - setActiveLink(incomingLinks[nextIndex]); - setIncomingPointer(nextIndex + 1); + const handleSimulateLink = async () => { + await simulateIncomingLink(); }; - const handleRecordLaunch = ( - browser: string, - profile: string | null | undefined, + const handleRecordLaunch = async ( + browser: BrowserProfile, persist: 'just-once' | 'always' ) => { if (!activeLink) return; - - setHistory(prev => [ - { - id: `hist-${Date.now()}`, - url: activeLink.url, - decidedAt: new Date().toISOString(), - browser, - profile, - persist, - sourceApp: activeLink.sourceApp, - contactName: activeLink.contactName, - }, + setStatusById(prev => ({ ...prev, - ]); - - cycleIncomingLink(); + [activeLink.id]: 'launching', + })); + try { + await resolveIncomingLink({ + link: activeLink, + browser: { + name: browser.name, + profile: browser.profile ?? null, + }, + persist, + }); + setErrorsById(prev => { + const next = { ...prev }; + delete next[activeLink.id]; + return next; + }); + } catch (err) { + setStatusById(prev => ({ + ...prev, + [activeLink.id]: 'failed', + })); + throw err; + } }; const renderPage = () => { @@ -85,7 +135,9 @@ export default function App() { ); @@ -98,7 +150,9 @@ export default function App() { ); @@ -111,7 +165,19 @@ export default function App() { onNavigate={setCurrentPage} activeLink={activeLink} > - {renderPage()} + {ready ? ( + initError ? ( +
+ {initError} +
+ ) : ( + renderPage() + ) + ) : ( +
+ Initialising routing service… +
+ )} ); } diff --git a/apps/desktop/src/Layout.tsx b/apps/desktop/src/Layout.tsx index 8004480..f6cbf79 100644 --- a/apps/desktop/src/Layout.tsx +++ b/apps/desktop/src/Layout.tsx @@ -90,15 +90,17 @@ export default function Layout({ {activeLink.contactName}

-
- Recommended:{' '} - - {activeLink.recommendedBrowser.name} - {activeLink.recommendedBrowser.profile - ? ` · ${activeLink.recommendedBrowser.profile}` - : ''} - -
+ {activeLink.recommendedBrowser ? ( +
+ Recommended:{' '} + + {activeLink.recommendedBrowser.name} + {activeLink.recommendedBrowser.profile + ? ` · ${activeLink.recommendedBrowser.profile}` + : ''} + +
+ ) : null} ) : (

diff --git a/apps/desktop/src/OpenWithDialog.tsx b/apps/desktop/src/OpenWithDialog.tsx index 17921f9..703a861 100644 --- a/apps/desktop/src/OpenWithDialog.tsx +++ b/apps/desktop/src/OpenWithDialog.tsx @@ -12,7 +12,11 @@ type Props = { open?: boolean; onClose?: () => void; browsers: BrowserProfile[]; - onChoose: (browser: BrowserProfile, persist: 'just-once' | 'always') => void; + onChoose: ( + browser: BrowserProfile, + persist: 'just-once' | 'always' + ) => Promise | void; + disabled?: boolean; }; export default function OpenWithDialog({ @@ -20,6 +24,7 @@ export default function OpenWithDialog({ onClose: onCloseProp, browsers, onChoose, + disabled = false, }: Props) { const storeOpen = useUIStore((s: UIState) => s.isDialogOpen); const storeSelected = useUIStore((s: UIState) => s.selectedBrowserId); @@ -29,6 +34,7 @@ export default function OpenWithDialog({ const open = openProp ?? storeOpen; const [localSelected, setLocalSelected] = useState(null); + const [submitting, setSubmitting] = useState(false); const defaultSelection = storeSelected ?? browsers[0]?.id ?? null; const selected = localSelected ?? defaultSelection; @@ -40,12 +46,19 @@ export default function OpenWithDialog({ else closeDialog(); }; - const handleChoose = (persist: 'just-once' | 'always') => { + const handleChoose = async (persist: 'just-once' | 'always') => { const browser = browsers.find(b => b.id === selected); if (browser) { - setSelectedBrowser(browser.id); - onChoose(browser, persist); - setLocalSelected(null); + setSubmitting(true); + try { + await onChoose(browser, persist); + setSelectedBrowser(browser.id); + setLocalSelected(null); + setSubmitting(false); + } catch (error) { + setSubmitting(false); + throw error; + } } }; @@ -124,15 +137,17 @@ export default function OpenWithDialog({

diff --git a/apps/desktop/src/lib/models.ts b/apps/desktop/src/lib/models.ts index 86848fe..a208034 100644 --- a/apps/desktop/src/lib/models.ts +++ b/apps/desktop/src/lib/models.ts @@ -5,7 +5,7 @@ export type ActiveLink = { sourceContext: string; contactName: string; preview: string; - recommendedBrowser: { + recommendedBrowser?: { name: string; profile?: string | null; }; diff --git a/apps/desktop/src/lib/preferences.ts b/apps/desktop/src/lib/preferences.ts new file mode 100644 index 0000000..7dce175 --- /dev/null +++ b/apps/desktop/src/lib/preferences.ts @@ -0,0 +1,24 @@ +import { invoke } from '@tauri-apps/api/core'; + +export type FallbackPreference = { + browser: string; + profile?: string | null; +}; + +export type PreferencesSnapshot = { + fallback: FallbackPreference | null; +}; + +export async function fetchPreferences() { + return invoke('get_preferences'); +} + +export async function updateFallbackPreference(input: { + browser: string | null; + profile?: string | null; +}) { + await invoke('set_fallback_browser', { + browser: input.browser, + profile: input.profile ?? null, + }); +} diff --git a/apps/desktop/src/lib/routing.ts b/apps/desktop/src/lib/routing.ts new file mode 100644 index 0000000..adaa451 --- /dev/null +++ b/apps/desktop/src/lib/routing.ts @@ -0,0 +1,180 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { ActiveLink, LaunchHistoryItem } from './models'; + +type BrowserDescriptorWire = { + name: string; + profile?: string | null; +}; + +export type IncomingLinkWire = { + id: string; + url: string; + source_app: string; + source_context?: string | null; + contact_name?: string | null; + preview?: string | null; + recommended_browser?: BrowserDescriptorWire | null; + arrived_at?: string | null; +}; + +export type LaunchDecisionWire = { + id: string; + url: string; + browser: string; + profile?: string | null; + persist: 'just-once' | 'always'; + decided_at?: string | null; + source_app: string; + contact_name?: string | null; +}; + +export type RoutingSnapshotWire = { + active: IncomingLinkWire | null; + history: LaunchDecisionWire[]; +}; + +export type RoutingStatusWire = { + id: string; + browser: string; + status: 'launching' | 'launched' | 'failed'; +}; + +export type RoutingErrorWire = { + id: string; + browser: string; + message: string; +}; + +export function mapIncomingLink( + wire: IncomingLinkWire | null +): ActiveLink | null { + if (!wire) return null; + return { + id: wire.id, + url: wire.url, + sourceApp: wire.source_app, + sourceContext: wire.source_context ?? '', + contactName: wire.contact_name ?? '', + preview: wire.preview ?? '', + recommendedBrowser: wire.recommended_browser + ? { + name: wire.recommended_browser.name, + profile: wire.recommended_browser.profile ?? null, + } + : undefined, + arrivedAt: wire.arrived_at ?? new Date().toISOString(), + }; +} + +export function mapLaunchDecision(wire: LaunchDecisionWire): LaunchHistoryItem { + return { + id: wire.id, + url: wire.url, + decidedAt: wire.decided_at ?? new Date().toISOString(), + browser: wire.browser, + profile: wire.profile ?? null, + persist: wire.persist, + sourceApp: wire.source_app, + contactName: wire.contact_name ?? '', + }; +} + +export async function fetchRoutingSnapshot() { + const snapshot = await invoke('routing_snapshot'); + return { + active: mapIncomingLink(snapshot.active), + history: snapshot.history.map(mapLaunchDecision), + }; +} + +export async function resolveIncomingLink(input: { + link: ActiveLink; + browser: { name: string; profile?: string | null }; + persist: 'just-once' | 'always'; +}) { + await invoke('resolve_incoming_link', { + decision: { + id: input.link.id, + url: input.link.url, + browser: input.browser.name, + profile: input.browser.profile ?? null, + persist: input.persist, + source_app: input.link.sourceApp, + contact_name: input.link.contactName ?? '', + }, + }); +} + +export async function registerIncomingLink(link: IncomingLinkWire) { + await invoke('register_incoming_link', { link }); +} + +export async function simulateIncomingLink(payload?: { + url?: string; + sourceApp?: string; + contactName?: string; + sourceContext?: string; + preview?: string; +}) { + await invoke('simulate_incoming_link', { + payload: { + url: payload?.url, + source_app: payload?.sourceApp, + contact_name: payload?.contactName, + source_context: payload?.sourceContext, + preview: payload?.preview, + }, + }); +} + +export async function listenIncomingLink( + callback: (link: ActiveLink) => void +): Promise { + const unlisten = await listen( + 'routing://incoming', + event => { + const mapped = mapIncomingLink(event.payload); + if (mapped) callback(mapped); + } + ); + return unlisten; +} + +export async function listenLaunchDecision( + callback: (decision: LaunchHistoryItem) => void +): Promise { + const unlisten = await listen( + 'routing://decision', + event => { + callback(mapLaunchDecision(event.payload)); + } + ); + return unlisten; +} + +export async function listenRoutingStatus( + callback: (status: RoutingStatusWire) => void +): Promise { + const unlisten = await listen('routing://status', event => + callback(event.payload) + ); + return unlisten; +} + +export async function listenRoutingError( + callback: (error: RoutingErrorWire) => void +): Promise { + const unlisten = await listen('routing://error', event => + callback(event.payload) + ); + return unlisten; +} + +export async function fetchAvailableBrowsers() { + return invoke('get_available_browsers'); +} + +export async function fetchProfilesFor(browser: string) { + return invoke('get_profiles', { browserKind: browser }); +} diff --git a/apps/desktop/src/pages/Dashboard.tsx b/apps/desktop/src/pages/Dashboard.tsx index c42f609..2a6233b 100644 --- a/apps/desktop/src/pages/Dashboard.tsx +++ b/apps/desktop/src/pages/Dashboard.tsx @@ -5,12 +5,13 @@ import type { ActiveLink, LaunchHistoryItem } from '../lib/models'; type DashboardProps = { activeLink: ActiveLink | null; recentHistory: LaunchHistoryItem[]; - onSimulateNext: () => void; + statusById: Record; + errorsById: Record; + onSimulateNext: () => Promise; onRecordLaunch: ( - browser: string, - profile: string | null | undefined, + browser: BrowserProfile, persist: 'just-once' | 'always' - ) => void; + ) => Promise; }; const installedBrowsers: BrowserProfile[] = [ @@ -21,13 +22,30 @@ const installedBrowsers: BrowserProfile[] = [ { id: 'edge', name: 'Microsoft Edge', profile: 'Admin' }, ]; +const STATUS_CLASS: Record<'launching' | 'failed' | 'launched', string> = { + launching: 'border-amber-300/40 bg-amber-500/10 text-amber-200', + failed: 'border-red-400/40 bg-red-500/10 text-red-200', + launched: 'border-emerald-300/40 bg-emerald-500/10 text-emerald-200', +}; + +const STATUS_LABEL: Record<'launching' | 'failed' | 'launched', string> = { + launching: 'Launching', + failed: 'Failed', + launched: 'Launched', +}; + export default function Dashboard({ activeLink, recentHistory, + statusById, + errorsById, onSimulateNext, onRecordLaunch, }: DashboardProps) { const [dialogOpen, setDialogOpen] = useState(false); + const [isSimulating, setIsSimulating] = useState(false); + const [isRouting, setIsRouting] = useState(false); + const [actionError, setActionError] = useState(null); const recommendedBrowser = useMemo( () => activeLink?.recommendedBrowser, @@ -50,21 +68,78 @@ export default function Dashboard({ ]; }, [recommendedBrowser]); - const handleRecommendedLaunch = () => { + const routingStats = useMemo(() => { + const launching = Object.values(statusById).filter( + status => status === 'launching' + ).length; + const launched = Object.values(statusById).filter( + status => status === 'launched' + ).length; + const failed = Object.values(statusById).filter( + status => status === 'failed' + ).length; + return { + active: activeLink ? 1 : 0, + launching, + launched, + failed, + total: recentHistory.length, + }; + }, [activeLink, recentHistory.length, statusById]); + + const handleSimulate = async () => { + setIsSimulating(true); + setActionError(null); + try { + await onSimulateNext(); + } finally { + setIsSimulating(false); + } + }; + + const handleRecommendedLaunch = async () => { if (!recommendedBrowser) return; - onRecordLaunch( - recommendedBrowser.name, - recommendedBrowser.profile ?? null, - 'always' - ); + const matchingBrowser = + dialogBrowsers[0] ?? + ({ + id: `recommended-${recommendedBrowser.name.toLowerCase()}`, + name: recommendedBrowser.name, + profile: recommendedBrowser.profile ?? undefined, + } satisfies BrowserProfile); + + setIsRouting(true); + setActionError(null); + try { + await onRecordLaunch(matchingBrowser, 'always'); + } catch (err) { + const message = + err instanceof Error + ? err.message + : 'Unable to open the recommended browser.'; + setActionError(message); + } finally { + setIsRouting(false); + } }; - const handleManualLaunch = ( + const handleManualLaunch = async ( browser: BrowserProfile, persist: 'just-once' | 'always' ) => { - onRecordLaunch(browser.name, browser.profile ?? null, persist); - setDialogOpen(false); + setIsRouting(true); + setActionError(null); + try { + await onRecordLaunch(browser, persist); + setDialogOpen(false); + } catch (err) { + const message = + err instanceof Error + ? err.message + : 'Something went wrong while launching the link.'; + setActionError(message); + } finally { + setIsRouting(false); + } }; return ( @@ -74,7 +149,9 @@ export default function Dashboard({
Incoming link - Live + + {activeLink ? 'Live' : 'Idle'} +

{activeLink @@ -99,10 +176,11 @@ export default function Dashboard({

+ {actionError ? ( +

{actionError}

+ ) : null}
@@ -147,13 +228,15 @@ export default function Dashboard({
@@ -163,6 +246,25 @@ export default function Dashboard({ ) : null} +
+

Routing stats

+

+ Live indicators show how the hand-off service is behaving while the + desktop shell stays in the background. +

+
+ + + + + +
+
+
@@ -182,41 +284,62 @@ export default function Dashboard({ No launches recorded yet. Confirm a browser to see it appear here. ) : ( - recentHistory.map(item => ( -
  • -
    -
    -

    - {item.browser}{' '} - - {item.profile ?? 'Default profile'} + recentHistory.map(item => { + const status = statusById[item.id]; + const error = errorsById[item.id]; + const statusClass = status ? STATUS_CLASS[status] : ''; + const statusLabel = status ? STATUS_LABEL[status] : ''; + return ( +

  • +
    +
    +

    + {item.browser}{' '} + + {item.profile ?? 'Default profile'} + +

    +

    + {item.url.replace(/^https?:\/\//, '')} +

    +

    + {item.sourceApp} •{' '} + {new Date(item.decidedAt).toLocaleTimeString( + undefined, + { + hour: '2-digit', + minute: '2-digit', + } + )} +

    +
    +
    + + {item.persist === 'always' ? 'Persisted' : 'Just once'} -

    -

    - {item.url.replace(/^https?:\/\//, '')} -

    -

    - {item.sourceApp} •{' '} - {new Date(item.decidedAt).toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - })} -

    -
    -
    - - {item.persist === 'always' ? 'Persisted' : 'Just once'} - - - {item.contactName} - + + {item.contactName} + + {status ? ( + + {statusLabel} + + ) : null} + {error ? ( + + {error} + + ) : null} +
    -
  • - - )) + + ); + }) )}
    @@ -259,7 +382,27 @@ export default function Dashboard({ onClose={() => setDialogOpen(false)} browsers={dialogBrowsers} onChoose={handleManualLaunch} + disabled={isRouting} />
    ); } + +function StatCard({ + label, + value, + accent = 'text-zinc-100', +}: { + label: string; + value: number; + accent?: string; +}) { + return ( +
    +

    + {label} +

    +

    {value}

    +
    + ); +} diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index da4e0ea..a2593e6 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -1,13 +1,277 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + arch as osArch, + family as osFamily, + platform as osPlatform, + type as resolveOsType, + version as osVersion, +} from '@tauri-apps/plugin-os'; +import { fetchAvailableBrowsers, fetchProfilesFor } from '../lib/routing'; +import { + fetchPreferences, + updateFallbackPreference, + type FallbackPreference, +} from '../lib/preferences'; export default function Settings() { const [rememberChoice, setRememberChoice] = useState(true); const [showIcons, setShowIcons] = useState(false); const [debugMode, setDebugMode] = useState(false); - const [defaultBrowser, setDefaultBrowser] = useState('Arc · Workspace'); + const [availableBrowsers, setAvailableBrowsers] = useState([]); + const [availableProfiles, setAvailableProfiles] = useState([]); + const [fallbackBrowser, setFallbackBrowser] = useState(''); + const [fallbackProfile, setFallbackProfile] = useState(''); + const [savingFallback, setSavingFallback] = useState(false); + const [fallbackStatus, setFallbackStatus] = useState(null); + const [defaultStatus, setDefaultStatus] = useState< + 'checking' | 'default' | 'not-default' | 'error' + >('checking'); + const [defaultError, setDefaultError] = useState(null); + const [systemInfo, setSystemInfo] = useState<{ + platform: ReturnType; + osType: ReturnType; + family: ReturnType; + arch: ReturnType; + version: string; + } | null>(null); + + useEffect(() => { + refreshDefaultStatus(); + loadBrowsers(); + try { + setSystemInfo({ + platform: osPlatform(), + osType: resolveOsType(), + family: osFamily(), + arch: osArch(), + version: osVersion(), + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to read system metadata', err); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const defaultStatusLabel = useMemo(() => { + switch (defaultStatus) { + case 'default': + return 'Open With Browser is the default handler.'; + case 'not-default': + return 'Open With Browser is not the default yet.'; + case 'error': + return 'Unable to determine current default.'; + default: + return 'Checking current default…'; + } + }, [defaultStatus]); + + const defaultSettingsCta = useMemo(() => { + switch (systemInfo?.osType) { + case 'windows': + return 'Open Windows default-app settings'; + case 'macos': + return 'Open macOS default-app settings'; + case 'linux': + return 'Open Linux default-app settings'; + default: + return 'Open default-app settings'; + } + }, [systemInfo]); + + async function refreshDefaultStatus() { + setDefaultStatus('checking'); + setDefaultError(null); + try { + const result = await invoke('is_default_browser'); + setDefaultStatus(result ? 'default' : 'not-default'); + } catch (err) { + setDefaultStatus('error'); + setDefaultError( + err instanceof Error + ? err.message + : 'Could not read the current default browser.' + ); + } + } + + async function loadBrowsers() { + try { + const list = await fetchAvailableBrowsers(); + setAvailableBrowsers(list); + await loadPreferences(list); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to load local browsers', err); + } + } + + async function loadPreferences(browserList?: string[]) { + try { + const snapshot = await fetchPreferences(); + if (snapshot.fallback) { + applyFallback(snapshot.fallback, browserList); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to load preferences', err); + } + } + + function applyFallback( + fallback: FallbackPreference, + browsers: string[] = availableBrowsers + ) { + setFallbackBrowser(fallback.browser); + setFallbackProfile(fallback.profile ?? ''); + + if (!browsers.includes(fallback.browser)) { + setAvailableBrowsers(prev => + prev.includes(fallback.browser) ? prev : [fallback.browser, ...prev] + ); + } + + void loadProfilesForBrowser(fallback.browser, fallback.profile ?? ''); + } + + async function loadProfilesForBrowser(browser: string, profile?: string) { + if (!browser) { + setAvailableProfiles([]); + setFallbackProfile(''); + return; + } + + try { + const profiles = await fetchProfilesFor(browser); + setAvailableProfiles(profiles); + + if (profile && profiles.includes(profile)) { + setFallbackProfile(profile); + } else if (profiles.length === 0) { + setFallbackProfile(''); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`Unable to load profiles for ${browser}`, err); + setAvailableProfiles([]); + } + } + + async function handleOpenDefaultSettings() { + try { + await invoke('register_browser_handlers'); + await invoke('open_default_browser_settings'); + } catch (err) { + setDefaultError( + err instanceof Error + ? err.message + : 'System rejected the settings request.' + ); + } + } + + async function handleFallbackBrowserChange(value: string) { + setFallbackBrowser(value); + setFallbackProfile(''); + await loadProfilesForBrowser(value); + setFallbackStatus(null); + } + + function handleFallbackProfileChange(value: string) { + setFallbackProfile(value); + setFallbackStatus(null); + } + + async function handleSaveFallback() { + setSavingFallback(true); + setFallbackStatus(null); + try { + await updateFallbackPreference({ + browser: fallbackBrowser || null, + profile: fallbackProfile || null, + }); + setFallbackStatus( + fallbackBrowser + ? `Links without a rule will open in ${fallbackBrowser}${fallbackProfile ? ` · ${fallbackProfile}` : ''}.` + : 'Fallback routing cleared. You will be prompted for each link.' + ); + } catch (err) { + setFallbackStatus( + err instanceof Error + ? err.message + : 'Failed to update fallback preference.' + ); + } finally { + setSavingFallback(false); + } + } return (
    +
    +
    +
    +

    + System setup +

    +

    + Make Open With Browser the default handler so links intercepted + from chat apps land here first. The operating system will show a + confirmation dialog before the change takes effect. +

    +

    + {defaultStatusLabel} +

    + {defaultError ? ( +

    {defaultError}

    + ) : null} +
    +
    + + + {availableBrowsers.length > 0 ? ( +
    +

    Detected browsers

    +

    {availableBrowsers.join(', ')}

    +
    + ) : null} + {systemInfo ? ( +
    +

    System

    +

    + {[ + systemInfo.osType, + systemInfo.platform, + systemInfo.arch, + systemInfo.version, + ].join(' · ')} +

    +

    Family: {systemInfo.family}

    +
    + ) : null} +
    +
    +
    +
    @@ -67,24 +331,50 @@ export default function Settings() {

    Browser orchestration

    -
    - Requests without a matching rule will default to{' '} - - {defaultBrowser} - {' '} - unless a manual choice overrides it. + + {fallbackBrowser ? ( + + ) : null} + +
    + + {fallbackStatus ? ( + {fallbackStatus} + ) : null}
    From 0843c3b9cb185fd15b75d2cf9012e15adbb18a56 Mon Sep 17 00:00:00 2001 From: theg Date: Tue, 21 Oct 2025 00:41:36 +0530 Subject: [PATCH 2/7] feat: implement robust browser routing --- .../src-tauri/capabilities/default.json | 3 +- apps/desktop/src-tauri/src/browser_details.rs | 73 +- apps/desktop/src-tauri/src/commands.rs | 34 +- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/preferences.rs | 76 +- apps/desktop/src-tauri/src/routing.rs | 414 ++++++++- apps/desktop/src/App.tsx | 237 ++++- apps/desktop/src/Layout.tsx | 4 +- apps/desktop/src/OpenWithDialog.tsx | 35 +- apps/desktop/src/lib/models.ts | 14 +- apps/desktop/src/lib/preferences.ts | 9 +- apps/desktop/src/lib/routing.ts | 39 +- apps/desktop/src/lib/storage.ts | 124 +++ apps/desktop/src/pages/Dashboard.tsx | 97 +- apps/desktop/src/pages/Rules.tsx | 854 ++++++++++++++++-- apps/desktop/src/pages/Settings.tsx | 102 ++- 16 files changed, 1843 insertions(+), 273 deletions(-) create mode 100644 apps/desktop/src/lib/storage.ts diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 3532012..7b16642 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "opener:default", - "os:default" + "os:default", + "store:default" ] } diff --git a/apps/desktop/src-tauri/src/browser_details.rs b/apps/desktop/src-tauri/src/browser_details.rs index 4e9cb71..b82c27a 100644 --- a/apps/desktop/src-tauri/src/browser_details.rs +++ b/apps/desktop/src-tauri/src/browser_details.rs @@ -1,5 +1,6 @@ use crowser::browser; -use dirs::{config_dir, data_local_dir}; +use dirs::{config_dir, data_local_dir, home_dir}; +use serde::Serialize; use serde_json::Value; use std::{ fs, @@ -16,6 +17,12 @@ pub enum Browsers { Safari, } +#[derive(Debug, Clone, Serialize)] +pub struct ProfileDescriptor { + pub display_name: String, + pub directory: String, +} + pub fn get_browsers() -> Vec { let browser_vector = browser::get_all_existing_browsers(); let browser_names: Vec = browser_vector.iter().map(|s| s.name.to_owned()).collect(); @@ -38,7 +45,7 @@ pub fn parse_browser_kind>(value: S) -> Option { pub fn get_chrome_based_profiles( os_paths: [&str; 3], -) -> Result, Box> { +) -> Result, Box> { let os_type = tauri_plugin_os::type_(); let base_dir = match os_type { OsType::Windows | OsType::Macos => data_local_dir(), @@ -56,7 +63,7 @@ pub fn get_chrome_based_profiles( path.push(suffix); if path.exists() { - let mut file = fs::File::open(path)?; + let mut file = fs::File::open(&path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; @@ -73,24 +80,55 @@ pub fn get_chrome_based_profiles( )) as Box })?; - let mut profile_names: Vec = Vec::new(); + let mut profiles: Vec = Vec::new(); for (_profile_key, profile_data) in info_cache.iter() { - if let Some(name_value) = profile_data.get("gaia_name") { - if let Some(name_str) = name_value.as_str() { - profile_names.push(name_str.to_owned()); - } + let directory = profile_data + .get("profile_dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Default".to_string()); + + let display = profile_data + .get("gaia_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()) + .or_else(|| { + profile_data + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) + }) + .unwrap_or_else(|| directory.clone()); + + if !profiles.iter().any(|p| p.directory == directory) { + profiles.push(ProfileDescriptor { + display_name: display, + directory, + }); } } - return Ok(profile_names); + if !profiles.iter().any(|p| p.directory == "Default") { + profiles.push(ProfileDescriptor { + display_name: "Default".to_string(), + directory: "Default".to_string(), + }); + } + + profiles.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + return Ok(profiles); } } Ok(Vec::new()) } -pub fn get_chrome_profiles(kind: Browsers) -> Result, Box> { +pub fn get_chrome_profiles( + kind: Browsers, +) -> Result, Box> { let paths: [&str; 3] = match kind { Browsers::Chrome => [ "Google\\Chrome\\User Data\\Local State", @@ -113,11 +151,11 @@ pub fn get_chrome_profiles(kind: Browsers) -> Result, Box Result, Box> { +pub fn get_firefox_profiles() -> Result, Box> { let os_type = tauri_plugin_os::type_(); let base_dir = match os_type { OsType::Windows | OsType::Macos => data_local_dir(), - OsType::Linux => dirs::home_dir(), + OsType::Linux => home_dir(), _ => None, }; @@ -132,16 +170,21 @@ pub fn get_firefox_profiles() -> Result, Box> if path.exists() { match fs::read_dir(path) { Ok(entries) => { - let profile_names: Vec = entries + let mut profiles: Vec = entries .filter_map(Result::ok) .filter_map(|entry| match entry.file_type() { Ok(file_type) if file_type.is_dir() => { - Some(entry.file_name().to_string_lossy().into_owned()) + let dir = entry.file_name().to_string_lossy().into_owned(); + Some(ProfileDescriptor { + display_name: dir.clone(), + directory: dir, + }) } _ => None, }) .collect(); - return Ok(profile_names); + profiles.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + return Ok(profiles); } Err(e) => { eprintln!("Error reading directory: {}", e); diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 6b10f85..86598d5 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,14 +1,15 @@ use crate::{ browser_details::{ get_browsers, get_chrome_profiles, get_firefox_profiles, parse_browser_kind, Browsers, + ProfileDescriptor, }, platform, - preferences::{FallbackPreference, PreferencesState}, + preferences::{FallbackPreference, PreferencesState, ProfilePreference}, routing::{ simulate_link_payload, IncomingLink, LaunchDecision, RoutingSnapshot, RoutingStateHandle, }, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::process::Command; use tauri::{AppHandle, Manager}; use tauri_plugin_os::OsType; @@ -80,7 +81,7 @@ pub fn get_available_browsers() -> Vec { } #[tauri::command] -pub fn get_profiles(browser_kind: String) -> Result, String> { +pub fn get_profiles(browser_kind: String) -> Result, String> { let kind = parse_browser_kind(browser_kind.as_str()) .ok_or_else(|| format!("Unsupported browser: {browser_kind}"))?; @@ -128,6 +129,9 @@ pub async fn simulate_incoming_link( #[tauri::command] pub async fn is_default_browser(app_handle: AppHandle) -> Result { + #[cfg(target_os = "windows")] + let _ = &app_handle; + match tauri_plugin_os::type_() { #[cfg(target_os = "windows")] OsType::Windows => { @@ -205,6 +209,12 @@ pub struct PreferencesSnapshot { pub fallback: Option, } +#[derive(Debug, Deserialize)] +pub struct ProfileSelectionInput { + pub label: Option, + pub directory: Option, +} + #[tauri::command] pub async fn get_preferences(app_handle: AppHandle) -> Result { if let Some(state) = app_handle.try_state::() { @@ -219,7 +229,7 @@ pub async fn get_preferences(app_handle: AppHandle) -> Result, - profile: Option, + profile: Option, ) -> Result<(), String> { let state = app_handle .try_state::() @@ -228,12 +238,18 @@ pub async fn set_fallback_browser( match browser { Some(name) if !name.is_empty() => { state - .set_fallback(Some(FallbackPreference { - browser: name, - profile: profile.filter(|p| !p.is_empty()), - })) + .set_fallback( + &app_handle, + Some(FallbackPreference { + browser: name, + profile: profile.map(|p| ProfilePreference { + label: p.label, + directory: p.directory, + }), + }), + ) .await } - _ => state.set_fallback(None).await, + _ => state.set_fallback(&app_handle, None).await, } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5b4624b..d3798e2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ fn greet(name: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() + .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { diff --git a/apps/desktop/src-tauri/src/preferences.rs b/apps/desktop/src-tauri/src/preferences.rs index 8132516..258e9ce 100644 --- a/apps/desktop/src-tauri/src/preferences.rs +++ b/apps/desktop/src-tauri/src/preferences.rs @@ -1,9 +1,11 @@ use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use tauri::{async_runtime::RwLock, AppHandle, Manager}; +use serde_json::Value; +use std::io::ErrorKind; +use tauri::{async_runtime::RwLock, AppHandle}; +use tauri_plugin_store::{Error as StoreError, StoreExt}; -const PREFERENCES_FILE: &str = "preferences.json"; +const PREFERENCES_STORE: &str = "preferences.json"; +const PREFERENCES_KEY: &str = "preferences"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Preferences { @@ -15,27 +17,25 @@ pub struct Preferences { pub struct FallbackPreference { pub browser: String, #[serde(default)] - pub profile: Option, + pub profile: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfilePreference { + #[serde(default)] + pub label: Option, + #[serde(default)] + pub directory: Option, } pub struct PreferencesState { - path: PathBuf, inner: RwLock, } impl PreferencesState { pub fn load(app: &AppHandle) -> Result { - let base_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - - if !base_dir.exists() { - fs::create_dir_all(&base_dir).map_err(|e| e.to_string())?; - } - - let path = base_dir.join(PREFERENCES_FILE); - let prefs = read_preferences(&path)?; - + let prefs = load_preferences(app)?; Ok(Self { - path, inner: RwLock::new(prefs), }) } @@ -45,31 +45,55 @@ impl PreferencesState { guard.fallback.clone() } - pub async fn set_fallback(&self, fallback: Option) -> Result<(), String> { + pub async fn set_fallback( + &self, + app: &AppHandle, + fallback: Option, + ) -> Result<(), String> { { let mut guard = self.inner.write().await; guard.fallback = fallback.clone(); } - persist_preferences(&self.path, &self.inner).await + persist_preferences(app, &self.inner).await } } -fn read_preferences(path: &Path) -> Result { - if !path.exists() { - return Ok(Preferences::default()); +fn load_preferences(app: &AppHandle) -> Result { + let store = app + .store(PREFERENCES_STORE) + .map_err(|err| err.to_string())?; + + if let Err(err) = store.reload() { + match err { + StoreError::Io(ref io_err) if io_err.kind() == ErrorKind::NotFound => {} + other => return Err(other.to_string()), + } } - let contents = fs::read_to_string(path).map_err(|e| e.to_string())?; - serde_json::from_str(&contents).map_err(|e| e.to_string()) + if let Some(data) = store.get(PREFERENCES_KEY) { + serde_json::from_value::(data).map_err(|err| err.to_string()) + } else { + let prefs = Preferences::default(); + let value = serde_json::to_value(&prefs).map_err(|err| err.to_string())?; + store.set(PREFERENCES_KEY.to_string(), value); + store.save().map_err(|err| err.to_string())?; + Ok(prefs) + } } -async fn persist_preferences(path: &Path, data: &RwLock) -> Result<(), String> { +async fn persist_preferences(app: &AppHandle, data: &RwLock) -> Result<(), String> { let snapshot = { let guard = data.read().await; guard.clone() }; - let serialized = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())?; - fs::write(path, serialized).map_err(|e| e.to_string()) + let store = app + .store(PREFERENCES_STORE) + .map_err(|err| err.to_string())?; + + let value: Value = serde_json::to_value(&snapshot).map_err(|err| err.to_string())?; + + store.set(PREFERENCES_KEY.to_string(), value); + store.save().map_err(|err| err.to_string()) } diff --git a/apps/desktop/src-tauri/src/routing.rs b/apps/desktop/src-tauri/src/routing.rs index 26aa640..9479804 100644 --- a/apps/desktop/src-tauri/src/routing.rs +++ b/apps/desktop/src-tauri/src/routing.rs @@ -1,17 +1,30 @@ use crate::preferences::PreferencesState; +use chrono::Utc; +use crowser::browser::{get_all_existing_browsers, get_browser_path}; use serde::{Deserialize, Serialize}; +use std::env; +use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use tauri::async_runtime::{self, RwLock}; use tauri::{Emitter, Manager, State}; -use tauri_plugin_opener::OpenerExt; use tokio::time::{sleep, Duration}; +use url::Url; use uuid::Uuid; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use dirs::{config_dir, home_dir}; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserDescriptor { pub name: String, #[serde(default)] - pub profile: Option, + pub profile_label: Option, + #[serde(default)] + pub profile_directory: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,7 +50,9 @@ pub struct LaunchDecision { pub url: String, pub browser: String, #[serde(default)] - pub profile: Option, + pub profile_label: Option, + #[serde(default)] + pub profile_directory: Option, pub persist: PersistChoice, #[serde(default)] pub decided_at: Option, @@ -99,6 +114,14 @@ impl RoutingService { if link.id.is_empty() { link.id = Uuid::new_v4().to_string(); } + link.url = normalize_url(&link.url); + append_log( + app_handle, + &format!( + "Incoming link registered: id={} url={} source_app={}", + link.id, link.url, link.source_app + ), + ); { let mut guard = self.inner.write().await; guard.active = Some(link.clone()); @@ -109,11 +132,22 @@ impl RoutingService { if let Some(prefs) = app_handle.try_state::() { if let Some(fallback) = prefs.fallback().await { + let profile_label = fallback + .profile + .as_ref() + .and_then(|p| p.label.clone()) + .filter(|s| !s.is_empty()); + let profile_directory = fallback + .profile + .as_ref() + .and_then(|p| p.directory.clone()) + .filter(|s| !s.is_empty()); let decision = LaunchDecision { id: link.id.clone(), url: link.url.clone(), browser: fallback.browser.clone(), - profile: fallback.profile.clone(), + profile_label, + profile_directory, persist: PersistChoice::Always, decided_at: None, source_app: link.source_app.clone(), @@ -122,6 +156,10 @@ impl RoutingService { if let Err(err) = self.resolve(app_handle, decision).await { eprintln!("automatic fallback failed: {err}"); + append_log( + app_handle, + &format!("Automatic fallback failed for link id={}: {}", link.id, err), + ); } } } @@ -134,6 +172,19 @@ impl RoutingService { app_handle: &tauri::AppHandle, mut decision: LaunchDecision, ) -> Result { + decision.url = normalize_url(&decision.url); + + if decision.url.is_empty() { + append_log( + app_handle, + &format!( + "Launch decision rejected: empty or invalid URL for id={}", + decision.id + ), + ); + return Err("Link does not contain a valid URL to open.".to_string()); + } + if decision.decided_at.is_none() { decision.decided_at = Some(current_timestamp()); } @@ -161,19 +212,111 @@ impl RoutingService { }, ); - let open_result = app - .opener() - .open_url(launch_event.url.clone(), None::<&str>); + let Some(browser_path) = resolve_browser_path(&launch_event.browser) else { + let message = format!( + "No executable found for browser '{}' while handling id={}.", + launch_event.browser, launch_event.id + ); + append_log(&app, &message); + let _ = app.emit( + "routing://error", + RoutingError { + id: launch_event.id.clone(), + browser: launch_event.browser.clone(), + message, + }, + ); + let _ = app.emit( + "routing://status", + RoutingStatus { + id: launch_event.id.clone(), + browser: launch_event.browser.clone(), + status: LaunchState::Failed, + }, + ); + return; + }; + + let path_display = browser_path.display().to_string(); + let profile_info = match ( + &launch_event.profile_label, + &launch_event.profile_directory, + ) { + (Some(label), Some(directory)) => { + format!(" profile_label={label} profile_directory={directory}") + } + (Some(label), None) => format!(" profile_label={label}"), + (None, Some(directory)) => format!(" profile_directory={directory}"), + _ => String::new(), + }; + append_log( + &app, + &format!( + "Launching browser for id={} url={} via {} ({}){}", + launch_event.id, + launch_event.url, + launch_event.browser, + path_display, + profile_info + ), + ); - let status = match open_result { - Ok(_) => LaunchState::Launched, - Err(err) => { + let browser_name = launch_event.browser.clone(); + let url_to_open = launch_event.url.clone(); + let profile_directory = launch_event.profile_directory.clone(); + let app_for_errors = app.clone(); + + let launch_result = async_runtime::spawn_blocking(move || { + launch_with_browser( + browser_path, + &browser_name, + &url_to_open, + profile_directory, + ) + }) + .await; + + let status = match launch_result { + Ok(Ok(())) => { + append_log( + &app, + &format!( + "Launch succeeded for id={} url={}", + launch_event.id, launch_event.url + ), + ); + LaunchState::Launched + } + Ok(Err(err)) => { + append_log( + &app, + &format!( + "Launch failed for id={} url={}: {}", + launch_event.id, launch_event.url, err + ), + ); let _ = app.emit( "routing://error", RoutingError { id: launch_event.id.clone(), browser: launch_event.browser.clone(), - message: err.to_string(), + message: err, + }, + ); + LaunchState::Failed + } + Err(join_err) => { + let message = format!( + "Launch task panicked for id={} url={}: {}", + launch_event.id, launch_event.url, join_err + ); + append_log(&app, &message); + let _ = app_for_errors.emit( + "routing://error", + RoutingError { + id: launch_event.id.clone(), + browser: launch_event.browser.clone(), + message, }, ); LaunchState::Failed @@ -201,12 +344,258 @@ impl RoutingService { } } +fn normalize_url(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return String::new(); + } + + if Url::parse(trimmed).is_ok() { + return trimmed.to_string(); + } + + let candidate = format!( + "https://{}", + trimmed + .trim_start_matches("https://") + .trim_start_matches("http://") + ); + + if Url::parse(&candidate).is_ok() { + candidate + } else { + trimmed.to_string() + } +} + impl Default for RoutingService { fn default() -> Self { Self::new() } } +fn resolve_browser_path(name: &str) -> Option { + let needle = normalize_browser_key(name); + for browser in get_all_existing_browsers() { + if normalize_browser_key(browser.name) == needle { + if let Some(path) = get_browser_path(&browser) { + return Some(path); + } + } + } + None +} + +fn normalize_browser_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +fn launch_with_browser( + path: PathBuf, + browser_name: &str, + url: &str, + profile_directory: Option, +) -> Result<(), String> { + let mut command = Command::new(&path); + + if let Some(profile_dir) = profile_directory.as_deref() { + add_profile_args(&mut command, browser_name, profile_dir); + } + + if let Some(user_data_dir) = browser_user_data_dir(browser_name) { + command.arg(format!("--user-data-dir={}", user_data_dir.display())); + } + + command.arg(url); + + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.creation_flags(CREATE_NO_WINDOW); + } + + command.spawn().map(|_| ()).map_err(|err| err.to_string()) +} + +fn add_profile_args(command: &mut Command, browser_name: &str, profile: &str) { + let trimmed = profile.trim(); + if trimmed.is_empty() { + return; + } + + let key = normalize_browser_key(browser_name); + match key.as_str() { + k if matches!( + k, + "chrome" + | "chromebeta" + | "chromedev" + | "chromecanary" + | "chromium" + | "edge" + | "edgebeta" + | "edgedev" + | "edgecanary" + | "brave" + | "vivaldi" + | "thorium" + ) => { + command.arg(format!("--profile-directory={trimmed}")); + } + k if matches!(k, "firefox" | "firefoxbeta" | "waterfox") => { + command.args(["-P", trimmed]); + } + _ => {} + } +} + +fn browser_user_data_dir(browser_name: &str) -> Option { + let key = normalize_browser_key(browser_name); + + #[cfg(target_os = "windows")] + { + let base = env::var_os("LOCALAPPDATA")?; + let mut path = PathBuf::from(base); + match key.as_str() { + "chrome" => { + path.push("Google"); + path.push("Chrome"); + path.push("User Data"); + Some(path) + } + "chromebeta" => { + path.push("Google"); + path.push("Chrome Beta"); + path.push("User Data"); + Some(path) + } + "chromedev" => { + path.push("Google"); + path.push("Chrome Dev"); + path.push("User Data"); + Some(path) + } + "chromecanary" => { + path.push("Google"); + path.push("Chrome SxS"); + path.push("User Data"); + Some(path) + } + "chromium" => { + path.push("Chromium"); + path.push("User Data"); + Some(path) + } + "edge" => { + path.push("Microsoft"); + path.push("Edge"); + path.push("User Data"); + Some(path) + } + "edgebeta" => { + path.push("Microsoft"); + path.push("Edge Beta"); + path.push("User Data"); + Some(path) + } + "edgedev" => { + path.push("Microsoft"); + path.push("Edge Dev"); + path.push("User Data"); + Some(path) + } + "edgecanary" => { + path.push("Microsoft"); + path.push("Edge SxS"); + path.push("User Data"); + Some(path) + } + "brave" => { + path.push("BraveSoftware"); + path.push("Brave-Browser"); + path.push("User Data"); + Some(path) + } + "vivaldi" => { + path.push("Vivaldi"); + path.push("User Data"); + Some(path) + } + "thorium" => { + path.push("Thorium"); + path.push("User Data"); + Some(path) + } + _ => None, + } + } + + #[cfg(target_os = "linux")] + { + let mut path = config_dir()?; + match key.as_str() { + "chrome" | "chromium" => { + path.push("google-chrome"); + Some(path) + } + "brave" => { + path.push("BraveSoftware"); + path.push("Brave-Browser"); + Some(path) + } + "vivaldi" => { + path.push("vivaldi"); + Some(path) + } + _ => None, + } + } + + #[cfg(target_os = "macos")] + { + let mut path = home_dir()?; + match key.as_str() { + "chrome" => { + path.push("Library"); + path.push("Application Support"); + path.push("Google"); + path.push("Chrome"); + Some(path) + } + "brave" => { + path.push("Library"); + path.push("Application Support"); + path.push("BraveSoftware"); + path.push("Brave-Browser"); + Some(path) + } + "edge" => { + path.push("Library"); + path.push("Application Support"); + path.push("Microsoft Edge"); + Some(path) + } + _ => None, + } + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + let _ = browser_name; + None + } +} + +fn append_log(_app: &tauri::AppHandle, message: &str) { + let timestamp = Utc::now().to_rfc3339(); + let entry = format!("[{timestamp}] {message}\n"); + print!("{entry}"); +} + #[derive(Debug, Clone, Serialize)] pub struct RoutingStatus { pub id: String, @@ -273,7 +662,8 @@ pub async fn simulate_link_payload(payload: Option) -> Inc .or_else(|| Some("Shared link detected.".to_string())), recommended_browser: Some(BrowserDescriptor { name: "Arc".to_string(), - profile: Some("Workspace".to_string()), + profile_label: Some("Workspace".to_string()), + profile_directory: None, }), arrived_at: Some(current_timestamp()), } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 1f764f1..8349ed7 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,20 +1,29 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import Layout from './Layout'; import Dashboard from './pages/Dashboard'; import Rules from './pages/Rules'; import Settings from './pages/Settings'; import { ActiveLink, LaunchHistoryItem } from './lib/models'; import { + fetchAvailableBrowsers, fetchRoutingSnapshot, listenIncomingLink, listenLaunchDecision, listenRoutingStatus, listenRoutingError, resolveIncomingLink, - simulateIncomingLink, + fetchProfilesFor, type RoutingStatusWire, } from './lib/routing'; import type { BrowserProfile } from './OpenWithDialog'; +import { + DEFAULT_UI_SETTINGS, + loadUiSettings, + persistLastSelectedBrowser, + setUiSetting, + type UiSettings, +} from './lib/storage'; +import { useUIStore } from './store/uiStore'; type PageKey = 'dashboard' | 'rules' | 'settings'; type StatusMap = Record; @@ -28,6 +37,42 @@ export default function App() { const [errorsById, setErrorsById] = useState({}); const [ready, setReady] = useState(false); const [initError, setInitError] = useState(null); + const [browserCatalog, setBrowserCatalog] = useState([]); + const [uiSettings, setUiSettings] = useState(DEFAULT_UI_SETTINGS); + const [settingsReady, setSettingsReady] = useState(false); + + const setDialogSelectedBrowser = useUIStore( + state => state.setSelectedBrowser + ); + const resetDialogSelection = useUIStore(state => state.resetSelection); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const settings = await loadUiSettings(); + if (cancelled) return; + setUiSettings(settings); + setSettingsReady(true); + if (settings.lastSelectedBrowserId) { + setDialogSelectedBrowser(settings.lastSelectedBrowserId); + } else { + resetDialogSelection(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to load UI settings', err); + if (!cancelled) { + setSettingsReady(true); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [resetDialogSelection, setDialogSelectedBrowser]); useEffect(() => { let unlisten: Array<() => void> = []; @@ -90,11 +135,158 @@ export default function App() { }; }, []); + useEffect(() => { + let cancelled = false; + + const normalize = (value: string | null | undefined) => + (value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + const browserId = (name: string, directory: string | null | undefined) => { + const base = normalize(name); + const suffix = directory ? normalize(directory) : 'default'; + return `${base}__${suffix}`; + }; + + (async () => { + try { + const names = await fetchAvailableBrowsers(); + const catalog = new Map(); + + for (const name of names) { + try { + const profiles = await fetchProfilesFor(name); + if (profiles && profiles.length > 0) { + profiles.forEach(profile => { + const id = browserId(name, profile.directory); + catalog.set(id, { + id, + name, + profileLabel: profile.display_name, + profileDirectory: profile.directory, + }); + }); + } + + const defaultId = browserId(name, null); + if (!catalog.has(defaultId)) { + catalog.set(defaultId, { + id: defaultId, + name, + profileLabel: null, + profileDirectory: null, + }); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`Unable to load profiles for ${name}`, err); + const defaultId = browserId(name, null); + if (!catalog.has(defaultId)) { + catalog.set(defaultId, { + id: defaultId, + name, + profileLabel: null, + profileDirectory: null, + }); + } + } + } + + if (!cancelled) { + setBrowserCatalog(Array.from(catalog.values())); + } + } catch (err) { + if (!cancelled) { + // eslint-disable-next-line no-console + console.warn('Unable to load available browsers', err); + setBrowserCatalog([]); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!settingsReady) return; + + if (uiSettings.lastSelectedBrowserId) { + const exists = browserCatalog.some( + browser => browser.id === uiSettings.lastSelectedBrowserId + ); + + if (!exists) { + (async () => { + await persistLastSelectedBrowser(null); + setUiSettings(prev => ({ + ...prev, + lastSelectedBrowserId: null, + })); + resetDialogSelection(); + })().catch(err => { + // eslint-disable-next-line no-console + console.warn('Unable to reset last browser selection', err); + }); + } + } + }, [browserCatalog, resetDialogSelection, settingsReady, uiSettings.lastSelectedBrowserId]); + const recentHistory = useMemo(() => history.slice(0, 5), [history]); - const handleSimulateLink = async () => { - await simulateIncomingLink(); - }; + const handleRememberChoiceChange = useCallback( + async (value: boolean) => { + try { + await setUiSetting('rememberChoice', value); + setUiSettings(prev => ({ + ...prev, + rememberChoice: value, + })); + if (!value) { + await persistLastSelectedBrowser(null); + setUiSettings(prev => ({ + ...prev, + lastSelectedBrowserId: null, + })); + resetDialogSelection(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to update remember choice setting', err); + } + }, + [resetDialogSelection] + ); + + const handleShowIconsChange = useCallback(async (value: boolean) => { + try { + await setUiSetting('showIcons', value); + setUiSettings(prev => ({ + ...prev, + showIcons: value, + })); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to update show icons setting', err); + } + }, []); + + const handleDebugModeChange = useCallback(async (value: boolean) => { + try { + await setUiSetting('debugMode', value); + setUiSettings(prev => ({ + ...prev, + debugMode: value, + })); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to update debug mode setting', err); + } + }, []); const handleRecordLaunch = async ( browser: BrowserProfile, @@ -110,10 +302,24 @@ export default function App() { link: activeLink, browser: { name: browser.name, - profile: browser.profile ?? null, + profileLabel: browser.profileLabel ?? null, + profileDirectory: browser.profileDirectory ?? null, }, persist, }); + if (uiSettings.rememberChoice) { + try { + await persistLastSelectedBrowser(browser.id); + setUiSettings(prev => ({ + ...prev, + lastSelectedBrowserId: browser.id, + })); + setDialogSelectedBrowser(browser.id); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to persist last browser selection', err); + } + } setErrorsById(prev => { const next = { ...prev }; delete next[activeLink.id]; @@ -134,26 +340,37 @@ export default function App() { return ( ); case 'rules': - return ; + return ; case 'settings': - return ; + return ( + + ); default: return ( ); } diff --git a/apps/desktop/src/Layout.tsx b/apps/desktop/src/Layout.tsx index f6cbf79..2d0540d 100644 --- a/apps/desktop/src/Layout.tsx +++ b/apps/desktop/src/Layout.tsx @@ -95,8 +95,8 @@ export default function Layout({ Recommended:{' '} {activeLink.recommendedBrowser.name} - {activeLink.recommendedBrowser.profile - ? ` · ${activeLink.recommendedBrowser.profile}` + {activeLink.recommendedBrowser.profileLabel + ? ` · ${activeLink.recommendedBrowser.profileLabel}` : ''}
    diff --git a/apps/desktop/src/OpenWithDialog.tsx b/apps/desktop/src/OpenWithDialog.tsx index 703a861..f1f55ac 100644 --- a/apps/desktop/src/OpenWithDialog.tsx +++ b/apps/desktop/src/OpenWithDialog.tsx @@ -5,7 +5,8 @@ export type BrowserProfile = { id: string; name: string; icon?: string; - profile?: string | null; + profileLabel?: string | null; + profileDirectory?: string | null; }; type Props = { @@ -17,6 +18,7 @@ type Props = { persist: 'just-once' | 'always' ) => Promise | void; disabled?: boolean; + showIcons?: boolean; }; export default function OpenWithDialog({ @@ -25,6 +27,7 @@ export default function OpenWithDialog({ browsers, onChoose, disabled = false, + showIcons = true, }: Props) { const storeOpen = useUIStore((s: UIState) => s.isDialogOpen); const storeSelected = useUIStore((s: UIState) => s.selectedBrowserId); @@ -105,21 +108,25 @@ export default function OpenWithDialog({ onChange={() => setLocalSelected(browser.id)} className='sr-only' /> -
    - {browser.icon ? ( - - ) : ( - fallbackGlyph - )} -
    -
    + {showIcons ? ( +
    + {browser.icon ? ( + + ) : ( + fallbackGlyph + )} +
    + ) : null} +
    {browser.name} - {browser.profile ?? 'Default profile'} + {browser.profileLabel ?? 'Default profile'}
    ('resolve_incoming_link', { @@ -98,7 +108,8 @@ export async function resolveIncomingLink(input: { id: input.link.id, url: input.link.url, browser: input.browser.name, - profile: input.browser.profile ?? null, + profile_label: input.browser.profileLabel ?? null, + profile_directory: input.browser.profileDirectory ?? null, persist: input.persist, source_app: input.link.sourceApp, contact_name: input.link.contactName ?? '', @@ -176,5 +187,7 @@ export async function fetchAvailableBrowsers() { } export async function fetchProfilesFor(browser: string) { - return invoke('get_profiles', { browserKind: browser }); + return invoke('get_profiles', { + browserKind: browser, + }); } diff --git a/apps/desktop/src/lib/storage.ts b/apps/desktop/src/lib/storage.ts new file mode 100644 index 0000000..e0e4217 --- /dev/null +++ b/apps/desktop/src/lib/storage.ts @@ -0,0 +1,124 @@ +import { LazyStore } from '@tauri-apps/plugin-store'; + +export type UiSettings = { + rememberChoice: boolean; + showIcons: boolean; + debugMode: boolean; + lastSelectedBrowserId: string | null; +}; + +export const DEFAULT_UI_SETTINGS: UiSettings = { + rememberChoice: true, + showIcons: false, + debugMode: false, + lastSelectedBrowserId: null, +}; + +const settingsStore = new LazyStore('ui-settings.json', { + defaults: DEFAULT_UI_SETTINGS, +}); + +export async function loadUiSettings(): Promise { + await settingsStore.init(); + const entries = await settingsStore.entries(); + const snapshot: Partial = {}; + const writableSnapshot = snapshot as Record< + keyof UiSettings, + UiSettings[keyof UiSettings] | undefined + >; + + entries.forEach(([key, rawValue]) => { + const typedKey = key as keyof UiSettings; + if (rawValue === undefined) { + writableSnapshot[typedKey] = undefined; + } else { + writableSnapshot[typedKey] = rawValue as UiSettings[keyof UiSettings]; + } + }); + + return { + ...DEFAULT_UI_SETTINGS, + ...snapshot, + } as UiSettings; +} + +export async function setUiSetting( + key: K, + value: UiSettings[K] +): Promise { + await settingsStore.init(); + await settingsStore.set(key, value); + await settingsStore.save(); +} + +export async function persistLastSelectedBrowser( + browserId: string | null +): Promise { + await setUiSetting('lastSelectedBrowserId', browserId); +} + +export type RulePolicy = 'Always' | 'Just once' | 'Fallback'; + +export type DomainRule = { + id: string; + domain: string; + browserId: string | null; + browserLabel: string; + policy: RulePolicy; + latency: string; + enabled: boolean; +}; + +export type FileTypeRule = { + id: string; + extension: string; + browserId: string | null; + browserLabel: string; + policy: RulePolicy; +}; + +export type RulesSnapshot = { + domainRules: DomainRule[]; + fileTypeRules: FileTypeRule[]; +}; + +const DEFAULT_RULES: RulesSnapshot = { + domainRules: [], + fileTypeRules: [], +}; + +const rulesStore = new LazyStore('routing-rules.json', { + defaults: DEFAULT_RULES, +}); + +export async function loadRules(): Promise { + await rulesStore.init(); + const [domainRules, fileTypeRules] = await Promise.all([ + rulesStore.get('domainRules'), + rulesStore.get('fileTypeRules'), + ]); + + return { + domainRules: domainRules ?? [], + fileTypeRules: fileTypeRules ?? [], + }; +} + +export async function setDomainRules(rules: DomainRule[]): Promise { + await rulesStore.init(); + await rulesStore.set('domainRules', rules); + await rulesStore.save(); +} + +export async function setFileTypeRules(rules: FileTypeRule[]): Promise { + await rulesStore.init(); + await rulesStore.set('fileTypeRules', rules); + await rulesStore.save(); +} + +export async function saveRules(snapshot: RulesSnapshot): Promise { + await rulesStore.init(); + await rulesStore.set('domainRules', snapshot.domainRules); + await rulesStore.set('fileTypeRules', snapshot.fileTypeRules); + await rulesStore.save(); +} diff --git a/apps/desktop/src/pages/Dashboard.tsx b/apps/desktop/src/pages/Dashboard.tsx index 2a6233b..636d536 100644 --- a/apps/desktop/src/pages/Dashboard.tsx +++ b/apps/desktop/src/pages/Dashboard.tsx @@ -4,24 +4,17 @@ import type { ActiveLink, LaunchHistoryItem } from '../lib/models'; type DashboardProps = { activeLink: ActiveLink | null; + browsers: BrowserProfile[]; recentHistory: LaunchHistoryItem[]; statusById: Record; errorsById: Record; - onSimulateNext: () => Promise; onRecordLaunch: ( browser: BrowserProfile, persist: 'just-once' | 'always' ) => Promise; + showIcons: boolean; }; -const installedBrowsers: BrowserProfile[] = [ - { id: 'arc', name: 'Arc', profile: 'Workspace' }, - { id: 'chrome-work', name: 'Google Chrome', profile: 'Workspace' }, - { id: 'chrome-personal', name: 'Google Chrome', profile: 'Personal' }, - { id: 'safari', name: 'Safari', profile: 'Personal' }, - { id: 'edge', name: 'Microsoft Edge', profile: 'Admin' }, -]; - const STATUS_CLASS: Record<'launching' | 'failed' | 'launched', string> = { launching: 'border-amber-300/40 bg-amber-500/10 text-amber-200', failed: 'border-red-400/40 bg-red-500/10 text-red-200', @@ -36,14 +29,14 @@ const STATUS_LABEL: Record<'launching' | 'failed' | 'launched', string> = { export default function Dashboard({ activeLink, + browsers, recentHistory, statusById, errorsById, - onSimulateNext, onRecordLaunch, + showIcons, }: DashboardProps) { const [dialogOpen, setDialogOpen] = useState(false); - const [isSimulating, setIsSimulating] = useState(false); const [isRouting, setIsRouting] = useState(false); const [actionError, setActionError] = useState(null); @@ -53,20 +46,23 @@ export default function Dashboard({ ); const dialogBrowsers = useMemo(() => { - if (!recommendedBrowser) return installedBrowsers; - const recommendedId = installedBrowsers.find( - b => - b.name === recommendedBrowser.name && - (b.profile ?? null) === (recommendedBrowser.profile ?? null) - )?.id; + if (browsers.length === 0) return []; + if (!recommendedBrowser) return browsers; + + const match = browsers.find(browser => { + const nameMatches = + browser.name.toLowerCase() === + recommendedBrowser.name.toLowerCase(); + const profileMatches = + (browser.profileLabel ?? '').toLowerCase() === + (recommendedBrowser.profileLabel ?? '').toLowerCase(); + return nameMatches && profileMatches; + }); - if (!recommendedId) return installedBrowsers; + if (!match) return browsers; - return [ - installedBrowsers.find(b => b.id === recommendedId)!, - ...installedBrowsers.filter(b => b.id !== recommendedId), - ]; - }, [recommendedBrowser]); + return [match, ...browsers.filter(browser => browser.id !== match.id)]; + }, [browsers, recommendedBrowser]); const routingStats = useMemo(() => { const launching = Object.values(statusById).filter( @@ -87,25 +83,9 @@ export default function Dashboard({ }; }, [activeLink, recentHistory.length, statusById]); - const handleSimulate = async () => { - setIsSimulating(true); - setActionError(null); - try { - await onSimulateNext(); - } finally { - setIsSimulating(false); - } - }; - const handleRecommendedLaunch = async () => { - if (!recommendedBrowser) return; - const matchingBrowser = - dialogBrowsers[0] ?? - ({ - id: `recommended-${recommendedBrowser.name.toLowerCase()}`, - name: recommendedBrowser.name, - profile: recommendedBrowser.profile ?? undefined, - } satisfies BrowserProfile); + if (!recommendedBrowser || dialogBrowsers.length === 0) return; + const matchingBrowser = dialogBrowsers[0]; setIsRouting(true); setActionError(null); @@ -175,22 +155,20 @@ export default function Dashboard({ )}
    - {actionError ? (

    {actionError}

    + ) : dialogBrowsers.length === 0 ? ( +

    + No browsers detected yet. Refresh from Settings to register installed + browsers. +

    ) : null}
    @@ -217,8 +195,8 @@ export default function Dashboard({

    {recommendedBrowser?.name} - {recommendedBrowser?.profile - ? ` · ${recommendedBrowser.profile}` + {recommendedBrowser?.profileLabel + ? ` · ${recommendedBrowser.profileLabel}` : ''}

    @@ -228,19 +206,27 @@ export default function Dashboard({

    + {dialogBrowsers.length === 0 ? ( +

    + No browsers detected yet. Check Settings → System setup to load + installed browsers. +

    + ) : null} ) : null} @@ -299,7 +285,7 @@ export default function Dashboard({

    {item.browser}{' '} - {item.profile ?? 'Default profile'} + {item.profileLabel ?? 'Default profile'}

    @@ -378,11 +364,12 @@ export default function Dashboard({ 0} onClose={() => setDialogOpen(false)} browsers={dialogBrowsers} onChoose={handleManualLaunch} disabled={isRouting} + showIcons={showIcons} /> ); diff --git a/apps/desktop/src/pages/Rules.tsx b/apps/desktop/src/pages/Rules.tsx index a3ba04d..c587179 100644 --- a/apps/desktop/src/pages/Rules.tsx +++ b/apps/desktop/src/pages/Rules.tsx @@ -1,43 +1,453 @@ -const domainRules = [ - { - domain: 'mail.company.com', - browser: 'Arc · Workspace', - policy: 'Always', - latency: '< 100 ms', - }, - { - domain: 'docs.google.com', - browser: 'Chrome · Finance', - policy: 'Always', - latency: 'Stable', - }, - { - domain: 'linear.app', - browser: 'Safari · Personal', - policy: 'Just once', - latency: 'Auto', - }, -]; - -const fileTypeRules = [ - { - type: '.fig', - browser: 'Arc · Workspace', - policy: 'Always', - }, - { - type: '.pdf', - browser: 'Chrome · Finance', - policy: 'Always', - }, - { - type: '.md', - browser: 'Safari · Personal', - policy: 'Fallback', - }, -]; - -export default function Rules() { +import { + ChangeEvent, + FormEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { BrowserProfile } from '../OpenWithDialog'; +import { + loadRules, + setDomainRules, + setFileTypeRules, + type DomainRule, + type FileTypeRule, + type RulePolicy, +} from '../lib/storage'; +import { simulateIncomingLink } from '../lib/routing'; + +type RulesProps = { + availableBrowsers: BrowserProfile[]; +}; + +const POLICY_OPTIONS: RulePolicy[] = ['Always', 'Just once', 'Fallback']; +const LATENCY_OPTIONS = ['< 100 ms', 'Stable', 'Auto']; + +const createId = () => + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + +const formatBrowserLabel = (browser: BrowserProfile) => + browser.profileLabel + ? `${browser.name} · ${browser.profileLabel}` + : browser.name; + +const normalise = (value: string) => value.trim().toLowerCase(); + +const ensureNavigableUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + return `https://${trimmed}`; +}; + +export default function Rules({ availableBrowsers }: RulesProps) { + const [domainRules, setDomainRulesState] = useState([]); + const [fileTypeRules, setFileTypeRulesState] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [addingDomain, setAddingDomain] = useState(false); + const [addingFileType, setAddingFileType] = useState(false); + const [domainForm, setDomainForm] = useState({ + domain: '', + browser: '', + policy: POLICY_OPTIONS[0], + latency: LATENCY_OPTIONS[0], + enabled: true, + }); + const [fileForm, setFileForm] = useState({ + extension: '', + browser: '', + policy: POLICY_OPTIONS[0], + }); + + const csvInputRef = useRef(null); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const snapshot = await loadRules(); + if (cancelled) return; + setDomainRulesState(snapshot.domainRules); + setFileTypeRulesState(snapshot.fileTypeRules); + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error + ? `Unable to load rules: ${err.message}` + : 'Unable to load rules.' + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const browserOptions = useMemo( + () => + availableBrowsers.map(browser => ({ + id: browser.id, + label: formatBrowserLabel(browser), + browser, + })), + [availableBrowsers] + ); + + const resolveBrowserSelection = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return { browserId: null, browserLabel: '' }; + } + + const match = browserOptions.find( + option => normalise(option.label) === normalise(trimmed) + ); + + if (match) { + return { + browserId: match.id, + browserLabel: match.label, + }; + } + + return { + browserId: null, + browserLabel: trimmed, + }; + }; + + const resetDomainForm = () => + setDomainForm({ + domain: '', + browser: '', + policy: POLICY_OPTIONS[0], + latency: LATENCY_OPTIONS[0], + enabled: true, + }); + + const resetFileForm = () => + setFileForm({ + extension: '', + browser: '', + policy: POLICY_OPTIONS[0], + }); + + const handleSubmitDomainRule = async ( + event: FormEvent + ): Promise => { + event.preventDefault(); + setError(null); + + if (!domainForm.domain.trim() || !domainForm.browser.trim()) { + setError('Domain and browser are required to save a rule.'); + return; + } + + const selection = resolveBrowserSelection(domainForm.browser); + const nextRule: DomainRule = { + id: createId(), + domain: domainForm.domain.trim(), + browserId: selection.browserId, + browserLabel: selection.browserLabel, + policy: domainForm.policy, + latency: domainForm.latency, + enabled: domainForm.enabled, + }; + + const previous = domainRules; + const next = [...domainRules, nextRule]; + + setDomainRulesState(next); + setAddingDomain(false); + resetDomainForm(); + + try { + await setDomainRules(next); + } catch (err) { + setError( + err instanceof Error + ? `Failed to save rule: ${err.message}` + : 'Failed to save rule.' + ); + setDomainRulesState(previous); + } + }; + + const handleTestDomainRule = async (rule: DomainRule) => { + setError(null); + const url = ensureNavigableUrl(rule.domain); + if (!url) { + setError('Rule domain must be a valid URL or host to test.'); + return; + } + + try { + await simulateIncomingLink({ + url, + sourceApp: 'Rules panel', + sourceContext: rule.browserLabel, + preview: `Testing rule for ${rule.domain}`, + }); + } catch (err) { + setError( + err instanceof Error + ? `Failed to launch test: ${err.message}` + : 'Failed to launch test.' + ); + } + }; + + const handleSubmitFileRule = async ( + event: FormEvent + ): Promise => { + event.preventDefault(); + setError(null); + + if (!fileForm.extension.trim() || !fileForm.browser.trim()) { + setError('File type and browser are required to save a rule.'); + return; + } + + const extension = + fileForm.extension.startsWith('.') + ? fileForm.extension.trim().toLowerCase() + : `.${fileForm.extension.trim().toLowerCase()}`; + + const selection = resolveBrowserSelection(fileForm.browser); + + const nextRule: FileTypeRule = { + id: createId(), + extension, + browserId: selection.browserId, + browserLabel: selection.browserLabel, + policy: fileForm.policy, + }; + + const previous = fileTypeRules; + const next = [...fileTypeRules, nextRule]; + + setFileTypeRulesState(next); + setAddingFileType(false); + resetFileForm(); + + try { + await setFileTypeRules(next); + } catch (err) { + setError( + err instanceof Error + ? `Failed to save file rule: ${err.message}` + : 'Failed to save file rule.' + ); + setFileTypeRulesState(previous); + } + }; + + const handleToggleDomainRule = async (ruleId: string) => { + setError(null); + const previous = domainRules; + const next = domainRules.map(rule => + rule.id === ruleId ? { ...rule, enabled: !rule.enabled } : rule + ); + + setDomainRulesState(next); + try { + await setDomainRules(next); + } catch (err) { + setError( + err instanceof Error + ? `Failed to update rule: ${err.message}` + : 'Failed to update rule.' + ); + setDomainRulesState(previous); + } + }; + + const handleDeleteDomainRule = async (ruleId: string) => { + setError(null); + const previous = domainRules; + const next = domainRules.filter(rule => rule.id !== ruleId); + + setDomainRulesState(next); + try { + await setDomainRules(next); + } catch (err) { + setError( + err instanceof Error + ? `Failed to remove rule: ${err.message}` + : 'Failed to remove rule.' + ); + setDomainRulesState(previous); + } + }; + + const handleDeleteFileRule = async (ruleId: string) => { + setError(null); + const previous = fileTypeRules; + const next = fileTypeRules.filter(rule => rule.id !== ruleId); + + setFileTypeRulesState(next); + try { + await setFileTypeRules(next); + } catch (err) { + setError( + err instanceof Error + ? `Failed to remove file rule: ${err.message}` + : 'Failed to remove file rule.' + ); + setFileTypeRulesState(previous); + } + }; + + const optionAt = (index: number) => { + const option = browserOptions[index]; + if (!option) { + return { + browserId: null, + browserLabel: 'Default browser', + }; + } + return { + browserId: option.id, + browserLabel: option.label, + }; + }; + + const handleAutoGenerateFileRules = async () => { + setError(null); + const generated: FileTypeRule[] = [ + { + id: createId(), + extension: '.pdf', + ...optionAt(0), + policy: 'Always', + }, + { + id: createId(), + extension: '.fig', + ...optionAt(1), + policy: 'Always', + }, + { + id: createId(), + extension: '.md', + browserId: null, + browserLabel: 'Prompt me', + policy: 'Fallback', + }, + ]; + + const previous = fileTypeRules; + setFileTypeRulesState(generated); + try { + await setFileTypeRules(generated); + } catch (err) { + setError( + err instanceof Error + ? `Failed to auto-generate rules: ${err.message}` + : 'Failed to auto-generate rules.' + ); + setFileTypeRulesState(previous); + } + }; + + const handleCsvClick = () => { + csvInputRef.current?.click(); + }; + + const handleCsvChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = async () => { + const previous = domainRules; + try { + const text = String(reader.result ?? ''); + const lines = text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + + if (lines.length === 0) { + setError('No rows found in CSV.'); + return; + } + + const parsed: DomainRule[] = [...domainRules]; + + lines.forEach(line => { + const [ + domain, + browser, + policy = 'Always', + latency = 'Auto', + enabled, + ] = line.split(',').map(part => part.trim()); + + if (!domain || !browser) { + return; + } + + const selection = resolveBrowserSelection(browser); + const policyValue = POLICY_OPTIONS.find( + option => normalise(option) === normalise(policy) + ); + + const latencyValue = latency || LATENCY_OPTIONS[2]; + const enabledValue = + enabled !== undefined + ? !['false', '0', 'no', 'disabled'].includes( + enabled.toLowerCase() + ) + : true; + + parsed.push({ + id: createId(), + domain, + browserId: selection.browserId, + browserLabel: selection.browserLabel, + policy: policyValue ?? POLICY_OPTIONS[0], + latency: latencyValue, + enabled: enabledValue, + }); + }); + + setDomainRulesState(parsed); + await setDomainRules(parsed); + setError(null); + } catch (err) { + setDomainRulesState(previous); + setError( + err instanceof Error + ? `Failed to import CSV: ${err.message}` + : 'Failed to import CSV.' + ); + } finally { + event.target.value = ''; + } + }; + + reader.onerror = () => { + setError('Unable to read the selected file.'); + event.target.value = ''; + }; + + reader.readAsText(file); + }; + return (

    @@ -52,10 +462,19 @@ export default function Rules() { shell.

    - + {error ? ( +

    {error}

    + ) : null} + {loading ? ( +

    Loading rules…

    + ) : null}
    @@ -68,10 +487,122 @@ export default function Rules() { dialog renders.

    - + + {addingDomain ? ( +
    + + + + + +
    + + +
    +
    + ) : null} +
    @@ -81,31 +612,81 @@ export default function Rules() { + - {domainRules.map(rule => ( - - - - - - + - ))} + ) : ( + domainRules.map(rule => ( + + + + + + + + + )) + )}
    Policy Latency budget StatusActions
    - {rule.domain} - {rule.browser} - - {rule.policy} - - {rule.latency} - - Active - + {domainRules.length === 0 ? ( +
    + No domain rules yet. Add one to preselect browsers + automatically.
    + + {rule.browserLabel} + + {rule.policy} + + + {rule.latency} + + + {rule.enabled ? 'Active' : 'Paused'} + + +
    + + +
    +
    +
    @@ -118,37 +699,140 @@ export default function Rules() { the cloud.

    - - -
    - {fileTypeRules.map(rule => ( -
    + + +
    + + + {addingFileType ? ( +
    + + + +
    + +
    - ))} +
    + ) : null} + +
    + {fileTypeRules.length === 0 ? ( +
    + No file type fallbacks configured. Add a rule or auto-generate a + starting set. +
    + ) : ( + fileTypeRules.map(rule => ( +
    +
    + {rule.extension} +
    +
    + {rule.browserLabel} +
    +
    + Policy:{' '} + + {rule.policy} + +
    + +
    + )) + )}
    + + {browserOptions.map(option => ( + +

    Execution notes

    diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index a2593e6..8bb9d38 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -7,21 +7,39 @@ import { type as resolveOsType, version as osVersion, } from '@tauri-apps/plugin-os'; -import { fetchAvailableBrowsers, fetchProfilesFor } from '../lib/routing'; +import { + fetchAvailableBrowsers, + fetchProfilesFor, + type ProfileDescriptorWire, +} from '../lib/routing'; import { fetchPreferences, updateFallbackPreference, type FallbackPreference, } from '../lib/preferences'; -export default function Settings() { - const [rememberChoice, setRememberChoice] = useState(true); - const [showIcons, setShowIcons] = useState(false); - const [debugMode, setDebugMode] = useState(false); +type SettingsProps = { + rememberChoice: boolean; + showIcons: boolean; + debugMode: boolean; + onRememberChoiceChange: (value: boolean) => Promise | void; + onShowIconsChange: (value: boolean) => Promise | void; + onDebugModeChange: (value: boolean) => Promise | void; +}; + +export default function Settings({ + rememberChoice, + showIcons, + debugMode, + onRememberChoiceChange, + onShowIconsChange, + onDebugModeChange, +}: SettingsProps) { const [availableBrowsers, setAvailableBrowsers] = useState([]); - const [availableProfiles, setAvailableProfiles] = useState([]); + const [availableProfiles, setAvailableProfiles] = useState([]); const [fallbackBrowser, setFallbackBrowser] = useState(''); - const [fallbackProfile, setFallbackProfile] = useState(''); + const [fallbackProfileDirectory, setFallbackProfileDirectory] = useState(''); + const [fallbackProfileLabel, setFallbackProfileLabel] = useState(''); const [savingFallback, setSavingFallback] = useState(false); const [fallbackStatus, setFallbackStatus] = useState(null); const [defaultStatus, setDefaultStatus] = useState< @@ -124,7 +142,10 @@ export default function Settings() { browsers: string[] = availableBrowsers ) { setFallbackBrowser(fallback.browser); - setFallbackProfile(fallback.profile ?? ''); + const profileLabel = fallback.profile?.label ?? ''; + const profileDirectory = fallback.profile?.directory ?? ''; + setFallbackProfileLabel(profileLabel); + setFallbackProfileDirectory(profileDirectory); if (!browsers.includes(fallback.browser)) { setAvailableBrowsers(prev => @@ -132,13 +153,18 @@ export default function Settings() { ); } - void loadProfilesForBrowser(fallback.browser, fallback.profile ?? ''); + void loadProfilesForBrowser(fallback.browser, profileDirectory, profileLabel); } - async function loadProfilesForBrowser(browser: string, profile?: string) { + async function loadProfilesForBrowser( + browser: string, + directory?: string, + label?: string + ) { if (!browser) { setAvailableProfiles([]); - setFallbackProfile(''); + setFallbackProfileDirectory(''); + setFallbackProfileLabel(''); return; } @@ -146,10 +172,24 @@ export default function Settings() { const profiles = await fetchProfilesFor(browser); setAvailableProfiles(profiles); - if (profile && profiles.includes(profile)) { - setFallbackProfile(profile); + if (directory) { + const match = profiles.find(p => p.directory === directory); + if (match) { + setFallbackProfileDirectory(match.directory); + setFallbackProfileLabel(match.display_name); + } else if (profiles.length === 0) { + setFallbackProfileDirectory(''); + setFallbackProfileLabel(''); + } else { + setFallbackProfileDirectory(directory); + setFallbackProfileLabel(label ?? directory); + } } else if (profiles.length === 0) { - setFallbackProfile(''); + setFallbackProfileDirectory(''); + setFallbackProfileLabel(''); + } else { + setFallbackProfileDirectory(''); + setFallbackProfileLabel(''); } } catch (err) { // eslint-disable-next-line no-console @@ -173,13 +213,16 @@ export default function Settings() { async function handleFallbackBrowserChange(value: string) { setFallbackBrowser(value); - setFallbackProfile(''); + setFallbackProfileDirectory(''); + setFallbackProfileLabel(''); await loadProfilesForBrowser(value); setFallbackStatus(null); } function handleFallbackProfileChange(value: string) { - setFallbackProfile(value); + setFallbackProfileDirectory(value); + const match = availableProfiles.find(profile => profile.directory === value); + setFallbackProfileLabel(match?.display_name ?? value); setFallbackStatus(null); } @@ -189,11 +232,19 @@ export default function Settings() { try { await updateFallbackPreference({ browser: fallbackBrowser || null, - profile: fallbackProfile || null, + profile: fallbackBrowser + ? fallbackProfileDirectory + ? { + label: + fallbackProfileLabel || fallbackProfileDirectory || null, + directory: fallbackProfileDirectory, + } + : null + : null, }); setFallbackStatus( fallbackBrowser - ? `Links without a rule will open in ${fallbackBrowser}${fallbackProfile ? ` · ${fallbackProfile}` : ''}.` + ? `Links without a rule will open in ${fallbackBrowser}${fallbackProfileLabel ? ` · ${fallbackProfileLabel}` : ''}.` : 'Fallback routing cleared. You will be prompted for each link.' ); } catch (err) { @@ -302,7 +353,7 @@ export default function Settings() { setRememberChoice(e.target.checked)} + onChange={e => void onRememberChoiceChange(e.target.checked)} className='h-5 w-5 rounded border border-white/10 bg-black/50 accent-emerald-400' /> @@ -320,7 +371,7 @@ export default function Settings() { setShowIcons(e.target.checked)} + onChange={e => void onShowIconsChange(e.target.checked)} className='h-5 w-5 rounded border border-white/10 bg-black/50 accent-amber-400' /> @@ -350,14 +401,17 @@ export default function Settings() { From 96e4f93bbd118105caf5d57c848149ed79afd2f7 Mon Sep 17 00:00:00 2001 From: theg Date: Tue, 21 Oct 2025 00:53:57 +0530 Subject: [PATCH 3/7] fix: profile resolution --- apps/desktop/src-tauri/src/browser_details.rs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/browser_details.rs b/apps/desktop/src-tauri/src/browser_details.rs index b82c27a..1939467 100644 --- a/apps/desktop/src-tauri/src/browser_details.rs +++ b/apps/desktop/src-tauri/src/browser_details.rs @@ -82,26 +82,47 @@ pub fn get_chrome_based_profiles( let mut profiles: Vec = Vec::new(); - for (_profile_key, profile_data) in info_cache.iter() { + for (profile_key, profile_data) in info_cache.iter() { let directory = profile_data .get("profile_dir") .and_then(|v| v.as_str()) .map(|s| s.to_owned()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "Default".to_string()); + .unwrap_or_else(|| profile_key.to_owned()); let display = profile_data .get("gaia_name") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(|s| s.to_owned()) + .or_else(|| { + profile_data + .get("brave_sync_profile_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()) + }) + .or_else(|| { + profile_data + .get("supervised_user_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()) + }) .or_else(|| { profile_data .get("name") .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) .map(|s| s.to_owned()) }) - .unwrap_or_else(|| directory.clone()); + .unwrap_or_else(|| { + if profile_key.eq_ignore_ascii_case("default") { + "Default".to_string() + } else { + directory.clone() + } + }); if !profiles.iter().any(|p| p.directory == directory) { profiles.push(ProfileDescriptor { From 592313dee93d0af366ddef7dcf734ee63e06132f Mon Sep 17 00:00:00 2001 From: theg Date: Tue, 21 Oct 2025 01:02:14 +0530 Subject: [PATCH 4/7] fix: attempt to fix overscroll --- apps/desktop/src/Layout.tsx | 2 +- apps/desktop/src/index.css | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/Layout.tsx b/apps/desktop/src/Layout.tsx index 2d0540d..c4ee15b 100644 --- a/apps/desktop/src/Layout.tsx +++ b/apps/desktop/src/Layout.tsx @@ -227,7 +227,7 @@ export default function Layout({ initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.35, ease: 'easeOut', delay: 0.05 }} - className='flex-1 overflow-y-auto px-5 py-6 sm:px-6 md:px-8 lg:px-10 lg:py-8' + className='flex-1 w-full overflow-y-auto overflow-x-hidden overscroll-y-contain overscroll-x-none scrollbar-stable px-5 py-6 sm:px-6 md:px-8 lg:px-10 lg:py-8' > {children} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 06ca851..fb29058 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -9,12 +9,17 @@ color-scheme: dark; } + html, + body { + height: 100%; + } + body { @apply font-sans bg-zinc-950 text-zinc-100 antialiased; } #root { - @apply min-h-screen w-full bg-transparent; + @apply flex min-h-full w-full flex-col overflow-hidden bg-transparent; } } @@ -30,6 +35,12 @@ 0 1px 0 rgba(255, 255, 255, 0.04) inset; } +@layer utilities { + .scrollbar-stable { + scrollbar-gutter: stable both-edges; + } +} + @layer components { .panel { @apply rounded-[22px] border border-white/5 bg-zinc-950/70 p-5 shadow-soft sm:rounded-[26px] sm:p-6; From de79b81816b806bc45c567497890f971cad5c08d Mon Sep 17 00:00:00 2001 From: theg Date: Tue, 21 Oct 2025 03:31:05 +0530 Subject: [PATCH 5/7] feat: implement end to end flow --- apps/desktop/bun.lock | 3 + apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.lock | 80 ++- apps/desktop/src-tauri/Cargo.toml | 3 +- .../src-tauri/capabilities/default.json | 8 +- apps/desktop/src-tauri/src/commands.rs | 26 +- apps/desktop/src-tauri/src/diagnostics.rs | 55 ++ apps/desktop/src-tauri/src/lib.rs | 86 ++- apps/desktop/src-tauri/src/routing.rs | 19 +- apps/desktop/src-tauri/tauri.conf.json | 4 +- apps/desktop/src/App.tsx | 213 ++++++- apps/desktop/src/Layout.tsx | 118 ++-- apps/desktop/src/OpenWithDialog.tsx | 38 +- apps/desktop/src/components/ui/Select.tsx | 518 ++++++++++++++++++ apps/desktop/src/lib/autostart.ts | 29 + apps/desktop/src/lib/diagnostics.ts | 19 + apps/desktop/src/lib/routing.ts | 15 +- apps/desktop/src/pages/Dashboard.tsx | 263 +++------ apps/desktop/src/pages/Rules.tsx | 190 +++---- apps/desktop/src/pages/Settings.tsx | 293 ++++++++-- 20 files changed, 1512 insertions(+), 469 deletions(-) create mode 100644 apps/desktop/src-tauri/src/diagnostics.rs create mode 100644 apps/desktop/src/components/ui/Select.tsx create mode 100644 apps/desktop/src/lib/autostart.ts create mode 100644 apps/desktop/src/lib/diagnostics.ts diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 004a11b..7a2aa28 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@tailwindcss/vite": "^4.1.14", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.0", @@ -204,6 +205,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.8.4", "", { "os": "win32", "cpu": "x64" }, ""], + "@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-smSt0vydfVB950AeYRbO2S/c01SZrgMVg4FOrFLQLom0R0amsu/8zYaxgttriBdxcofjBZuHv4hmROBQIBVXmA=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, ""], "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 98496aa..02c0995 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,6 +16,7 @@ "dependencies": { "@tailwindcss/vite": "^4.1.14", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-autostart": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.0", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 48c0972..f1a3423 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -213,6 +213,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -729,13 +740,33 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -746,7 +777,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2521,12 +2552,13 @@ dependencies = [ "chrono", "core-foundation 0.9.4", "crowser", - "dirs", + "dirs 6.0.0", "percent-encoding", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-single-instance", @@ -3063,6 +3095,17 @@ dependencies = [ "bitflags 2.9.4", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3819,7 +3862,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3870,7 +3913,7 @@ checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3942,6 +3985,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.0" @@ -4448,7 +4505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.3", @@ -5407,6 +5464,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.52.0" @@ -5455,7 +5521,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 50a170b..d2146eb 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,9 +18,10 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" +tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" crowser = "0.4.1" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 7b16642..058a6e6 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -5,8 +5,14 @@ "windows": ["main"], "permissions": [ "core:default", + "core:tray:default", + "core:tray:allow-new", + "core:tray:allow-set-icon", + "core:tray:allow-set-tooltip", + "core:tray:allow-set-menu", "opener:default", "os:default", - "store:default" + "store:default", + "autostart:default" ] } diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 86598d5..4852e50 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -3,6 +3,7 @@ use crate::{ get_browsers, get_chrome_profiles, get_firefox_profiles, parse_browser_kind, Browsers, ProfileDescriptor, }, + diagnostics::{DiagnosticEntry, DiagnosticsState}, platform, preferences::{FallbackPreference, PreferencesState, ProfilePreference}, routing::{ @@ -11,7 +12,7 @@ use crate::{ }; use serde::{Deserialize, Serialize}; use std::process::Command; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, State}; use tauri_plugin_os::OsType; fn map_error(err: Box) -> String { @@ -253,3 +254,26 @@ pub async fn set_fallback_browser( _ => state.set_fallback(&app_handle, None).await, } } + +#[tauri::command] +pub fn get_diagnostics(state: State) -> Vec { + let mut entries = state.snapshot(); + entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + entries +} + +#[tauri::command] +pub fn clear_diagnostics(state: State) { + state.clear(); +} + +#[tauri::command] +pub fn export_diagnostics(state: State) -> Result { + let entries = state.snapshot(); + let contents = entries + .into_iter() + .map(|entry| format!("[{}] {}", entry.timestamp, entry.message)) + .collect::>() + .join("\n"); + Ok(contents) +} diff --git a/apps/desktop/src-tauri/src/diagnostics.rs b/apps/desktop/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..186eb3c --- /dev/null +++ b/apps/desktop/src-tauri/src/diagnostics.rs @@ -0,0 +1,55 @@ +use chrono::Utc; +use serde::Serialize; +use std::sync::RwLock; +use uuid::Uuid; + +const MAX_ENTRIES: usize = 500; + +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosticEntry { + pub id: String, + pub timestamp: String, + pub message: String, +} + +#[derive(Default)] +pub struct DiagnosticsState { + entries: RwLock>, +} + +impl DiagnosticsState { + pub fn new() -> Self { + Self { + entries: RwLock::new(Vec::new()), + } + } + + pub fn record(&self, message: impl Into) -> DiagnosticEntry { + let entry = DiagnosticEntry { + id: Uuid::new_v4().to_string(), + timestamp: Utc::now().to_rfc3339(), + message: message.into(), + }; + let mut guard = self.entries.write().expect("diagnostics lock poisoned"); + guard.push(entry.clone()); + if guard.len() > MAX_ENTRIES { + let excess = guard.len() - MAX_ENTRIES; + guard.drain(0..excess); + } + entry + } + + pub fn snapshot(&self) -> Vec { + self.entries + .read() + .expect("diagnostics lock poisoned") + .clone() + } + + pub fn clear(&self) { + self.entries + .write() + .expect("diagnostics lock poisoned") + .clear(); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d3798e2..8975617 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod browser_details; mod commands; +mod diagnostics; mod domain; mod link; mod platform; @@ -7,15 +8,21 @@ mod preferences; mod routing; use commands::{ - get_available_browsers, get_preferences, get_profiles, is_default_browser, - open_default_browser_settings, register_browser_handlers, register_incoming_link, - resolve_incoming_link, routing_snapshot, set_fallback_browser, simulate_incoming_link, + clear_diagnostics, export_diagnostics, get_available_browsers, get_diagnostics, + get_preferences, get_profiles, is_default_browser, open_default_browser_settings, + register_browser_handlers, register_incoming_link, resolve_incoming_link, routing_snapshot, + set_fallback_browser, simulate_incoming_link, }; #[cfg(any(target_os = "macos", target_os = "ios"))] use link::handle_open_urls; use link::{handle_cli_arguments, LinkSource}; use routing::RoutingService; -use tauri::Manager; +use tauri::{ + menu::{MenuBuilder, MenuEvent, MenuItemBuilder}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, WindowEvent, +}; +use tauri_plugin_autostart::MacosLauncher; #[cfg(any(target_os = "macos", target_os = "ios"))] use tauri::RunEvent; @@ -31,11 +38,19 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { let args = argv.into_iter().skip(1).collect::>(); handle_cli_arguments(&app.app_handle(), &args, LinkSource::SecondaryInstance); })) + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + }) .manage(RoutingService::new()) + .manage(diagnostics::DiagnosticsState::default()) .setup(|app| { let args = std::env::args().skip(1).collect::>(); handle_cli_arguments(&app.handle(), &args, LinkSource::InitialLaunch); @@ -51,6 +66,64 @@ pub fn run() { Err(err) => eprintln!("failed to load preferences: {err}"), } + let show_item = MenuItemBuilder::with_id("show", "Show window").build(app)?; + let hide_item = MenuItemBuilder::with_id("hide", "Hide window").build(app)?; + let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?; + + let tray_menu = MenuBuilder::new(app) + .item(&show_item) + .item(&hide_item) + .separator() + .item(&quit_item) + .build()?; + + let mut tray_builder = TrayIconBuilder::new() + .menu(&tray_menu) + .show_menu_on_left_click(true) + .tooltip("Open With Browser") + .on_menu_event(|app, event: MenuEvent| match event.id().as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + "hide" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event: TrayIconEvent| match event { + TrayIconEvent::Click { button, button_state, .. } + if button == MouseButton::Left + && button_state == MouseButtonState::Up => + { + if let Some(window) = tray.app_handle().get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + TrayIconEvent::DoubleClick { .. } => { + if let Some(window) = tray.app_handle().get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + _ => {} + }); + + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + + tray_builder.build(app)?; + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -65,7 +138,10 @@ pub fn run() { open_default_browser_settings, register_browser_handlers, get_preferences, - set_fallback_browser + set_fallback_browser, + get_diagnostics, + clear_diagnostics, + export_diagnostics ]); let app = builder diff --git a/apps/desktop/src-tauri/src/routing.rs b/apps/desktop/src-tauri/src/routing.rs index 9479804..451bcea 100644 --- a/apps/desktop/src-tauri/src/routing.rs +++ b/apps/desktop/src-tauri/src/routing.rs @@ -590,10 +590,15 @@ fn browser_user_data_dir(browser_name: &str) -> Option { } } -fn append_log(_app: &tauri::AppHandle, message: &str) { - let timestamp = Utc::now().to_rfc3339(); - let entry = format!("[{timestamp}] {message}\n"); - print!("{entry}"); +fn append_log(app: &tauri::AppHandle, message: &str) { + if let Some(store) = app.try_state::() { + let entry = store.record(message.to_string()); + let _ = app.emit("diagnostics://entry", entry.clone()); + print!("[{}] {}\n", entry.timestamp, entry.message); + } else { + let timestamp = Utc::now().to_rfc3339(); + print!("[{timestamp}] {message}\n"); + } } #[derive(Debug, Clone, Serialize)] @@ -660,11 +665,7 @@ pub async fn simulate_link_payload(payload: Option) -> Inc preview: data .preview .or_else(|| Some("Shared link detected.".to_string())), - recommended_browser: Some(BrowserDescriptor { - name: "Arc".to_string(), - profile_label: Some("Workspace".to_string()), - profile_directory: None, - }), + recommended_browser: None, arrived_at: Some(current_timestamp()), } } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 775ade9..6989086 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "desktop", + "productName": "open-with-browser", "version": "0.1.0", "identifier": "com.acmvit.openwithbrowser", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "desktop", + "title": "Open With Browser", "width": 800, "height": 600 } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8349ed7..e78cac5 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Layout from './Layout'; import Dashboard from './pages/Dashboard'; import Rules from './pages/Rules'; @@ -16,6 +16,7 @@ import { type RoutingStatusWire, } from './lib/routing'; import type { BrowserProfile } from './OpenWithDialog'; +import { fetchPreferences } from './lib/preferences'; import { DEFAULT_UI_SETTINGS, loadUiSettings, @@ -24,6 +25,11 @@ import { type UiSettings, } from './lib/storage'; import { useUIStore } from './store/uiStore'; +import { + isTauriEnvironment, + loadAutostartState, + setAutostartState, +} from './lib/autostart'; type PageKey = 'dashboard' | 'rules' | 'settings'; type StatusMap = Record; @@ -40,12 +46,36 @@ export default function App() { const [browserCatalog, setBrowserCatalog] = useState([]); const [uiSettings, setUiSettings] = useState(DEFAULT_UI_SETTINGS); const [settingsReady, setSettingsReady] = useState(false); + const [hasFallback, setHasFallback] = useState(null); + const [fallbackPromptVisible, setFallbackPromptVisible] = useState(false); + const [dismissedFallbackFor, setDismissedFallbackFor] = useState< + string | null + >(null); + const [autostartEnabled, setAutostartEnabled] = useState(false); + const [autostartReady, setAutostartReady] = useState(false); + const [autostartStatus, setAutostartStatus] = useState(null); + const [pendingFallbackFocus, setPendingFallbackFocus] = useState(false); + const hasFallbackRef = useRef(null); const setDialogSelectedBrowser = useUIStore( state => state.setSelectedBrowser ); const resetDialogSelection = useUIStore(state => state.resetSelection); + const focusMainWindow = useCallback(async () => { + if (!isTauriEnvironment()) return; + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const windowRef = getCurrentWindow(); + await windowRef.show(); + await windowRef.unminimize(); + await windowRef.setFocus(); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to focus main window', err); + } + }, []); + useEffect(() => { let cancelled = false; @@ -74,6 +104,64 @@ export default function App() { }; }, [resetDialogSelection, setDialogSelectedBrowser]); + useEffect(() => { + if (!isTauriEnvironment()) { + setAutostartReady(true); + return; + } + + let cancelled = false; + + (async () => { + try { + const enabled = await loadAutostartState(); + if (!cancelled) { + setAutostartEnabled(enabled); + setAutostartReady(true); + } + } catch (err) { + if (!cancelled) { + setAutostartReady(true); + setAutostartStatus( + err instanceof Error + ? `Unable to read autostart preference: ${err.message}` + : 'Unable to read autostart preference.' + ); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const snapshot = await fetchPreferences(); + if (!cancelled) { + const hasValue = Boolean(snapshot.fallback); + hasFallbackRef.current = hasValue; + setHasFallback(hasValue); + } + } catch (err) { + if (!cancelled) { + setHasFallback(null); + hasFallbackRef.current = null; + // eslint-disable-next-line no-console + console.warn('Unable to read fallback preference', err); + } + } + })(); + + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let unlisten: Array<() => void> = []; @@ -86,6 +174,14 @@ export default function App() { const removeIncoming = await listenIncomingLink(link => { setActiveLink(link); + if (hasFallbackRef.current === false) { + setFallbackPromptVisible(true); + void focusMainWindow(); + } else if (hasFallbackRef.current === null) { + setPendingFallbackFocus(true); + setFallbackPromptVisible(true); + void focusMainWindow(); + } }); const removeDecision = await listenLaunchDecision(decision => { setHistory(prev => [ @@ -133,7 +229,51 @@ export default function App() { return () => { unlisten.forEach(fn => fn()); }; - }, []); + }, [focusMainWindow]); + + useEffect(() => { + if (activeLink && hasFallback === false) { + if (dismissedFallbackFor !== activeLink.id) { + setFallbackPromptVisible(true); + } + } + }, [activeLink, hasFallback, dismissedFallbackFor]); + + useEffect(() => { + if (!activeLink) { + setFallbackPromptVisible(false); + setPendingFallbackFocus(false); + } + }, [activeLink]); + + useEffect(() => { + hasFallbackRef.current = hasFallback; + + if (hasFallback) { + setFallbackPromptVisible(false); + setDismissedFallbackFor(null); + setPendingFallbackFocus(false); + return; + } + + if (hasFallback === false && pendingFallbackFocus) { + setFallbackPromptVisible(true); + setPendingFallbackFocus(false); + void focusMainWindow(); + } + }, [hasFallback, pendingFallbackFocus, focusMainWindow]); + + useEffect(() => { + if (!activeLink) return; + if (hasFallback === false) { + void focusMainWindow(); + } + }, [activeLink, hasFallback, focusMainWindow]); + + useEffect(() => { + if (!fallbackPromptVisible) return; + void focusMainWindow(); + }, [fallbackPromptVisible, focusMainWindow]); useEffect(() => { let cancelled = false; @@ -234,7 +374,12 @@ export default function App() { }); } } - }, [browserCatalog, resetDialogSelection, settingsReady, uiSettings.lastSelectedBrowserId]); + }, [ + browserCatalog, + resetDialogSelection, + settingsReady, + uiSettings.lastSelectedBrowserId, + ]); const recentHistory = useMemo(() => history.slice(0, 5), [history]); @@ -288,6 +433,54 @@ export default function App() { } }, []); + const handleFallbackChanged = useCallback((value: boolean) => { + setHasFallback(value); + if (value) { + setFallbackPromptVisible(false); + setDismissedFallbackFor(null); + } + }, []); + + const handleOpenFallbackSettings = useCallback(() => { + setCurrentPage('settings'); + setFallbackPromptVisible(false); + setDismissedFallbackFor(activeLink?.id ?? null); + }, [activeLink]); + + const handleDismissFallbackPrompt = useCallback(() => { + setFallbackPromptVisible(false); + setDismissedFallbackFor(activeLink?.id ?? null); + }, [activeLink]); + + const handleAutostartChange = useCallback( + async (value: boolean) => { + if (value === autostartEnabled) return; + + if (!isTauriEnvironment()) { + setAutostartEnabled(value); + setAutostartStatus(null); + return; + } + + try { + await setAutostartState(value); + setAutostartEnabled(value); + setAutostartStatus( + value + ? 'App will start automatically at login.' + : 'Autostart disabled.' + ); + } catch (err) { + setAutostartStatus( + err instanceof Error + ? `Failed to update autostart: ${err.message}` + : 'Failed to update autostart setting.' + ); + } + }, + [autostartEnabled] + ); + const handleRecordLaunch = async ( browser: BrowserProfile, persist: 'just-once' | 'always' @@ -346,6 +539,9 @@ export default function App() { errorsById={errorsById} onRecordLaunch={handleRecordLaunch} showIcons={uiSettings.showIcons} + needsFallbackPrompt={fallbackPromptVisible} + onOpenFallbackSettings={handleOpenFallbackSettings} + onDismissFallbackPrompt={handleDismissFallbackPrompt} /> ); case 'rules': @@ -359,6 +555,12 @@ export default function App() { onRememberChoiceChange={handleRememberChoiceChange} onShowIconsChange={handleShowIconsChange} onDebugModeChange={handleDebugModeChange} + hasFallback={hasFallback} + onFallbackChanged={handleFallbackChanged} + autostartEnabled={autostartEnabled} + autostartReady={autostartReady} + autostartStatus={autostartStatus} + onAutostartChange={handleAutostartChange} /> ); default: @@ -371,6 +573,9 @@ export default function App() { errorsById={errorsById} onRecordLaunch={handleRecordLaunch} showIcons={uiSettings.showIcons} + needsFallbackPrompt={fallbackPromptVisible} + onOpenFallbackSettings={handleOpenFallbackSettings} + onDismissFallbackPrompt={handleDismissFallbackPrompt} /> ); } @@ -392,7 +597,7 @@ export default function App() { ) ) : (
    - Initialising routing service… + Initializing routing service…
    )} diff --git a/apps/desktop/src/Layout.tsx b/apps/desktop/src/Layout.tsx index c4ee15b..f547c74 100644 --- a/apps/desktop/src/Layout.tsx +++ b/apps/desktop/src/Layout.tsx @@ -34,20 +34,15 @@ export default function Layout({ }; const navPanelContent = ( -
    -
    -
    - ⌘ -
    -
    -

    - Open With -

    -

    Browser Studio

    -
    -
    +
    +
    +

    + Open With Browser +

    +

    Desktop

    +
    -
    ); @@ -185,42 +156,29 @@ export default function Layout({ ) : null}
    -
    -

    +

    +

    {activeNavLabel}

    -

    - Open chat links without leaving your flow +

    + {activeNavLabel}

    -

    - Preview the incoming context, confirm the browser, and hand off in - the background. -

    {activeLink ? ( -
    -
    - - {activeLink.contactName} - - - {activeLink.sourceApp} •{' '} - {new Date(activeLink.arrivedAt).toLocaleTimeString( - undefined, - { - hour: '2-digit', - minute: '2-digit', - } - )} - -
    -
    - ) : ( -
    - No active link +
    + + {activeLink.contactName} + + + {activeLink.sourceApp} •{' '} + {new Date(activeLink.arrivedAt).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + })} +
    - )} + ) : null} { + const handleClose = useCallback(() => { setLocalSelected(null); if (onCloseProp) onCloseProp(); else closeDialog(); - }; + }, [closeDialog, onCloseProp]); + + useEffect(() => { + if (!open) return undefined; + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + handleClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [open, handleClose]); + + if (!open) return null; const handleChoose = async (persist: 'just-once' | 'always') => { const browser = browsers.find(b => b.id === selected); @@ -66,28 +82,28 @@ export default function OpenWithDialog({ }; return ( -
    +
    -
    +

    Open with

    Choose the browser profile that should receive this launch request.

    -
    +
    {browsers.map(browser => { const isSelected = selected === browser.id; const fallbackGlyph = browser.name.slice(0, 1).toUpperCase(); @@ -141,7 +157,7 @@ export default function OpenWithDialog({ })}
    -
    +
    + {name ? : null} + {open ? ( +
    + {options.map((option, index) => { + const isSelected = option.value === selectedValue; + return ( + + ); + })} +
    + ) : null} +
    + ); +} + +export type ComboboxOption = { + value: string; + label: string; + badge?: string | null; +}; + +type ComboboxProps = { + options: ComboboxOption[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; + allowCustom?: boolean; + disabled?: boolean; + emptyLabel?: string; + className?: string; +}; + +export function Combobox({ + options, + value, + onChange, + placeholder = 'Search…', + allowCustom = true, + disabled = false, + emptyLabel = 'No matches', + className = '', +}: ComboboxProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(-1); + const triggerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + const listboxId = useId(); + const buttonId = useId(); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter(option => option.label.toLowerCase().includes(q)); + }, [options, query]); + + useEffect(() => { + if (!open) return; + const handleClickOutside = (event: MouseEvent) => { + if ( + event.target instanceof Node && + !triggerRef.current?.contains(event.target) && + !listRef.current?.contains(event.target) + ) { + setOpen(false); + } + }; + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener( + 'keydown', + handleKeydown as unknown as EventListener + ); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener( + 'keydown', + handleKeydown as unknown as EventListener + ); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + inputRef.current?.focus(); + }, [open]); + + const commitValue = (next: string) => { + onChange(next); + setOpen(false); + setQuery(''); + setActiveIndex(-1); + triggerRef.current?.focus(); + }; + + const handleSelect = (option: ComboboxOption) => { + commitValue(option.label); + }; + + const handleSubmitCustom = () => { + const trimmed = query.trim(); + if (!allowCustom || !trimmed) return; + commitValue(trimmed); + }; + + const currentLabel = value || ''; + + const handleTriggerKey = (event: KeyboardEvent) => { + if (disabled) return; + if ( + event.key === 'ArrowDown' || + event.key === 'Enter' || + event.key === ' ' + ) { + event.preventDefault(); + setOpen(true); + } + }; + + const handleOptionKey = ( + event: KeyboardEvent, + index: number + ) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (index < filtered.length - 1) { + const next = listRef.current?.querySelector( + `[data-combobox-index="${index + 1}"]` + ); + next?.focus(); + setActiveIndex(index + 1); + } + break; + case 'ArrowUp': + event.preventDefault(); + if (index > 0) { + const prev = listRef.current?.querySelector( + `[data-combobox-index="${index - 1}"]` + ); + prev?.focus(); + setActiveIndex(index - 1); + } else { + inputRef.current?.focus(); + setActiveIndex(-1); + } + break; + case 'Enter': + case ' ': + event.preventDefault(); + handleSelect(filtered[index]); + break; + default: + } + }; + + return ( +
    + + {open ? ( +
    + { + setQuery(event.target.value); + setActiveIndex(-1); + }} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + if (activeIndex >= 0 && filtered[activeIndex]) { + handleSelect(filtered[activeIndex]); + } else { + handleSubmitCustom(); + } + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + if (filtered.length > 0) { + const first = + listRef.current?.querySelector( + '[data-combobox-index="0"]' + ); + first?.focus(); + setActiveIndex(0); + } + } else if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }} + placeholder={placeholder} + className='mb-2 w-full rounded-[12px] border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 shadow-soft-sm focus:border-emerald-300/60 focus:outline-none' + /> +
    + {filtered.length === 0 ? ( +
    + {emptyLabel} +
    + ) : ( + filtered.map((option, index) => ( + + )) + )} + {allowCustom && query.trim() && ( + + )} +
    +
    + ) : null} +
    + ); +} diff --git a/apps/desktop/src/lib/autostart.ts b/apps/desktop/src/lib/autostart.ts new file mode 100644 index 0000000..8bd9a3b --- /dev/null +++ b/apps/desktop/src/lib/autostart.ts @@ -0,0 +1,29 @@ +const isTauri = typeof window !== 'undefined' && '__TAURI__' in window; + +export async function loadAutostartState(): Promise { + if (!isTauri) return false; + try { + const { isEnabled } = await import('@tauri-apps/plugin-autostart'); + return await isEnabled(); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Autostart state unavailable', error); + return false; + } +} + +export async function setAutostartState(enable: boolean): Promise { + if (!isTauri) return; + const { enable: enableAutostart, disable: disableAutostart } = await import( + '@tauri-apps/plugin-autostart' + ); + if (enable) { + await enableAutostart(); + } else { + await disableAutostart(); + } +} + +export function isTauriEnvironment(): boolean { + return isTauri; +} diff --git a/apps/desktop/src/lib/diagnostics.ts b/apps/desktop/src/lib/diagnostics.ts new file mode 100644 index 0000000..b909489 --- /dev/null +++ b/apps/desktop/src/lib/diagnostics.ts @@ -0,0 +1,19 @@ +import { invoke } from '@tauri-apps/api/core'; + +export type DiagnosticEntry = { + id: string; + timestamp: string; + message: string; +}; + +export async function fetchDiagnostics() { + return invoke('get_diagnostics'); +} + +export async function clearDiagnostics() { + await invoke('clear_diagnostics'); +} + +export async function exportDiagnostics() { + return invoke('export_diagnostics'); +} diff --git a/apps/desktop/src/lib/routing.ts b/apps/desktop/src/lib/routing.ts index e9b43ba..2626202 100644 --- a/apps/desktop/src/lib/routing.ts +++ b/apps/desktop/src/lib/routing.ts @@ -57,13 +57,14 @@ export function mapIncomingLink( wire: IncomingLinkWire | null ): ActiveLink | null { if (!wire) return null; - const recommendedBrowser: BrowserSelection | undefined = wire.recommended_browser - ? { - name: wire.recommended_browser.name, - profileLabel: wire.recommended_browser.profile_label ?? null, - profileDirectory: wire.recommended_browser.profile_directory ?? null, - } - : undefined; + const recommendedBrowser: BrowserSelection | undefined = + wire.recommended_browser + ? { + name: wire.recommended_browser.name, + profileLabel: wire.recommended_browser.profile_label ?? null, + profileDirectory: wire.recommended_browser.profile_directory ?? null, + } + : undefined; return { id: wire.id, url: wire.url, diff --git a/apps/desktop/src/pages/Dashboard.tsx b/apps/desktop/src/pages/Dashboard.tsx index 636d536..3ff7963 100644 --- a/apps/desktop/src/pages/Dashboard.tsx +++ b/apps/desktop/src/pages/Dashboard.tsx @@ -13,6 +13,9 @@ type DashboardProps = { persist: 'just-once' | 'always' ) => Promise; showIcons: boolean; + needsFallbackPrompt: boolean; + onOpenFallbackSettings: () => void; + onDismissFallbackPrompt: () => void; }; const STATUS_CLASS: Record<'launching' | 'failed' | 'launched', string> = { @@ -35,6 +38,9 @@ export default function Dashboard({ errorsById, onRecordLaunch, showIcons, + needsFallbackPrompt, + onOpenFallbackSettings, + onDismissFallbackPrompt, }: DashboardProps) { const [dialogOpen, setDialogOpen] = useState(false); const [isRouting, setIsRouting] = useState(false); @@ -51,8 +57,7 @@ export default function Dashboard({ const match = browsers.find(browser => { const nameMatches = - browser.name.toLowerCase() === - recommendedBrowser.name.toLowerCase(); + browser.name.toLowerCase() === recommendedBrowser.name.toLowerCase(); const profileMatches = (browser.profileLabel ?? '').toLowerCase() === (recommendedBrowser.profileLabel ?? '').toLowerCase(); @@ -64,25 +69,6 @@ export default function Dashboard({ return [match, ...browsers.filter(browser => browser.id !== match.id)]; }, [browsers, recommendedBrowser]); - const routingStats = useMemo(() => { - const launching = Object.values(statusById).filter( - status => status === 'launching' - ).length; - const launched = Object.values(statusById).filter( - status => status === 'launched' - ).length; - const failed = Object.values(statusById).filter( - status => status === 'failed' - ).length; - return { - active: activeLink ? 1 : 0, - launching, - launched, - failed, - total: recentHistory.length, - }; - }, [activeLink, recentHistory.length, statusById]); - const handleRecommendedLaunch = async () => { if (!recommendedBrowser || dialogBrowsers.length === 0) return; const matchingBrowser = dialogBrowsers[0]; @@ -125,149 +111,114 @@ export default function Dashboard({ return (
    -
    -
    -
    - Incoming link - +
    +
    +
    + + Incoming link + + {activeLink ? 'Live' : 'Idle'}
    -

    - {activeLink - ? activeLink.url.replace(/^https?:\/\//, '') - : 'Waiting for the next hand-off'} -

    {activeLink ? ( -

    - Shared by{' '} - - {activeLink.contactName} - {' '} - via {activeLink.sourceApp}. Keep the chat window focused while - the browser spins up in the background. -

    +
    +

    + {activeLink.url.replace(/^https?:\/\//, '')} +

    +

    + {activeLink.contactName} • {activeLink.sourceApp} +

    + {activeLink.sourceContext ? ( +

    + {activeLink.sourceContext} +

    + ) : null} + {activeLink.preview ? ( +

    + “{activeLink.preview}” +

    + ) : null} +
    ) : (

    - As soon as a messaging app shares a link, it will appear here - with the suggested browser profile. + Waiting for the next link hand-off.

    )}
    -
    + +
    +
    +

    + Recommendation +

    +

    + {recommendedBrowser + ? `${recommendedBrowser.name}${ + recommendedBrowser.profileLabel + ? ` · ${recommendedBrowser.profileLabel}` + : '' + }` + : 'None'} +

    +
    + {actionError ? (

    {actionError}

    ) : dialogBrowsers.length === 0 ? (

    - No browsers detected yet. Refresh from Settings to register installed - browsers. + No browsers detected. Refresh in Settings to scan installations.

    ) : null}
    -
    - - {activeLink ? ( -
    -
    -

    - Contact -

    -

    - {activeLink.contactName} -

    -

    - {activeLink.sourceContext} -

    -

    - Message preview: “{activeLink.preview}” -

    -
    -
    -

    - Recommended browser -

    -

    - {recommendedBrowser?.name} - {recommendedBrowser?.profileLabel - ? ` · ${recommendedBrowser.profileLabel}` - : ''} -

    -

    - Based on your rule set for links shared from{' '} - {activeLink.sourceApp}. -

    -
    + {needsFallbackPrompt ? ( +
    +
    + Set a fallback browser so unmatched links open automatically. +
    +
    - {dialogBrowsers.length === 0 ? ( -

    - No browsers detected yet. Check Settings → System setup to load - installed browsers. -

    - ) : null}
    -
    - ) : null} -
    - -
    -

    Routing stats

    -

    - Live indicators show how the hand-off service is behaving while the - desktop shell stays in the background. -

    -
    - - - - - + ) : null}
    -
    -
    -

    Recent hand-offs

    -

    - Track how messaging links were opened so you can adjust rules or - resolve issues quickly. -

    -
    - - {recentHistory.length} captured - -
    -
      +

      Recent hand-offs

      +
        {recentHistory.length === 0 ? (
      • - No launches recorded yet. Confirm a browser to see it appear here. + No launches recorded yet.
      • ) : ( recentHistory.map(item => { @@ -330,39 +281,6 @@ export default function Dashboard({
    -
    -

    Why this flow

    -
    -
    -

    - Stay in WhatsApp -

    -

    - Link launches are orchestrated without stealing focus, so you keep - typing while the browser adopts the task. -

    -
    -
    -

    - Tuned per context -

    -

    - Recommendations depend on which conversation the link came from - and which profile you trust with it. -

    -
    -
    -

    - Override anytime -

    -

    - One click to pick another browser or make it a one-off. The system - adapts based on your choices. -

    -
    -
    -
    - 0} onClose={() => setDialogOpen(false)} @@ -374,22 +292,3 @@ export default function Dashboard({
    ); } - -function StatCard({ - label, - value, - accent = 'text-zinc-100', -}: { - label: string; - value: number; - accent?: string; -}) { - return ( -
    -

    - {label} -

    -

    {value}

    -
    - ); -} diff --git a/apps/desktop/src/pages/Rules.tsx b/apps/desktop/src/pages/Rules.tsx index c587179..1e68918 100644 --- a/apps/desktop/src/pages/Rules.tsx +++ b/apps/desktop/src/pages/Rules.tsx @@ -1,3 +1,5 @@ +/* global HTMLInputElement, HTMLFormElement */ + import { ChangeEvent, FormEvent, @@ -16,6 +18,7 @@ import { type RulePolicy, } from '../lib/storage'; import { simulateIncomingLink } from '../lib/routing'; +import { Combobox, Select } from '../components/ui/Select'; type RulesProps = { availableBrowsers: BrowserProfile[]; @@ -25,8 +28,9 @@ const POLICY_OPTIONS: RulePolicy[] = ['Always', 'Just once', 'Fallback']; const LATENCY_OPTIONS = ['< 100 ms', 'Stable', 'Auto']; const createId = () => - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() + typeof globalThis.crypto !== 'undefined' && + typeof globalThis.crypto.randomUUID === 'function' + ? globalThis.crypto.randomUUID() : Math.random().toString(36).slice(2); const formatBrowserLabel = (browser: BrowserProfile) => @@ -106,6 +110,34 @@ export default function Rules({ availableBrowsers }: RulesProps) { [availableBrowsers] ); + const comboboxOptions = useMemo( + () => + browserOptions.map(option => ({ + value: option.id, + label: option.label, + badge: option.browser.profileDirectory ?? null, + })), + [browserOptions] + ); + + const policySelectOptions = useMemo( + () => + POLICY_OPTIONS.map(option => ({ + value: option, + label: option, + })), + [] + ); + + const latencySelectOptions = useMemo( + () => + LATENCY_OPTIONS.map(option => ({ + value: option, + label: option, + })), + [] + ); + const resolveBrowserSelection = (value: string) => { const trimmed = value.trim(); if (!trimmed) { @@ -221,10 +253,9 @@ export default function Rules({ availableBrowsers }: RulesProps) { return; } - const extension = - fileForm.extension.startsWith('.') - ? fileForm.extension.trim().toLowerCase() - : `.${fileForm.extension.trim().toLowerCase()}`; + const extension = fileForm.extension.startsWith('.') + ? fileForm.extension.trim().toLowerCase() + : `.${fileForm.extension.trim().toLowerCase()}`; const selection = resolveBrowserSelection(fileForm.browser); @@ -371,7 +402,13 @@ export default function Rules({ availableBrowsers }: RulesProps) { const file = event.target.files?.[0]; if (!file) return; - const reader = new FileReader(); + const FileReaderCtor = globalThis.FileReader; + if (!FileReaderCtor) { + setError('FileReader API is unavailable in this environment.'); + return; + } + + const reader = new FileReaderCtor(); reader.onload = async () => { const previous = domainRules; try { @@ -456,22 +493,15 @@ export default function Rules({ availableBrowsers }: RulesProps) {

    Routing rules

    -

    - Describe how domains and file types map to browser profiles. These - rules power the asynchronous hand-off without blocking the desktop - shell. -

    - {error ? ( -

    {error}

    - ) : null} + {error ?

    {error}

    : null} {loading ? (

    Loading rules…

    ) : null} @@ -479,14 +509,7 @@ export default function Rules({ availableBrowsers }: RulesProps) {
    -
    -

    Domain policies

    -

    - Control which browser profile is selected when a link matches a - domain. The asynchronous worker resolves these rules before the - dialog renders. -

    -
    +

    Domain rules

    - - {browserOptions.map(option => ( - -

    Execution notes

    diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index 8bb9d38..c7ce0a8 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { arch as osArch, family as osFamily, @@ -17,6 +18,13 @@ import { updateFallbackPreference, type FallbackPreference, } from '../lib/preferences'; +import { + clearDiagnostics, + exportDiagnostics, + fetchDiagnostics, + type DiagnosticEntry, +} from '../lib/diagnostics'; +import { Select } from '../components/ui/Select'; type SettingsProps = { rememberChoice: boolean; @@ -25,6 +33,12 @@ type SettingsProps = { onRememberChoiceChange: (value: boolean) => Promise | void; onShowIconsChange: (value: boolean) => Promise | void; onDebugModeChange: (value: boolean) => Promise | void; + hasFallback: boolean | null; + onFallbackChanged: (hasFallback: boolean) => void; + autostartEnabled: boolean; + autostartReady: boolean; + autostartStatus: string | null; + onAutostartChange: (value: boolean) => Promise | void; }; export default function Settings({ @@ -34,14 +48,28 @@ export default function Settings({ onRememberChoiceChange, onShowIconsChange, onDebugModeChange, + hasFallback, + onFallbackChanged, + autostartEnabled, + autostartReady, + autostartStatus, + onAutostartChange, }: SettingsProps) { const [availableBrowsers, setAvailableBrowsers] = useState([]); - const [availableProfiles, setAvailableProfiles] = useState([]); + const [availableProfiles, setAvailableProfiles] = useState< + ProfileDescriptorWire[] + >([]); const [fallbackBrowser, setFallbackBrowser] = useState(''); - const [fallbackProfileDirectory, setFallbackProfileDirectory] = useState(''); + const [fallbackProfileDirectory, setFallbackProfileDirectory] = + useState(''); const [fallbackProfileLabel, setFallbackProfileLabel] = useState(''); const [savingFallback, setSavingFallback] = useState(false); const [fallbackStatus, setFallbackStatus] = useState(null); + const [diagnostics, setDiagnostics] = useState([]); + const [diagnosticsLoading, setDiagnosticsLoading] = useState(true); + const [diagnosticsStatus, setDiagnosticsStatus] = useState( + null + ); const [defaultStatus, setDefaultStatus] = useState< 'checking' | 'default' | 'not-default' | 'error' >('checking'); @@ -54,6 +82,25 @@ export default function Settings({ version: string; } | null>(null); + const browserSelectOptions = useMemo( + () => [ + { value: '', label: 'Ask each time' }, + ...availableBrowsers.map(browser => ({ value: browser, label: browser })), + ], + [availableBrowsers] + ); + + const profileSelectOptions = useMemo( + () => [ + { value: '', label: 'No specific profile' }, + ...availableProfiles.map(profile => ({ + value: profile.directory, + label: profile.display_name || profile.directory, + })), + ], + [availableProfiles] + ); + useEffect(() => { refreshDefaultStatus(); loadBrowsers(); @@ -72,6 +119,50 @@ export default function Settings({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + let cancelled = false; + let unlisten: UnlistenFn | null = null; + + (async () => { + try { + const snapshot = await fetchDiagnostics(); + if (!cancelled) { + setDiagnostics(snapshot); + setDiagnosticsLoading(false); + } + unlisten = await listen( + 'diagnostics://entry', + event => { + setDiagnostics(prev => { + const filtered = prev.filter( + entry => entry.id != event.payload.id + ); + const next = [event.payload, ...filtered]; + return next.slice(0, 500); + }); + setDiagnosticsLoading(false); + } + ); + } catch (err) { + if (!cancelled) { + setDiagnosticsLoading(false); + setDiagnosticsStatus( + err instanceof Error + ? `Unable to load diagnostics: ${err.message}` + : 'Unable to load diagnostics.' + ); + } + } + })(); + + return () => { + cancelled = true; + if (unlisten) { + void unlisten(); + } + }; + }, []); + const defaultStatusLabel = useMemo(() => { switch (defaultStatus) { case 'default': @@ -130,6 +221,12 @@ export default function Settings({ const snapshot = await fetchPreferences(); if (snapshot.fallback) { applyFallback(snapshot.fallback, browserList); + onFallbackChanged(true); + } else { + setFallbackBrowser(''); + setFallbackProfileDirectory(''); + setFallbackProfileLabel(''); + onFallbackChanged(false); } } catch (err) { // eslint-disable-next-line no-console @@ -153,7 +250,11 @@ export default function Settings({ ); } - void loadProfilesForBrowser(fallback.browser, profileDirectory, profileLabel); + void loadProfilesForBrowser( + fallback.browser, + profileDirectory, + profileLabel + ); } async function loadProfilesForBrowser( @@ -221,7 +322,9 @@ export default function Settings({ function handleFallbackProfileChange(value: string) { setFallbackProfileDirectory(value); - const match = availableProfiles.find(profile => profile.directory === value); + const match = availableProfiles.find( + profile => profile.directory === value + ); setFallbackProfileLabel(match?.display_name ?? value); setFallbackStatus(null); } @@ -235,13 +338,13 @@ export default function Settings({ profile: fallbackBrowser ? fallbackProfileDirectory ? { - label: - fallbackProfileLabel || fallbackProfileDirectory || null, + label: fallbackProfileLabel || fallbackProfileDirectory || null, directory: fallbackProfileDirectory, } : null : null, }); + onFallbackChanged(Boolean(fallbackBrowser)); setFallbackStatus( fallbackBrowser ? `Links without a rule will open in ${fallbackBrowser}${fallbackProfileLabel ? ` · ${fallbackProfileLabel}` : ''}.` @@ -258,6 +361,45 @@ export default function Settings({ } } + async function handleExportDiagnostics() { + try { + const payload = await exportDiagnostics(); + const nav = + typeof globalThis.navigator !== 'undefined' + ? globalThis.navigator + : null; + if (nav?.clipboard) { + await nav.clipboard.writeText(payload ?? ''); + setDiagnosticsStatus('Diagnostics copied to clipboard.'); + } else { + setDiagnosticsStatus( + 'Clipboard unavailable. Select and copy from the list.' + ); + } + } catch (err) { + setDiagnosticsStatus( + err instanceof Error + ? `Failed to export diagnostics: ${err.message}` + : 'Failed to export diagnostics.' + ); + } + } + + async function handleClearDiagnostics() { + try { + await clearDiagnostics(); + setDiagnostics([]); + setDiagnosticsLoading(false); + setDiagnosticsStatus('Diagnostics cleared.'); + } catch (err) { + setDiagnosticsStatus( + err instanceof Error + ? `Failed to clear diagnostics: ${err.message}` + : 'Failed to clear diagnostics.' + ); + } + } + return (
    @@ -323,21 +465,6 @@ export default function Settings({
    -
    -
    -
    -

    Experience

    -

    - Personalize how the desktop shell surfaces asynchronous launches - and how link hand-offs should behave by default. -

    -
    - -
    -
    -

    General settings

    @@ -375,47 +502,57 @@ export default function Settings({ className='h-5 w-5 rounded border border-white/10 bg-black/50 accent-amber-400' /> + + + {autostartStatus ? ( +

    {autostartStatus}

    + ) : null}

    Browser orchestration

    - + onChange={value => void handleFallbackBrowserChange(value)} + /> +
    {fallbackBrowser ? ( - + onChange={value => handleFallbackProfileChange(value)} + disabled={availableProfiles.length === 0} + /> + ) : null}
    @@ -434,7 +571,25 @@ export default function Settings({
    -

    Diagnostics

    +
    +

    Diagnostics

    +
    + + +
    +
    - + {diagnosticsStatus ? ( +

    {diagnosticsStatus}

    + ) : null} +
    + {diagnosticsLoading ? ( +

    Loading diagnostics…

    + ) : diagnostics.length === 0 ? ( +

    + No diagnostic entries yet. +

    + ) : ( + diagnostics.map(entry => ( +
    +

    + {new Date(entry.timestamp).toLocaleString()} +

    +

    + {entry.message} +

    +
    + )) + )} +
    From 3d92ce3a9d17b1db1a7a851df917de8e33c08eb6 Mon Sep 17 00:00:00 2001 From: theg Date: Tue, 21 Oct 2025 03:38:13 +0530 Subject: [PATCH 6/7] fix: for snake_case --- apps/desktop/src/lib/routing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/routing.ts b/apps/desktop/src/lib/routing.ts index 2626202..e599e4e 100644 --- a/apps/desktop/src/lib/routing.ts +++ b/apps/desktop/src/lib/routing.ts @@ -189,6 +189,6 @@ export async function fetchAvailableBrowsers() { export async function fetchProfilesFor(browser: string) { return invoke('get_profiles', { - browserKind: browser, + browser_kind: browser, }); } From 9b118bdd598dd0897a3d1506ba74c611a95dbf6a Mon Sep 17 00:00:00 2001 From: theg Date: Tue, 21 Oct 2025 04:07:32 +0530 Subject: [PATCH 7/7] refix: it should be normal casing like it is rn --- apps/desktop/bun.lock | 359 ++++++++++++++++-- apps/desktop/eslint.config.js | 2 + apps/desktop/src/App.tsx | 197 ++++++---- apps/desktop/src/index.css | 4 +- apps/desktop/src/lib/routing.ts | 2 +- apps/desktop/src/store/appStore.ts | 97 +++++ .../desktop/src/tests/OpenWithDialog.test.tsx | 9 +- apps/desktop/tsconfig.json | 1 + apps/desktop/vite.config.ts | 2 +- 9 files changed, 568 insertions(+), 105 deletions(-) create mode 100644 apps/desktop/src/store/appStore.ts diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 7a2aa28..ef55e35 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -17,7 +17,13 @@ "zustand": "^5.0.8", }, "devDependencies": { + "@tailwindcss/vite": "^4.1.15", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/jest": "^30.0.0", + "@types/jest-axe": "^3.5.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.46.1", @@ -29,14 +35,25 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-refresh": "^0.4.24", + "jest-axe": "^10.0.0", + "jsdom": "^27.0.1", "prettier": "^3.6.2", "rollup": "^4.52.5", "typescript": "~5.8.3", "vite": "^7.0.4", + "vitest": "^3.2.4", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.0.5", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.1" } }, "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.2", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.2" } }, "sha512-ccKogJI+0aiDhOahdjANIc9SDixSud1gbwdVrhn7kMopAtLXqsz9MKmQQtIl6Y5aC2IYq+j4dz/oedL2AVMmVQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, ""], "@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, ""], @@ -69,12 +86,26 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, ""], + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, ""], "@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, ""], "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, ""], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.14", "", { "peerDependencies": { "postcss": "^8.4" } }, "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, ""], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], @@ -103,7 +134,17 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="], + + "@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="], + + "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + + "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + + "@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + + "@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, ""], @@ -169,35 +210,37 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.15", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.15" } }, "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.15", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.15", "@tailwindcss/oxide-darwin-arm64": "4.1.15", "@tailwindcss/oxide-darwin-x64": "4.1.15", "@tailwindcss/oxide-freebsd-x64": "4.1.15", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.15", "@tailwindcss/oxide-linux-arm64-musl": "4.1.15", "@tailwindcss/oxide-linux-x64-gnu": "4.1.15", "@tailwindcss/oxide-linux-x64-musl": "4.1.15", "@tailwindcss/oxide-wasm32-wasi": "4.1.15", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.15", "@tailwindcss/oxide-win32-x64-msvc": "4.1.15" } }, "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.15", "", { "os": "android", "cpu": "arm64" }, "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.15", "", { "os": "linux", "cpu": "arm" }, "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.15", "", { "os": "linux", "cpu": "x64" }, "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.15", "", { "os": "linux", "cpu": "x64" }, "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.15", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.15", "", { "os": "win32", "cpu": "x64" }, "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.15", "", { "dependencies": { "@tailwindcss/node": "4.1.15", "@tailwindcss/oxide": "4.1.15", "tailwindcss": "4.1.15" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-B6s60MZRTUil+xKoZoGe6i0Iar5VuW+pmcGlda2FX+guDuQ1G1sjiIy1W0frneVpeL/ZjZ4KEgWZHNrIm++2qA=="], "@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, ""], @@ -213,6 +256,16 @@ "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, ""], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, ""], @@ -221,14 +274,36 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, ""], + "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, ""], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="], + + "@types/jest-axe": ["@types/jest-axe@3.5.9", "", { "dependencies": { "@types/jest": "*", "axe-core": "^3.5.5" } }, "sha512-z98CzR0yVDalCEuhGXXO4/zN4HHuSebAukXDjTLJyjEAgoUf1H1i+sr7SUB/mz8CRS/03/XChsx0dcLjHkndoQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@24.9.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ=="], + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, ""], "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, ""], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + + "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/type-utils": "8.46.1", "@typescript-eslint/utils": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA=="], @@ -251,16 +326,36 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, ""], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], @@ -275,20 +370,28 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "axe-core": ["axe-core@3.5.6", "", {}, "sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.17", "", { "bin": "dist/cli.js" }, ""], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": "cli.js" }, ""], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -299,9 +402,13 @@ "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, ""], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], + + "ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -313,8 +420,16 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@5.3.1", "", { "dependencies": { "@asamuzakjp/css-color": "^4.0.3", "@csstools/css-syntax-patches-for-csstree": "^1.0.14", "css-tree": "^3.1.0" } }, "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ=="], + "csstype": ["csstype@3.1.3", "", {}, ""], + "data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -323,22 +438,34 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, ""], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.237", "", {}, ""], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -347,6 +474,8 @@ "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -385,8 +514,14 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="], + + "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -463,12 +598,22 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -503,6 +648,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -527,12 +674,30 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jest-axe": ["jest-axe@10.0.0", "", { "dependencies": { "axe-core": "4.10.2", "chalk": "4.1.2", "jest-matcher-utils": "29.2.2", "lodash.merge": "4.6.2" } }, "sha512-9QR0M7//o5UVRnEUUm68IsGapHrcKGakYy9dKWWMX79LmeUKguDI6DREyljC5I13j78OUmtKLF5My6ccffLFBg=="], + + "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-matcher-utils": ["jest-matcher-utils@29.2.2", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.2.1", "jest-get-type": "^29.2.0", "pretty-format": "^29.2.1" } }, "sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw=="], + + "jest-message-util": ["jest-message-util@30.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="], + + "jest-mock": ["jest-mock@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="], + + "jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + + "jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, ""], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsdom": ["jsdom@27.0.1", "", { "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, ""], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -551,6 +716,8 @@ "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], @@ -577,21 +744,25 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, ""], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="], @@ -631,12 +802,18 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, ""], "picomatch": ["picomatch@4.0.3", "", {}, ""], @@ -651,6 +828,8 @@ "prettier-linter-helpers": ["prettier-linter-helpers@1.0.0", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w=="], + "pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -661,14 +840,18 @@ "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, ""], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-refresh": ["react-refresh@0.17.0", "", {}, ""], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -677,6 +860,8 @@ "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], @@ -685,6 +870,10 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, ""], "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, ""], @@ -707,8 +896,18 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, ""], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], @@ -721,24 +920,46 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="], "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + + "tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -757,12 +978,28 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, ""], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, ""], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -773,9 +1010,17 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yallist": ["yallist@3.1.1", "", {}, ""], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -785,10 +1030,16 @@ "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, ""], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.1.15", "", {}, "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], @@ -801,24 +1052,78 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.15", "", {}, "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "expect/jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "lru-cache/yallist": ["yallist@3.1.1", "", {}, ""], + "jest-axe/axe-core": ["axe-core@4.10.2", "", {}, "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w=="], + + "jest-diff/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-matcher-utils/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "vite/rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4" }, "bin": "dist/bin/rollup" }, ""], + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "expect/jest-matcher-utils/jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="], + + "jest-diff/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "vite/rollup/@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, ""], "vite/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, ""], + + "jest-diff/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], + + "jest-matcher-utils/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], } } diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js index 5fbbced..b5e7871 100644 --- a/apps/desktop/eslint.config.js +++ b/apps/desktop/eslint.config.js @@ -40,6 +40,8 @@ export default [ ...reactHooks.configs.recommended.rules, ...prettierConfig.rules, + 'no-undef': 'off', + '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' }, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e78cac5..5953a76 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import Layout from './Layout'; import Dashboard from './pages/Dashboard'; import Rules from './pages/Rules'; import Settings from './pages/Settings'; -import { ActiveLink, LaunchHistoryItem } from './lib/models'; +import type { BrowserProfile } from './OpenWithDialog'; import { fetchAvailableBrowsers, fetchRoutingSnapshot, @@ -13,16 +13,12 @@ import { listenRoutingError, resolveIncomingLink, fetchProfilesFor, - type RoutingStatusWire, } from './lib/routing'; -import type { BrowserProfile } from './OpenWithDialog'; import { fetchPreferences } from './lib/preferences'; import { - DEFAULT_UI_SETTINGS, loadUiSettings, persistLastSelectedBrowser, setUiSetting, - type UiSettings, } from './lib/storage'; import { useUIStore } from './store/uiStore'; import { @@ -30,31 +26,51 @@ import { loadAutostartState, setAutostartState, } from './lib/autostart'; - -type PageKey = 'dashboard' | 'rules' | 'settings'; -type StatusMap = Record; -type ErrorMap = Record; +import { useAppStore } from './store/appStore'; export default function App() { - const [currentPage, setCurrentPage] = useState('dashboard'); - const [activeLink, setActiveLink] = useState(null); - const [history, setHistory] = useState([]); - const [statusById, setStatusById] = useState({}); - const [errorsById, setErrorsById] = useState({}); - const [ready, setReady] = useState(false); - const [initError, setInitError] = useState(null); - const [browserCatalog, setBrowserCatalog] = useState([]); - const [uiSettings, setUiSettings] = useState(DEFAULT_UI_SETTINGS); - const [settingsReady, setSettingsReady] = useState(false); - const [hasFallback, setHasFallback] = useState(null); - const [fallbackPromptVisible, setFallbackPromptVisible] = useState(false); - const [dismissedFallbackFor, setDismissedFallbackFor] = useState< - string | null - >(null); - const [autostartEnabled, setAutostartEnabled] = useState(false); - const [autostartReady, setAutostartReady] = useState(false); - const [autostartStatus, setAutostartStatus] = useState(null); - const [pendingFallbackFocus, setPendingFallbackFocus] = useState(false); + const currentPage = useAppStore(state => state.currentPage); + const setCurrentPage = useAppStore(state => state.setCurrentPage); + const activeLink = useAppStore(state => state.activeLink); + const setActiveLink = useAppStore(state => state.setActiveLink); + const history = useAppStore(state => state.history); + const setHistory = useAppStore(state => state.setHistory); + const statusById = useAppStore(state => state.statusById); + const setStatusById = useAppStore(state => state.setStatusById); + const errorsById = useAppStore(state => state.errorsById); + const setErrorsById = useAppStore(state => state.setErrorsById); + const ready = useAppStore(state => state.ready); + const setReady = useAppStore(state => state.setReady); + const initError = useAppStore(state => state.initError); + const setInitError = useAppStore(state => state.setInitError); + const browserCatalog = useAppStore(state => state.browserCatalog); + const setBrowserCatalog = useAppStore(state => state.setBrowserCatalog); + const uiSettings = useAppStore(state => state.uiSettings); + const setUiSettings = useAppStore(state => state.setUiSettings); + const settingsReady = useAppStore(state => state.settingsReady); + const setSettingsReady = useAppStore(state => state.setSettingsReady); + const hasFallback = useAppStore(state => state.hasFallback); + const setHasFallback = useAppStore(state => state.setHasFallback); + const fallbackPromptVisible = useAppStore( + state => state.fallbackPromptVisible + ); + const setFallbackPromptVisible = useAppStore( + state => state.setFallbackPromptVisible + ); + const dismissedFallbackFor = useAppStore(state => state.dismissedFallbackFor); + const setDismissedFallbackFor = useAppStore( + state => state.setDismissedFallbackFor + ); + const autostartEnabled = useAppStore(state => state.autostartEnabled); + const setAutostartEnabled = useAppStore(state => state.setAutostartEnabled); + const autostartReady = useAppStore(state => state.autostartReady); + const setAutostartReady = useAppStore(state => state.setAutostartReady); + const autostartStatus = useAppStore(state => state.autostartStatus); + const setAutostartStatus = useAppStore(state => state.setAutostartStatus); + const pendingFallbackFocus = useAppStore(state => state.pendingFallbackFocus); + const setPendingFallbackFocus = useAppStore( + state => state.setPendingFallbackFocus + ); const hasFallbackRef = useRef(null); const setDialogSelectedBrowser = useUIStore( @@ -102,7 +118,12 @@ export default function App() { return () => { cancelled = true; }; - }, [resetDialogSelection, setDialogSelectedBrowser]); + }, [ + resetDialogSelection, + setDialogSelectedBrowser, + setSettingsReady, + setUiSettings, + ]); useEffect(() => { if (!isTauriEnvironment()) { @@ -134,7 +155,7 @@ export default function App() { return () => { cancelled = true; }; - }, []); + }, [setAutostartEnabled, setAutostartReady, setAutostartStatus]); useEffect(() => { let cancelled = false; @@ -160,7 +181,7 @@ export default function App() { return () => { cancelled = true; }; - }, []); + }, [setHasFallback]); useEffect(() => { let unlisten: Array<() => void> = []; @@ -229,7 +250,17 @@ export default function App() { return () => { unlisten.forEach(fn => fn()); }; - }, [focusMainWindow]); + }, [ + focusMainWindow, + setActiveLink, + setErrorsById, + setHistory, + setInitError, + setPendingFallbackFocus, + setReady, + setStatusById, + setFallbackPromptVisible, + ]); useEffect(() => { if (activeLink && hasFallback === false) { @@ -237,14 +268,14 @@ export default function App() { setFallbackPromptVisible(true); } } - }, [activeLink, hasFallback, dismissedFallbackFor]); + }, [activeLink, hasFallback, dismissedFallbackFor, setFallbackPromptVisible]); useEffect(() => { if (!activeLink) { setFallbackPromptVisible(false); setPendingFallbackFocus(false); } - }, [activeLink]); + }, [activeLink, setFallbackPromptVisible, setPendingFallbackFocus]); useEffect(() => { hasFallbackRef.current = hasFallback; @@ -261,7 +292,14 @@ export default function App() { setPendingFallbackFocus(false); void focusMainWindow(); } - }, [hasFallback, pendingFallbackFocus, focusMainWindow]); + }, [ + hasFallback, + pendingFallbackFocus, + focusMainWindow, + setDismissedFallbackFor, + setFallbackPromptVisible, + setPendingFallbackFocus, + ]); useEffect(() => { if (!activeLink) return; @@ -350,7 +388,7 @@ export default function App() { return () => { cancelled = true; }; - }, []); + }, [setBrowserCatalog]); useEffect(() => { if (!settingsReady) return; @@ -379,6 +417,7 @@ export default function App() { resetDialogSelection, settingsReady, uiSettings.lastSelectedBrowserId, + setUiSettings, ]); const recentHistory = useMemo(() => history.slice(0, 5), [history]); @@ -404,53 +443,67 @@ export default function App() { console.warn('Unable to update remember choice setting', err); } }, - [resetDialogSelection] + [resetDialogSelection, setUiSettings] ); - const handleShowIconsChange = useCallback(async (value: boolean) => { - try { - await setUiSetting('showIcons', value); - setUiSettings(prev => ({ - ...prev, - showIcons: value, - })); - } catch (err) { - // eslint-disable-next-line no-console - console.warn('Unable to update show icons setting', err); - } - }, []); + const handleShowIconsChange = useCallback( + async (value: boolean) => { + try { + await setUiSetting('showIcons', value); + setUiSettings(prev => ({ + ...prev, + showIcons: value, + })); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to update show icons setting', err); + } + }, + [setUiSettings] + ); - const handleDebugModeChange = useCallback(async (value: boolean) => { - try { - await setUiSetting('debugMode', value); - setUiSettings(prev => ({ - ...prev, - debugMode: value, - })); - } catch (err) { - // eslint-disable-next-line no-console - console.warn('Unable to update debug mode setting', err); - } - }, []); + const handleDebugModeChange = useCallback( + async (value: boolean) => { + try { + await setUiSetting('debugMode', value); + setUiSettings(prev => ({ + ...prev, + debugMode: value, + })); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Unable to update debug mode setting', err); + } + }, + [setUiSettings] + ); - const handleFallbackChanged = useCallback((value: boolean) => { - setHasFallback(value); - if (value) { - setFallbackPromptVisible(false); - setDismissedFallbackFor(null); - } - }, []); + const handleFallbackChanged = useCallback( + (value: boolean) => { + setHasFallback(value); + if (value) { + setFallbackPromptVisible(false); + setDismissedFallbackFor(null); + } + }, + [setDismissedFallbackFor, setFallbackPromptVisible, setHasFallback] + ); const handleOpenFallbackSettings = useCallback(() => { setCurrentPage('settings'); setFallbackPromptVisible(false); setDismissedFallbackFor(activeLink?.id ?? null); - }, [activeLink]); + }, [ + activeLink?.id, + setCurrentPage, + setDismissedFallbackFor, + setFallbackPromptVisible, + ]); const handleDismissFallbackPrompt = useCallback(() => { setFallbackPromptVisible(false); setDismissedFallbackFor(activeLink?.id ?? null); - }, [activeLink]); + }, [activeLink?.id, setDismissedFallbackFor, setFallbackPromptVisible]); const handleAutostartChange = useCallback( async (value: boolean) => { @@ -478,7 +531,7 @@ export default function App() { ); } }, - [autostartEnabled] + [autostartEnabled, setAutostartEnabled, setAutostartStatus] ); const handleRecordLaunch = async ( diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index fb29058..6ebca2a 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -1,7 +1,7 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @theme { - --font-sans: "Inter Variable", "Inter", system-ui, sans-serif; + --font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif; } @layer base { diff --git a/apps/desktop/src/lib/routing.ts b/apps/desktop/src/lib/routing.ts index e599e4e..2626202 100644 --- a/apps/desktop/src/lib/routing.ts +++ b/apps/desktop/src/lib/routing.ts @@ -189,6 +189,6 @@ export async function fetchAvailableBrowsers() { export async function fetchProfilesFor(browser: string) { return invoke('get_profiles', { - browser_kind: browser, + browserKind: browser, }); } diff --git a/apps/desktop/src/store/appStore.ts b/apps/desktop/src/store/appStore.ts new file mode 100644 index 0000000..f6611c5 --- /dev/null +++ b/apps/desktop/src/store/appStore.ts @@ -0,0 +1,97 @@ +import { create } from 'zustand'; +import type { ActiveLink, LaunchHistoryItem } from '../lib/models'; +import type { BrowserProfile } from '../OpenWithDialog'; +import { DEFAULT_UI_SETTINGS, type UiSettings } from '../lib/storage'; +import type { RoutingStatusWire } from '../lib/routing'; + +type Updater = T | ((previous: T) => T); + +type StatusMap = Record; +type ErrorMap = Record; +export type PageKey = 'dashboard' | 'rules' | 'settings'; + +type AppStore = { + currentPage: PageKey; + setCurrentPage: (page: PageKey) => void; + activeLink: ActiveLink | null; + setActiveLink: (link: ActiveLink | null) => void; + history: LaunchHistoryItem[]; + setHistory: (updater: Updater) => void; + statusById: StatusMap; + setStatusById: (updater: Updater) => void; + errorsById: ErrorMap; + setErrorsById: (updater: Updater) => void; + ready: boolean; + setReady: (ready: boolean) => void; + initError: string | null; + setInitError: (error: string | null) => void; + browserCatalog: BrowserProfile[]; + setBrowserCatalog: (catalog: BrowserProfile[]) => void; + uiSettings: UiSettings; + setUiSettings: (updater: Updater) => void; + settingsReady: boolean; + setSettingsReady: (ready: boolean) => void; + hasFallback: boolean | null; + setHasFallback: (value: boolean | null) => void; + fallbackPromptVisible: boolean; + setFallbackPromptVisible: (visible: boolean) => void; + dismissedFallbackFor: string | null; + setDismissedFallbackFor: (id: string | null) => void; + autostartEnabled: boolean; + setAutostartEnabled: (enabled: boolean) => void; + autostartReady: boolean; + setAutostartReady: (ready: boolean) => void; + autostartStatus: string | null; + setAutostartStatus: (status: string | null) => void; + pendingFallbackFocus: boolean; + setPendingFallbackFocus: (pending: boolean) => void; +}; + +function resolveUpdater(updater: Updater, previous: T): T { + return typeof updater === 'function' + ? (updater as (state: T) => T)(previous) + : updater; +} + +export const useAppStore = create(set => ({ + currentPage: 'dashboard', + setCurrentPage: page => set({ currentPage: page }), + activeLink: null, + setActiveLink: link => set({ activeLink: link }), + history: [], + setHistory: updater => + set(state => ({ history: resolveUpdater(updater, state.history) })), + statusById: {}, + setStatusById: updater => + set(state => ({ statusById: resolveUpdater(updater, state.statusById) })), + errorsById: {}, + setErrorsById: updater => + set(state => ({ errorsById: resolveUpdater(updater, state.errorsById) })), + ready: false, + setReady: ready => set({ ready }), + initError: null, + setInitError: error => set({ initError: error }), + browserCatalog: [], + setBrowserCatalog: catalog => set({ browserCatalog: catalog }), + uiSettings: DEFAULT_UI_SETTINGS, + setUiSettings: updater => + set(state => ({ uiSettings: resolveUpdater(updater, state.uiSettings) })), + settingsReady: false, + setSettingsReady: ready => set({ settingsReady: ready }), + hasFallback: null, + setHasFallback: value => set({ hasFallback: value }), + fallbackPromptVisible: false, + setFallbackPromptVisible: visible => set({ fallbackPromptVisible: visible }), + dismissedFallbackFor: null, + setDismissedFallbackFor: id => set({ dismissedFallbackFor: id }), + autostartEnabled: false, + setAutostartEnabled: enabled => set({ autostartEnabled: enabled }), + autostartReady: false, + setAutostartReady: ready => set({ autostartReady: ready }), + autostartStatus: null, + setAutostartStatus: status => set({ autostartStatus: status }), + pendingFallbackFocus: false, + setPendingFallbackFocus: pending => set({ pendingFallbackFocus: pending }), +})); + +export type { StatusMap, ErrorMap }; diff --git a/apps/desktop/src/tests/OpenWithDialog.test.tsx b/apps/desktop/src/tests/OpenWithDialog.test.tsx index 6366e7e..bf94b72 100644 --- a/apps/desktop/src/tests/OpenWithDialog.test.tsx +++ b/apps/desktop/src/tests/OpenWithDialog.test.tsx @@ -6,8 +6,13 @@ import OpenWithDialog, { type BrowserProfile } from '../OpenWithDialog'; import { describe, it, expect, vi } from 'vitest'; const browsers: BrowserProfile[] = [ - { id: 'b1', name: 'Chrome', profile: 'Personal' }, - { id: 'b2', name: 'Firefox', profile: 'Work' }, + { + id: 'b1', + name: 'Chrome', + profileLabel: 'Personal', + profileDirectory: null, + }, + { id: 'b2', name: 'Firefox', profileLabel: 'Work', profileDirectory: null }, ]; describe('OpenWithDialog (accessibility + keyboard)', () => { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index a7fc6fb..8c926f3 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -21,5 +21,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], + "exclude": ["src/tests/**"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index dabe4ca..f4dd3a7 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,6 +1,6 @@ import { defineConfig, type PluginOption } from 'vite'; import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite' +import tailwindcss from '@tailwindcss/vite'; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST;