diff --git a/Launch Codex Switcher Dashboard.cmd b/Launch Codex Switcher Dashboard.cmd new file mode 100644 index 00000000..4a60bfd1 --- /dev/null +++ b/Launch Codex Switcher Dashboard.cmd @@ -0,0 +1,3 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\launch-dashboard.ps1" diff --git a/README.md b/README.md index e78a8ffc..e9dda169 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,28 @@ The built application will be in `src-tauri/target/release/bundle/`. You can also serve the built dashboard over HTTP instead of opening the Tauri shell. ```bash -# Build the frontend and start the web server on 0.0.0.0:3210 +# Build the frontend and start the web server on 127.0.0.1:3210 pnpm lan ``` +For everyday use on Windows, launch the hardened local dashboard in a standalone +browser app window instead of a tab: + +```bash +pnpm dashboard +``` + +You can also double-click `Launch Codex Switcher Dashboard.cmd`. + Optional environment variables: -- `CODEX_SWITCHER_WEB_HOST` to override the bind host +- `CODEX_SWITCHER_WEB_HOST` to override the bind host. Non-local hosts require a session token. - `CODEX_SWITCHER_WEB_PORT` to override the port +- `CODEX_SWITCHER_WEB_TOKEN` to provide a fixed session token instead of generating one + +The browser dashboard serves the same UI and backend actions through `/api/invoke/*`. If you bind to a non-local host such as `0.0.0.0`, the server prints a tokenized URL that must be opened before the dashboard can call backend actions. -The browser dashboard serves the same UI and backend actions through `/api/invoke/*`, which makes it usable over LAN, Tailscale, or a remote host tunnel when you expose the chosen port safely. +Full account backups are encrypted with the passphrase you enter during export. The same passphrase is required during import. ## Disclaimer diff --git a/package.json b/package.json index be3bf074..dc717e1f 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "dashboard": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/launch-dashboard.ps1", + "test:web-safe": "node scripts/check-web-safe-tauri-window.mjs", "preview": "vite preview", - "tauri": "sh ./scripts/tauri.sh", + "tauri": "node ./scripts/tauri.mjs", "lan": "pnpm build && cargo run --manifest-path src-tauri/Cargo.toml --bin codex-web", "version:bump": "node scripts/bump-version.mjs", "version:patch": "node scripts/bump-version.mjs patch", @@ -29,13 +31,13 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.3.0", "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "tailwindcss": "^4.1.18", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.3.0", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.3.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5830e87a..d55ba646 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + picomatch: 4.0.4 + postcss: 8.5.10 + rollup: 4.59.0 + importers: .: @@ -31,8 +36,8 @@ importers: version: 19.2.3(react@19.2.3) devDependencies: '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + specifier: ^4.3.0 + version: 4.3.0(vite@7.3.5(jiti@2.7.0)(lightningcss@1.32.0)) '@tauri-apps/cli': specifier: ^2 version: 2.9.6 @@ -43,17 +48,17 @@ importers: specifier: ^19.1.6 version: 19.2.3(@types/react@19.2.8) '@vitejs/plugin-react': - specifier: ^4.6.0 - version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2)) + specifier: ^4.7.0 + version: 4.7.0(vite@7.3.5(jiti@2.7.0)(lightningcss@1.32.0)) tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 + specifier: ^4.3.0 + version: 4.3.0 typescript: specifier: ~5.8.3 version: 5.8.3 vite: - specifier: ^7.0.4 - version: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + specifier: ^7.3.5 + version: 7.3.5(jiti@2.7.0)(lightningcss@1.32.0) packages: @@ -315,207 +320,207 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rollup/rollup-android-arm-eabi@4.55.1': - resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.55.1': - resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.55.1': - resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.55.1': - resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.55.1': - resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.55.1': - resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': - resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.55.1': - resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.55.1': - resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.55.1': - resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.55.1': - resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.55.1': - resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.55.1': - resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.55.1': - resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.55.1': - resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.55.1': - resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.55.1': - resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.55.1': - resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.55.1': - resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.55.1': - resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.55.1': - resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.55.1': - resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.55.1': - resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.55.1': - resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.55.1': - resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -526,26 +531,26 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} - '@tailwindcss/vite@4.1.18': - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: - vite: ^5.2.0 || ^6 || ^7 + vite: ^5.2.0 || ^6 || ^7 || ^8 '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -701,8 +706,8 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} engines: {node: '>=10.13.0'} esbuild@0.27.2: @@ -718,7 +723,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: 4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -735,8 +740,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true js-tokens@4.0.0: @@ -752,78 +757,78 @@ packages: engines: {node: '>=6'} hasBin: true - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} lru-cache@5.1.1: @@ -846,12 +851,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} react-dom@19.2.3: @@ -867,8 +872,8 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} - rollup@4.55.1: - resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -883,11 +888,11 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tinyglobby@0.2.15: @@ -905,8 +910,8 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1161,148 +1166,148 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/rollup-android-arm-eabi@4.55.1': + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.55.1': + '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.55.1': + '@rollup/rollup-darwin-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.55.1': + '@rollup/rollup-darwin-x64@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.55.1': + '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.55.1': + '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.55.1': + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.55.1': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.55.1': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.55.1': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.55.1': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.55.1': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.55.1': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.55.1': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.55.1': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.55.1': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.55.1': + '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.55.1': + '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-openbsd-x64@4.55.1': + '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.55.1': + '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.55.1': + '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.55.1': + '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.55.1': + '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.55.1': + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true - '@tailwindcss/node@4.1.18': + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.4 - jiti: 2.6.1 - lightningcss: 1.30.2 + enhanced-resolve: 5.24.0 + jiti: 2.7.0 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.18 + tailwindcss: 4.3.0 - '@tailwindcss/oxide-android-arm64@4.1.18': + '@tailwindcss/oxide-android-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.18': + '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.18': + '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.18': + '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.18': + '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.18': + '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true - '@tailwindcss/oxide@4.1.18': + '@tailwindcss/oxide@4.3.0': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/vite@4.1.18(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@7.3.5(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - tailwindcss: 4.1.18 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 7.3.5(jiti@2.7.0)(lightningcss@1.32.0) '@tauri-apps/api@2.10.1': {} @@ -1400,7 +1405,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@4.7.0(vite@7.3.5(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -1408,7 +1413,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.5(jiti@2.7.0)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color @@ -1436,10 +1441,10 @@ snapshots: electron-to-chromium@1.5.267: {} - enhanced-resolve@5.18.4: + enhanced-resolve@5.24.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 esbuild@0.27.2: optionalDependencies: @@ -1472,9 +1477,9 @@ snapshots: escalade@3.2.0: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fsevents@2.3.3: optional: true @@ -1483,7 +1488,7 @@ snapshots: graceful-fs@4.2.11: {} - jiti@2.6.1: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -1491,54 +1496,54 @@ snapshots: json5@2.2.3: {} - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.30.2: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 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 + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 lru-cache@5.1.1: dependencies: @@ -1556,9 +1561,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} - postcss@8.5.6: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -1573,35 +1578,35 @@ snapshots: react@19.2.3: {} - rollup@4.55.1: + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.55.1 - '@rollup/rollup-android-arm64': 4.55.1 - '@rollup/rollup-darwin-arm64': 4.55.1 - '@rollup/rollup-darwin-x64': 4.55.1 - '@rollup/rollup-freebsd-arm64': 4.55.1 - '@rollup/rollup-freebsd-x64': 4.55.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 - '@rollup/rollup-linux-arm-musleabihf': 4.55.1 - '@rollup/rollup-linux-arm64-gnu': 4.55.1 - '@rollup/rollup-linux-arm64-musl': 4.55.1 - '@rollup/rollup-linux-loong64-gnu': 4.55.1 - '@rollup/rollup-linux-loong64-musl': 4.55.1 - '@rollup/rollup-linux-ppc64-gnu': 4.55.1 - '@rollup/rollup-linux-ppc64-musl': 4.55.1 - '@rollup/rollup-linux-riscv64-gnu': 4.55.1 - '@rollup/rollup-linux-riscv64-musl': 4.55.1 - '@rollup/rollup-linux-s390x-gnu': 4.55.1 - '@rollup/rollup-linux-x64-gnu': 4.55.1 - '@rollup/rollup-linux-x64-musl': 4.55.1 - '@rollup/rollup-openbsd-x64': 4.55.1 - '@rollup/rollup-openharmony-arm64': 4.55.1 - '@rollup/rollup-win32-arm64-msvc': 4.55.1 - '@rollup/rollup-win32-ia32-msvc': 4.55.1 - '@rollup/rollup-win32-x64-gnu': 4.55.1 - '@rollup/rollup-win32-x64-msvc': 4.55.1 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 scheduler@0.27.0: {} @@ -1610,14 +1615,14 @@ snapshots: source-map-js@1.2.1: {} - tailwindcss@4.1.18: {} + tailwindcss@4.3.0: {} - tapable@2.3.0: {} + tapable@2.3.3: {} tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 typescript@5.8.3: {} @@ -1627,17 +1632,17 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.5(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.55.1 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.10 + rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 + jiti: 2.7.0 + lightningcss: 1.32.0 yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03cbbe52..fb89279f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,10 @@ packages: - . +overrides: + picomatch: 4.0.4 + postcss: 8.5.10 + rollup: 4.59.0 + allowBuilds: esbuild: true diff --git a/scripts/check-web-safe-tauri-window.mjs b/scripts/check-web-safe-tauri-window.mjs new file mode 100644 index 00000000..520c470e --- /dev/null +++ b/scripts/check-web-safe-tauri-window.mjs @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const appSource = readFileSync(join(root, "src", "App.tsx"), "utf8"); + +const unsafeStaticWindowImport = + /import\s*\{[^}]*\bgetCurrentWindow\b[^}]*\}\s*from\s*["']@tauri-apps\/api\/window["']/.test( + appSource + ); +const unsafeTopLevelWindowHandle = + /const\s+\w+\s*=\s*getCurrentWindow\s*\(/.test(appSource); + +if (unsafeStaticWindowImport || unsafeTopLevelWindowHandle) { + throw new Error( + "App must not create a Tauri window handle at module load; the web dashboard lacks __TAURI_INTERNALS__." + ); +} diff --git a/scripts/launch-dashboard.ps1 b/scripts/launch-dashboard.ps1 new file mode 100644 index 00000000..520134c2 --- /dev/null +++ b/scripts/launch-dashboard.ps1 @@ -0,0 +1,89 @@ +param( + [switch]$Server +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$LocalDir = Join-Path $RepoRoot ".codex-local" +$HostName = "127.0.0.1" +$Port = 3210 +$Url = "http://${HostName}:${Port}/" +$HealthUrl = "http://${HostName}:${Port}/api/health" + +New-Item -ItemType Directory -Force -Path $LocalDir | Out-Null + +if ($Server) { + Set-Location $RepoRoot + pnpm lan + exit $LASTEXITCODE +} + +function Test-DashboardHealth { + try { + $response = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 2 + return $response.StatusCode -eq 200 + } catch { + return $false + } +} + +function Find-AppBrowser { + $candidates = @( + (Get-Command chrome.exe -ErrorAction SilentlyContinue).Source, + (Get-Command msedge.exe -ErrorAction SilentlyContinue).Source, + "$env:ProgramFiles\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe", + "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" + ) + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path $candidate)) { + return $candidate + } + } + + return $null +} + +if (-not (Test-DashboardHealth)) { + if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { + throw "pnpm was not found on PATH. Install pnpm or run this from a shell where pnpm works." + } + + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + throw "cargo was not found on PATH. Install Rust or run this from a shell where cargo works." + } + + $outLog = Join-Path $LocalDir "dashboard.out.log" + $errLog = Join-Path $LocalDir "dashboard.err.log" + + Start-Process ` + -FilePath "powershell.exe" ` + -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-Server") ` + -WorkingDirectory $RepoRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $outLog ` + -RedirectStandardError $errLog | Out-Null + + $deadline = (Get-Date).AddSeconds(90) + while ((Get-Date) -lt $deadline) { + if (Test-DashboardHealth) { + break + } + Start-Sleep -Milliseconds 500 + } + + if (-not (Test-DashboardHealth)) { + throw "Dashboard did not become healthy. Check $outLog and $errLog." + } +} + +$browser = Find-AppBrowser +if ($browser) { + Start-Process -FilePath $browser -ArgumentList @("--app=$Url", "--new-window") +} else { + Start-Process $Url +} diff --git a/scripts/tauri.mjs b/scripts/tauri.mjs new file mode 100644 index 00000000..fe90ab50 --- /dev/null +++ b/scripts/tauri.mjs @@ -0,0 +1,56 @@ +import { existsSync } from "node:fs"; +import { join, delimiter } from "node:path"; +import { homedir } from "node:os"; +import { spawn, spawnSync } from "node:child_process"; + +function canRun(command) { + const result = spawnSync(command, ["--version"], { + stdio: "ignore", + env: process.env, + }); + + return result.status === 0; +} + +function prependPath(path) { + process.env.PATH = `${path}${delimiter}${process.env.PATH ?? ""}`; + process.env.Path = process.env.PATH; +} + +if (!canRun("cargo")) { + const cargoBin = join(homedir(), ".cargo", "bin"); + if (existsSync(cargoBin)) { + prependPath(cargoBin); + } +} + +if (!canRun("cargo")) { + console.error("Error: cargo not found. Install Rust via rustup: https://rustup.rs"); + process.exit(1); +} + +const tauriCli = join(process.cwd(), "node_modules", "@tauri-apps", "cli", "tauri.js"); + +if (!existsSync(tauriCli)) { + console.error("Error: Tauri CLI not found. Run pnpm install."); + process.exit(1); +} + +const child = spawn(process.execPath, [tauriCli, ...process.argv.slice(2)], { + stdio: "inherit", + env: process.env, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); +}); + +child.on("error", (error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5340fa16..b30628d8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -57,6 +68,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -289,6 +311,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -426,11 +457,20 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" -version = "1.2.57" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "shlex", @@ -529,12 +569,14 @@ name = "codex-switcher" version = "0.2.2" dependencies = [ "anyhow", + "apple-native-keyring-store", "base64 0.22.1", "chacha20poly1305", "chrono", "dirs", "flate2", "futures", + "keyring-core", "pbkdf2", "rand 0.9.2", "reqwest 0.12.28", @@ -554,6 +596,8 @@ dependencies = [ "urlencoding", "uuid", "webbrowser", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", ] [[package]] @@ -1666,6 +1710,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2007,6 +2060,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -2188,6 +2242,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2461,12 +2524,76 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3718,6 +3845,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3980,9 +4126,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -5653,6 +5799,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -6214,6 +6373,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.14.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c67e49df..5c5b83c2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,3 +39,13 @@ url = "2" flate2 = "1" chacha20poly1305 = "0.10" pbkdf2 = "0.12" +keyring-core = "1.0.0" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store = "1.1" + +[target.'cfg(target_os = "macos")'.dependencies] +apple-native-keyring-store = { version = "1.0", features = ["keychain"] } + +[target.'cfg(target_os = "linux")'.dependencies] +zbus-secret-service-keyring-store = { version = "1.0", features = ["crypto-rust"] } diff --git a/src-tauri/src/api/usage.rs b/src-tauri/src/api/usage.rs index 6c4c1331..1b5acc07 100644 --- a/src-tauri/src/api/usage.rs +++ b/src-tauri/src/api/usage.rs @@ -106,7 +106,11 @@ pub async fn fetch_chatgpt_account_metadata( let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - anyhow::bail!("Accounts check API error: {status} - {body}"); + anyhow::bail!(external_http_error_message( + "Accounts check API", + status, + &body + )); } let payload: AccountsCheckResponse = response @@ -395,6 +399,28 @@ fn truncate_text(text: &str, max_len: usize) -> String { out } +fn external_http_error_message(context: &str, status: StatusCode, body: &str) -> String { + if is_browser_challenge_response(body) { + return format!( + "{context} error: {status} - ChatGPT returned a browser verification challenge. Open chatgpt.com in a browser, complete the challenge, then try again." + ); + } + + let body = body.trim(); + if body.is_empty() { + return format!("{context} error: {status}"); + } + + format!("{context} error: {status} - {}", truncate_text(body, 300)) +} + +fn is_browser_challenge_response(body: &str) -> bool { + let lower = body.to_ascii_lowercase(); + lower.contains("enable javascript and cookies to continue") + || lower.contains("cf_chl") + || lower.contains("challenge-platform") +} + fn extract_text_from_sse(body: &str) -> Option { let mut last_text: Option = None; for line in body.lines() { @@ -511,3 +537,22 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec { println!("[Usage] Refresh complete"); results } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_error_message_summarizes_cloudflare_challenge_html() { + let body = r#"Just a moment... + Enable JavaScript and cookies to continue"#; + + let message = external_http_error_message("Accounts check API", StatusCode::FORBIDDEN, body); + + assert_eq!( + message, + "Accounts check API error: 403 Forbidden - ChatGPT returned a browser verification challenge. Open chatgpt.com in a browser, complete the challenge, then try again." + ); + assert!(!message.contains("")); + } +} diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index 0ee5573b..788773a6 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -4,10 +4,22 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, +}; use chrono::{DateTime, Utc}; +use rand::RngCore; use crate::types::{AccountsStore, AuthData, StoredAccount}; +const ENCRYPTED_STORE_PREFIX: &str = "cswa1."; +const STORE_KEY_SERVICE: &str = "codex-switcher"; +const STORE_KEY_ACCOUNT: &str = "accounts-store"; +const STORE_KEY_BYTES: usize = 32; +const STORE_NONCE_BYTES: usize = 24; + /// Get the path to the codex-switcher config directory pub fn get_config_dir() -> Result { let home = dirs::home_dir().context("Could not find home directory")?; @@ -27,10 +39,17 @@ pub fn load_accounts() -> Result { return Ok(AccountsStore::default()); } - let content = fs::read_to_string(&path) + let content = fs::read(&path) .with_context(|| format!("Failed to read accounts file: {}", path.display()))?; - let store: AccountsStore = serde_json::from_str(&content) + let json = if content.starts_with(ENCRYPTED_STORE_PREFIX.as_bytes()) { + let key = get_or_create_store_key()?; + decrypt_accounts_store_json(&content, &key)? + } else { + content + }; + + let store: AccountsStore = serde_json::from_slice(&json) .with_context(|| format!("Failed to parse accounts file: {}", path.display()))?; Ok(store) @@ -46,10 +65,11 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { .with_context(|| format!("Failed to create config directory: {}", parent.display()))?; } - let content = - serde_json::to_string_pretty(store).context("Failed to serialize accounts store")?; + let content = serde_json::to_vec_pretty(store).context("Failed to serialize accounts store")?; + let key = get_or_create_store_key()?; + let encrypted = encrypt_accounts_store_json(&content, &key)?; - fs::write(&path, content) + fs::write(&path, encrypted) .with_context(|| format!("Failed to write accounts file: {}", path.display()))?; // Set restrictive permissions on Unix @@ -63,6 +83,112 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { Ok(()) } +fn get_or_create_store_key() -> Result<[u8; STORE_KEY_BYTES]> { + initialize_native_store()?; + let entry = keyring_core::Entry::new(STORE_KEY_SERVICE, STORE_KEY_ACCOUNT) + .context("Failed to open OS credential store")?; + + match entry.get_password() { + Ok(encoded) => decode_store_key(&encoded), + Err(keyring_core::Error::NoEntry) => { + let mut key = [0u8; STORE_KEY_BYTES]; + rand::rng().fill_bytes(&mut key); + entry + .set_password(&STANDARD_NO_PAD.encode(key)) + .context("Failed to save account-store key to OS credential store")?; + Ok(key) + } + Err(error) => { + Err(error).context("Failed to read account-store key from OS credential store") + } + } +} + +fn initialize_native_store() -> Result<()> { + #[cfg(target_os = "windows")] + { + keyring_core::set_default_store( + windows_native_keyring_store::Store::new() + .context("Failed to initialize Windows Credential Manager")?, + ); + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + keyring_core::set_default_store( + apple_native_keyring_store::keychain::Store::new() + .context("Failed to initialize macOS Keychain")?, + ); + return Ok(()); + } + + #[cfg(target_os = "linux")] + { + keyring_core::set_default_store( + zbus_secret_service_keyring_store::Store::new() + .context("Failed to initialize Linux Secret Service")?, + ); + return Ok(()); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + anyhow::bail!("No native credential store is configured for this platform") + } +} + +fn decode_store_key(encoded: &str) -> Result<[u8; STORE_KEY_BYTES]> { + let decoded = STANDARD_NO_PAD + .decode(encoded.trim()) + .context("Stored account encryption key is invalid base64")?; + let key: [u8; STORE_KEY_BYTES] = decoded + .try_into() + .map_err(|_| anyhow::anyhow!("Stored account encryption key has invalid length"))?; + Ok(key) +} + +fn encrypt_accounts_store_json(json: &[u8], key: &[u8; STORE_KEY_BYTES]) -> Result> { + let mut nonce = [0u8; STORE_NONCE_BYTES]; + rand::rng().fill_bytes(&mut nonce); + + let cipher = XChaCha20Poly1305::new(key.into()); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), json) + .map_err(|_| anyhow::anyhow!("Failed to encrypt accounts store"))?; + + let mut payload = Vec::with_capacity(STORE_NONCE_BYTES + ciphertext.len()); + payload.extend_from_slice(&nonce); + payload.extend_from_slice(&ciphertext); + + Ok(format!( + "{ENCRYPTED_STORE_PREFIX}{}", + STANDARD_NO_PAD.encode(payload) + ) + .into_bytes()) +} + +fn decrypt_accounts_store_json(encrypted: &[u8], key: &[u8; STORE_KEY_BYTES]) -> Result> { + let encoded = std::str::from_utf8(encrypted) + .context("Encrypted accounts store is not UTF-8")? + .strip_prefix(ENCRYPTED_STORE_PREFIX) + .context("Encrypted accounts store header is invalid")?; + + let payload = STANDARD_NO_PAD + .decode(encoded) + .context("Encrypted accounts store payload is invalid base64")?; + + if payload.len() <= STORE_NONCE_BYTES { + anyhow::bail!("Encrypted accounts store is truncated"); + } + + let (nonce, ciphertext) = payload.split_at(STORE_NONCE_BYTES); + let cipher = XChaCha20Poly1305::new(key.into()); + cipher + .decrypt(XNonce::from_slice(nonce), ciphertext) + .map_err(|_| anyhow::anyhow!("Failed to decrypt accounts store")) +} + /// Add a new account to the store pub fn add_account(account: StoredAccount) -> Result { let mut store = load_accounts()?; @@ -263,3 +389,23 @@ pub fn set_masked_account_ids(ids: Vec) -> Result<()> { save_accounts(&store)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{decrypt_accounts_store_json, encrypt_accounts_store_json}; + + #[test] + fn encrypted_accounts_store_payload_does_not_contain_plaintext_credentials() { + let key = [7u8; 32]; + let json = br#"{"accounts":[{"auth_data":{"type":"api_key","key":"sk-test-secret"}}]}"#; + + let encrypted = encrypt_accounts_store_json(json, &key).expect("encrypt store"); + + assert_ne!(encrypted, json); + assert!(!String::from_utf8_lossy(&encrypted).contains("sk-test-secret")); + assert_eq!( + decrypt_accounts_store_json(&encrypted, &key).expect("decrypt store"), + json + ); + } +} diff --git a/src-tauri/src/bin/codex-web.rs b/src-tauri/src/bin/codex-web.rs index 3338521b..cb4c584d 100644 --- a/src-tauri/src/bin/codex-web.rs +++ b/src-tauri/src/bin/codex-web.rs @@ -6,7 +6,7 @@ fn main() { } fn run() -> anyhow::Result<()> { - let host = std::env::var("CODEX_SWITCHER_WEB_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let host = codex_switcher_lib::web::default_web_host(); let port = std::env::var("CODEX_SWITCHER_WEB_PORT") .ok() .and_then(|value| value.parse::().ok()) diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 915bb77b..77a288a2 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -40,7 +40,7 @@ const FULL_FILE_VERSION: u8 = 1; const FULL_SALT_LEN: usize = 16; const FULL_NONCE_LEN: usize = 24; const FULL_KDF_ITERATIONS: u32 = 210_000; -const FULL_PRESET_PASSPHRASE: &str = "gT7kQ9mV2xN4pL8sR1dH6zW3cB5yF0uJ_aE7nK2tP9vM4rX1"; +pub(crate) const MIN_BACKUP_PASSPHRASE_LEN: usize = 12; const MAX_IMPORT_JSON_BYTES: u64 = 2 * 1024 * 1024; const MAX_IMPORT_FILE_BYTES: u64 = 8 * 1024 * 1024; @@ -227,28 +227,34 @@ pub async fn import_accounts_slim_text(payload: String) -> Result Result<(), String> { +pub async fn export_accounts_full_encrypted_file( + path: String, + passphrase: String, +) -> Result<(), String> { let store = load_accounts().map_err(|e| e.to_string())?; - let encrypted = - encode_full_encrypted_store(&store, FULL_PRESET_PASSPHRASE).map_err(|e| e.to_string())?; + let passphrase = validate_backup_passphrase(&passphrase).map_err(|e| e.to_string())?; + let encrypted = encode_full_encrypted_store(&store, passphrase).map_err(|e| e.to_string())?; write_encrypted_file(&path, &encrypted).map_err(|e| e.to_string())?; Ok(()) } /// Export full account config as encrypted bytes for browser clients. -pub async fn export_accounts_full_encrypted_bytes() -> Result, String> { +pub async fn export_accounts_full_encrypted_bytes(passphrase: String) -> Result, String> { let store = load_accounts().map_err(|e| e.to_string())?; - encode_full_encrypted_store(&store, FULL_PRESET_PASSPHRASE).map_err(|e| e.to_string()) + let passphrase = validate_backup_passphrase(&passphrase).map_err(|e| e.to_string())?; + encode_full_encrypted_store(&store, passphrase).map_err(|e| e.to_string()) } /// Import full account config from an encrypted file, skipping existing accounts. #[tauri::command] pub async fn import_accounts_full_encrypted_file( path: String, + passphrase: String, ) -> Result { let encrypted = read_encrypted_file(&path).map_err(|e| e.to_string())?; - let imported = decode_full_encrypted_store(&encrypted, FULL_PRESET_PASSPHRASE) - .map_err(|e| e.to_string())?; + let passphrase = validate_backup_passphrase(&passphrase).map_err(|e| e.to_string())?; + let imported = + decode_full_encrypted_store(&encrypted, passphrase).map_err(|e| e.to_string())?; validate_imported_store(&imported).map_err(|e| e.to_string())?; let current = load_accounts().map_err(|e| e.to_string())?; @@ -260,9 +266,10 @@ pub async fn import_accounts_full_encrypted_file( /// Import full account config from encrypted bytes uploaded through the browser UI. pub async fn import_accounts_full_encrypted_bytes( bytes: Vec, + passphrase: String, ) -> Result { - let imported = - decode_full_encrypted_store(&bytes, FULL_PRESET_PASSPHRASE).map_err(|e| e.to_string())?; + let passphrase = validate_backup_passphrase(&passphrase).map_err(|e| e.to_string())?; + let imported = decode_full_encrypted_store(&bytes, passphrase).map_err(|e| e.to_string())?; validate_imported_store(&imported).map_err(|e| e.to_string())?; let current = load_accounts().map_err(|e| e.to_string())?; @@ -682,6 +689,36 @@ fn validate_imported_store(store: &AccountsStore) -> anyhow::Result<()> { Ok(()) } +fn validate_backup_passphrase(passphrase: &str) -> anyhow::Result<&str> { + let trimmed = passphrase.trim(); + if trimmed.len() < MIN_BACKUP_PASSPHRASE_LEN { + anyhow::bail!("Backup passphrase must be at least {MIN_BACKUP_PASSPHRASE_LEN} characters"); + } + + Ok(trimmed) +} + +#[cfg(test)] +mod tests { + use super::{validate_backup_passphrase, MIN_BACKUP_PASSPHRASE_LEN}; + + #[test] + fn backup_passphrase_rejects_blank_and_short_values() { + assert!(validate_backup_passphrase(" ").is_err()); + assert!(validate_backup_passphrase("short").is_err()); + } + + #[test] + fn backup_passphrase_accepts_minimum_length_values() { + let passphrase = "a".repeat(MIN_BACKUP_PASSPHRASE_LEN); + + assert_eq!( + validate_backup_passphrase(&passphrase).expect("valid passphrase"), + passphrase + ); + } +} + fn merge_accounts_store( mut current: AccountsStore, imported: AccountsStore, diff --git a/src-tauri/src/web.rs b/src-tauri/src/web.rs index 009dcde4..5e89861b 100644 --- a/src-tauri/src/web.rs +++ b/src-tauri/src/web.rs @@ -2,7 +2,11 @@ use std::fs; use std::path::{Component, Path, PathBuf}; use anyhow::Context; -use base64::{engine::general_purpose::STANDARD, Engine as _}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine as _, +}; +use rand::RngCore; use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::{json, Value}; @@ -18,6 +22,21 @@ use crate::commands::{ set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, }; +pub(crate) const WEB_TOKEN_HEADER: &str = "x-codex-switcher-token"; +const WEB_TOKEN_COOKIE: &str = "codex_switcher_web_token"; +const WEB_TOKEN_BYTES: usize = 32; + +#[derive(Debug, Clone)] +pub struct WebSecurity { + token: Option, +} + +impl WebSecurity { + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AccountIdArgs { @@ -62,6 +81,12 @@ struct UploadAuthJsonArgs { struct UploadEncryptedArgs { #[serde(alias = "contents_base64")] contents_base64: String, + passphrase: String, +} + +#[derive(Debug, Deserialize)] +struct ExportEncryptedArgs { + passphrase: String, } #[derive(Debug, Deserialize)] @@ -70,7 +95,28 @@ struct FileImportArgs { name: String, } +pub fn default_web_host() -> String { + std::env::var("CODEX_SWITCHER_WEB_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()) +} + +pub fn web_security_for_host( + host: &str, + configured_token: Option, +) -> anyhow::Result { + if is_loopback_host(host) { + return Ok(WebSecurity { token: None }); + } + + let token = match configured_token { + Some(token) if !token.trim().is_empty() => token.trim().to_string(), + _ => generate_web_token(), + }; + + Ok(WebSecurity { token: Some(token) }) +} + pub fn run_lan_server(host: &str, port: u16) -> anyhow::Result<()> { + let security = web_security_for_host(host, std::env::var("CODEX_SWITCHER_WEB_TOKEN").ok())?; let address = format!("{host}:{port}"); let server = Server::http(&address) .map_err(|err| anyhow::anyhow!("Failed to bind HTTP server on {address}: {err}"))?; @@ -81,9 +127,12 @@ pub fn run_lan_server(host: &str, port: u16) -> anyhow::Result<()> { println!("Codex Switcher web server listening on http://{address}"); println!("Serving static files from {}", dist_dir.display()); + if let Some(token) = security.token() { + println!("Web access token required. Open http://{address}/?token={token}"); + } for request in server.incoming_requests() { - if let Err(error) = handle_request(request, &runtime, &dist_dir) { + if let Err(error) = handle_request(request, &runtime, &dist_dir, &security) { eprintln!("[web] request failed: {error:#}"); } } @@ -91,15 +140,32 @@ pub fn run_lan_server(host: &str, port: u16) -> anyhow::Result<()> { Ok(()) } -fn handle_request(mut request: Request, runtime: &Runtime, dist_dir: &Path) -> anyhow::Result<()> { +fn handle_request( + mut request: Request, + runtime: &Runtime, + dist_dir: &Path, + security: &WebSecurity, +) -> anyhow::Result<()> { let method = request.method().clone(); let url = request.url().to_string(); + let headers = request_headers(&request); if method == Method::Get && url == "/api/health" { respond_json(request, StatusCode(200), &json!({ "ok": true }))?; return Ok(()); } + if !request_is_authorized(&url, &headers, security) { + respond_text( + request, + StatusCode(401), + "Unauthorized", + "text/plain; charset=utf-8", + None, + )?; + return Ok(()); + } + if method == Method::Post && url.starts_with("/api/invoke/") { let command = url.trim_start_matches("/api/invoke/"); let payload = parse_request_json(&mut request)?; @@ -112,7 +178,7 @@ fn handle_request(mut request: Request, runtime: &Runtime, dist_dir: &Path) -> a } if method == Method::Get { - serve_static(request, dist_dir, &url)?; + serve_static(request, dist_dir, &url, security)?; return Ok(()); } @@ -121,6 +187,7 @@ fn handle_request(mut request: Request, runtime: &Runtime, dist_dir: &Path) -> a StatusCode(405), "Method Not Allowed", "text/plain; charset=utf-8", + None, )?; Ok(()) } @@ -175,7 +242,9 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result { - let encoded = STANDARD.encode(export_accounts_full_encrypted_bytes().await?); + let args: ExportEncryptedArgs = parse_args(payload)?; + let encoded = + STANDARD.encode(export_accounts_full_encrypted_bytes(args.passphrase).await?); to_json(encoded) } "import_accounts_full_encrypted_bytes" => { @@ -183,7 +252,7 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result to_json(get_masked_account_ids().await?), "set_masked_account_ids" => { @@ -224,7 +293,12 @@ where serde_json::to_value(value).map_err(|error| format!("Failed to serialize response: {error}")) } -fn serve_static(request: Request, dist_dir: &Path, url: &str) -> anyhow::Result<()> { +fn serve_static( + request: Request, + dist_dir: &Path, + url: &str, + security: &WebSecurity, +) -> anyhow::Result<()> { let requested = if url == "/" { PathBuf::from("index.html") } else { @@ -233,7 +307,7 @@ fn serve_static(request: Request, dist_dir: &Path, url: &str) -> anyhow::Result< let candidate = dist_dir.join(&requested); if candidate.is_file() { - return serve_file(request, candidate); + return serve_file(request, candidate, security, url); } if requested.extension().is_some() { @@ -242,11 +316,12 @@ fn serve_static(request: Request, dist_dir: &Path, url: &str) -> anyhow::Result< StatusCode(404), "Not Found", "text/plain; charset=utf-8", + None, )?; return Ok(()); } - serve_file(request, dist_dir.join("index.html")) + serve_file(request, dist_dir.join("index.html"), security, url) } fn sanitize_path(url: &str) -> anyhow::Result { @@ -264,12 +339,25 @@ fn sanitize_path(url: &str) -> anyhow::Result { Ok(candidate.to_path_buf()) } -fn serve_file(request: Request, path: PathBuf) -> anyhow::Result<()> { +fn serve_file( + request: Request, + path: PathBuf, + security: &WebSecurity, + url: &str, +) -> anyhow::Result<()> { let data = fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?; let mime = mime_type_for_path(&path); - let response = Response::from_data(data) + let mut response = Response::from_data(data) .with_header(header("Content-Type", mime)?) .with_header(header("Cache-Control", "no-cache")?); + if url_has_valid_token(url, security) { + if let Some(token) = security.token() { + response = response.with_header(header( + "Set-Cookie", + &format!("{WEB_TOKEN_COOKIE}={token}; Path=/; SameSite=Strict; HttpOnly"), + )?); + } + } request.respond(response)?; Ok(()) } @@ -287,14 +375,92 @@ fn respond_text( status: StatusCode, body: &str, content_type: &str, + extra_header: Option
, ) -> anyhow::Result<()> { - let response = Response::from_string(body.to_string()) + let mut response = Response::from_string(body.to_string()) .with_status_code(status) .with_header(header("Content-Type", content_type)?); + if let Some(extra_header) = extra_header { + response = response.with_header(extra_header); + } request.respond(response)?; Ok(()) } +fn request_headers(request: &Request) -> Vec<(String, String)> { + request + .headers() + .iter() + .map(|header| { + ( + header.field.to_string().to_ascii_lowercase(), + header.value.as_str().to_string(), + ) + }) + .collect() +} + +fn request_is_authorized( + url: &str, + headers: &[(impl AsRef, impl AsRef)], + security: &WebSecurity, +) -> bool { + security.token().is_none() + || url_has_valid_token(url, security) + || request_has_valid_web_token(headers, security) +} + +pub(crate) fn request_has_valid_web_token( + headers: &[(impl AsRef, impl AsRef)], + security: &WebSecurity, +) -> bool { + let Some(token) = security.token() else { + return true; + }; + + headers.iter().any(|(name, value)| { + let name = name.as_ref(); + let value = value.as_ref(); + if name.eq_ignore_ascii_case(WEB_TOKEN_HEADER) { + return value == token; + } + + name.eq_ignore_ascii_case("cookie") && cookie_contains_token(value, token) + }) +} + +fn cookie_contains_token(cookie_header: &str, token: &str) -> bool { + cookie_header + .split(';') + .map(str::trim) + .any(|part| part == format!("{WEB_TOKEN_COOKIE}={token}")) +} + +fn url_has_valid_token(url: &str, security: &WebSecurity) -> bool { + let Some(token) = security.token() else { + return true; + }; + + let Some(query) = url.split_once('?').map(|(_, query)| query) else { + return false; + }; + + query.split('&').any(|pair| { + let (name, value) = pair.split_once('=').unwrap_or((pair, "")); + name == "token" && value == token + }) +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") +} + +fn generate_web_token() -> String { + let mut bytes = [0u8; WEB_TOKEN_BYTES]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + fn header(name: &str, value: &str) -> anyhow::Result
{ Header::from_bytes(name.as_bytes(), value.as_bytes()).map_err(|_| { anyhow::anyhow!("Failed to create header {name}: invalid header value `{value}`") @@ -322,3 +488,46 @@ fn mime_type_for_path(path: &Path) -> &'static str { _ => "application/octet-stream", } } + +#[cfg(test)] +mod tests { + use super::{request_has_valid_web_token, web_security_for_host, WEB_TOKEN_HEADER}; + + #[test] + fn localhost_web_server_does_not_require_token_by_default() { + let security = web_security_for_host("127.0.0.1", None).expect("security config"); + + assert!(security.token().is_none()); + } + + #[test] + fn non_loopback_web_server_requires_token() { + let security = web_security_for_host("0.0.0.0", None).expect("security config"); + + assert!(security.token().is_some()); + } + + #[test] + fn web_token_validation_accepts_header_or_cookie_only_when_matching() { + let security = web_security_for_host("0.0.0.0", Some("secret-token".to_string())) + .expect("security config"); + + assert!(request_has_valid_web_token( + &[("x-codex-switcher-token", "secret-token")], + &security + )); + assert!(request_has_valid_web_token( + &[( + "cookie", + "theme=dark; codex_switcher_web_token=secret-token" + )], + &security + )); + assert!(!request_has_valid_web_token( + &[(WEB_TOKEN_HEADER, "wrong-token")], + &security + )); + let empty_headers: Vec<(&str, &str)> = Vec::new(); + assert!(!request_has_valid_web_token(&empty_headers, &security)); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4611f702..1aad153a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https://api.openai.com https://chatgpt.com https://auth.openai.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 579bc20a..b1e4793b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { useAccounts } from "./hooks/useAccounts"; import { useForceCloseCodexProcesses } from "./hooks/useForceCloseCodexProcesses"; import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; @@ -24,6 +23,7 @@ const DEFAULT_PRIMARY_WINDOW_MINUTES = 300; const LIMIT_FULL_THRESHOLD = 99.5; const SWITCH_ACCOUNT_BLOCKED_EVENT = "switch-account-blocked"; type ThemeMode = "light" | "dark"; +type AppWindow = import("@tauri-apps/api/window").Window; interface SwitchAccountBlockedPayload { accountId?: string; error?: string; @@ -34,11 +34,19 @@ type AutoWarmupLedger = Record< lastSuccessfulWarmupAt?: number; } >; -const appWindow = getCurrentWindow(); +let appWindowPromise: Promise | null = null; const isMacOs = typeof navigator !== "undefined" && /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent); +async function getAppWindow(): Promise { + if (!isTauriRuntime()) return null; + appWindowPromise ??= import("@tauri-apps/api/window").then(({ getCurrentWindow }) => + getCurrentWindow() + ); + return appWindowPromise; +} + function readStoredStringArray(key: string): string[] { if (typeof window === "undefined") return []; try { @@ -265,14 +273,20 @@ function App() { const handleTitlebarDrag = useCallback( (event: React.MouseEvent) => { if (!isTauriRuntime() || event.button !== 0) return; - void appWindow.startDragging(); + void (async () => { + const appWindow = await getAppWindow(); + await appWindow?.startDragging(); + })(); }, [] ); const handleTitlebarDoubleClick = useCallback(() => { if (!isTauriRuntime()) return; - void appWindow.toggleMaximize(); + void (async () => { + const appWindow = await getAppWindow(); + await appWindow?.toggleMaximize(); + })(); }, []); const toggleMask = (accountId: string) => { @@ -367,29 +381,37 @@ function App() { if (!isTauriRuntime() || isMacOs) return; let unlisten: (() => void) | undefined; + let disposed = false; + let appWindow: AppWindow | null = null; const syncMaximizedState = async () => { try { + if (!appWindow || disposed) return; setIsWindowMaximized(await appWindow.isMaximized()); } catch (err) { console.error("Failed to read window state:", err); } }; - void syncMaximizedState(); - - appWindow - .onResized(() => { - void syncMaximizedState(); - }) - .then((fn) => { - unlisten = fn; - }) - .catch((err) => { - console.error("Failed to watch window resize:", err); - }); + void (async () => { + appWindow = await getAppWindow(); + if (!appWindow || disposed) return; + await syncMaximizedState(); + + appWindow + .onResized(() => { + void syncMaximizedState(); + }) + .then((fn) => { + unlisten = fn; + }) + .catch((err) => { + console.error("Failed to watch window resize:", err); + }); + })(); return () => { + disposed = true; unlisten?.(); }; }, []); @@ -954,6 +976,8 @@ function App() { return a.name.localeCompare(b.name); }); }, [otherAccounts, otherAccountsSort]); + const hasMacNativeControls = isTauriRuntime() && isMacOs; + const showNativeWindowControls = isTauriRuntime() && !isMacOs; return (
@@ -962,13 +986,16 @@ function App() {
- {!isMacOs && ( + {showNativeWindowControls && (