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 0d6d808..ef55e35 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -4,11 +4,16 @@ "": { "name": "desktop", "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", "motion": "^12.23.24", "react": "^19.1.0", "react-dom": "^19.1.0", + "tailwindcss": "^4.1.14", "zustand": "^5.0.8", }, "devDependencies": { @@ -243,8 +248,14 @@ "@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=="], + + "@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=="], @@ -703,29 +714,29 @@ "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.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=="], + "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.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "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.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "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.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "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.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "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.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "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.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "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.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "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.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "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.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "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.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "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=="], @@ -923,7 +934,7 @@ "synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="], - "tailwindcss": ["tailwindcss@4.1.15", "", {}, "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ=="], + "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1025,6 +1036,10 @@ "@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=="], @@ -1037,6 +1052,8 @@ "@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=="], @@ -1071,6 +1088,26 @@ "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=="], 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/package.json b/apps/desktop/package.json index 071177a..0b87ed1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,11 +14,16 @@ "format:check": "prettier --check ." }, "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", "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..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" @@ -457,8 +468,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 +516,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 +549,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 +562,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 +616,7 @@ dependencies = [ "signal-hook", "tiny_http", "tungstenite", - "which", + "which 6.0.3", ] [[package]] @@ -717,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]] @@ -734,7 +777,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -841,7 +884,7 @@ dependencies = [ "rustc_version", "toml 0.9.8", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1233,6 +1276,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 +2549,25 @@ dependencies = [ name = "open-with-browser" version = "0.1.0" 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", + "tauri-plugin-store", + "tokio", + "url", "uuid", + "which 5.0.0", + "winreg 0.52.0", ] [[package]] @@ -2522,6 +2586,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" @@ -3019,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" @@ -3687,6 +3774,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 +3804,7 @@ checksum = "6121216ff67fe4bcfe64508ea1700bc15f74937d835a07b4a209cc00a8926a84" dependencies = [ "bitflags 2.9.4", "block2 0.6.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3766,7 +3862,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3817,7 +3913,7 @@ checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3889,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" @@ -3911,6 +4021,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 +4298,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" @@ -4334,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", @@ -4750,6 +4921,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 +5140,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 +5200,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 +5272,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 +5296,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 +5320,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 +5356,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 +5380,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 +5404,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 +5428,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 +5464,25 @@ 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" +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" @@ -5243,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 f2dfd24..d2146eb 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,11 +18,25 @@ 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" 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..058a6e6 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,14 @@ "windows": ["main"], "permissions": [ "core:default", - "opener: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", + "autostart:default" ] } diff --git a/apps/desktop/src-tauri/src/browser_details.rs b/apps/desktop/src-tauri/src/browser_details.rs index 5a4d89f..1939467 100644 --- a/apps/desktop/src-tauri/src/browser_details.rs +++ b/apps/desktop/src-tauri/src/browser_details.rs @@ -1,10 +1,12 @@ use crowser::browser; +use dirs::{config_dir, data_local_dir, home_dir}; +use serde::Serialize; +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 { @@ -15,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(); @@ -23,11 +31,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,28 +43,27 @@ 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 file = fs::File::open(&path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; @@ -70,40 +73,95 @@ 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})?; - - let mut profile_names: 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()); - } + .ok_or_else(|| { + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "Could not find 'profile' or 'info_cache' in JSON.", + )) as Box + })?; + + let mut profiles: Vec = Vec::new(); + + 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(|| 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(|| { + 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 { + 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> { - - let paths: Vec<&str> = match kind { - Browsers::Chrome => vec![ +pub fn get_chrome_profiles( + kind: Browsers, +) -> Result, Box> { + 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,43 +169,43 @@ 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() +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 => 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 + 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()) - } - _ => None, + .filter_map(|entry| match entry.file_type() { + Ok(file_type) if file_type.is_dir() => { + 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); @@ -158,5 +216,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..4852e50 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,22 +1,88 @@ -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, + ProfileDescriptor, + }, + diagnostics::{DiagnosticEntry, DiagnosticsState}, + platform, + preferences::{FallbackPreference, PreferencesState, ProfilePreference}, + routing::{ + simulate_link_payload, IncomingLink, LaunchDecision, RoutingSnapshot, RoutingStateHandle, + }, }; +use serde::{Deserialize, Serialize}; +use std::process::Command; +use tauri::{AppHandle, Manager, State}; +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() } #[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}"))?; @@ -27,4 +93,187 @@ 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 { + #[cfg(target_os = "windows")] + let _ = &app_handle; + + 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, +} + +#[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::() { + 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( + &app_handle, + Some(FallbackPreference { + browser: name, + profile: profile.map(|p| ProfilePreference { + label: p.label, + directory: p.directory, + }), + }), + ) + .await + } + _ => 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/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..8975617 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,8 +1,30 @@ mod browser_details; mod commands; +mod diagnostics; mod domain; +mod link; +mod platform; +mod preferences; +mod routing; -use commands::{get_available_browsers, get_profiles}; +use commands::{ + 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::{ + 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; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] @@ -12,13 +34,132 @@ 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_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); + + 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}"), + } + + 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![ 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, + get_diagnostics, + clear_diagnostics, + export_diagnostics + ]); + + 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..258e9ce --- /dev/null +++ b/apps/desktop/src-tauri/src/preferences.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::ErrorKind; +use tauri::{async_runtime::RwLock, AppHandle}; +use tauri_plugin_store::{Error as StoreError, StoreExt}; + +const PREFERENCES_STORE: &str = "preferences.json"; +const PREFERENCES_KEY: &str = "preferences"; + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfilePreference { + #[serde(default)] + pub label: Option, + #[serde(default)] + pub directory: Option, +} + +pub struct PreferencesState { + inner: RwLock, +} + +impl PreferencesState { + pub fn load(app: &AppHandle) -> Result { + let prefs = load_preferences(app)?; + Ok(Self { + 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, + app: &AppHandle, + fallback: Option, + ) -> Result<(), String> { + { + let mut guard = self.inner.write().await; + guard.fallback = fallback.clone(); + } + + persist_preferences(app, &self.inner).await + } +} + +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()), + } + } + + 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(app: &AppHandle, data: &RwLock) -> Result<(), String> { + let snapshot = { + let guard = data.read().await; + guard.clone() + }; + + 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 new file mode 100644 index 0000000..451bcea --- /dev/null +++ b/apps/desktop/src-tauri/src/routing.rs @@ -0,0 +1,685 @@ +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 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_label: Option, + #[serde(default)] + pub profile_directory: 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_label: Option, + #[serde(default)] + pub profile_directory: 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(); + } + 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()); + } + 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 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_label, + profile_directory, + 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}"); + append_log( + app_handle, + &format!("Automatic fallback failed for link id={}: {}", link.id, err), + ); + } + } + } + + Ok(link) + } + + pub async fn resolve( + &self, + 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()); + } + + { + 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 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 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, + }, + ); + 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 + } + }; + + 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; + } +} + +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) { + 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)] +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: None, + 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-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 4f2af35..5953a76 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,81 +1,583 @@ -import { useMemo, 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'; - -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(), - }, -]; +import type { BrowserProfile } from './OpenWithDialog'; +import { + fetchAvailableBrowsers, + fetchRoutingSnapshot, + listenIncomingLink, + listenLaunchDecision, + listenRoutingStatus, + listenRoutingError, + resolveIncomingLink, + fetchProfilesFor, +} from './lib/routing'; +import { fetchPreferences } from './lib/preferences'; +import { + loadUiSettings, + persistLastSelectedBrowser, + setUiSetting, +} from './lib/storage'; +import { useUIStore } from './store/uiStore'; +import { + isTauriEnvironment, + loadAutostartState, + setAutostartState, +} from './lib/autostart'; +import { useAppStore } from './store/appStore'; export default function App() { - const [currentPage, setCurrentPage] = useState('dashboard'); - const [incomingPointer, setIncomingPointer] = useState(1); - const [activeLink, setActiveLink] = useState(incomingLinks[0]); - const [history, setHistory] = useState([]); + 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( + 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; + + (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, + setSettingsReady, + setUiSettings, + ]); + + 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; + }; + }, [setAutostartEnabled, setAutostartReady, setAutostartStatus]); + + 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; + }; + }, [setHasFallback]); + + 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); + 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 => [ + 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()); + }; + }, [ + focusMainWindow, + setActiveLink, + setErrorsById, + setHistory, + setInitError, + setPendingFallbackFocus, + setReady, + setStatusById, + setFallbackPromptVisible, + ]); + + useEffect(() => { + if (activeLink && hasFallback === false) { + if (dismissedFallbackFor !== activeLink.id) { + setFallbackPromptVisible(true); + } + } + }, [activeLink, hasFallback, dismissedFallbackFor, setFallbackPromptVisible]); + + useEffect(() => { + if (!activeLink) { + setFallbackPromptVisible(false); + setPendingFallbackFocus(false); + } + }, [activeLink, setFallbackPromptVisible, setPendingFallbackFocus]); + + 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, + setDismissedFallbackFor, + setFallbackPromptVisible, + setPendingFallbackFocus, + ]); + + useEffect(() => { + if (!activeLink) return; + if (hasFallback === false) { + void focusMainWindow(); + } + }, [activeLink, hasFallback, focusMainWindow]); + + useEffect(() => { + if (!fallbackPromptVisible) return; + void focusMainWindow(); + }, [fallbackPromptVisible, focusMainWindow]); + + 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; + }; + }, [setBrowserCatalog]); + + 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, + setUiSettings, + ]); const recentHistory = useMemo(() => history.slice(0, 5), [history]); - const cycleIncomingLink = () => { - const nextIndex = incomingPointer % incomingLinks.length; - setActiveLink(incomingLinks[nextIndex]); - setIncomingPointer(nextIndex + 1); - }; + 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, 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); + } + }, + [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); + } + }, + [setUiSettings] + ); - const handleRecordLaunch = ( - browser: string, - profile: string | null | undefined, + 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?.id, + setCurrentPage, + setDismissedFallbackFor, + setFallbackPromptVisible, + ]); + + const handleDismissFallbackPrompt = useCallback(() => { + setFallbackPromptVisible(false); + setDismissedFallbackFor(activeLink?.id ?? null); + }, [activeLink?.id, setDismissedFallbackFor, setFallbackPromptVisible]); + + 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, setAutostartEnabled, setAutostartStatus] + ); + + 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, + 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]; + return next; + }); + } catch (err) { + setStatusById(prev => ({ + ...prev, + [activeLink.id]: 'failed', + })); + throw err; + } }; const renderPage = () => { @@ -84,22 +586,49 @@ export default function App() { return ( ); case 'rules': - return ; + return ; case 'settings': - return ; + return ( + + ); default: return ( ); } @@ -111,7 +640,19 @@ export default function App() { onNavigate={setCurrentPage} activeLink={activeLink} > - {renderPage()} + {ready ? ( + initError ? ( +
+ {initError} +
+ ) : ( + renderPage() + ) + ) : ( +
+ Initializing routing service… +
+ )} ); } diff --git a/apps/desktop/src/Layout.tsx b/apps/desktop/src/Layout.tsx index 8004480..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

+
-
); @@ -183,49 +156,36 @@ 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} {children} diff --git a/apps/desktop/src/OpenWithDialog.tsx b/apps/desktop/src/OpenWithDialog.tsx index 5e5f104..7311448 100644 --- a/apps/desktop/src/OpenWithDialog.tsx +++ b/apps/desktop/src/OpenWithDialog.tsx @@ -1,26 +1,24 @@ -/* eslint-env browser */ -/* global HTMLDivElement, HTMLInputElement, KeyboardEvent, FocusEvent, Node, requestAnimationFrame, setTimeout */ -import { - useState, - useEffect, - useRef, - useCallback, - type MouseEvent, -} from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { type UIState, useUIStore } from './store/uiStore'; export type BrowserProfile = { id: string; name: string; icon?: string; - profile?: string | null; + profileLabel?: string | null; + profileDirectory?: string | null; }; 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; + showIcons?: boolean; }; export default function OpenWithDialog({ @@ -28,6 +26,8 @@ export default function OpenWithDialog({ onClose: onCloseProp, browsers, onChoose, + disabled = false, + showIcons = true, }: Props) { const storeOpen = useUIStore((s: UIState) => s.isDialogOpen); const storeSelected = useUIStore((s: UIState) => s.selectedBrowserId); @@ -35,184 +35,119 @@ export default function OpenWithDialog({ const closeDialog = useUIStore((s: UIState) => s.closeDialog); const open = openProp ?? storeOpen; - const [selectedId, setSelectedId] = useState( - storeSelected ?? browsers[0]?.id ?? null - ); - const backdropRef = useRef(null); - const dialogRef = useRef(null); + const [localSelected, setLocalSelected] = useState(null); + const [submitting, setSubmitting] = useState(false); + const defaultSelection = storeSelected ?? browsers[0]?.id ?? null; + const selected = localSelected ?? defaultSelection; const handleClose = useCallback(() => { - setSelectedId(null); + setLocalSelected(null); if (onCloseProp) onCloseProp(); else closeDialog(); - }, [onCloseProp, closeDialog]); - - const handleChoose = (persist: 'just-once' | 'always') => { - const browser = browsers.find(b => b.id === selectedId); - if (browser) { - setSelectedBrowser(browser.id); - onChoose(browser, persist); - setSelectedId(null); - } - }; - - const handleBackdropClick = (e: MouseEvent) => { - if (e.target === backdropRef.current) handleClose(); - }; + }, [closeDialog, onCloseProp]); useEffect(() => { - if (!open || !dialogRef.current) return; - - const dlg = dialogRef.current; - - const scheduleInit = () => { - setSelectedId(browsers[0]?.id ?? null); - - const focusFirst = () => { - const firstRadio = dlg.querySelector( - 'input[type="radio"]' - ); - if (firstRadio) { - try { - firstRadio.focus({ preventScroll: true }); - } catch { - firstRadio.focus(); - } - return; - } - const firstFocusable = dlg.querySelector( - 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="#-1"])' - ); - firstFocusable?.focus(); - }; - - if (typeof requestAnimationFrame !== 'undefined') - requestAnimationFrame(() => focusFirst()); - else setTimeout(() => focusFirst(), 0); - }; - - scheduleInit(); + if (!open) return undefined; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') return handleClose(); - - if (e.key === 'Tab') { - const focusable = Array.from( - dlg.querySelectorAll( - 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' - ) - ); - if (!focusable.length) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - - if (e.shiftKey && active === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && active === last) { - e.preventDefault(); - first.focus(); - } - } - }; - - const handleFocusIn = (e: FocusEvent) => { - const target = e.target as Node | null; - if (!dlg.contains(target)) { - const first = dlg.querySelector( - 'input[type="radio"], a[href], button:not([disabled]), textarea:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - first?.focus(); + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + handleClose(); } }; - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('focusin', handleFocusIn); + window.addEventListener('keydown', handleKeyDown); return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('focusin', handleFocusIn); + window.removeEventListener('keydown', handleKeyDown); }; - }, [open, browsers, handleClose]); + }, [open, handleClose]); if (!open) return null; + const handleChoose = async (persist: 'just-once' | 'always') => { + const browser = browsers.find(b => b.id === selected); + if (browser) { + setSubmitting(true); + try { + await onChoose(browser, persist); + setSelectedBrowser(browser.id); + setLocalSelected(null); + setSubmitting(false); + } catch (error) { + setSubmitting(false); + throw error; + } + } + }; + return ( -
+
-
-

- Open with -

-

+

+

Open with

+

Choose the browser profile that should receive this launch request.

-
- {browsers.map(({ id, name, icon, profile }) => { - const selected = id === selectedId; +
+ {browsers.map(browser => { + const isSelected = selected === browser.id; + const fallbackGlyph = browser.name.slice(0, 1).toUpperCase(); return (
+ {error ?

{error}

: null} + {loading ? ( +

Loading rules…

+ ) : null}
-
-

Domain policies

-

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

-
-
+ + {addingDomain ? ( +
+ +
+ Browser profile + + setDomainForm(form => ({ + ...form, + browser: next, + })) + } + placeholder='Search browsers' + /> +
+
+ Policy + + setDomainForm(form => ({ + ...form, + latency: next, + })) + } + /> +
+ +
+ + +
+
+ ) : null} +
@@ -81,71 +622,202 @@ 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'} + + +
+ + +
+
+
-
-

File type fallbacks

-

- When links resolve to files, we can preselect the right browser - profile so the asynchronous worker can open locally or hand off to - the cloud. -

+

File type rules

+
+ +
-
-
- {fileTypeRules.map(rule => ( -
-
- {rule.type} -
-
- {rule.browser} -
-
- Policy:{' '} - - {rule.policy} - -
-

Browser orchestration

-
+ + {fallbackBrowser ? ( +
+ Default profile (optional) + - -
- Requests without a matching rule will default to{' '} - - {defaultBrowser} - {' '} - unless a manual choice overrides it. + {savingFallback ? 'Saving…' : 'Save fallback'} + + {fallbackStatus ? ( + {fallbackStatus} + ) : null}
-

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} +

+
+ )) + )} +
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;