diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5eacaa1e9..3dbb2f7ded 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ on: - "tests/**" - "scripts/**" - "app/**" + - "desktop/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -215,6 +216,7 @@ jobs: - 'tests/**' - 'scripts/**' - 'app/**' + - 'desktop/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -1149,12 +1151,12 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. - macos-app: - name: macos app + widget: + name: macos widget + bundle needs: [changes, gates] if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: macos-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -1167,13 +1169,80 @@ jobs: bun-version: 1.3.14 - name: Install dependencies - run: bun install --frozen-lockfile + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable - - name: Test macOS menu bar app + - name: Test MenuBarCore run: bun run test:macos - - name: Build macOS menu bar app - run: bun run build:macos + - name: Build dashboard + run: bun run build:gui + + - name: Prepare desktop sidecar + run: bun desktop/scripts/prepare-sidecar.ts + + - name: Build WidgetKit appex + run: bash desktop/scripts/build-widget.sh + + - name: Build unsigned desktop app + working-directory: desktop + run: bunx tauri build --ci --bundles app + + - name: Verify WidgetKit appex and desktop app + run: | + app=desktop/src-tauri/target/release/bundle/macos/OpenCodex.app + test -x "$app/Contents/MacOS/OpenCodex" + test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" + test -x "$app/Contents/MacOS/ocx" + codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex" + + desktop-shell: + name: desktop shell + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Install Tauri Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + components: rustfmt, clippy + + - name: Prepare desktop check resources + run: | + set -euo pipefail + triple="$(rustc -vV | sed -n 's/^host: //p')" + mkdir -p desktop/src-tauri/binaries desktop/src-tauri/resources/gui/dist + : > "desktop/src-tauri/binaries/ocx-${triple}" + chmod +x "desktop/src-tauri/binaries/ocx-${triple}" + : > desktop/src-tauri/resources/gui/dist/.keep + + - name: Check Rust formatting + run: cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check + + - name: Run Rust clippy + run: cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Run Rust tests + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml ci: name: ci @@ -1182,7 +1251,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, macos-app] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, widget, desktop-shell] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1248,12 +1317,15 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate macos-app" + GATED_JOBS="$GATED_JOBS structure-gate widget" + GATED_JOBS="$GATED_JOBS desktop-shell" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|widget) + echo "$scoped" ;; + desktop-shell) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c3c200258..dd63e98a51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,61 +69,258 @@ jobs: process.exit(1); } NODE - package-macos: + package-standalone: needs: validate-dispatch - runs-on: macos-latest - timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: bun-linux-x64 + smoke: true + - os: macos-latest + target: bun-darwin-arm64 + smoke: true + - os: macos-latest + target: bun-darwin-x64 + smoke: false + - os: windows-latest + target: bun-windows-x64 + smoke: true + - os: ubuntu-latest + target: bun-linux-arm64 + smoke: false + runs-on: ${{ matrix.os }} + timeout-minutes: 25 permissions: contents: read - outputs: - archive_name: ${{ steps.package.outputs.archive_name }} - checksum_name: ${{ steps.package.outputs.checksum_name }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - - name: Package the macOS companion - id: package + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Build standalone binary + run: bun run build:standalone --target ${{ matrix.target }} + + - name: Smoke test standalone binary + if: matrix.smoke && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + binary="dist/standalone/${{ matrix.target }}/ocx" + "$binary" --version + OPENCODEX_HOME="$RUNNER_TEMP/ocx-home" "$binary" start --port 10177 >"$RUNNER_TEMP/ocx.log" 2>&1 & + pid=$! + trap 'kill "$pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 30); do curl -fsS http://127.0.0.1:10177/healthz && break || sleep 1; done + curl -fsS http://127.0.0.1:10177/healthz + test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:10177/)" = 200 + + - name: Smoke test standalone binary (Windows) + if: matrix.smoke && runner.os == 'Windows' + shell: pwsh + run: | + $binary = "dist/standalone/${{ matrix.target }}/ocx.exe" + & $binary --version + $env:OPENCODEX_HOME = Join-Path $env:RUNNER_TEMP "ocx-home" + $process = Start-Process -FilePath $binary -ArgumentList "start", "--port", "10177" -PassThru + try { + for ($i = 0; $i -lt 30; $i++) { + try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Out-Null; break } catch { Start-Sleep -Seconds 1 } + } + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/healthz | Select-Object -ExpandProperty Content + Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/ | Out-Null + } finally { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } + + - name: Archive standalone release + shell: bash env: RELEASE_VERSION: ${{ inputs.version }} - UNIVERSAL: "1" - # A monotonic numeric CFBundleVersion. Preview versions carry a suffix that - # Apple does not accept in that field, so the script uses the numeric core - # plus this run number. - MACOS_BUILD_NUMBER: ${{ github.run_number }} - # NOTE: intentionally no MACOS_SIGN_IDENTITY here. The build script honours - # it, but an identity NAME alone cannot sign on a hosted runner — the - # certificate and private key are never imported into a keychain, so codesign - # fails with "no identity found". Real Developer ID signing needs a protected - # P12 import, a temporary keychain, notarytool credentials, and stapling, all - # as one security-reviewed change. Until then the asset is ad-hoc signed and - # the docs carry the Gatekeeper first-launch path. - run: bash scripts/package-macos-release.sh - - - name: Upload the release asset + STANDALONE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + cd "dist/standalone/$STANDALONE_TARGET" + if [[ "$RUNNER_OS" == "Windows" ]]; then + powershell -NoProfile -Command 'Compress-Archive -Path ocx.exe,gui -DestinationPath ("../../ocx-{0}-{1}.zip" -f $env:RELEASE_VERSION,$env:STANDALONE_TARGET) -Force' + else + tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui + fi + cd ../../.. + if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + else sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" + fi + + - name: Upload standalone release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: macos-release + name: standalone-${{ matrix.target }} + path: | + dist/ocx-*.tar.gz + dist/ocx-*.zip + dist/ocx-*.sha256 + if-no-files-found: error + retention-days: 7 + + package-desktop: + needs: validate-dispatch + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: universal-apple-darwin + bundles: app,dmg + sidecar-targets: macos + artifact-suffixes: macos.dmg,macos.app.tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + bundles: msi + sidecar-targets: x86_64-pc-windows-msvc + artifact-suffixes: windows-x64.msi + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + sidecar-targets: x86_64-unknown-linux-gnu + artifact-suffixes: linux-x86_64.AppImage,linux-amd64.deb + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install project dependencies + run: bun install --frozen-lockfile + + - name: Build dashboard + run: bun run build:gui + + - name: Setup Rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master + with: + toolchain: stable + + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev + + - name: Install desktop dependencies + working-directory: desktop + run: bun install --frozen-lockfile + + - name: Prepare macOS sidecars + if: runner.os == 'macOS' + run: | + bun desktop/scripts/prepare-sidecar.ts --target aarch64-apple-darwin + bun desktop/scripts/prepare-sidecar.ts --target x86_64-apple-darwin + + - name: Prepare sidecar + if: runner.os != 'macOS' + run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} + + - name: Build WidgetKit extension + if: runner.os == 'macOS' + run: bash desktop/scripts/build-widget.sh + + # Release signing is intentionally secret-gated. Developer ID, notarization, + # and updater signatures require maintainer-owned credentials; builds without + # those secrets remain useful for local validation but are not release assets. + - name: Build desktop bundles + working-directory: desktop + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + + - name: Rename release assets + env: + RELEASE_VERSION: ${{ inputs.version }} + DESKTOP_TARGET: ${{ matrix.target }} + run: | + bun desktop/scripts/collect-release-assets.ts \ + --version "$RELEASE_VERSION" \ + --target "$DESKTOP_TARGET" \ + --out dist/release + + - name: Upload desktop release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-${{ matrix.target }} path: dist/release/ if-no-files-found: error retention-days: 7 - attach-macos: + attach-release: runs-on: ubuntu-latest - needs: [publish, package-macos] + needs: [publish, package-standalone, package-desktop] if: ${{ inputs.dry-run != true }} + env: + UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} timeout-minutes: 10 permissions: contents: write steps: - - name: Download the packaged asset + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download standalone packaged assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: macos-release + pattern: standalone-* + merge-multiple: true path: dist/release + - name: Download desktop packaged assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-* + merge-multiple: true + path: dist/release + + # Generate latest.json only when the updater key is configured; then require + # signatures for all four updater platforms before publishing it. + - name: Generate updater manifest + if: env.UPDATER_SIGNING_CONFIGURED == 'true' + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + bun desktop/scripts/updater-manifest.ts \ + --version "$RELEASE_VERSION" \ + --dir dist/release \ + --repo lidge-jun/opencodex \ + --out dist/release/latest.json \ + --require-all + - name: Verify the checksum before uploading run: | cd dist/release diff --git a/.gitignore b/.gitignore index e6dd44882e..8f9c5f9fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,8 @@ go/ native/**/target/ dist/macos/ dist/release/ +desktop/src-tauri/binaries/ +desktop/src-tauri/resources/ +desktop/src-tauri/widget/ +desktop/src-tauri/gen/ +desktop/src-tauri/target/ diff --git a/AGENTS.md b/AGENTS.md index 469912b5e3..5fc447e7c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,8 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. -- `app/` — native macOS menu bar companion (Swift + AppKit, no third-party - dependencies). `MenuBarCore` is the testable transport/model layer, - `MenuBarUI` the AppKit views, `MenuBarApp` the entry point. Its tests are +- `app/` — native macOS WidgetKit extension bundled into the Tauri desktop app; + `MenuBarCore` is its snapshot model/formatting layer. Its tests are executables, not XCTest bundles — Command Line Tools ships neither a usable XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. @@ -46,6 +45,7 @@ Bun-native TypeScript with no separate server compile step. gone, and on a new `src/` area nobody claimed. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. +- `desktop/` — Tauri v2 desktop shell, bootstrap UI, and compiled proxy sidecar preparation. - `devlog/` — planning and investigation notes, tracked in this repository. See "The `devlog` directory" below for what may and may not go there. diff --git a/README.md b/README.md index 88a6438b3d..d5a2b010cf 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,16 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### macOS menu bar app +### macOS desktop app and widget -A native companion for proxy status, usage, and provider quotas without opening the -dashboard. The source lives in [`app/`](./app) (Swift + AppKit, no third-party -dependencies). Download it from the +Download the desktop app for macOS, Windows, or Linux from the +[latest releases](https://github.com/lidge-jun/opencodex/releases). + +A native desktop app and WidgetKit extension for proxy status, usage, and provider +quotas without opening the dashboard. The snapshot model lives in [`app/`](./app) +(`MenuBarCore`). Download it from the [releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with -`bun run build:macos`. +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. The first launch needs a right-click → Open, because the app is ad-hoc signed rather than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) diff --git a/app/Info.plist b/app/Info.plist deleted file mode 100644 index 3d52873063..0000000000 --- a/app/Info.plist +++ /dev/null @@ -1,42 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - OpenCodexMenuBar - CFBundleIdentifier - com.opencodex.menubar - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - OpenCodex - CFBundleDisplayName - OpenCodex - CFBundlePackageType - APPL - CFBundleIconFile - OpenCodex - CFBundleShortVersionString - 0.0.0 - CFBundleVersion - 0.0.0 - LSUIElement - - LSMinimumSystemVersion - 13.0 - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - NSHumanReadableCopyright - MIT — opencodex contributors - - diff --git a/app/Package.swift b/app/Package.swift index 9e5f137275..0e14e3bd5b 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -2,26 +2,14 @@ import PackageDescription let package = Package( - name: "OpenCodexMenuBar", + name: "OpenCodexWidget", platforms: [.macOS(.v13)], products: [ - .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), - .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), - .executable(name: "UIProbe", targets: ["UIProbe"]), - .executable(name: "IconProbe", targets: ["IconProbe"]), ], targets: [ .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), - // AppKit views live in a library so both the app and the visual-QA probe can - // build the same surface. An executable target cannot be imported. - .target(name: "MenuBarUI", dependencies: ["MenuBarCore"], path: "Sources/MenuBarUI"), - .executableTarget( - name: "MenuBarApp", - dependencies: ["MenuBarCore", "MenuBarUI"], - path: "Sources/MenuBarApp" - ), .executableTarget( name: "OpenCodexWidget", dependencies: ["MenuBarCore"], @@ -41,15 +29,6 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/MenuBarCoreTests" ), - // UI-layer tests need AppKit and an NSApplication, so they are a separate - // executable from the dependency-free core suite. - .executableTarget( - name: "MenuBarUITests", - dependencies: ["MenuBarCore", "MenuBarUI"], - path: "Sources/MenuBarUITests" - ), - .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), - .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), ], swiftLanguageVersions: [.v5] ) diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift deleted file mode 100644 index f9a359114d..0000000000 --- a/app/Sources/IconProbe/main.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Renders every menu bar glyph state to one sheet so the state signal can be verified -// visually. The notch previously did not render at all, which made protected and -// at-risk indistinguishable. -import AppKit -import MenuBarCore -import MenuBarUI - -let states: [(String, ProxyState)] = [ - ("protected", .running(StartupHealth(status: "protected"))), - ("at-risk", .running(StartupHealth(status: "at-risk"))), - ("loading", .loading), - ("stopped", .unreachable), -] - -let scale: CGFloat = 6 -let cell = NSSize(width: 17 * scale, height: 17 * scale) -let sheet = NSImage(size: NSSize(width: cell.width * CGFloat(states.count), height: cell.height)) -sheet.lockFocus() -NSColor.white.setFill() -NSRect(origin: .zero, size: sheet.size).fill() -for (i, entry) in states.enumerated() { - let img = StatusIcon.image(for: entry.1) - let rect = NSRect(x: CGFloat(i) * cell.width, y: 0, width: cell.width, height: cell.height) - NSGraphicsContext.current?.imageInterpolation = .none - img.draw(in: rect.insetBy(dx: 8, dy: 8)) -} -sheet.unlockFocus() -if let tiff = sheet.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), - let png = rep.representation(using: .png, properties: [:]) { - try? png.write(to: URL(fileURLWithPath: "/tmp/glyphs.png")) -} -print("wrote /tmp/glyphs.png:", states.map(\.0).joined(separator: ", ")) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift deleted file mode 100644 index 710ac8c51b..0000000000 --- a/app/Sources/MenuBarApp/main.swift +++ /dev/null @@ -1,11 +0,0 @@ -import AppKit -import MenuBarUI - -let app = NSApplication.shared -// .accessory keeps it out of the Dock; LSUIElement in Info.plist does the same for the -// packaged bundle, and this covers `swift run` during development. -app.setActivationPolicy(.accessory) - -let delegate = AppDelegate() -app.delegate = delegate -app.run() diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift index 412a5ff5c0..23b34e1d0c 100644 --- a/app/Sources/MenuBarCore/Discovery.swift +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -57,7 +57,7 @@ public enum ProxyDiscovery { /// Reads `runtime-port.json`, falling back to the default port on any problem. /// /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to - /// the default rather than throwing. A menu bar app that refuses to start because a + /// the default rather than throwing. A desktop app that refuses to start because a /// cache file is unreadable would be worse than one that probes the usual port. public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { let file = directory.appendingPathComponent("runtime-port.json") diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 4125657a62..5154fd7e83 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -2,7 +2,7 @@ import Foundation /// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. /// -/// Polling is deliberately conservative. A menu bar app that hits a local server every +/// Polling is deliberately conservative. A desktop app that hits a local server every /// five seconds forever is a battery complaint waiting to happen, so heavy aggregation /// endpoints are fetched only while the popover is open, and repeated failures back the /// liveness tick off rather than hammering a proxy the user has stopped on purpose. diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 5318827814..e9a3198424 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -275,7 +275,7 @@ public actor ProxyClient { request.httpMethod = method request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - request.setValue("OpenCodexMenuBar/\(version)", forHTTPHeaderField: "User-Agent") + request.setValue("OpenCodexWidget/\(version)", forHTTPHeaderField: "User-Agent") if let credential = key ?? apiKey { request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") } diff --git a/app/Sources/MenuBarCore/WidgetSnapshot.swift b/app/Sources/MenuBarCore/WidgetSnapshot.swift index 3dc4f9cd98..6ea48c8978 100644 --- a/app/Sources/MenuBarCore/WidgetSnapshot.swift +++ b/app/Sources/MenuBarCore/WidgetSnapshot.swift @@ -127,7 +127,7 @@ public final class WidgetSnapshotStore: @unchecked Sendable { private var loggedFailures = Set() public init( - widgetBundleID: String = "com.opencodex.menubar.widget", + widgetBundleID: String = "com.opencodex.desktop.widget", fileManager: FileManager = .default, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser ) { diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 570e10bdea..d46a667e0f 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -283,7 +283,7 @@ enum TransportSuite { let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" t.expect(url.contains("range=7d"), "expected range=7d in \(url)") t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") - t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexMenuBar/dev") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexWidget/dev") } t.test("requests: the provider patch sends exactly {\"disabled\":true}") { diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift deleted file mode 100644 index 7ca5c1d8e2..0000000000 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ /dev/null @@ -1,254 +0,0 @@ -import AppKit -import MenuBarCore - -public final class AppDelegate: NSObject, NSApplicationDelegate { - private var statusItem: NSStatusItem? - /// A key-capable panel rather than `NSPopover`. - /// - /// This is the single most-tested decision in this file. `NSPopover` from an - /// accessory (`LSUIElement`) process creates a window that never appears in - /// `NSApp.windows` and reports `canBecomeKey == false`, so macOS will not route key - /// events to it no matter how the process is activated — Escape and the Tab path - /// simply never arrive. A `nonactivatingPanel` that overrides `canBecomeKey` - /// measures as `canBecomeKey=1 isKey=1` under the same conditions. - private let panel = PopoverPanel() - private let controller = PopoverViewController() - private var coordinator: PollingCoordinator? - private var actions: ActionCoordinator? - private var client: ProxyClient? - private let widgetStore = WidgetSnapshotStore() - /// The snapshot the UI is currently showing, for decisions that need context - /// (the start command to display, the default provider to protect). - private var latest: ProxySnapshot? - private var endpoint = ProxyEndpoint.default - private var pollTask: Task? - /// Fallback Escape handling for the case where the panel is visible but another - /// process holds focus. Installed on open, removed on close. - private var escapeMonitor: Any? - - public override init() { super.init() } - - public func applicationDidFinishLaunching(_ notification: Notification) { - endpoint = ProxyDiscovery.resolve() - let client = ProxyClient(endpoint: endpoint) - self.client = client - let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - self.coordinator = coordinator - self.actions = ActionCoordinator(client: client) - - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.image = StatusIcon.image(for: .loading) - item.button?.imagePosition = .imageOnly - item.button?.target = self - item.button?.action = #selector(togglePopover) - item.button?.setAccessibilityLabel("OpenCodex proxy status") - statusItem = item - - controller.onDashboard = { [weak self] in self?.openDashboard() } - controller.onCompanionSettings = { [weak self] in self?.openCompanionSettings() } - controller.onStop = { [weak self] in self?.stopProxy() } - controller.onRefresh = { [weak self] in self?.refreshNow() } - controller.onAddKey = { [weak self] in self?.openDashboard() } - controller.onRetry = { [weak self] in self?.refreshNow() } - controller.onToggleProvider = { [weak self] name, disable in - self?.toggleProvider(name, disable: disable) - } - controller.onQuit = { NSApp.terminate(nil) } - - panel.contentViewController = controller - panel.onDismiss = { [weak self] in self?.handlePanelClosed() } - - // The observer closure is `@Sendable` and crosses actor boundaries, so it must - // not capture the delegate. It hops to the main actor and looks the delegate up - // there instead. - Task { - await coordinator.observe { snapshot in - Task { @MainActor in - (NSApp.delegate as? AppDelegate)?.render(snapshot) - } - } - await MainActor.run { (NSApp.delegate as? AppDelegate)?.startPolling() } - } - } - - public func applicationWillTerminate(_ notification: Notification) { - pollTask?.cancel() - removeEscapeMonitor() - panel.dismiss() - } - - // MARK: - Polling - - @MainActor - fileprivate func startPolling() { - guard let coordinator else { return } - pollTask?.cancel() - pollTask = Task { - while !Task.isCancelled { - await coordinator.refresh() - let interval = await coordinator.currentInterval - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - private func refreshNow() { - Task { [coordinator] in await coordinator?.refresh(includeHeavy: true) } - } - - @MainActor - fileprivate func render(_ snapshot: ProxySnapshot) { - latest = snapshot - let title = snapshot.menuBarTitle ?? "" - statusItem?.button?.title = title - statusItem?.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) - statusItem?.button?.imagePosition = title.isEmpty ? .imageOnly : .imageLeading - statusItem?.button?.image = StatusIcon.image(for: snapshot.state) - statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" - controller.apply(snapshot) - let widgetSnapshot = WidgetSnapshot.make(from: snapshot) - Task.detached { [widgetStore] in widgetStore.writeIfChanged(widgetSnapshot) } - } - - // MARK: - Actions - - #if DEBUG - /// Testing hook: drives the exact presentation path a status-item click uses, so a - /// harness can verify key focus and Escape without Accessibility permission. - /// Debug-only — it is not part of the shipped surface. - public func debugTogglePanel() { togglePopover() } - #endif - - @objc private func togglePopover() { - guard let button = statusItem?.button else { return } - if panel.isShown { - panel.dismiss() - } else { - panel.present(from: button) - installEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(true) } - } - } - - /// Called by the panel whenever it closes, however it was dismissed. - private func handlePanelClosed() { - removeEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(false) } - } - - /// The panel is key-capable, so `cancelOperation(_:)` handles Escape in the normal - /// case. This local monitor is belt-and-braces for the window where the panel is up - /// but focus sits elsewhere in this process, such as the confirmation sheet. - private func installEscapeMonitor() { - removeEscapeMonitor() - escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - // While a confirmation is up, Escape belongs to the alert: consuming it - // here dismissed the panel and stranded the alert with no way to cancel. - guard event.keyCode == 53, - self?.panel.isShown == true, - self?.panel.isPresentingModal == false - else { return event } - self?.panel.dismiss() - return nil - } - } - - - private func removeEscapeMonitor() { - if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) } - escapeMonitor = nil - } - - private func openDashboard() { - NSWorkspace.shared.open(endpoint.baseURL) - } - - private func openCompanionSettings() { - guard let url = URL(string: "\(endpoint.baseURL.absoluteString)/#/usage#usage-section-companion") else { return } - NSWorkspace.shared.open(url) - } - - /// Stopping is destructive: it interrupts in-flight requests and stops the launchd - /// service, so nothing restarts the proxy. It always confirms first. - private func stopProxy() { - let alert = NSAlert() - alert.messageText = "Stop the OpenCodex proxy?" - alert.informativeText = - "In-flight requests will be interrupted, and OpenCodex will not restart on its own." - alert.alertStyle = .warning - alert.addButton(withTitle: "Stop proxy") - alert.addButton(withTitle: "Cancel") - - // The alert takes key focus, which would otherwise trip resignKey and dismiss - // the panel behind it — leaving a user who chose Cancel with nothing. - panel.isPresentingModal = true - NSApp.activate(ignoringOtherApps: true) - let confirmed = alert.runModal() == .alertFirstButtonReturn - panel.isPresentingModal = false - - guard confirmed else { - panel.makeKeyAndOrderFront(nil) - return - } - - let startCommand = latest?.lastKnownStartCommand ?? "ocx start" - controller.showResult("Stopping…", isError: false) - - Task { [actions, coordinator] in - let outcome = await actions?.stop(startCommand: startCommand) ?? .failed("Unavailable.") - await coordinator?.refresh() - await MainActor.run { [weak self] in - switch outcome { - case .succeeded: - self?.controller.showResult("Proxy stopped.", isError: false) - case .requiresManualStart(let command): - // Not a failure — the API has no start endpoint by design. - self?.controller.showResult("Proxy stopped. Start it again with \(command)", isError: false) - case .stoppedWithRestoreFailure(let command): - // The proxy is down but native Codex still points at the dead port. - self?.controller.showResult( - "Proxy stopped, but restoring native Codex failed. Run `ocx restore`, then \(command)", - isError: true - ) - case .failed(let message): - self?.controller.showResult(message, isError: true) - } - } - } - } - - /// Optimistic toggle: the switch has already moved, so a rejection must move it back - /// rather than leave the UI showing a state the proxy refused. - private func toggleProvider(_ name: String, disable: Bool) { - let defaultProvider = latest?.defaultProvider - controller.setProviderBusy(name, true, intended: !disable) - - Task { [actions, coordinator] in - let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) - ?? .failed("Unavailable.") - await MainActor.run { [weak self] in - switch outcome { - case .succeeded: - self?.controller.showResult( - disable ? "\(name) disabled." : "\(name) enabled.", - isError: false - ) - case .failed(let message): - self?.controller.revertProvider(name, to: !disable) - self?.controller.showResult(message, isError: true) - case .requiresManualStart, .stoppedWithRestoreFailure: - // Not reachable for a provider write. - break - } - } - // Re-read so the summary line and switch states match the proxy, not our - // optimistic guess. refreshAndWait rather than refresh: a coalesced refresh - // returns immediately, which would re-enable the switch against pre-write - // data. - await coordinator?.refreshAndWait() - await MainActor.run { [weak self] in - self?.controller.setProviderBusy(name, false) - } - } - } -} diff --git a/app/Sources/MenuBarUI/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift deleted file mode 100644 index a23c4bf42c..0000000000 --- a/app/Sources/MenuBarUI/CompanionViews.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AppKit -import MenuBarCore - -final class ModelsListView: NSView { - private let stack = NSStackView() - private let caption = makeLabel("MODELS", font: Theme.micro, color: Theme.faint) - - init() { - super.init(frame: .zero) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - stack.addArrangedSubview(caption) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - clearRows() - let rows = snapshot.todayRows.sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) }.prefix(5) - isHidden = !snapshot.settings.showModels || rows.isEmpty - for row in rows { - let model = [row.provider, row.model].compactMap { $0 }.joined(separator: "/") - let cost = snapshot.settings.showCost ? " · \(Format.cost(row.estimatedCostUsd))" : "" - stack.addArrangedSubview(makeLabel( - "\(model) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))\(cost)", - font: Theme.caption, color: Theme.text - )) - } - } - - private func clearRows() { - for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } - } -} - -final class AccountsListView: NSView { - private let stack = NSStackView() - private let caption = makeLabel("ACCOUNTS", font: Theme.micro, color: Theme.faint) - - init() { - super.init(frame: .zero) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - stack.addArrangedSubview(caption) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - clearRows() - let rows = (snapshot.today?.accounts ?? []).sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } - isHidden = !snapshot.settings.showAccounts || rows.isEmpty - for row in rows { - stack.addArrangedSubview(makeLabel( - "\(row.accountLogLabel ?? Format.unknown) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))", - font: Theme.caption, color: Theme.text - )) - } - } - - private func clearRows() { - for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } - } -} diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift deleted file mode 100644 index 5c579f88be..0000000000 --- a/app/Sources/MenuBarUI/PopoverPanel.swift +++ /dev/null @@ -1,170 +0,0 @@ -import AppKit - -/// The popover surface. -/// -/// Deliberately a panel rather than `NSPopover`. Measured on macOS 27 from an accessory -/// (`LSUIElement`) process: the window `NSPopover` creates never appears in -/// `NSApp.windows` and reports `canBecomeKey == false`, so the OS refuses to route key -/// events to it — Escape and Tab never arrive regardless of how the process is -/// activated. The same probe against this panel reports `canBecomeKey=1 isKey=1`. -/// -/// `nonactivatingPanel` keeps the click-through feel of a menu bar popover: opening it -/// does not steal focus from the user's editor. -public final class PopoverPanel: NSPanel { - /// Invoked whenever the panel closes, however it was dismissed. - public var onDismiss: (() -> Void)? - - private var clickOutsideMonitor: Any? - - public init() { - super.init( - contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), - styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], - backing: .buffered, - defer: false - ) - isFloatingPanel = true - level = .statusBar - hidesOnDeactivate = false - becomesKeyOnlyIfNeeded = false - isOpaque = false - backgroundColor = .clear - hasShadow = true - isMovable = false - animationBehavior = .utilityWindow - } - - /// Wraps the content in a real popover material. - /// - /// A borderless panel has NO background of its own: without this the dashboard - /// composites straight onto whatever application is underneath, so labels collide - /// with the app behind it and contrast depends on that app's colours. `NSPopover` - /// supplies this surface automatically; a panel must build it. - public override var contentViewController: NSViewController? { - didSet { - guard let content = contentViewController?.view else { return } - contentView = PopoverSurface.make(content: content) - } - } - - /// Suspends resign-key dismissal, so presenting a modal sheet does not tear the - /// panel down behind it and strand a user who chose Cancel. - public var isPresentingModal = false - - public override var canBecomeKey: Bool { true } - /// Never main: this is chrome, not a document window. - public override var canBecomeMain: Bool { false } - - public var isShown: Bool { isVisible } - - /// Presents under a status item button, clamped to the visible screen. - public func present(from button: NSStatusBarButton) { - guard let buttonWindow = button.window else { return } - layoutContent() - - let size = contentViewController?.preferredContentSize ?? frame.size - setContentSize(size) - - let buttonRect = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) - var origin = NSPoint( - x: buttonRect.midX - size.width / 2, - y: buttonRect.minY - size.height - 6 - ) - - if let screen = buttonWindow.screen ?? NSScreen.main { - let visible = screen.visibleFrame - origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) - origin.y = max(origin.y, visible.minY + 8) - } - - setFrameOrigin(origin) - makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - installClickOutsideMonitor() - } - - public func dismiss() { - // Idempotent: a late monitor callback must not re-run teardown. - guard isVisible else { return } - removeClickOutsideMonitor() - orderOut(nil) - onDismiss?() - } - - /// Transient behaviour: clicking anywhere else dismisses, matching what a menu bar - /// popover trained the user to expect. - private func installClickOutsideMonitor() { - removeClickOutsideMonitor() - clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] _ in - self?.dismiss() - } - } - - private func removeClickOutsideMonitor() { - if let monitor = clickOutsideMonitor { NSEvent.removeMonitor(monitor) } - clickOutsideMonitor = nil - } - - public override func cancelOperation(_ sender: Any?) { dismiss() } - - public override func resignKey() { - super.resignKey() - // Losing key focus means the user moved on — unless we put the focus elsewhere - // ourselves by presenting a confirmation. - guard !isPresentingModal else { return } - if isVisible { dismiss() } - } - - private func layoutContent() { - contentViewController?.view.layoutSubtreeIfNeeded() - } -} - -private enum PopoverSurface { - static func make(content: NSView) -> NSView { - let surface: NSView -#if compiler(>=6.2) - if #available(macOS 26, *) { - let glass = NSGlassEffectView() - glass.cornerRadius = 16 - glass.style = .regular - glass.contentView = content - surface = glass - } else { - surface = makeMaterialSurface(content: content) - } -#else - surface = makeMaterialSurface(content: content) -#endif - - let host = NSView() - host.addSubview(surface) - surface.translatesAutoresizingMaskIntoConstraints = false - content.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - surface.topAnchor.constraint(equalTo: host.topAnchor), - surface.leadingAnchor.constraint(equalTo: host.leadingAnchor), - surface.trailingAnchor.constraint(equalTo: host.trailingAnchor), - surface.bottomAnchor.constraint(equalTo: host.bottomAnchor), - content.topAnchor.constraint(equalTo: surface.topAnchor), - content.leadingAnchor.constraint(equalTo: surface.leadingAnchor), - content.trailingAnchor.constraint(equalTo: surface.trailingAnchor), - content.bottomAnchor.constraint(equalTo: surface.bottomAnchor), - ]) - return host - } - - private static func makeMaterialSurface(content: NSView) -> NSView { - let effect = NSVisualEffectView() - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 10 - effect.layer?.masksToBounds = true - effect.addSubview(content) - return effect - } -} diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift deleted file mode 100644 index 1cc8dd51ae..0000000000 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ /dev/null @@ -1,384 +0,0 @@ -import AppKit -import MenuBarCore - -/// The popover body: one column ordered by urgency. -/// -/// Deliberately not a tab bar. A menu bar popover is a glance surface, and tabs would put -/// the answer to "is it fine?" one click away three times out of four. -/// -/// Fixed header and action row with a scrolling middle: the quota and provider sections -/// grow with the user's configuration, and an uncapped popover would eventually run off -/// the screen. -public final class PopoverViewController: NSViewController { - public override init(nibName: NSNib.Name?, bundle: Bundle?) { - super.init(nibName: nibName, bundle: bundle) - } - - public required init?(coder: NSCoder) { nil } - - /// The popover never grows past this; the variable middle scrolls instead. - private static let maxHeight: CGFloat = 480 - - // Fixed chrome - private let header = StatusHeaderView() - private let dashboardButton = NSButton() - private let stopButton = NSButton() - private let overflowButton = NSButton() - /// State-specific call to action: "Add key…" or "Retry". - private let primaryButton = NSButton() - - // Scrolling body - private let scrollView = NSScrollView() - private let body = NSStackView() - private let metrics = MetricsView() - private let timelineChart = TimelineChartView() - private let models = ModelsListView() - private let accounts = AccountsListView() - private let quotaStack = NSStackView() - private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) - private let providers = ProviderListView() - /// Transient result of the last write action. Actions that report nothing leave the - /// user guessing whether anything happened. - private let resultBanner = makeLabel("", font: Theme.caption, color: Theme.muted) - private let skeleton = SkeletonView() - private let guidanceLabel: NSTextField = { - let field = makeLabel("", font: Theme.caption, color: Theme.muted) - // Guidance is a sentence, not a stat: let it wrap instead of truncating away - // the half that explains what to do. - field.lineBreakMode = .byWordWrapping - field.maximumNumberOfLines = 3 - field.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - return field - }() - private let commandField = NSTextField(labelWithString: "") - private let metricsSeparator = makeSeparator() - private let quotaSeparator = makeSeparator() - - public var onDashboard: (() -> Void)? - public var onCompanionSettings: (() -> Void)? - public var onStop: (() -> Void)? - public var onQuit: (() -> Void)? - public var onRefresh: (() -> Void)? - /// `(provider, shouldDisable)`. - public var onToggleProvider: ((String, Bool) -> Void)? - /// Distinct callbacks: "Retry" must retry in place, while "Add key…" navigates to - /// the dashboard. Routing both through one handler made Retry open a browser. - public var onAddKey: (() -> Void)? - public var onRetry: (() -> Void)? - - private var snapshot: ProxySnapshot? - private var scrollHeight: NSLayoutConstraint? - /// Guards the banner's auto-hide so a newer result is not cleared by an older timer. - private var resultToken = 0 - - public override func loadView() { - configureControls() - resultBanner.isHidden = true - resultBanner.lineBreakMode = .byWordWrapping - resultBanner.maximumNumberOfLines = 3 - resultBanner.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - providers.onToggle = { [weak self] name, disable in - self?.onToggleProvider?(name, disable) - } - - body.orientation = .vertical - body.alignment = .leading - body.spacing = Theme.rowGap - body.setViews( - [skeleton, metrics, timelineChart, metricsSeparator, models, quotaStack, quotaEmpty, - accounts, providers, quotaSeparator, resultBanner, guidanceLabel, commandField], - in: .top - ) - body.translatesAutoresizingMaskIntoConstraints = false - - // A flipped clip view puts the scroll origin at the TOP. Without this, content - // that overflows opens scrolled to the bottom, hiding the status and metrics the - // urgency order exists to surface first. - scrollView.contentView = FlippedClipView() - scrollView.documentView = body - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.translatesAutoresizingMaskIntoConstraints = false - - let actions = NSStackView(views: [dashboardButton, stopButton, primaryButton, NSView(), overflowButton]) - actions.orientation = .horizontal - actions.spacing = Theme.rowGap - actions.alignment = .centerY - - let column = NSStackView(views: [header, makeSeparator(), scrollView, actions]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = Theme.rowGap - column.edgeInsets = NSEdgeInsets( - top: Theme.gutter, left: Theme.gutter, - bottom: Theme.gutter, right: Theme.gutter - ) - column.translatesAutoresizingMaskIntoConstraints = false - - let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 300)) - root.addSubview(column) - - let contentWidth = Theme.width - Theme.gutter * 2 - NSLayoutConstraint.activate([ - column.topAnchor.constraint(equalTo: root.topAnchor), - column.leadingAnchor.constraint(equalTo: root.leadingAnchor), - column.trailingAnchor.constraint(equalTo: root.trailingAnchor), - column.bottomAnchor.constraint(equalTo: root.bottomAnchor), - root.widthAnchor.constraint(equalToConstant: Theme.width), - header.widthAnchor.constraint(equalToConstant: contentWidth), - actions.widthAnchor.constraint(equalToConstant: contentWidth), - scrollView.widthAnchor.constraint(equalToConstant: contentWidth), - body.widthAnchor.constraint(equalToConstant: contentWidth), - ]) - - let heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: 120) - heightConstraint.isActive = true - scrollHeight = heightConstraint - - view = root - } - - private func configureControls() { - for (button, title) in [(dashboardButton, "Dashboard"), (stopButton, "Stop proxy")] { - button.title = title - button.bezelStyle = .rounded - button.controlSize = .small - button.font = Theme.caption - button.target = self - } - dashboardButton.action = #selector(dashboardTapped) - stopButton.action = #selector(stopTapped) - - primaryButton.bezelStyle = .rounded - primaryButton.controlSize = .small - primaryButton.font = Theme.caption - primaryButton.target = self - primaryButton.action = #selector(primaryTapped) - primaryButton.isHidden = true - - overflowButton.title = "···" - overflowButton.bezelStyle = .rounded - overflowButton.controlSize = .small - overflowButton.font = Theme.caption - overflowButton.target = self - overflowButton.action = #selector(overflowTapped) - overflowButton.setAccessibilityLabel("More actions") - - quotaStack.orientation = .vertical - quotaStack.alignment = .leading - quotaStack.spacing = Theme.tightGap - - commandField.font = Theme.numericSmall - commandField.textColor = Theme.text - commandField.isSelectable = true - commandField.isBordered = false - commandField.drawsBackground = false - } - - public func apply(_ snapshot: ProxySnapshot) { - self.snapshot = snapshot - header.apply(snapshot) - - let showsData = snapshot.showsData - let isLoading = !snapshot.hasEverLoaded && snapshot.state == .loading - - // Loading shows structure, not empty copy: the shape of the answer is already - // known, only the values are missing. - skeleton.isHidden = !isLoading - - metrics.isHidden = !showsData - metricsSeparator.isHidden = !showsData - quotaSeparator.isHidden = !showsData - if showsData { - metrics.apply(snapshot) - timelineChart.apply(snapshot) - models.apply(snapshot) - accounts.apply(snapshot) - applyQuotas(snapshot) - providers.apply(snapshot) - } else { - timelineChart.isHidden = true - models.isHidden = true - accounts.isHidden = true - quotaStack.isHidden = true - quotaEmpty.isHidden = true - providers.isHidden = true - } - - applyGuidance(snapshot) - applyActions(snapshot, isLoading: isLoading) - resize() - } - - private func applyQuotas(_ snapshot: ProxySnapshot) { - for view in quotaStack.arrangedSubviews { - quotaStack.removeArrangedSubview(view) - view.removeFromSuperview() - } - let rows = snapshot.quotaRows - quotaStack.isHidden = rows.isEmpty - // "Not fetched yet" and "the proxy reported none" are different facts. - quotaEmpty.isHidden = !(rows.isEmpty && snapshot.quotasLoaded) - for quota in rows { - let row = QuotaRowView(quota: quota) - row.translatesAutoresizingMaskIntoConstraints = false - quotaStack.addArrangedSubview(row) - row.widthAnchor.constraint(equalTo: quotaStack.widthAnchor).isActive = true - } - } - - /// Shows the outcome of a write action, then clears itself. A banner that never - /// leaves would become permanent furniture. - public func showResult(_ text: String, isError: Bool) { - resultBanner.stringValue = text - resultBanner.textColor = isError ? Theme.red : Theme.muted - resultBanner.isHidden = false - refreshSize() - - resultToken &+= 1 - let token = resultToken - DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in - guard let self, self.resultToken == token else { return } - self.resultBanner.isHidden = true - self.refreshSize() - } - } - - public func revertProvider(_ name: String, to enabled: Bool) { - providers.revert(name, to: enabled) - } - - public func setProviderBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { - providers.setBusy(name, busy, intended: intended) - } - - /// Re-measures after content changes height (disclosure, banner). - public func refreshSize() { resize() } - - /// Guidance text plus any command the user should run. Commands are shown as - /// selectable text; the app never executes them. - private func applyGuidance(_ snapshot: ProxySnapshot) { - var guidance: String? - var command: String? - - switch snapshot.nextAction { - case .none: - // A running-but-at-risk proxy still has advice worth surfacing. - if case .running = snapshot.state, let recommended = snapshot.recommendedCommand { - guidance = "Recommended:" - command = recommended - } - case .runCommand(let value): - guidance = "Start it again with:" - command = value - case .addAPIKey: - guidance = "This proxy is bound to a non-loopback address and needs a key." - case .retry: - guidance = snapshot.dataAge.map { "Showing data from \(Format.age($0)). Retrying automatically." } - ?? "Retrying automatically." - } - - guidanceLabel.isHidden = guidance == nil - guidanceLabel.stringValue = guidance ?? "" - commandField.isHidden = command == nil - commandField.stringValue = command ?? "" - if let command { - commandField.setAccessibilityLabel("Command to run: \(command)") - } - } - - private func applyActions(_ snapshot: ProxySnapshot, isLoading: Bool) { - // Nothing is actionable before the first read completes. - dashboardButton.isEnabled = !isLoading - overflowButton.isEnabled = !isLoading - stopButton.isEnabled = snapshot.state.isRunning - stopButton.isHidden = !snapshot.state.isRunning - - switch snapshot.nextAction { - case .addAPIKey: - primaryButton.isHidden = false - primaryButton.title = "Add key…" - primaryButton.keyEquivalent = "\r" - case .retry: - primaryButton.isHidden = false - primaryButton.title = "Retry" - primaryButton.keyEquivalent = "\r" - case .none, .runCommand: - primaryButton.isHidden = true - primaryButton.keyEquivalent = "" - } - } - - private func resize() { - view.layoutSubtreeIfNeeded() - let bodyHeight = ceil(body.fittingSize.height) - // Chrome is the header, separator, action row, and insets. - let chrome = ceil(header.fittingSize.height) + Theme.gutter * 2 + Theme.rowGap * 3 + 28 - let natural = chrome + bodyHeight - let capped = min(Self.maxHeight, natural) - // Scrollers appear only when the content genuinely overflows; a scroll bar on a - // three-line loading state reads as a broken layout. - let overflowing = natural > Self.maxHeight - scrollView.hasVerticalScroller = overflowing - scrollHeight?.constant = max(0, capped - chrome) - preferredContentSize = NSSize(width: Theme.width, height: max(96, capped)) - } - - // MARK: - Actions - - @objc private func dashboardTapped() { onDashboard?() } - @objc private func stopTapped() { onStop?() } - @objc private func primaryTapped() { - switch snapshot?.nextAction { - case .addAPIKey: onAddKey?() - case .retry: onRetry?() - default: break - } - } - @objc private func refreshTapped() { onRefresh?() } - @objc private func quitTapped() { onQuit?() } - - @objc private func overflowTapped() { - let menu = NSMenu() - menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self - menu.addItem(withTitle: "Open dashboard", action: #selector(dashboardTapped), keyEquivalent: "").target = self - menu.addItem(withTitle: "Companion settings…", action: #selector(companionSettingsTapped), keyEquivalent: "").target = self - menu.addItem(.separator()) - menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) - } - @objc private func companionSettingsTapped() { onCompanionSettings?() } - - /// AppKit routes Escape here for the whole responder chain, which `keyDown` does not - /// reliably receive inside a popover. - public override func cancelOperation(_ sender: Any?) { - view.window?.performClose(nil) - } -} - -/// Top-anchored clip view. AppKit scroll views are bottom-origin by default. -final class FlippedClipView: NSClipView { - override var isFlipped: Bool { true } -} - -/// Loading structure: grey bars where values will appear, so the first paint shows the -/// shape of the answer instead of empty space or a spinner. -final class SkeletonView: NSView { - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: 84) - } - - override func draw(_ dirtyRect: NSRect) { - Theme.raised.setFill() - let widths: [CGFloat] = [72, 0, 96, 140, 120, 110] - var y = bounds.maxY - 12 - for width in widths { - guard width > 0 else { y -= 8; continue } - let rect = NSRect(x: 0, y: y, width: width, height: 9) - NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() - y -= 15 - } - } -} diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift deleted file mode 100644 index c0d5b6dc0b..0000000000 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ /dev/null @@ -1,255 +0,0 @@ -import AppKit -import MenuBarCore - -/// Collapsed provider list with per-provider enable/disable switches. -/// -/// Collapsed by default: reading status is frequent, toggling a provider is rare, and -/// the urgency order in `003` puts actions below information. -public final class ProviderListView: NSView { - private let disclosure = NSButton() - private let summary = makeLabel("", font: Theme.caption, color: Theme.muted) - private let rows = NSStackView() - private var expanded = false - private var snapshot: ProxySnapshot? - /// Providers with a write in flight, mapped to the state the USER chose. A poll can - /// still be carrying pre-write data, so the intended value — not the snapshot — is - /// what a rebuilt row must show. - private var pending: [String: Bool] = [:] - - /// `(provider, shouldDisable)`. - public var onToggle: ((String, Bool) -> Void)? - - public override init(frame: NSRect) { - super.init(frame: frame) - - disclosure.bezelStyle = .disclosure - disclosure.setButtonType(.onOff) - disclosure.title = "" - disclosure.target = self - disclosure.action = #selector(toggleExpanded) - disclosure.setAccessibilityLabel("Show providers") - - rows.orientation = .vertical - rows.alignment = .leading - rows.spacing = Theme.tightGap - rows.isHidden = true - - let header = NSStackView(views: [disclosure, summary]) - header.orientation = .horizontal - header.spacing = Theme.tightGap - header.alignment = .centerY - - let column = NSStackView(views: [header, rows]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = Theme.tightGap - column.translatesAutoresizingMaskIntoConstraints = false - addSubview(column) - NSLayoutConstraint.activate([ - column.topAnchor.constraint(equalTo: topAnchor), - column.leadingAnchor.constraint(equalTo: leadingAnchor), - column.trailingAnchor.constraint(equalTo: trailingAnchor), - column.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - public convenience init() { self.init(frame: .zero) } - - public required init?(coder: NSCoder) { nil } - - public func apply(_ snapshot: ProxySnapshot) { - self.snapshot = snapshot - - guard snapshot.providersLoaded else { - isHidden = true - return - } - isHidden = false - - if snapshot.providers.isEmpty { - summary.stringValue = "No providers configured." - disclosure.isHidden = true - rows.isHidden = true - return - } - - disclosure.isHidden = false - let enabled = snapshot.providers.filter(\.isEnabled).count - summary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" - rebuildRows(snapshot) - rows.isHidden = !expanded - } - - private func rebuildRows(_ snapshot: ProxySnapshot) { - for view in rows.arrangedSubviews { - rows.removeArrangedSubview(view) - view.removeFromSuperview() - } - - for provider in snapshot.visibleProviders.sorted(by: { $0.name < $1.name }) { - let isDefault = provider.name == snapshot.defaultProvider - let row = ProviderRowView( - provider: provider, - isDefault: isDefault - ) { [weak self] shouldDisable in - self?.onToggle?(provider.name, shouldDisable) - } - // A refresh that lands mid-write must not undo the optimistic state: apply - // the intended value first, then mark the row busy. - if let intended = pending[provider.name] { - row.setEnabled(intended) - row.setBusy(true) - } - row.translatesAutoresizingMaskIntoConstraints = false - rows.addArrangedSubview(row) - row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true - } - } - - /// Shared by the disclosure button and the test hook. - func setExpanded(_ value: Bool) { - expanded = value - disclosure.state = value ? .on : .off - rows.isHidden = !expanded - disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") - (window?.contentViewController as? PopoverViewController)?.refreshSize() - } - - var providerRows: [NSView] { rows.arrangedSubviews } - - @objc private func toggleExpanded() { - expanded = disclosure.state == .on - rows.isHidden = !expanded - disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") - // The popover has to grow or shrink with the disclosure. - (window?.contentViewController as? PopoverViewController)?.refreshSize() - } - - /// Reverts a switch after the proxy rejected the change. - public func revert(_ name: String, to enabled: Bool) { - pending[name] = nil - for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { - row.setEnabled(enabled) - row.setBusy(false) - } - } - - /// Marks a provider as having a write in flight. Its switch stays inert until the - /// authoritative refresh lands, so a poll cannot resurrect the pre-toggle state and - /// a second click cannot race the first. - /// `intended` is the state the user selected, retained so a poll landing mid-write - /// cannot snap the switch back. - public func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { - if busy { - pending[name] = intended ?? pending[name] ?? true - } else { - pending[name] = nil - } - for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { - if busy, let value = pending[name] { row.setEnabled(value) } - row.setBusy(busy) - } - } -} - -public final class ProviderRowView: NSView { - public let providerName: String - private let toggle = NSSwitch() - private let onToggle: (Bool) -> Void - private var baseEnabled = true - private var isBusy = false - - init(provider: ProviderSummary, isDefault: Bool, onToggle: @escaping (Bool) -> Void) { - self.providerName = provider.name - self.onToggle = onToggle - super.init(frame: .zero) - - let name = makeLabel(provider.name, font: Theme.caption, color: Theme.text) - let detail = makeLabel( - isDefault ? "default" : (provider.authMode ?? ""), - font: Theme.micro, - color: Theme.faint - ) - - let labels = NSStackView(views: [name, detail]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 0 - - toggle.state = provider.isEnabled ? .on : .off - toggle.controlSize = .mini - toggle.target = self - toggle.action = #selector(switched) - - // The proxy rejects only DISABLING the default provider (`provider-routes.ts:178` - // guards on `rawBody.disabled && name === defaultProvider`). Enabling it is - // valid, so a default provider that is currently off must stay toggleable — - // otherwise the app strands the user in a state it cannot leave. - let wouldDisableDefault = isDefault && provider.isEnabled - toggle.isEnabled = !wouldDisableDefault - toggle.toolTip = wouldDisableDefault - ? "This is the default provider. Choose another default in the dashboard first." - : nil - baseEnabled = toggle.isEnabled - toggle.setAccessibilityLabel("\(provider.name) enabled") - - let row = NSStackView(views: [labels, NSView(), toggle]) - row.orientation = .horizontal - row.spacing = Theme.rowGap - row.alignment = .centerY - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) - NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } - - var toggleState: Bool { toggle.state == .on } - var toggleIsEnabled: Bool { toggle.isEnabled } - - /// Inert while its write is in flight, so a second click cannot race the first. - func setBusy(_ busy: Bool) { - isBusy = busy - toggle.isEnabled = busy ? false : baseEnabled - alphaValue = busy ? 0.6 : 1 - } - - @objc private func switched() { - // Optimistic: the switch has already moved. The caller reverts on failure. - onToggle(toggle.state == .off) - } -} - - -// MARK: - Test inspection - -/// Read-only hooks so the UI suite can assert on rendered control state rather than on -/// the view's private bookkeeping. -package extension ProviderListView { - /// Expands the list without going through a click, so tests do not depend on - /// NSButton action dispatch. - func expandForTesting() { setExpanded(true) } - - func isToggleOn(_ name: String) -> Bool? { row(name)?.isOn } - func isToggleEnabled(_ name: String) -> Bool? { row(name)?.isToggleEnabled } - func hasProviderForTesting(_ name: String) -> Bool { row(name) != nil } - - private func row(_ name: String) -> ProviderRowView? { - for case let row as ProviderRowView in providerRows where row.providerName == name { - return row - } - return nil - } -} - -package extension ProviderRowView { - var isOn: Bool { toggleState } - var isToggleEnabled: Bool { toggleIsEnabled } -} diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift deleted file mode 100644 index e7eb2951b1..0000000000 --- a/app/Sources/MenuBarUI/StatusIcon.swift +++ /dev/null @@ -1,73 +0,0 @@ -import AppKit -import MenuBarCore - -/// The menu bar glyph. -/// -/// Drawn as vector paths rather than shipped as PNGs, so it stays crisp at every scale -/// factor and inverts correctly as a template image. -/// -/// Colour is deliberately absent here. macOS menu bar items are monochrome by -/// convention, and a coloured dot up there is the tell of an app that does not respect -/// the platform. State is carried by fill and by a notch instead. The coloured dot lives -/// inside the popover, where it sits beside a word and so never encodes meaning by -/// colour alone. -public enum StatusIcon { - public static let size = NSSize(width: 17, height: 17) - - public static func image(for state: ProxyState) -> NSImage { - switch state { - case .running(let health) where health.isProtected: - return mark(filled: true, notched: false, alpha: 1) - case .running: - return mark(filled: true, notched: true, alpha: 1) - case .loading, .degraded: - return mark(filled: false, notched: false, alpha: 1) - case .unreachable, .unauthorized: - return mark(filled: false, notched: false, alpha: 0.4) - } - } - - /// A rounded mark reduced to menu bar scale. - /// - /// The notch is carved out of the geometry with an even-odd path rather than by - /// compositing. An earlier version stroked with `.clear` and `.clear` composite mode, - /// which silently did nothing — the rendered at-risk glyph was indistinguishable from - /// the protected one, so the state signal was invisible. - private static func mark(filled: Bool, notched: Bool, alpha: CGFloat) -> NSImage { - let image = NSImage(size: size, flipped: false) { rect in - let inset = rect.insetBy(dx: 2.5, dy: 2.5) - let path = NSBezierPath(roundedRect: inset, xRadius: 4, yRadius: 4) - - if notched { - // A slot carved out of the trailing edge, kept fully inside the mark so - // the silhouette stays clean. Even-odd winding turns the subpath into a - // hole rather than a second filled shape. - let notch = NSBezierPath( - roundedRect: NSRect( - x: inset.maxX - 4.2, - y: inset.midY - 1.1, - width: 3.0, - height: 2.2 - ), - xRadius: 1.1, - yRadius: 1.1 - ) - path.append(notch) - path.windingRule = .evenOdd - } - - NSColor.black.withAlphaComponent(alpha).setStroke() - NSColor.black.withAlphaComponent(alpha).setFill() - - if filled { - path.fill() - } else { - path.lineWidth = 1.6 - path.stroke() - } - return true - } - image.isTemplate = true - return image - } -} diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift deleted file mode 100644 index b1e8b4241f..0000000000 --- a/app/Sources/MenuBarUI/Theme.swift +++ /dev/null @@ -1,103 +0,0 @@ -import AppKit - -/// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on -/// what "healthy" looks like. -/// -/// For SURFACES, AppKit's semantic colours win over a hardcoded hex: they track -/// light/dark plus the increased-contrast and vibrancy accessibility settings, which a -/// literal cannot. -/// -/// The TEXT tiers are a deliberate exception. Measured against the popover material, -/// `tertiaryLabelColor` renders at 2.01:1 in light and 2.39:1 in dark — it is designed -/// for disabled affordances, not for information the user has to read. All four text and -/// mark tokens below are therefore calibrated against the rendered material and verified -/// numerically rather than trusted by name. -enum Theme { - // Surfaces - static let separator = NSColor.separatorColor - static let raised = NSColor.controlBackgroundColor - - // Text: --text / --muted / --faint - // - // `tertiaryLabelColor` measured 2.01:1 in light and 2.39:1 in dark against the - // popover material — well under the 4.5:1 required for normal text. AppKit's - // tertiary tier is intended for disabled affordances, not for information the user - // has to read, and every label using this tier here (range heading, metric captions, - // quota window labels) carries real meaning. Calibrated tokens replace it. - /// All three text tiers are calibrated against the RENDERED popover material, not - /// picked from AppKit's semantic palette. Measured backgrounds: light (220,219,218), - /// dark (102,101,101). - /// - /// The dark material constrains this hard — pure white measures only 5.81:1 against - /// it — so the tiers are packed into the band that remains while keeping every text - /// tier above 4.5:1 and preserving `text > muted > faint` in both appearances. - static let text = dynamic(light: 0x1A1A1A, dark: 0xFFFFFF) - static let muted = dynamic(light: 0x3D3D3D, dark: 0xF2F2F2) - /// Small supporting text that must still be legible: 10-11pt captions and labels. - static let faint = dynamic(light: 0x545454, dark: 0xEDEDED) - /// Graphical marks only, held to the 3:1 non-text threshold rather than 4.5:1. - static let graphMark = dynamic(light: 0x707070, dark: 0xD2D2D2) - - // State colours, taken verbatim from styles.css. - static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) - static let amber = dynamic(light: 0x9A4A08, dark: 0xFBBF24) - static let red = dynamic(light: 0xB91C1C, dark: 0xF87171) - - // Type ladder: --text-micro / --text-caption / --text-label / --text-control. - static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) - static let caption = NSFont.systemFont(ofSize: 11) - static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) - /// Monospaced digits are the AppKit equivalent of `font-variant-numeric: tabular-nums`. - /// Without this, polling makes every digit jitter. - static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) - static let numericSmall = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) - - // Geometry: --space-* and --radius-sm. - static let gutter: CGFloat = 12 - static let rowGap: CGFloat = 8 - static let tightGap: CGFloat = 4 - static let radius: CGFloat = 8 - static let width: CGFloat = 340 - - static func color(for tone: ProxyToneBridge) -> NSColor { - switch tone { - case .neutral: return muted - case .good: return green - case .warning: return amber - case .bad: return red - } - } - - /// Quota fill: green under 80, amber to 95, red above. The percentage is always - /// printed beside the bar, so colour is reinforcement rather than the only signal. - static func quotaColor(percent: Double?) -> NSColor { - guard let percent else { return faint } - if percent > 95 { return red } - if percent >= 80 { return amber } - return green - } - - /// `light-dark()` equivalent: resolves per appearance instead of at creation time. - private static func dynamic(light: Int, dark: Int) -> NSColor { - NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - return NSColor(hex: isDark ? dark : light) - } - } -} - -/// Mirrors `ProxyState.Tone` without importing AppKit into the core module. -enum ProxyToneBridge { - case neutral, good, warning, bad -} - -extension NSColor { - convenience init(hex: Int) { - self.init( - srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, - green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, - alpha: 1 - ) - } -} diff --git a/app/Sources/MenuBarUI/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift deleted file mode 100644 index 56a8a6288f..0000000000 --- a/app/Sources/MenuBarUI/TimelineChartView.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AppKit -import MenuBarCore - -public final class TimelineChartView: NSView { - private var timeline: UsageTimeline? - private var settings = CompanionSettings.defaults - private let colors = [0x0A84FF, 0xFF9F0A, 0x30D158, 0xBF5AF2, 0xFF453A, 0x64D2FF] - - public override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: 104) - } - - public func apply(_ snapshot: ProxySnapshot) { - settings = snapshot.settings - timeline = snapshot.timeline - isHidden = !settings.showChart || timeline == nil - setAccessibilityLabel("Usage timeline") - needsDisplay = true - } - - public override func draw(_ dirtyRect: NSRect) { - guard let timeline, !timeline.isEmpty else { - if settings.showChart { - drawText("No token usage in this window.", in: NSRect(x: 0, y: 36, width: bounds.width, height: 16), font: Theme.caption, color: Theme.muted) - } - return - } - let chartHeight: CGFloat = 72 - let maxValue = settings.chartStyle == .stackedBar ? timeline.stackedMax : timeline.maxPoint - drawText(Format.tokens(Int(maxValue.rounded())), in: NSRect(x: 0, y: chartHeight + 8, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted, alignment: .right) - let window = timeline.buckets * timeline.bucketSeconds / 3600 - let windowLabel: String - if window < 48 { - windowLabel = "\(window)h" - } else { - windowLabel = "\(window / 24)d" - } - drawText(windowLabel, in: NSRect(x: 0, y: chartHeight + 8, width: 40, height: 14), font: Theme.micro, color: Theme.muted) - - let plot = NSRect(x: 0, y: 20, width: bounds.width, height: chartHeight) - Theme.muted.setStroke() - let baseline = NSBezierPath() - baseline.move(to: NSPoint(x: plot.minX, y: plot.minY)) - baseline.line(to: NSPoint(x: plot.maxX, y: plot.minY)) - baseline.lineWidth = 0.5 - baseline.stroke() - - if settings.chartStyle == .stackedBar { - drawBars(timeline, in: plot, maxValue: maxValue) - } else { - drawLines(timeline, in: plot, maxValue: maxValue) - } - - drawLegend(timeline, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14)) - } - - private func drawText( - _ text: String, in rect: NSRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left - ) { - let style = NSMutableParagraphStyle() - style.alignment = alignment - NSAttributedString( - string: text, - attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style] - ).draw(in: rect) - } - - private func drawLines(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { - guard timeline.buckets > 1, maxValue > 0 else { return } - for (seriesIndex, series) in timeline.series.enumerated() { - let path = NSBezierPath() - for (index, value) in series.points.enumerated() { - let x = plot.minX + plot.width * CGFloat(index) / CGFloat(max(timeline.buckets - 1, 1)) - let y = plot.minY + plot.height * CGFloat(value / maxValue) - if index == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) } - } - NSColor(hex: colors[seriesIndex % colors.count]).setStroke() - path.lineWidth = 1.5 - path.stroke() - } - } - - private func drawBars(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { - guard timeline.buckets > 0, maxValue > 0 else { return } - let width = max(1, plot.width / CGFloat(timeline.buckets) - 1) - for bucket in 0.. 0 { - let extra = entries.count - visible - let suffixWidth = extra > 0 - ? NSAttributedString(string: "+\(extra) more", attributes: attributes).size().width + separator - : 0 - let entryWidth = entries.prefix(visible).reduce(CGFloat.zero) { width, entry in - width + dotSize + 4 + entry.1.size().width + separator - } - if entryWidth + suffixWidth <= rect.width || visible == 0 { break } - visible -= 1 - } - let extra = entries.count - visible - var x = rect.minX - for (index, text) in entries.prefix(visible) { - let dot = NSRect(x: x, y: rect.midY - dotSize / 2, width: dotSize, height: dotSize) - NSColor(hex: colors[index % colors.count]).setFill() - NSBezierPath(ovalIn: dot).fill() - x += dotSize + 4 - text.draw(at: NSPoint(x: x, y: rect.minY)) - x += text.size().width + separator - } - if extra > 0 { - NSAttributedString(string: "+\(extra) more", attributes: attributes) - .draw(at: NSPoint(x: x, y: rect.minY)) - } - } -} diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift deleted file mode 100644 index 70c229b685..0000000000 --- a/app/Sources/MenuBarUI/Views.swift +++ /dev/null @@ -1,264 +0,0 @@ -import AppKit -import MenuBarCore - -// MARK: - Shared helpers - -func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { - let field = NSTextField(labelWithString: text) - field.font = font - field.textColor = color - field.lineBreakMode = .byTruncatingTail - return field -} - -func makeRow(_ views: [NSView], spacing: CGFloat = Theme.rowGap) -> NSStackView { - let stack = NSStackView(views: views) - stack.orientation = .horizontal - stack.spacing = spacing - stack.alignment = .firstBaseline - return stack -} - -func makeSeparator() -> NSView { - let line = NSView() - line.wantsLayer = true - line.layer?.backgroundColor = Theme.separator.cgColor - line.translatesAutoresizingMaskIntoConstraints = false - line.heightAnchor.constraint(equalToConstant: 1).isActive = true - return line -} - -// MARK: - Status header - -/// `● Running 127.0.0.1:10100` -/// -/// The dot never travels alone: the word beside it carries the same meaning, so the UI -/// stays readable without colour perception (WCAG 1.4.1). -final class StatusHeaderView: NSView { - private let dot = StatusDotView() - private let title = makeLabel("", font: Theme.label, color: Theme.text) - private let endpoint = makeLabel("", font: Theme.caption, color: Theme.muted) - private let detail = makeLabel("", font: Theme.caption, color: Theme.muted) - - init() { - super.init(frame: .zero) - let top = makeRow([dot, title, NSView(), endpoint], spacing: Theme.rowGap) - top.alignment = .centerY - top.distribution = .fill - endpoint.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - let stack = NSStackView(views: [top, detail]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 2 - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - let state = snapshot.state - title.stringValue = state.title - endpoint.stringValue = snapshot.endpoint.display - dot.tone = bridge(state.tone) - - if let text = state.detail { - detail.stringValue = text - detail.isHidden = false - } else { - detail.isHidden = true - } - - setAccessibilityLabel("Proxy \(state.title) at \(snapshot.endpoint.display)") - } - - private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { - switch tone { - case .neutral: return .neutral - case .good: return .good - case .warning: return .warning - case .bad: return .bad - } - } -} - -final class StatusDotView: NSView { - var tone: ProxyToneBridge = .neutral { - didSet { needsDisplay = true } - } - - override var intrinsicContentSize: NSSize { NSSize(width: 8, height: 8) } - - override func draw(_ dirtyRect: NSRect) { - let rect = NSRect(x: 0, y: (bounds.height - 8) / 2, width: 8, height: 8) - Theme.color(for: tone).setFill() - NSBezierPath(ovalIn: rect).fill() - } -} - -// MARK: - Metrics - -/// Three columns plus a range header that echoes the response, never the request. -final class MetricsView: NSView { - private let rangeLabel = makeLabel("USAGE", font: Theme.micro, color: Theme.faint) - private let columns: [(caption: NSTextField, value: NSTextField)] - private let emptyLabel = makeLabel("", font: Theme.caption, color: Theme.muted) - private let stack: NSStackView - private let columnsRow: NSStackView - - init() { - let captions = ["TOKENS", "REQUESTS", "COST"] - columns = captions.map { caption in - (makeLabel(caption, font: Theme.micro, color: Theme.faint), - makeLabel(Format.unknown, font: Theme.numeric, color: Theme.text)) - } - - let columnViews: [NSView] = columns.map { pair in - let column = NSStackView(views: [pair.caption, pair.value]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = 1 - return column - } - columnsRow = NSStackView(views: columnViews) - columnsRow.orientation = .horizontal - columnsRow.distribution = .fillEqually - columnsRow.alignment = .top - - stack = NSStackView(views: [rangeLabel, columnsRow, emptyLabel]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - - super.init(frame: .zero) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - let usage = snapshot.today ?? snapshot.usage - isHidden = !snapshot.settings.showToday - rangeLabel.stringValue = usage?.rangeLabel ?? "USAGE" - columnsRow.arrangedSubviews[2].isHidden = !snapshot.settings.showCost - - // Three states: known-empty gets copy, unknown gets em dashes, data gets values. - switch snapshot.usageIsEmpty { - case .some(true): - columnsRow.isHidden = true - emptyLabel.isHidden = false - emptyLabel.stringValue = "No requests in this period." - default: - columnsRow.isHidden = false - emptyLabel.isHidden = true - let summary = usage?.summary - let requests = Format.count(summary?.requests) - columns[0].value.stringValue = Format.tokens(summary?.totalTokens) - columns[1].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests - columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) - columns[0].value.setAccessibilityLabel( - "\(Format.tokens(summary?.totalTokens)) tokens" - ) - columns[1].value.setAccessibilityLabel( - (summary?.hasEstimates ?? false) - ? "\(requests) requests, partly estimated" - : "\(requests) requests" - ) - columns[2].value.setAccessibilityLabel( - "\(Format.cost(summary?.estimatedCostUsd)) estimated cost" - ) - } - } -} - -// MARK: - Quotas - -/// `OpenAI ▓▓▓▓▓░░░░░ 44%` -final class QuotaRowView: NSView { - init(quota: NormalizedQuota) { - super.init(frame: .zero) - - let name = makeLabel(quota.providerLabel, font: Theme.caption, color: Theme.text) - name.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - name.lineBreakMode = .byTruncatingTail - - // Which window a number belongs to is not decoration: 42% of an API-usage window - // and 42% of a month mean very different things. - let window = makeLabel( - quota.hasPercent ? quota.windowLabel : "", - font: Theme.micro, color: Theme.faint - ) - - let labels = NSStackView(views: [name, window]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 0 - - let bar = QuotaBarView() - bar.percent = quota.percent - - let value = makeLabel(Format.percent(quota.percent), font: Theme.numericSmall, color: Theme.muted) - value.alignment = .right - - let row = NSStackView(views: [labels, bar, value]) - row.orientation = .horizontal - row.spacing = Theme.rowGap - row.alignment = .centerY - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) - - NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), - labels.widthAnchor.constraint(equalToConstant: 132), - value.widthAnchor.constraint(equalToConstant: 36), - ]) - - // The percentage is spoken, not merely drawn as a filled width. - let reset = Format.resetsIn(quota.resetAt) - setAccessibilityLabel( - quota.hasPercent - ? "\(quota.providerLabel): \(Format.percent(quota.percent)) of \(quota.windowLabel) quota, resets in \(reset)" - : "\(quota.providerLabel): quota unknown" - ) - } - - required init?(coder: NSCoder) { nil } -} - -final class QuotaBarView: NSView { - var percent: Double? - - override var intrinsicContentSize: NSSize { NSSize(width: 110, height: 6) } - - override func draw(_ dirtyRect: NSRect) { - let track = NSRect(x: 0, y: (bounds.height - 6) / 2, width: bounds.width, height: 6) - Theme.raised.setFill() - NSBezierPath(roundedRect: track, xRadius: 3, yRadius: 3).fill() - - // A nil percent draws no fill at all — a zero-width bar would read as "0% used", - // which is a different fact from "unknown". - guard let percent else { return } - let clamped = max(0, min(100, percent)) - guard clamped > 0 else { return } - let fill = NSRect(x: 0, y: track.origin.y, width: track.width * CGFloat(clamped / 100), height: 6) - Theme.quotaColor(percent: percent).setFill() - NSBezierPath(roundedRect: fill, xRadius: 3, yRadius: 3).fill() - } -} diff --git a/app/Sources/MenuBarUITests/Harness.swift b/app/Sources/MenuBarUITests/Harness.swift deleted file mode 100644 index 0deb1d6ae4..0000000000 --- a/app/Sources/MenuBarUITests/Harness.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -/// A dependency-free assertion harness. -/// -/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line -/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles -/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to -/// run the unit tests of a menu bar companion would put the tests out of reach for most -/// contributors and for any CI runner without Xcode selected. -/// -/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that -/// both a human and CI can read. If the package ever gains a full-Xcode requirement for -/// other reasons, migrating these cases to swift-testing is mechanical. -public struct TestFailure { - let test: String - let message: String - let file: String - let line: Int -} - -public final class TestRunner { - private(set) var passed = 0 - private(set) var failures: [TestFailure] = [] - private var current = "" - - public init() {} - - public func test(_ name: String, _ body: () throws -> Void) { - current = name - let failuresBefore = failures.count - do { - try body() - } catch { - failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) - print("FAIL — \(name): threw \(error)") - return - } - // A case that recorded an expectation failure is not a pass, even though its - // body returned normally. - if failures.count == failuresBefore { - passed += 1 - print("ok — \(name)") - } - } - - public func expect( - _ condition: Bool, - _ message: @autoclosure () -> String, - file: String = #file, - line: Int = #line - ) { - guard !condition else { return } - let failure = TestFailure(test: current, message: message(), file: file, line: line) - failures.append(failure) - print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") - } - - public func equal( - _ actual: T, - _ expected: T, - _ label: String = "", - file: String = #file, - line: Int = #line - ) { - expect( - actual == expected, - "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", - file: file, - line: line - ) - } - - public func notNil( - _ value: T?, - _ label: String, - file: String = #file, - line: Int = #line - ) -> T? { - expect(value != nil, "\(label) should not be nil", file: file, line: line) - return value - } - - public func isNil( - _ value: T?, - _ label: String, - file: String = #file, - line: Int = #line - ) { - expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) - } - - /// Prints the summary and returns the process exit code. - public func summarize() -> Int32 { - print("") - if failures.isEmpty { - print("\(passed) passed, 0 failed") - return 0 - } - print("\(passed) passed, \(failures.count) FAILED") - for failure in failures { - print(" - \(failure.test): \(failure.message)") - } - return 1 - } -} diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift deleted file mode 100644 index daf7e872b2..0000000000 --- a/app/Sources/MenuBarUITests/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -import AppKit -import MenuBarCore -import MenuBarUI - -// UI-layer tests. Separate from MenuBarCoreTests because these need AppKit and an -// NSApplication; the core suite deliberately has no UI dependency. -// -// These cover the Phase 3 behaviours that were defects in earlier review rounds: -// optimistic rollback, pending state surviving a poll, and the direction-sensitive -// default-provider guard. - -let app = NSApplication.shared -app.setActivationPolicy(.prohibited) - -let runner = TestRunner() - -func provider(_ name: String, enabled: Bool = true) -> ProviderSummary { - let json = #"{"name":"\#(name)","disabled":\#(enabled ? "false" : "true")}"# - return try! JSONDecoder().decode(ProviderSummary.self, from: Data(json.utf8)) -} - -func snapshot( - providers: [ProviderSummary], - defaultProvider: String? = "openai" -) -> ProxySnapshot { - ProxySnapshot( - state: .running(StartupHealth(status: "protected")), - endpoint: .default, - providers: providers, - defaultProvider: defaultProvider, - lastUpdated: Date(), - providersLoaded: true - ) -} - -// MARK: - Default-provider guard direction - -runner.test("ui: an enabled default provider cannot be switched off") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("openai"), provider("anthropic")])) - list.expandForTesting() - - runner.equal(list.isToggleEnabled("openai"), false, "enabled default is inert") - runner.equal(list.isToggleEnabled("anthropic"), true, "non-default is toggleable") -} - -// The proxy guard is `disabled && name === defaultProvider`, so ENABLING the default is -// valid. Making the control inert whenever isDefault stranded the user. -runner.test("ui: a disabled default provider can still be switched back on") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("openai", enabled: false)])) - list.expandForTesting() - - runner.equal(list.isToggleEnabled("openai"), true, "disabled default must be recoverable") -} - -// MARK: - Optimistic update and rollback - -runner.test("ui: a rejected write restores the switch it moved") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - // User switches it off; the write is in flight. - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") - runner.equal(list.isToggleEnabled("anthropic"), false, "inert while in flight") - - // The proxy rejects it. - list.revert("anthropic", to: true) - runner.equal(list.isToggleOn("anthropic"), true, "reverted to the server's value") - runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") -} - -runner.test("ui: a successful write clears busy without reverting") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - // The authoritative refresh now reports it disabled. - list.apply(snapshot(providers: [provider("anthropic", enabled: false)])) - list.setBusy("anthropic", false) - - runner.equal(list.isToggleOn("anthropic"), false, "server state retained") - runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") -} - -// MARK: - Pending state versus a stale poll - -// This is the defect a reviewer caught: rebuildRows initialised each switch from the -// snapshot, so a poll carrying pre-write data snapped the switch back mid-write. -runner.test("ui: a stale poll cannot undo an in-flight optimistic change") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") - - // A poll that started before the write lands, still reporting the old value. - list.apply(snapshot(providers: [provider("anthropic", enabled: true)])) - - runner.equal(list.isToggleOn("anthropic"), false, "stale poll must not snap it back") - runner.equal(list.isToggleEnabled("anthropic"), false, "still inert while in flight") -} - -runner.test("ui: pending state is per provider and does not leak") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic"), provider("xai")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleEnabled("anthropic"), false, "target is inert") - runner.equal(list.isToggleEnabled("xai"), true, "sibling is unaffected") - runner.equal(list.isToggleOn("xai"), true, "sibling keeps its value") -} - -// MARK: - Empty and unloaded states - -runner.test("ui: providers are hidden until they have actually been read") { - let list = ProviderListView() - var unloaded = snapshot(providers: []) - unloaded.providersLoaded = false - list.apply(unloaded) - runner.equal(list.isHidden, true, "not fetched yet is not the same as none") - - list.apply(snapshot(providers: [])) - runner.equal(list.isHidden, false, "an empty result renders its own copy") -} - -runner.test("ui: hidden providers do not create rows") { - let list = ProviderListView() - var current = snapshot(providers: [provider("openai"), provider("anthropic")]) - current.settings = CompanionSettings(hiddenProviders: ["openai"]) - list.apply(current) - list.expandForTesting() - runner.equal(list.hasProviderForTesting("openai"), false) - runner.equal(list.hasProviderForTesting("anthropic"), true) -} - -runner.test("ui: chart setting hides the timeline view") { - let chart = TimelineChartView() - var current = snapshot(providers: []) - current.timeline = try! JSONDecoder().decode( - UsageTimeline.self, - from: Data(#"{"start":0,"end":1,"bucketSeconds":1,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"#.utf8) - ) - current.settings = CompanionSettings(showChart: false) - chart.apply(current) - runner.equal(chart.isHidden, true) -} - -runner.test("ui: menu title renders from a companion template") { - let report = try! JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":3}}"#.utf8) - ) - var current = snapshot(providers: []) - current.today = report - current.settings = CompanionSettings(menuBarTemplate: "req {requests}") - runner.equal(current.menuBarTitle, "req 3") -} - -exit(runner.summarize()) diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index b130d1fda5..edc91171f4 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -293,8 +293,8 @@ struct OpenCodexWidgetView: View { Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") .font(.title2) Text(failure == .missing - ? "Open the OpenCodex menu bar app to start sharing usage." - : "Snapshot unreadable — refresh from the menu bar app.") + ? "Open the OpenCodex desktop app to start sharing usage." + : "Snapshot unreadable — refresh from the desktop app.") .font(.caption) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift deleted file mode 100644 index 02eca1592b..0000000000 --- a/app/Sources/UIProbe/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -// Visual-QA harness (not shipped). -// -// Presents the real PopoverPanel over a deliberately loud backdrop and captures it with -// CGWindowListCreateImage, so every UI state can be inspected without depending on free -// menu bar space. -// -// Two harness decisions are load-bearing, both learned the hard way: -// * Present through the REAL panel. An earlier version used a plain NSWindow, which -// supplied its own background and hid the fact that the panel had none at all. -// * Capture through the window server. cacheDisplay(in:to:) skips text rendering and -// produced screenshots with no labels. -// -// PROBE_STATE: live | stopped | unauthorized | loading | degraded | empty | overflow -// PROBE_TAG: output filename suffix -// PROBE_APPEARANCE: light | dark (forces appearance without touching system settings) - -import AppKit -import MenuBarCore -import MenuBarUI - -// Presents the real PopoverPanel over a contrasting backdrop and captures it through the -// window server, so the UI can be inspected without depending on menu bar space. -final class ProbeDelegate: NSObject, NSApplicationDelegate { - let controller = PopoverViewController() - var window: NSWindow? - - func applicationDidFinishLaunching(_ n: Notification) { - // Force an appearance for contrast measurement without touching system settings. - if let name = ProcessInfo.processInfo.environment["PROBE_APPEARANCE"] { - NSApp.appearance = NSAppearance(named: name == "dark" ? .darkAqua : .aqua) - } - let endpoint = ProxyDiscovery.resolve() - let client = ProxyClient(endpoint: endpoint) - let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - - // A loud backdrop first: if the panel has no surface of its own, this shows - // straight through and the defect is unmissable. - let backdrop = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), - styleMask: [.titled], backing: .buffered, defer: false) - backdrop.title = "backdrop" - let strip = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 620)) - strip.wantsLayer = true - strip.layer?.backgroundColor = NSColor.systemRed.cgColor - for i in 0..<14 { - let bar = NSView(frame: NSRect(x: 0, y: CGFloat(i) * 44, width: 520, height: 22)) - bar.wantsLayer = true - bar.layer?.backgroundColor = NSColor.systemYellow.cgColor - strip.addSubview(bar) - } - backdrop.contentView = strip - backdrop.center() - backdrop.makeKeyAndOrderFront(nil) - - // Present through the real panel so its surface (or absence of one) is captured. - let realPanel = PopoverPanel() - realPanel.contentViewController = controller - controller.view.layoutSubtreeIfNeeded() - let size = controller.preferredContentSize - realPanel.setContentSize(NSSize(width: 340, height: max(size.height, 200))) - realPanel.setFrameOrigin(NSPoint(x: backdrop.frame.midX - 170, y: backdrop.frame.midY - 150)) - realPanel.makeKeyAndOrderFront(nil) - window = realPanel - NSApp.activate(ignoringOtherApps: true) - - Task { - var snap: ProxySnapshot - let mode = ProcessInfo.processInfo.environment["PROBE_STATE"] ?? "live" - switch mode { - case "stopped": - snap = ProxySnapshot(state: .unreachable, endpoint: endpoint, - lastKnownStartCommand: "ocx service start") - case "unauthorized": - snap = ProxySnapshot(state: .unauthorized, endpoint: endpoint) - case "loading": - snap = ProxySnapshot(state: .loading, endpoint: endpoint) - case "degraded": - snap = ProxySnapshot(state: .degraded("The proxy returned an unexpected status (503)."), - endpoint: endpoint, lastUpdated: Date().addingTimeInterval(-120)) - case "overflow": - let many = (1...24).map { i in - #"{"provider":"p\#(i)","label":"Provider \#(i)","quota":{"weeklyPercent":\#(i * 3)}}"# - }.joined(separator: ",") - let quotas = (try? JSONDecoder().decode([QuotaReport].self, from: Data("[\(many)]".utf8))) ?? [] - let usage = try? JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":100,"totalTokens":1200},"models":[{"provider":"p","model":"m","requests":100,"totalTokens":1200}]}"#.utf8)) - let timeline = try? JSONDecoder().decode( - UsageTimeline.self, - from: Data(#"{"start":0,"end":3600,"bucketSeconds":900,"buckets":4,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"p/m","provider":"p","model":"m","total":1200,"points":[100,200,300,600]}],"availableModels":["p/m"],"missingMeasurements":0}"#.utf8)) - snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, settings: CompanionSettings(menuBarMetric: .tokens), - today: usage, timeline: timeline, - quotas: quotas, - quotasLoaded: true) - case "empty": - let usage = try? JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":0},"models":[],"accounts":[]}"#.utf8)) - snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, today: usage, quotas: [], providers: [], - providersLoaded: true, quotasLoaded: true) - default: - await coordinator.setPopoverOpen(true) - snap = await coordinator.current - } - await MainActor.run { - self.controller.apply(snap) - // Expand the provider list so its toggles are visible in the capture. - if ProcessInfo.processInfo.environment["PROBE_EXPAND"] == "1" { - self.expandProviders(in: self.controller.view) - } - if ProcessInfo.processInfo.environment["PROBE_RESULT"] != nil { - self.controller.showResult( - ProcessInfo.processInfo.environment["PROBE_RESULT"]!, - isError: ProcessInfo.processInfo.environment["PROBE_RESULT_ERROR"] == "1") - } - self.controller.view.layoutSubtreeIfNeeded() - // Match the real popover: size to content instead of a fixed frame. - let h = self.controller.preferredContentSize.height - if h > 0, let w = self.window { - w.setContentSize(NSSize(width: 340, height: h)) - } - } - try? await Task.sleep(nanoseconds: 1_200_000_000) - await MainActor.run { self.capture() } - } - } - - @MainActor func expandProviders(in view: NSView) { - for sub in view.subviews { - if let button = sub as? NSButton, button.bezelStyle == .disclosure { - button.state = .on - if let target = button.target, let action = button.action { - _ = target.perform(action, with: button) - } - } - expandProviders(in: sub) - } - } - - @MainActor func capture() { - guard let w = window else { return } - let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" - // CGWindowListCreateImage rather than shelling out to screencapture: nothing - // under app/ may construct a Process (030 security rule). The bitmap-rep path - // is not an option either — it skips text rendering entirely. - let id = CGWindowID(w.windowNumber) - if let cg = CGWindowListCreateImage( - .null, .optionIncludingWindow, id, [.boundsIgnoreFraming, .bestResolution] - ) { - let rep = NSBitmapImageRep(cgImage: cg) - if let png = rep.representation(using: .png, properties: [:]) { - try? png.write(to: URL(fileURLWithPath: "/tmp/popover-\(tag).png")) - } - } - NSApp.terminate(nil) - } -} - -let app = NSApplication.shared -app.setActivationPolicy(.regular) -let d = ProbeDelegate() -app.delegate = d -app.run() diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist index 360a159103..568e5fce75 100644 --- a/app/Widget-Info.plist +++ b/app/Widget-Info.plist @@ -4,7 +4,7 @@ CFBundleDevelopmentRegionen CFBundleExecutableOpenCodexWidget - CFBundleIdentifiercom.opencodex.menubar.widget + CFBundleIdentifiercom.opencodex.desktop.widget CFBundleInfoDictionaryVersion6.0 CFBundleNameOpenCodex CFBundlePackageTypeXPC! diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000000..9106a50f67 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,69 @@ +# OpenCodex desktop shell + +The Tauri shell attaches to the local OpenCodex proxy and keeps the dashboard +in the proxy's loopback origin. During development: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri dev +``` + +The sidecar is generated from the repository's standalone binary build and is +not checked into git. + +The CI desktop-shell job performs Rust-only checks. It creates an empty +platform-named sidecar stub and a placeholder dashboard resource directory +solely for Tauri's external-binary and resource validation; it does not build +or run the standalone binary. + +For a macOS release build, prepare the sidecar and WidgetKit extension before invoking +Tauri: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` + +## Release packaging and updates + +The release workflow builds a macOS DMG, Windows MSI, Linux AppImage, and Debian package. +It collects the platform artifacts beside checksum files and creates `latest.json` for the +Tauri updater. The public updater key and endpoint live in `src-tauri/tauri.conf.json`; +the private key must never be committed. The manifest is generated only when the updater +key secret is configured and then requires all four platforms to be signed. + +To package locally: + +```sh +bun run build:gui +cd desktop +bun install --frozen-lockfile +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build --ci --bundles app,dmg +``` + +Release signing is supplied through environment variables: + +```sh +export TAURI_SIGNING_PRIVATE_KEY="..." +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." +export APPLE_CERTIFICATE="..." +export APPLE_CERTIFICATE_PASSWORD="..." +export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" +export APPLE_ID="..." +export APPLE_PASSWORD="..." +export APPLE_TEAM_ID="..." +export MACOS_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY" +``` + +Generate a Tauri updater key pair with: + +```sh +bunx tauri signer generate +``` + +Keep the private key in a local secret store. Windows SmartScreen signing is not wired +yet; the release workflow documents that installers may show an unsigned-publisher warning. diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000000..09668ce09b --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,13 @@ +{ + "name": "@opencodex/desktop", + "private": true, + "scripts": { + "dev": "tauri dev", + "build": "tauri build", + "prepare-sidecar": "bun scripts/prepare-sidecar.ts", + "prepare-widget": "bash scripts/build-widget.sh" + }, + "devDependencies": { + "@tauri-apps/cli": "2.5.0" + } +} diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh new file mode 100755 index 0000000000..59827dcea2 --- /dev/null +++ b/desktop/scripts/build-widget.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "prepare-widget requires macOS." >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +desktop_dir="$(cd "$script_dir/.." && pwd)" +repo_root="$(cd "$desktop_dir/.." && pwd)" +package_dir="$repo_root/app" +output_dir="$desktop_dir/src-tauri/widget/OpenCodexWidget.appex" +configuration="${CONFIGURATION:-release}" +universal="${UNIVERSAL:-1}" + +if [[ "$universal" != "0" && "$universal" != "1" ]]; then + echo "UNIVERSAL must be 0 or 1." >&2 + exit 1 +fi + +build_root="$(mktemp -d "${TMPDIR:-/tmp}/opencodex-widget.XXXXXX")" +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT + +build_widget() { + local arch="$1" + local scratch="$build_root/$arch" + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --product OpenCodexWidget + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --show-bin-path +} + +if [[ "$universal" == "1" ]]; then + arm64_bin="$(build_widget arm64 | tail -n 1)/OpenCodexWidget" + x86_64_bin="$(build_widget x86_64 | tail -n 1)/OpenCodexWidget" + executable="$build_root/OpenCodexWidget" + lipo -create "$arm64_bin" "$x86_64_bin" -output "$executable" +else + executable="$(build_widget "$(uname -m)" | tail -n 1)/OpenCodexWidget" +fi + +[[ -x "$executable" ]] || { echo "Swift build did not produce $executable" >&2; exit 1; } + +rm -rf "$output_dir" +mkdir -p "$output_dir/Contents/MacOS" +cp "$executable" "$output_dir/Contents/MacOS/OpenCodexWidget" +cp "$package_dir/Widget-Info.plist" "$output_dir/Contents/Info.plist" + +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$desktop_dir/src-tauri/tauri.conf.json" | head -n 1)" +version_core="${version%%-*}" +[[ "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid Tauri version: $version" >&2 + exit 1 +} +plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" + +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \ + --timestamp "$output_dir" +else + codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ + --timestamp=none "$output_dir" +fi + +echo "$output_dir" diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts new file mode 100644 index 0000000000..f946e1d4a9 --- /dev/null +++ b/desktop/scripts/collect-release-assets.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +type BundleKind = "dmg" | "app.tar.gz" | "msi" | "appimage" | "deb"; + +interface BundleSpec { + kind: BundleKind; + dir: string; + name: string; +} + +const bundlesByTarget: Record = { + "universal-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "aarch64-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "x86_64-apple-darwin": [ + { kind: "dmg", dir: "dmg", name: "macos.dmg" }, + { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, + ], + "x86_64-pc-windows-msvc": [{ kind: "msi", dir: "msi", name: "windows-x64.msi" }], + "x86_64-unknown-linux-gnu": [ + { kind: "appimage", dir: "appimage", name: "linux-x86_64.AppImage" }, + { kind: "deb", dir: "deb", name: "linux-amd64.deb" }, + ], +}; + +export interface CollectReleaseAssetsOptions { + version: string; + target: string; + out: string; + repoRoot?: string; +} + +function findBundle(directory: string, kind: BundleKind): string { + if (!existsSync(directory)) { + throw new Error(`Missing ${kind} bundle directory: ${directory}`); + } + const artifact = readdirSync(directory) + .filter(name => name.toLowerCase().endsWith(`.${kind.toLowerCase()}`)); + if (artifact.length === 0) throw new Error(`No ${kind} bundle found in ${directory}`); + if (artifact.length > 1) { + throw new Error(`Multiple ${kind} bundles found in ${directory}: ${artifact.join(", ")}`); + } + return join(directory, artifact[0]); +} + +export function collectReleaseAssets(options: CollectReleaseAssetsOptions): string[] { + const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); + const bundles = bundlesByTarget[options.target]; + if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + + const output = resolve(options.out); + mkdirSync(output, { recursive: true }); + const written: string[] = []; + for (const bundle of bundles) { + const source = findBundle( + join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + bundle.kind, + ); + const destinationName = `OpenCodex-${options.version}-${bundle.name}`; + const destination = join(output, destinationName); + copyFileSync(source, destination); + written.push(destination); + + const signature = `${source}.sig`; + if (existsSync(signature)) { + copyFileSync(signature, `${destination}.sig`); + written.push(`${destination}.sig`); + } + + const digest = createHash("sha256").update(readFileSync(destination)).digest("hex"); + const checksum = `${destination}.sha256`; + writeFileSync(checksum, `${digest} ${destinationName}\n`); + written.push(checksum); + } + return written; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const target = argument("--target"); + const out = argument("--out"); + if (!version || !target || !out) { + throw new Error("Usage: collect-release-assets.ts --version --target --out "); + } + for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); +} diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts new file mode 100644 index 0000000000..502127870d --- /dev/null +++ b/desktop/scripts/prepare-sidecar.ts @@ -0,0 +1,59 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const targetByTriple: Record = { + "aarch64-apple-darwin": "bun-darwin-arm64", + "x86_64-apple-darwin": "bun-darwin-x64", + "x86_64-pc-windows-msvc": "bun-windows-x64", + "x86_64-unknown-linux-gnu": "bun-linux-x64", + "aarch64-unknown-linux-gnu": "bun-linux-arm64", +}; + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +function hostTriple(): string | undefined { + const result = Bun.spawnSync(["rustc", "-vV"], { stdout: "pipe", stderr: "ignore" }); + if (result.exitCode !== 0) return undefined; + const host = result.stdout.toString().match(/^host:\s*(\S+)$/m)?.[1]; + return host; +} + +const repoRoot = resolve(import.meta.dir, "../.."); +const triple = + argument("--target") ?? + process.env.TARGET ?? + process.env.RUST_TARGET ?? + Bun.env.RUST_TARGET ?? + hostTriple(); +if (!triple || !targetByTriple[triple]) { + throw new Error( + `Unsupported Rust target ${triple ?? "(host unavailable)"}; pass --target ${Object.keys(targetByTriple).join("|")}`, + ); +} + +const target = targetByTriple[triple]; +const source = join(repoRoot, "dist", "standalone", target); +const executable = join(source, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +if (!existsSync(executable)) { + const result = Bun.spawnSync([ + process.execPath, + "run", + "build:standalone", + "--target", + target, + ], { cwd: repoRoot, stdout: "inherit", stderr: "inherit" }); + if (result.exitCode !== 0) process.exit(result.exitCode); +} + +const desktopRoot = resolve(import.meta.dir, ".."); +const binaries = join(desktopRoot, "src-tauri", "binaries"); +const resources = join(desktopRoot, "src-tauri", "resources", "gui", "dist"); +mkdirSync(binaries, { recursive: true }); +mkdirSync(resources, { recursive: true }); +const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`); +copyFileSync(executable, destination); +cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true }); +console.log(`Prepared ${destination}`); diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts new file mode 100644 index 0000000000..e67459d848 --- /dev/null +++ b/desktop/scripts/updater-manifest.ts @@ -0,0 +1,98 @@ +import { + existsSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +export interface UpdaterManifestOptions { + version: string; + dir: string; + repo: string; + out: string; + warn?: (message: string) => void; + requireAll?: boolean; +} + +interface PlatformUpdate { + signature: string; + url: string; +} + +export interface UpdaterManifest { + version: string; + notes: string; + pub_date: string; + platforms: Record; +} + +const platformFiles: Record = { + "darwin-aarch64": "macos.app.tar.gz", + "darwin-x86_64": "macos.app.tar.gz", + "windows-x86_64": "windows-x64.msi", + "linux-x86_64": "linux-x86_64.AppImage", +}; + +export function buildUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { + const dir = resolve(options.dir); + const warn = options.warn ?? console.warn; + const platforms: Record = {}; + const missing: string[] = []; + for (const [platform, suffix] of Object.entries(platformFiles)) { + const base = `OpenCodex-${options.version}-${suffix}`; + const signaturePath = join(dir, `${base}.sig`); + if (!existsSync(signaturePath)) { + missing.push(platform); + if (!options.requireAll) { + warn(`Skipping ${platform}: missing ${signaturePath}`); + } + continue; + } + platforms[platform] = { + signature: readFileSync(signaturePath, "utf8").trim(), + url: `https://github.com/${options.repo}/releases/download/v${options.version}/${base}`, + }; + } + if (options.requireAll && missing.length > 0) { + throw new Error(`Missing signed updater platforms: ${missing.join(", ")}`); + } + if (Object.keys(platforms).length === 0) { + throw new Error("No signed updater platforms remain"); + } + return { + version: options.version, + notes: `https://github.com/${options.repo}/releases/tag/v${options.version}`, + pub_date: new Date().toISOString(), + platforms, + }; +} + +export function writeUpdaterManifest(options: UpdaterManifestOptions): UpdaterManifest { + const manifest = buildUpdaterManifest(options); + const output = resolve(options.out); + const temporary = `${output}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`); + renameSync(temporary, output); + return manifest; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const dir = argument("--dir"); + const repo = argument("--repo"); + const out = argument("--out"); + const requireAll = Bun.argv.includes("--require-all"); + if (!version || !dir || !repo || !out) { + throw new Error( + "Usage: updater-manifest.ts --version --dir --repo --out [--require-all]", + ); + } + writeUpdaterManifest({ version, dir, repo, out, requireAll }); + console.log(`Wrote ${out}`); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000000..95bc1a674a --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5654 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.4", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.5", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "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 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]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.4", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.4", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.9.4", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "opencodex-desktop" +version = "2.61.0" +dependencies = [ + "reqwest 0.12.24", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tokio", + "uuid", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +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.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.9.4", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e47d95bc83ed33b2ecf84f4187ad1ab9685d18ff28db000c99deac8ce180e3" +dependencies = [ + "base64 0.21.7", + "chrono", + "hex", + "indexmap 1.9.3", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3cee93715c2e266b9338b7544da68a9f24e227722ba482bd1c024367c77c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.61.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.9.4", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.5", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars", + "serde", + "serde_json", + "tauri-utils", + "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.20", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.20", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.12.24", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.5", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.5.10", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.7.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +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.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000000..8683695613 --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "opencodex-desktop" +version = "2.61.0" +description = "OpenCodex desktop shell" +authors = ["OpenCodex contributors"] +license = "MIT" +edition = "2021" +rust-version = "1.77" + +[lib] +name = "opencodex_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "=2.6.3", features = [] } + +[dependencies] +reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "=1.0.219", features = ["derive"] } +serde_json = "=1.0.140" +uuid = { version = "=1.18.1", features = ["v4"] } +tauri = { version = "=2.11.6", features = ["tray-icon", "image-png"] } +tauri-plugin-autostart = "=2.5.0" +tauri-plugin-opener = "=2.5.3" +tauri-plugin-process = "=2.3.0" +tauri-plugin-shell = "=2.2.0" +tauri-plugin-single-instance = "=2.4.0" +tauri-plugin-updater = "=2.9.0" +tokio = { version = "=1.45.1", features = ["sync", "time"] } + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +strip = "symbols" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 0000000000..d860e1e6a7 --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000000..622143b1fa --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Bootstrap-only shell permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-title", + "opener:default", + "autostart:default" + ] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000..e77df1e28f Binary files /dev/null and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000..c6a3f4f321 Binary files /dev/null and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000000..9299f36004 Binary files /dev/null and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000000..0a93d6825e Binary files /dev/null and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000..5437a9844d Binary files /dev/null and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000..6cb3949d35 Binary files /dev/null and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000..b2ba7ef594 Binary files /dev/null and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000..7c578ed553 Binary files /dev/null and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000..46537b6bfb Binary files /dev/null and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000..dba929dbcd Binary files /dev/null and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000..98c1c62f4f Binary files /dev/null and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000..812f7a506e Binary files /dev/null and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000..90d34e7435 Binary files /dev/null and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000..03afa12a7b Binary files /dev/null and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000..3ca608c923 Binary files /dev/null and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000000..78c4c78335 Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000000..94ac887238 Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/tray/icon.png b/desktop/src-tauri/icons/tray/icon.png new file mode 100644 index 0000000000..f475a9e9c7 Binary files /dev/null and b/desktop/src-tauri/icons/tray/icon.png differ diff --git a/desktop/src-tauri/src/auth.rs b/desktop/src-tauri/src/auth.rs new file mode 100644 index 0000000000..81a16467b0 --- /dev/null +++ b/desktop/src-tauri/src/auth.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Auth { + home: PathBuf, + environment_token: Option, +} + +impl Auth { + pub fn new(home: PathBuf) -> Self { + Self { + home, + environment_token: std::env::var("OPENCODEX_ADMIN_AUTH_TOKEN") + .ok() + .filter(|value| !value.is_empty()), + } + } + + pub fn token(&self) -> Option { + self.environment_token.clone().or_else(|| { + std::fs::read_to_string(self.home.join("admin-api-token")) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }) + } + + pub fn user_agent() -> &'static str { + concat!("OpenCodexDesktop/", env!("CARGO_PKG_VERSION")) + } +} diff --git a/desktop/src-tauri/src/discovery.rs b/desktop/src-tauri/src/discovery.rs new file mode 100644 index 0000000000..2eaded758d --- /dev/null +++ b/desktop/src-tauri/src/discovery.rs @@ -0,0 +1,116 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_PORT: u16 = 10100; +const HOME_ENV: &str = "OPENCODEX_HOME"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyEndpoint { + pub host: &'static str, + pub port: u16, +} + +impl ProxyEndpoint { + pub fn url(&self, path: &str) -> String { + format!("http://{}:{}{}", self.host, self.port, path) + } +} + +#[derive(Debug, Deserialize)] +struct RuntimePort { + port: u16, +} + +pub fn config_directory(environment: impl Fn(&str) -> Option, home: &Path) -> PathBuf { + environment(HOME_ENV) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .map(|value| expand_tilde(PathBuf::from(value), home)) + .unwrap_or_else(|| home.join(".opencodex")) +} + +pub fn resolve(environment: impl Fn(&str) -> Option, home: &Path) -> ProxyEndpoint { + let directory = config_directory(environment, home); + let path = directory.join("runtime-port.json"); + let port = std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .map(|record| record.port) + .filter(|port| (1..=u16::MAX).contains(port)) + .unwrap_or(DEFAULT_PORT); + ProxyEndpoint { + host: "127.0.0.1", + port, + } +} + +fn expand_tilde(path: PathBuf, home: &Path) -> PathBuf { + if path == Path::new("~") { + return home.to_path_buf(); + } + path.strip_prefix("~/") + .map(|rest| home.join(rest)) + .unwrap_or(path) +} + +pub fn current() -> (ProxyEndpoint, PathBuf) { + let home = dirs_home(); + let directory = config_directory(|key| std::env::var(key).ok(), &home); + let endpoint = resolve(|key| std::env::var(key).ok(), &home); + (endpoint, directory) +} + +fn dirs_home() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{create_dir_all, write}; + + #[test] + fn resolves_home_override_and_runtime_port() { + let root = std::env::temp_dir().join(format!("ocx-discovery-{}", std::process::id())); + let home = root.join("home"); + let custom = home.join("custom"); + create_dir_all(&custom).unwrap(); + write( + custom.join("runtime-port.json"), + r#"{"pid":1,"port":12345}"#, + ) + .unwrap(); + let endpoint = resolve(|key| (key == HOME_ENV).then(|| "~/custom".into()), &home); + assert_eq!( + endpoint, + ProxyEndpoint { + host: "127.0.0.1", + port: 12345 + } + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn any_failure_falls_back_to_default() { + let home = std::env::temp_dir().join("ocx-missing-home"); + let endpoint = resolve(|_| None, &home); + assert_eq!(endpoint.port, DEFAULT_PORT); + } + + #[test] + fn empty_override_and_invalid_port_use_default() { + let root = + std::env::temp_dir().join(format!("ocx-discovery-invalid-{}", std::process::id())); + let home = root.join("home"); + let directory = root.join("custom"); + create_dir_all(&directory).unwrap(); + write(directory.join("runtime-port.json"), r#"{"port":0}"#).unwrap(); + let endpoint = resolve(|key| (key == HOME_ENV).then(|| " ".into()), &home); + assert_eq!(endpoint.port, DEFAULT_PORT); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/desktop/src-tauri/src/formatting.rs b/desktop/src-tauri/src/formatting.rs new file mode 100644 index 0000000000..5f940b68fe --- /dev/null +++ b/desktop/src-tauri/src/formatting.rs @@ -0,0 +1,79 @@ +pub fn tokens(value: Option) -> String { + abbreviate(value, true) +} + +pub fn count(value: Option) -> String { + abbreviate(value, false) +} + +pub fn cost(value: Option) -> String { + let Some(value) = value else { + return "—".into(); + }; + if value < 1_000.0 { + return format!("${value:.2}"); + } + format!("${}", abbreviate_float(value, false)) +} + +fn abbreviate(value: Option, integer: bool) -> String { + let Some(value) = value else { + return "—".into(); + }; + if !integer && value < 10_000 { + return format!("{value}"); + } + if integer && value < 1_000 { + return format!("{value}"); + } + abbreviate_float(value as f64, integer) +} + +fn abbreviate_float(value: f64, integer: bool) -> String { + let units = [ + (1_000_000_000_000.0, "T"), + (1_000_000_000.0, "B"), + (1_000_000.0, "M"), + (1_000.0, "K"), + ]; + for (threshold, suffix) in units { + if value >= threshold * 0.9995 { + let scaled = value / threshold; + let decimals = if integer || scaled >= 100.0 { + 0 + } else if scaled >= 10.0 { + 1 + } else { + 2 + }; + let rendered = format!("{scaled:.decimals$}"); + return format!( + "{}{}", + rendered.trim_end_matches('0').trim_end_matches('.'), + suffix + ); + } + } + format!("{value:.0}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_boundaries_match_swift_formatting() { + assert_eq!(tokens(Some(999_600)), "1M"); + assert_eq!(tokens(Some(1_234)), "1K"); + assert_eq!(tokens(Some(2_401_634_303)), "2B"); + assert_eq!(tokens(None), "—"); + } + + #[test] + fn counts_and_costs_have_expected_precision() { + assert_eq!(count(Some(9_999)), "9999"); + assert_eq!(count(Some(12_345)), "12.3K"); + assert_eq!(cost(Some(12.345)), "$12.35"); + assert_eq!(cost(Some(1_234.0)), "$1.23K"); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000000..aa688daca9 --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,119 @@ +mod auth; +mod discovery; +mod formatting; +mod logging; +mod proxy; +mod sidecar; +mod tray; +mod updater; +mod widget; +mod window; + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri_plugin_autostart::MacosLauncher; +use tauri_plugin_shell::process::CommandChild; + +pub struct AppState { + pub proxy: proxy::ProxyClient, + pub spawned_by_us: AtomicBool, + pub child: Mutex>, +} + +impl AppState { + pub fn shutdown_child(&self) { + if !self.spawned_by_us.swap(false, Ordering::AcqRel) { + return; + } + if let Ok(mut child) = self.child.lock() { + if let Some(child) = child.take() { + let _ = child.kill(); + } + } + } +} + +#[tauri::command] +fn show_dashboard(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } +} + +#[tauri::command] +fn hide_dashboard(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + window::hide(&window); + } +} + +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + })) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + None, + )) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .invoke_handler(tauri::generate_handler![show_dashboard, hide_dashboard]) + .setup(|app| { + let (endpoint, home) = discovery::current(); + let proxy = proxy::ProxyClient::new(endpoint, auth::Auth::new(home)) + .map_err(|error| error.to_string())?; + let child = tauri::async_runtime::block_on(sidecar::ensure_proxy( + app.handle(), + &proxy, + endpoint, + )) + .map_err(std::io::Error::other)?; + app.manage(AppState { + proxy: proxy.clone(), + spawned_by_us: AtomicBool::new(child.is_some()), + child: Mutex::new(child), + }); + app.manage(updater::PendingUpdate(Mutex::new(None))); + app.manage(tray::TrayState::default()); + + let window = WebviewWindowBuilder::new( + app, + "main", + WebviewUrl::App(format!("index.html?port={}", endpoint.port).into()), + ) + .title("OpenCodex") + .inner_size(1100.0, 720.0) + .visible(false) + .user_agent(&window::webview_user_agent()) + .on_navigation(window::navigation_allowed(endpoint)) + .build()?; + window::configure(&window); + window::set_tray_policy(app.handle(), false); + let dashboard = endpoint.url("/#/usage"); + if tauri::async_runtime::block_on(proxy.is_alive()).is_ok() { + let _ = window.eval(format!("window.location.replace({dashboard:?})")); + } + tray::install(app.handle(), proxy)?; + if !cfg!(debug_assertions) { + updater::start_background_checks(app.handle().clone()); + } + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while building OpenCodex desktop shell") + .run(|app, event| { + if matches!(event, tauri::RunEvent::Exit) { + if let Some(state) = app.try_state::() { + state.shutdown_child(); + } + } + }); +} diff --git a/desktop/src-tauri/src/logging.rs b/desktop/src-tauri/src/logging.rs new file mode 100644 index 0000000000..a3b71fb5f4 --- /dev/null +++ b/desktop/src-tauri/src/logging.rs @@ -0,0 +1,14 @@ +use std::{ + collections::HashSet, + sync::{Mutex, OnceLock}, +}; + +pub fn log_once(scope: &str, message: &str) { + static LOGGED: OnceLock>> = OnceLock::new(); + let logged = LOGGED.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut logged) = logged.lock() { + if logged.insert(format!("{scope}: {message}")) { + eprintln!("{scope}: {message}"); + } + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000000..c0e7716829 --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + opencodex_desktop_lib::run(); +} diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs new file mode 100644 index 0000000000..fc8dc71ec5 --- /dev/null +++ b/desktop/src-tauri/src/proxy.rs @@ -0,0 +1,111 @@ +use crate::{auth::Auth, discovery::ProxyEndpoint}; +use reqwest::{Client, Method, StatusCode}; +use serde_json::Value; +use std::time::Duration; + +#[derive(Clone)] +pub struct ProxyClient { + client: Client, + endpoint: ProxyEndpoint, + auth: Auth, +} + +#[derive(Debug)] +pub enum ProxyError { + Unreachable, + Unauthorized, + Http(StatusCode), + Decode(reqwest::Error), +} + +impl ProxyClient { + pub fn new(endpoint: ProxyEndpoint, auth: Auth) -> Result { + Ok(Self { + client: Client::builder() + .timeout(Duration::from_secs(4)) + .user_agent(Auth::user_agent()) + .build()?, + endpoint, + auth, + }) + } + + pub fn endpoint(&self) -> ProxyEndpoint { + self.endpoint + } + + pub async fn is_alive(&self) -> Result { + self.get("/healthz").await + } + + pub async fn companion_settings(&self) -> Result { + self.get("/api/companion/settings").await + } + + pub async fn usage_summary(&self) -> Result { + self.get("/api/usage?range=7d").await + } + + pub async fn usage_today(&self) -> Result { + self.get("/api/usage?range=today").await + } + + pub async fn startup_health(&self) -> Result { + self.get("/api/startup-health").await + } + + pub async fn quotas(&self) -> Result { + self.get("/api/provider-quotas").await + } + + pub async fn timeline(&self, query: &str) -> Result { + self.get(&format!("/api/usage/timeline?{query}")).await + } + + pub async fn stop(&self) -> Result { + self.request(Method::POST, "/api/stop").await + } + + async fn get(&self, path: &str) -> Result { + self.request(Method::GET, path).await + } + + async fn request(&self, method: Method, path: &str) -> Result { + let response = self.send(&method, path, None).await?; + if response.status() == StatusCode::UNAUTHORIZED { + let token = self.auth.token().ok_or(ProxyError::Unauthorized)?; + let response = self.send(&method, path, Some(token)).await?; + return decode(response).await; + } + decode(response).await + } + + async fn send( + &self, + method: &Method, + path: &str, + token: Option, + ) -> Result { + let mut request = self.client.request(method.clone(), self.endpoint.url(path)); + if let Some(value) = token { + request = request.header("X-OpenCodex-API-Key", value); + } + request.send().await.map_err(|error| { + if error.is_connect() { + ProxyError::Unreachable + } else { + ProxyError::Decode(error) + } + }) + } +} + +async fn decode(response: reqwest::Response) -> Result { + if response.status() == StatusCode::UNAUTHORIZED { + return Err(ProxyError::Unauthorized); + } + if !response.status().is_success() { + return Err(ProxyError::Http(response.status())); + } + response.json().await.map_err(ProxyError::Decode) +} diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs new file mode 100644 index 0000000000..434c1d6288 --- /dev/null +++ b/desktop/src-tauri/src/sidecar.rs @@ -0,0 +1,47 @@ +use crate::{discovery::ProxyEndpoint, proxy::ProxyClient}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_shell::{process::CommandChild, ShellExt}; +use tokio::time::{sleep, timeout, Duration, Instant}; + +pub async fn ensure_proxy( + app: &AppHandle, + proxy: &ProxyClient, + endpoint: ProxyEndpoint, +) -> Result, String> { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if matches!( + timeout(Duration::from_millis(250), proxy.is_alive()).await, + Ok(Ok(_)) + ) { + return Ok(None); + } + if Instant::now() >= deadline { + break; + } + sleep(Duration::from_millis(150)).await; + } + + let gui_dist = app + .path() + .resource_dir() + .map_err(|error| error.to_string())? + .join("gui") + .join("dist"); + let command = app + .shell() + .sidecar("ocx") + .map_err(|error| error.to_string())? + .args(["start", "--port", &endpoint.port.to_string()]) + .env("OPENCODEX_GUI_DIST", gui_dist); + let (_events, child) = command.spawn().map_err(|error| error.to_string())?; + + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + if proxy.is_alive().await.is_ok() { + return Ok(Some(child)); + } + } + let _ = child.kill(); + Err("the OpenCodex sidecar did not become healthy".into()) +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs new file mode 100644 index 0000000000..56f2550645 --- /dev/null +++ b/desktop/src-tauri/src/tray.rs @@ -0,0 +1,351 @@ +use crate::{formatting, proxy::ProxyClient, updater, widget, window}; +use serde_json::Value; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{ + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, Wry, +}; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_opener::OpenerExt; + +pub struct TrayState { + pub menu: Mutex>, + pub installing: AtomicBool, +} + +pub struct UpdateMenu { + check_updates: MenuItem, + install_update: MenuItem, +} + +impl Default for TrayState { + fn default() -> Self { + Self { + menu: Mutex::new(None), + installing: AtomicBool::new(false), + } + } +} + +pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; + let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; + let login = CheckMenuItem::with_id( + app, + "start-at-login", + "Start at Login", + true, + app.autolaunch().is_enabled().unwrap_or(false), + None::<&str>, + )?; + let spawned_by_us = app + .state::() + .spawned_by_us + .load(Ordering::Relaxed); + let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", spawned_by_us, None::<&str>)?; + let stop_item = stop.clone(); + let check_updates = MenuItem::with_id( + app, + "check-updates", + "Check for Updates…", + true, + None::<&str>, + )?; + let install_update = + MenuItem::with_id(app, "install-update", "Install update", false, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[ + &open, + &browser, + &PredefinedMenuItem::separator(app)?, + &login, + &stop, + &PredefinedMenuItem::separator(app)?, + &check_updates, + &install_update, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + if let Ok(mut state) = app.state::().menu.lock() { + *state = Some(UpdateMenu { + check_updates: check_updates.clone(), + install_update: install_update.clone(), + }); + } + + let tray = TrayIconBuilder::with_id("main") + .icon(icon()) + .icon_as_template(true) + .menu(&menu) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + if let Some(window) = tray.app_handle().get_webview_window("main") { + window::show(&window); + } + } + }) + .on_menu_event(move |app, event| match event.id().as_ref() { + "open-dashboard" => { + if let Some(window) = app.get_webview_window("main") { + window::show(&window); + } + } + "open-browser" => { + let endpoint = app.state::().proxy.endpoint(); + let _ = app + .opener() + .open_url(format!("{}#/usage", endpoint.url("/")), None::); + } + "start-at-login" => { + let enabled = app.autolaunch().is_enabled().unwrap_or(false); + if enabled { + let _ = app.autolaunch().disable(); + } else { + let _ = app.autolaunch().enable(); + } + } + "stop-proxy" => { + if app + .state::() + .spawned_by_us + .load(Ordering::Relaxed) + { + let proxy = app.state::().proxy.clone(); + let app = app.clone(); + let stop_item = stop_item.clone(); + tauri::async_runtime::spawn(async move { + let stopped = proxy.stop().await.is_ok() || proxy.is_alive().await.is_err(); + if stopped { + app.state::().shutdown_child(); + let _ = stop_item.set_enabled(false); + } + }); + } + } + "check-updates" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + updater::check_and_show(&app).await; + }); + } + "install-update" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let update = app + .state::() + .0 + .lock() + .ok() + .and_then(|mut pending| pending.take()); + let Some(update) = update else { + return; + }; + let version = update.version.clone(); + let retry_update = update.clone(); + set_installing(&app, &version); + if let Err(error) = updater::install(&app, update).await { + if let Ok(mut pending) = + app.state::().0.lock() + { + *pending = Some(retry_update); + } + set_install_failed(&app, &version); + crate::logging::log_once("updater install failed", &error); + } + }); + } + "quit" => app.exit(0), + _ => {} + }) + .build(app)?; + + refresh_title(&tray, &proxy); + widget::refresh(&proxy); + let tray = tray.clone(); + tauri::async_runtime::spawn(async move { + let mut tick = 0; + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + refresh_title(&tray, &proxy); + tick += 1; + if tick % 5 == 0 { + widget::refresh(&proxy); + } + } + }); + Ok(()) +} + +pub fn show_update_available(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu.install_update.set_text(updater::update_label(version)); + let _ = menu.install_update.set_enabled(true); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.check_updates.set_text("Check for Updates…"); + } + } + } +} + +pub fn show_up_to_date(app: &AppHandle) { + if let Some(state) = app.try_state::() { + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu + .check_updates + .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.install_update.set_enabled(false); + } + } + } +} + +pub fn is_installing(app: &AppHandle) -> bool { + app.try_state::() + .is_some_and(|state| state.installing.load(Ordering::Acquire)) +} + +fn set_installing(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + state.installing.store(true, Ordering::Release); + if let Ok(menu) = state.menu.lock() { + if let Some(menu) = menu.as_ref() { + let _ = menu + .install_update + .set_text(format!("Installing update v{version}…")); + let _ = menu.install_update.set_enabled(false); + let _ = menu.check_updates.set_enabled(false); + } + } + } +} + +fn set_install_failed(app: &AppHandle, version: &str) { + if let Some(state) = app.try_state::() { + state.installing.store(false, Ordering::Release); + } + show_update_available(app, version); +} + +fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { + let proxy = proxy.clone(); + let tray = tray.clone(); + tauri::async_runtime::spawn(async move { + let Ok(settings) = proxy.companion_settings().await else { + return; + }; + let Ok(usage) = proxy.usage_summary().await else { + return; + }; + let quotas = proxy.quotas().await.unwrap_or(Value::Null); + if let Some(title) = render_title(&settings, &usage, "as) { + let _ = tray.set_title(Some(&title)); + } + }); +} + +pub(crate) fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option { + let metric = settings + .pointer("/settings/menuBarMetric") + .and_then(Value::as_str) + .unwrap_or("tokens"); + let summary = usage.get("summary").unwrap_or(usage); + let quota = quota_percent(quotas); + let value = match metric { + "requests" => formatting::count(summary.get("requests").and_then(Value::as_i64)), + "cost" => formatting::cost(summary.get("estimatedCostUsd").and_then(Value::as_f64)), + "quota" => format_percent(quota), + "none" => return None, + _ => formatting::tokens(summary.get("totalTokens").and_then(Value::as_i64)), + }; + let template = settings + .pointer("/settings/menuBarTemplate") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let rendered = template + .map(|value| { + value + .replace( + "{requests}", + &formatting::count(summary.get("requests").and_then(Value::as_i64)), + ) + .replace( + "{totalTokens}", + &formatting::tokens(summary.get("totalTokens").and_then(Value::as_i64)), + ) + .replace( + "{costUsd}", + &formatting::cost(summary.get("estimatedCostUsd").and_then(Value::as_f64)), + ) + .replace( + "{inputTokens}", + &formatting::tokens(summary.get("inputTokens").and_then(Value::as_i64)), + ) + .replace( + "{outputTokens}", + &formatting::tokens(summary.get("outputTokens").and_then(Value::as_i64)), + ) + .replace("{quotaPercent}", &format_percent(quota)) + }) + .unwrap_or(value); + let rendered = rendered.trim(); + if rendered.is_empty() { + None + } else if rendered.chars().count() > 24 { + Some(format!( + "{}…", + rendered.chars().take(23).collect::() + )) + } else { + Some(rendered.to_owned()) + } +} + +fn quota_percent(value: &Value) -> Option { + let reports = value.get("reports")?.as_array()?; + let mut values = Vec::new(); + for report in reports { + let Some(quota) = report.get("quota") else { + continue; + }; + for key in ["weeklyPercent", "monthlyPercent", "fiveHourPercent"] { + if let Some(value) = quota.get(key).and_then(Value::as_f64) { + values.push(value); + } + } + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + values.extend( + windows + .iter() + .filter_map(|window| window.get("percent").and_then(Value::as_f64)), + ); + } + } + values.into_iter().reduce(f64::min) +} + +fn format_percent(value: Option) -> String { + value + .map(|value| format!("{}%", value.round() as i64)) + .unwrap_or_else(|| "—".into()) +} + +fn icon() -> tauri::image::Image<'static> { + tauri::image::Image::from_bytes(include_bytes!("../icons/tray/icon.png")) + .expect("valid tray icon") +} diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs new file mode 100644 index 0000000000..4638c1565d --- /dev/null +++ b/desktop/src-tauri/src/updater.rs @@ -0,0 +1,71 @@ +use crate::{logging, tray}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; +use tauri_plugin_updater::{Update, UpdaterExt}; + +pub struct PendingUpdate(pub Mutex>); + +pub async fn check(app: &AppHandle) -> Result, String> { + app.updater() + .map_err(|error| error.to_string())? + .check() + .await + .map_err(|error| error.to_string()) +} + +pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> { + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|error| error.to_string())?; + app.restart(); +} + +pub fn update_label(version: &str) -> String { + format!("Install update v{version}") +} + +pub fn start_background_checks(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + loop { + check_and_show(&app).await; + tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await; + } + }); +} + +pub async fn check_and_show(app: &AppHandle) { + if tray::is_installing(app) { + return; + } + match check(app).await { + Ok(Some(update)) => { + if tray::is_installing(app) { + return; + } + let version = update.version.clone(); + if let Ok(mut pending) = app.state::().0.lock() { + *pending = Some(update); + } + tray::show_update_available(app, &version); + } + Ok(None) => { + if let Ok(mut pending) = app.state::().0.lock() { + *pending = None; + } + tray::show_up_to_date(app); + } + Err(error) => logging::log_once("updater check failed", &error), + } +} + +#[cfg(test)] +mod tests { + use super::update_label; + + #[test] + fn formats_update_menu_label() { + assert_eq!(update_label("2.62.0"), "Install update v2.62.0"); + } +} diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs new file mode 100644 index 0000000000..4db3005f03 --- /dev/null +++ b/desktop/src-tauri/src/widget.rs @@ -0,0 +1,495 @@ +#[cfg(target_os = "macos")] +mod macos { + use crate::{ + proxy::{ProxyClient, ProxyError}, + tray, + }; + use serde::Serialize; + use serde_json::{json, Value}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use uuid::Uuid; + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Today { + requests: Option, + total_tokens: Option, + estimated_cost_usd: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Quota { + provider_label: String, + window_label: String, + percent: Option, + reset_at: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Series { + id: String, + points: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Chart { + start: f64, + bucket_seconds: i64, + style: String, + series: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Snapshot { + schema_version: i64, + state: String, + state_title: String, + detail: Option, + endpoint_display: String, + menu_title: Option, + today: Option, + quotas: Vec, + chart: Option, + last_updated: Option, + generated_at: f64, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ErrorKind { + Unreachable, + Unauthorized, + Http, + Decode, + } + + fn state_for_error( + kind: ErrorKind, + detail: Option, + ) -> (&'static str, &'static str, Option) { + match kind { + ErrorKind::Unreachable => ( + "unreachable", + "Stopped", + Some("The proxy is not running.".into()), + ), + ErrorKind::Unauthorized => ( + "unauthorized", + "Needs API key", + Some("This proxy requires an API key.".into()), + ), + ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail), + } + } + + fn proxy_error(error: &ProxyError) -> (ErrorKind, Option) { + match error { + ProxyError::Unreachable => (ErrorKind::Unreachable, None), + ProxyError::Unauthorized => (ErrorKind::Unauthorized, None), + ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))), + ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())), + } + } + + fn number(value: Option<&Value>) -> Option { + value.and_then(Value::as_f64) + } + + fn integer(value: Option<&Value>) -> Option { + value.and_then(Value::as_i64) + } + + fn reset_at(value: Option<&Value>) -> Option { + let value = number(value)?; + Some(if value >= 1_000_000_000_000.0 { + value / 1000.0 + } else { + value + }) + } + + fn quotas(value: &Value) -> Vec { + let Some(reports) = value.get("reports").and_then(Value::as_array) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for report in reports { + let provider_label = report + .get("label") + .or_else(|| report.get("provider")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + let Some(quota) = report.get("quota") else { + continue; + }; + let mut push = |percent: Option<&Value>, window_label: &str, reset: Option<&Value>| { + if percent.is_some() || reset.is_some() { + rows.push(Quota { + provider_label: provider_label.clone(), + window_label: window_label.to_owned(), + percent: number(percent), + reset_at: reset_at(reset), + }); + } + }; + push( + quota.get("fiveHourPercent"), + "5h", + quota.get("fiveHourResetAt"), + ); + push( + quota.get("weeklyPercent"), + "week", + quota.get("weeklyResetAt"), + ); + push( + quota.get("monthlyPercent"), + "month", + quota.get("monthlyResetAt"), + ); + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + for window in windows { + let label = window + .get("label") + .and_then(Value::as_str) + .unwrap_or("window"); + push(window.get("percent"), label, window.get("resetAt")); + } + } + } + rows + } + + fn chart(value: &Value, settings: &Value) -> Option { + let start = number(value.get("start"))?; + let bucket_seconds = integer(value.get("bucketSeconds"))?; + let settings = settings.get("settings").unwrap_or(settings); + let style = settings + .get("chartStyle") + .and_then(Value::as_str) + .unwrap_or("line") + .to_owned(); + let series = value + .get("series") + .and_then(Value::as_array)? + .iter() + .take(6) + .filter_map(|item| { + Some(Series { + id: item.get("id")?.as_str()?.to_owned(), + points: item + .get("points")? + .as_array()? + .iter() + .filter_map(Value::as_f64) + .collect(), + }) + }) + .collect(); + Some(Chart { + start, + bucket_seconds, + style, + series, + }) + } + + fn timeline_query(settings: &Value) -> String { + let settings = settings.get("settings").unwrap_or(settings); + let get = |key: &str, fallback: &str| { + settings + .get(key) + .and_then(Value::as_str) + .unwrap_or(fallback) + .to_owned() + }; + let hours = settings + .get("chartHours") + .and_then(Value::as_i64) + .unwrap_or(24); + let bucket_minutes = settings + .get("bucketMinutes") + .and_then(Value::as_i64) + .unwrap_or(60); + let metric = get("tokenMetric", "total"); + let aggregation = get("aggregation", "sum"); + let grouping = get("chartGrouping", "model"); + let mut query = format!( + "hours={hours}&bucketMinutes={bucket_minutes}&metric={metric}&aggregation={aggregation}&grouping={grouping}" + ); + if let Some(models) = settings.get("models").and_then(Value::as_array) { + let models = models + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(","); + if !models.is_empty() { + query.push_str("&models="); + query.push_str(&models); + } + } + query + } + + fn snapshot_path() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + home.join("Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json") + } + + fn now_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + } + + fn without_generated_at(snapshot: &Snapshot) -> Snapshot { + let mut snapshot = snapshot.clone(); + snapshot.generated_at = 0.0; + snapshot + } + + fn write_if_changed( + path: &std::path::Path, + previous: Option<&Snapshot>, + snapshot: &Snapshot, + ) -> std::io::Result { + if previous.map(without_generated_at).as_ref() == Some(&without_generated_at(snapshot)) { + return Ok(false); + } + let Some(directory) = path.parent() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "snapshot path has no parent", + )); + }; + fs::create_dir_all(directory)?; + let bytes = serde_json::to_vec(snapshot).map_err(std::io::Error::other)?; + let temporary = directory.join(format!(".snapshot-{}.tmp", Uuid::new_v4())); + fs::write(&temporary, bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + } + fs::rename(temporary, path)?; + Ok(true) + } + + fn make_snapshot( + proxy: &ProxyClient, + settings: &Value, + health: &Value, + today: Option<&Value>, + quota_value: Option<&Value>, + timeline_value: Option<&Value>, + ) -> Snapshot { + let endpoint = proxy.endpoint(); + let detail = { + let parts = [health.get("status"), health.get("protection")] + .into_iter() + .filter_map(|value| value.and_then(Value::as_str)) + .filter(|part| !part.is_empty() && *part != "none") + .collect::>(); + (!parts.is_empty()).then(|| parts.join(" · ")) + }; + let today_snapshot = today + .and_then(|value| value.get("summary").or(Some(value))) + .map(|summary| Today { + requests: integer(summary.get("requests")), + total_tokens: integer(summary.get("totalTokens")), + estimated_cost_usd: number(summary.get("estimatedCostUsd")), + }); + let quotas_value = quota_value.unwrap_or(&Value::Null); + let menu_title = tray::render_title(settings, today.unwrap_or(&Value::Null), quotas_value); + let chart = timeline_value.and_then(|value| chart(value, settings)); + Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail, + endpoint_display: format!("{}:{}", endpoint.host, endpoint.port), + menu_title, + today: today_snapshot, + quotas: quotas(quotas_value), + chart, + last_updated: timeline_value.map(|_| now_seconds()), + generated_at: now_seconds(), + } + } + + pub async fn write(proxy: ProxyClient) { + let health = match proxy.startup_health().await { + Ok(value) => value, + Err(error) => { + let (kind, detail) = proxy_error(&error); + let (state, state_title, detail) = state_for_error(kind, detail); + let snapshot = Snapshot { + schema_version: 1, + state: state.into(), + state_title: state_title.into(), + detail, + endpoint_display: format!( + "{}:{}", + proxy.endpoint().host, + proxy.endpoint().port + ), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: now_seconds(), + }; + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + crate::logging::log_once("widget snapshot write failed", &error.to_string()); + } + crate::logging::log_once("widget snapshot health failed", state); + return; + } + }; + let settings = proxy + .companion_settings() + .await + .unwrap_or_else(|_| json!({ "settings": {} })); + let today = proxy.usage_today().await.ok(); + let quota_value = proxy.quotas().await.ok(); + let timeline_value = proxy.timeline(&timeline_query(&settings)).await.ok(); + let snapshot = make_snapshot( + &proxy, + &settings, + &health, + today.as_ref(), + quota_value.as_ref(), + timeline_value.as_ref(), + ); + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + crate::logging::log_once("widget snapshot write failed", &error.to_string()); + } + } + + pub fn refresh(proxy: &ProxyClient) { + let proxy = proxy.clone(); + tauri::async_runtime::spawn(async move { write(proxy).await }); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn serialization_uses_swift_field_names() { + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: Some("ok".into()), + endpoint_display: "127.0.0.1:10100".into(), + menu_title: Some("2K".into()), + today: Some(Today { + requests: Some(2), + total_tokens: Some(1234), + estimated_cost_usd: Some(0.12), + }), + quotas: vec![Quota { + provider_label: "OpenAI".into(), + window_label: "week".into(), + percent: Some(10.0), + reset_at: Some(1.0), + }], + chart: Some(Chart { + start: 1.0, + bucket_seconds: 3600, + style: "line".into(), + series: vec![Series { + id: "openai/gpt".into(), + points: vec![1.0, 2.0], + }], + }), + last_updated: Some(2.0), + generated_at: 3.0, + }; + assert_eq!( + serde_json::to_string(&snapshot).unwrap(), + r#"{"schemaVersion":1,"state":"running","stateTitle":"Running","detail":"ok","endpointDisplay":"127.0.0.1:10100","menuTitle":"2K","today":{"requests":2,"totalTokens":1234,"estimatedCostUsd":0.12},"quotas":[{"providerLabel":"OpenAI","windowLabel":"week","percent":10.0,"resetAt":1.0}],"chart":{"start":1.0,"bucketSeconds":3600,"style":"line","series":[{"id":"openai/gpt","points":[1.0,2.0]}]},"lastUpdated":2.0,"generatedAt":3.0}"# + ); + } + + #[test] + fn error_state_mapping_covers_four_kinds() { + assert_eq!( + state_for_error(ErrorKind::Unreachable, None).0, + "unreachable" + ); + assert_eq!( + state_for_error(ErrorKind::Unauthorized, None).0, + "unauthorized" + ); + assert_eq!( + state_for_error(ErrorKind::Http, Some("HTTP 500".into())).0, + "degraded" + ); + assert_eq!( + state_for_error(ErrorKind::Decode, Some("bad".into())).0, + "degraded" + ); + } + + #[test] + fn write_if_changed_ignores_generated_at() { + let path = std::env::temp_dir().join(format!("ocx-widget-{}.json", std::process::id())); + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: None, + endpoint_display: "127.0.0.1:10100".into(), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: 1.0, + }; + assert!(write_if_changed(&path, None, &snapshot).unwrap()); + let mut changed = snapshot.clone(); + changed.generated_at = 2.0; + assert!(!write_if_changed(&path, Some(&snapshot), &changed).unwrap()); + let _ = fs::remove_file(path); + } + + #[test] + fn chart_series_are_truncated_to_six() { + let series = (0..8) + .map(|index| json!({ "id": index.to_string(), "points": [1] })) + .collect::>(); + let value = json!({ "start": 1, "bucketSeconds": 60, "series": series }); + let result = chart(&value, &json!({ "settings": { "chartStyle": "line" } })).unwrap(); + assert_eq!(result.series.len(), 6); + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::refresh; + +#[cfg(not(target_os = "macos"))] +pub(crate) fn refresh(_: &crate::proxy::ProxyClient) {} diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs new file mode 100644 index 0000000000..f9550247ae --- /dev/null +++ b/desktop/src-tauri/src/window.rs @@ -0,0 +1,88 @@ +use crate::{auth::Auth, discovery::ProxyEndpoint}; +use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent}; + +pub fn webview_user_agent() -> String { + let platform = if cfg!(target_os = "macos") { + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)" + } else if cfg!(target_os = "windows") { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + } else { + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)" + }; + format!("{platform} {}", Auth::user_agent()) +} + +pub fn configure(window: &WebviewWindow) { + let window_for_close = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window_for_close.hide(); + apply_tray_policy(window_for_close.app_handle(), false); + } + }); +} + +pub fn navigation_allowed(endpoint: ProxyEndpoint) -> impl Fn(&Url) -> bool { + move |url| { + if url.scheme() == "tauri" { + return true; + } + if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { + return url.port_or_known_default() == Some(endpoint.port); + } + if matches!(url.scheme(), "http" | "https") { + let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); + return false; + } + url.scheme() == "about" && url.as_str() == "about:blank" + } +} + +pub fn show(window: &WebviewWindow) { + let _ = window.show(); + let _ = window.set_focus(); + apply_tray_policy(window.app_handle(), true); +} + +pub fn hide(window: &WebviewWindow) { + let _ = window.hide(); + apply_tray_policy(window.app_handle(), false); +} + +#[cfg(target_os = "macos")] +fn apply_tray_policy(app: &AppHandle, visible: bool) { + let policy = if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }; + let _ = app.set_dock_visibility(visible); + let _ = app.set_activation_policy(policy); +} + +#[cfg(not(target_os = "macos"))] +fn apply_tray_policy(_app: &AppHandle, _visible: bool) {} + +pub fn set_tray_policy(app: &AppHandle, visible: bool) { + apply_tray_policy(app, visible); +} + +#[cfg(test)] +mod tests { + use super::webview_user_agent; + + #[test] + fn webview_user_agent_marks_the_desktop_shell() { + let user_agent = webview_user_agent(); + assert!(user_agent.starts_with("Mozilla/5.0 ")); + assert!(user_agent.contains("OpenCodexDesktop/")); + if cfg!(target_os = "macos") { + assert!(user_agent.contains("(Macintosh; Intel Mac OS X 10_15_7)")); + } else if cfg!(target_os = "windows") { + assert!(user_agent.contains("(Windows NT 10.0; Win64; x64)")); + } else { + assert!(user_agent.contains("(X11; Linux x86_64)")); + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000000..6af54b5e1c --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenCodex", + "version": "2.61.0", + "identifier": "com.opencodex.desktop", + "build": { + "frontendDist": "../ui", + "devUrl": "http://localhost:1420" + }, + "app": { + "security": { + "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": true, + "externalBin": [ + "binaries/ocx" + ], + "resources": { + "resources/gui/dist": "gui/dist" + }, + "icon": [ + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png" + ], + "macOS": { + "minimumSystemVersion": "13.0", + "files": { + "PlugIns/OpenCodexWidget.appex": "widget/OpenCodexWidget.appex" + }, + "dmg": { + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + } + } + }, + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + }, + "wix": { + "language": "en-US" + } + }, + "linux": { + "deb": { + "depends": [] + }, + "appimage": { + "bundleMediaFramework": false + } + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK", + "endpoints": [ + "https://github.com/lidge-jun/opencodex/releases/latest/download/latest.json" + ] + } + } +} diff --git a/desktop/ui/index.html b/desktop/ui/index.html new file mode 100644 index 0000000000..e4d5c9a7de --- /dev/null +++ b/desktop/ui/index.html @@ -0,0 +1,29 @@ + + + + + + OpenCodex + + + +
+

OpenCodex

+

Connecting to the OpenCodex proxy…

+ +
+ + + diff --git a/desktop/ui/main.js b/desktop/ui/main.js new file mode 100644 index 0000000000..85c4ea8732 --- /dev/null +++ b/desktop/ui/main.js @@ -0,0 +1,34 @@ +const params = new URLSearchParams(window.location.search); +const port = Number(params.get("port") || "10100"); +const origin = `http://127.0.0.1:${port}`; +const dashboardUrl = `${origin}/#/usage`; +const status = document.querySelector("#status"); +const retry = document.querySelector("#retry"); +let checking = false; + +async function check() { + if (checking) return; + checking = true; + status.textContent = `Connecting to OpenCodex proxy at 127.0.0.1:${port}…`; + retry.disabled = true; + try { + const response = await fetch(`${origin}/healthz`, { + cache: "no-store", + }); + if (response.ok) { + status.textContent = "Proxy is ready. Loading dashboard…"; + window.location.replace(dashboardUrl); + return; + } + throw new Error(`HTTP ${response.status}`); + } catch { + status.textContent = "The proxy is not reachable yet."; + } finally { + checking = false; + retry.disabled = false; + } +} + +retry.addEventListener("click", check); +check(); +setInterval(check, 1500); diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 29a70bd919..2b68b5d457 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -97,6 +97,7 @@ export default defineConfig({ { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, + { label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index c134193f42..f59ebe9916 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -303,6 +303,7 @@ Utilisez `ocx service` pour maintenir un proxy d’arrière-plan toujours actif, ### `ocx tray [--json] [--no-start]` Installe et contrôle l’icône OpenCodex dans la zone de notification Windows. Elle démarre à l’ouverture de session et fournit des commandes du proxy accessibles en un clic. `start` et `stop` contrôlent uniquement l’icône ; utilisez son menu pour contrôler le proxy. `--no-start` s’applique à `install` et installe l’icône sans la lancer immédiatement. +Obsolète : l’application OpenCodex fournit la zone de notification sous Windows, macOS et Linux ; `ocx tray` reste disponible pour les installations sans l’application de bureau. ## Tableau de bord diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index c5a7d36578..9ff7e5a72c 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -49,6 +49,19 @@ ocx --version opencodex --version ``` +## Standalone binary (no npm) + +Release downloads also include a standalone `ocx` binary for supported macOS, Linux, and Windows +targets. It includes the Bun runtime and dashboard, so npm, Node, and a separate Bun installation +are not required. Download the archive for your platform, extract it, and run: + +```bash +./ocx --version +./ocx start +``` + +The extracted `gui/dist` directory must stay beside the binary so `GET /` can serve the dashboard. + ### Release channels The stable `latest` channel already includes GPT-5.6 Sol/Terra/Luna catalog support for ChatGPT, diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 042eb18304..7734c9f276 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Configure your first provider and route OpenAI Codex through openco This guide takes you from a fresh install to running Codex against a non-OpenAI model. +## Standalone binary (no npm) + +You can also use a release archive containing the `ocx` binary and Bun runtime without npm. +Extract it with its `gui/dist` directory beside the binary, then run `./ocx start`. + ## 1. Run the setup wizard ```bash diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md new file mode 100644 index 0000000000..c1405fc0c2 --- /dev/null +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -0,0 +1,84 @@ +--- +title: Desktop App +description: Install and use the OpenCodex desktop app on macOS, Windows, and Linux. +--- + +The OpenCodex desktop app combines a native tray with the web dashboard. It discovers an +existing local proxy, or starts the bundled `ocx` sidecar when no proxy is running. + +The dashboard remains available at [http://127.0.0.1:10100](http://127.0.0.1:10100). +The desktop app does not replace the proxy; it is a local shell around the dashboard and +its bundled runtime. + +## Install + +### macOS + +Download `OpenCodex--macos.dmg` from the +[latest release](https://github.com/lidge-jun/opencodex/releases). Open the DMG and drag +`OpenCodex.app` to Applications. + +On first launch, macOS Gatekeeper may warn that the developer cannot be verified. Right-click +the app, choose **Open**, and confirm **Open**. This build is signed for integrity but is not +yet notarized. + +### Windows + +Download `OpenCodex--windows-x64.msi` and run the installer. Windows SmartScreen may +warn because the installer is not yet code-signed; choose **More info → Run anyway** after +confirming that you downloaded it from the release page. + +### Linux + +Download `OpenCodex--linux-x86_64.AppImage` or +`OpenCodex--linux-amd64.deb` from the release page. + +For the AppImage: + +```bash +chmod +x OpenCodex--linux-x86_64.AppImage +./OpenCodex--linux-x86_64.AppImage +``` + +For Debian-based distributions: + +```bash +sudo apt install ./OpenCodex--linux-amd64.deb +``` + +The tray icon requires an AppIndicator-capable desktop environment. + +## First launch + +The app first looks for an existing `ocx` proxy on loopback, using the runtime port +metadata when available and falling back to port `10100`. If no proxy answers, it starts +the bundled sidecar. The dashboard is then opened inside the app's webview. + +Use the tray's **Open dashboard** or **Open in browser** action to move between the +embedded dashboard and your normal browser. The tray also provides update checks. + +## Updates + +Choose **Check for Updates…** in the tray menu to check immediately. Release builds also +check automatically after startup and every six hours. Updates are verified with the +project's signed updater public key before installation. On macOS, in-app updates download +`OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. +The release manifest is generated only when the updater key secret is configured and then +requires all four platforms to be signed. + +## Widget + +The macOS app includes the OpenCodex WidgetKit extension. See the +[macOS Menu Bar App guide](/opencodex/guides/macos-menu-bar/) for widget setup and the +privacy-safe snapshot details. + +## Uninstall + +On macOS, drag `OpenCodex.app` from Applications to the Trash. On Windows, remove +OpenCodex from **Installed apps**. On Debian-based Linux systems, run: + +```bash +sudo apt remove opencodex +``` + +For an AppImage, delete the downloaded file. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 209efa7566..c95888a716 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -9,16 +9,24 @@ per-provider quota pressure, without opening the dashboard. It is a separate application from the proxy. `ocx` keeps running as it always has; the companion is a read-mostly client that talks to the local management API. -## Install +## Desktop app (Tauri) + +The same dashboard can run inside the OpenCodex desktop app. The Usage companion panel +uses the OS selector to show the matching macOS, Windows, or Linux installation steps. +While the dashboard is inside the desktop shell, choose **Open in browser** to open the +current dashboard view in your normal browser. -Download `OpenCodex--macos-universal.zip` from the -[latest release](https://github.com/lidge-jun/opencodex/releases), unzip it, and move -`OpenCodex.app` to your Applications folder. +## Install -Verify the download if you like — every release ships a checksum beside it: +Install the desktop app from the +[latest release](https://github.com/lidge-jun/opencodex/releases). On macOS, download +`OpenCodex--macos.dmg`, open it, and drag `OpenCodex.app` to Applications. +Windows users can run `OpenCodex--windows-x64.msi`; Linux users can use the +AppImage or `OpenCodex--linux-amd64.deb`. ```bash -shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb ``` ## First launch: Gatekeeper @@ -104,7 +112,7 @@ Everything else — accounts, model configuration, storage — stays in the dash Add the widget from the desktop: right-click, choose **Edit Widgets**, then add **OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same -privacy-safe usage snapshot as the menu bar app. The widget refreshes when the app polls. +privacy-safe usage snapshot as the desktop app. The widget refreshes when the app polls. It requires macOS 14 or later and reads only the privacy-safe snapshot written by the OpenCodex app; it does not receive API keys or raw account data. @@ -138,11 +146,13 @@ Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun. ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script -directly: `bash scripts/build-macos-app.sh`. +The bundle appears in Tauri's release output, with the WidgetKit appex under +`OpenCodex.app/Contents/PlugIns/`. Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command Line Tools ships only current-architecture Swift compatibility libraries, and the build @@ -152,7 +162,7 @@ If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTIT sign with the hardened runtime instead of ad-hoc: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## Uninstall diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index a62daede0a..49b50f7d37 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## スタンドアロンバイナリ(npm 不要) + +リリースには、対応する macOS、Linux、Windows 向けのスタンドアロン `ocx` バイナリも含まれます。 +Bun ランタイムとダッシュボードが含まれるため、npm、Node、別途の Bun インストールは必要ありません。 +お使いの環境向けのアーカイブをダウンロードして展開し、次のように実行します。 + +```bash +./ocx --version +./ocx start +``` + +ダッシュボードを提供するため、展開した `gui/dist` ディレクトリはバイナリの隣に置いたままにしてください。 + ### 配布チャネル 安定チャネルの `latest` にも ChatGPT、OpenAI API キー、OpenRouter、実験段階の Cursor 経路のための diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index f9184dfc5f..9c2d25a93e 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 最初のプロバイダーを構成し、3 つのコマンドで O このガイドでは、新規インストールから非 OpenAI モデルに対して Codex を実行するまでを説明します。 +## スタンドアロンバイナリ(npm 不要) + +npm を使わず、Bun ランタイムを含むリリースアーカイブの `ocx` バイナリも利用できます。 +`gui/dist` ディレクトリをバイナリの隣に置いて展開し、`./ocx start` を実行してください。 + ## 1. セットアップウィザードを実行します ```bash diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index ff67a25ccc..bf429ac0dc 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -9,18 +9,28 @@ description: OpenCodex プロキシの状態、使用量、プロバイダーの プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは ローカルの管理 API に接続するクライアントとして動きます。 -## インストール +## デスクトップアプリ (Tauri) + +同じダッシュボードを OpenCodex デスクトップアプリ内で実行できます。Usage コンパニオン +パネルは OS に合ったインストール手順を表示し、デスクトップシェル内では **ブラウザーで開く** +を選ぶと現在の画面を通常のブラウザーで開けます。 -[リリースページ](https://github.com/lidge-jun/opencodex/releases)から -`OpenCodex--macos-universal.zip` をダウンロードし、展開して `OpenCodex.app` を -アプリケーションフォルダに移動します。 +## インストール -ダウンロードを検証する場合、リリースごとにチェックサムが添付されています。 +基本のインストール方法は OpenCodex デスクトップアプリです。[リリースページ](https://github.com/lidge-jun/opencodex/releases)から、macOS では +`OpenCodex--macos.dmg` をダウンロードし、DMG を開いて `OpenCodex.app` を +アプリケーションフォルダへドラッグします。Windows では +`OpenCodex--windows-x64.msi` を実行し、Linux では AppImage または +`OpenCodex--linux-amd64.deb` を使います。 ```bash -shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb ``` +Windows SmartScreen や macOS Gatekeeper の警告が表示されることがあります。アプリは +既存の `ocx` に接続し、見つからなければ同梱のサイドカーを起動します。 + ## 初回起動: Gatekeeper **初回起動はブロックされます。** 次のメッセージが表示されます。 @@ -134,11 +144,13 @@ macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接 -実行できます: `bash scripts/build-macos-app.sh`。 +バンドルは Tauri のリリース出力に生成され、WidgetKit 拡張は +`OpenCodex.app/Contents/PlugIns/` に含まれます。 ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には 現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは @@ -148,7 +160,7 @@ bun run build:macos ではなく hardened runtime で署名できます。 ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## アンインストール diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index bf37bc911b..877954e975 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -272,6 +272,7 @@ OpenCodex の更新後、既存の Windows シムにこの動作を適用する ### `ocx tray [--json] [--no-start]` Windows ステータス トレイ アイコンをインストールして制御します。 Windows ログイン時に開始され、ワンクリックでプロキシ コントロールを提供します。 `start` および `stop` はアイコンのみを制御します。そのメニューを使用してプロキシを制御します。 `--no-start` は `install` に適用され、トレイをすぐに起動せずにインストールします。 +非推奨: OpenCodex デスクトップアプリは Windows、macOS、Linux のトレイを提供します。`ocx tray` はデスクトップアプリを使わないインストール向けに残っています。 ## ダッシュボード diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 70a62f8609..146f25bb01 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -43,6 +43,19 @@ ocx --version opencodex --version ``` +## 독립 실행형 바이너리(npm 없음) + +릴리스에는 지원되는 macOS, Linux, Windows용 독립 실행형 `ocx` 바이너리도 포함됩니다. +Bun 런타임과 대시보드가 포함되어 있으므로 npm, Node 또는 별도의 Bun 설치가 필요하지 않습니다. +플랫폼에 맞는 아카이브를 다운로드해 압축을 풀고 다음과 같이 실행하세요. + +```bash +./ocx --version +./ocx start +``` + +대시보드를 제공하려면 압축을 푼 `gui/dist` 디렉터리를 바이너리 옆에 그대로 두어야 합니다. + ### 배포 채널 안정화 채널인 `latest`에도 ChatGPT, OpenAI API 키, OpenRouter, 실험 단계의 Cursor 경로를 위한 diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index f1a179649b..7e92511d56 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 첫 프로바이더를 설정하고 명령어 세 개로 OpenAI Cod 이 가이드는 새로 설치한 상태에서 OpenAI가 아닌 모델로 Codex를 실행하기까지의 과정을 안내합니다. +## 독립 실행형 바이너리(npm 없음) + +npm 없이 Bun 런타임이 포함된 릴리스 아카이브의 `ocx` 바이너리를 사용할 수도 있습니다. +`gui/dist` 디렉터리를 바이너리 옆에 둔 채 압축을 풀고 `./ocx start`를 실행하세요. + ## 1. 설정 마법사 실행 ```bash diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index 53159b4589..6aeb3e7742 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -9,18 +9,27 @@ description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 API에 붙는 클라이언트입니다. -## 설치 +## 데스크톱 앱 (Tauri) + +같은 대시보드를 OpenCodex 데스크톱 앱에서 실행할 수 있습니다. 사용량 패널은 운영체제에 +맞는 설치 단계를 보여주며, 데스크톱 셸 안에서는 **브라우저에서 열기**를 선택해 현재 +대시보드 화면을 일반 브라우저로 열 수 있습니다. -[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 -`OpenCodex-<버전>-macos-universal.zip`을 받아 압축을 풀고 `OpenCodex.app`을 응용 -프로그램 폴더로 옮기세요. +## 설치 -받은 파일을 검증하고 싶다면 릴리스마다 체크섬이 함께 올라갑니다. +기본 설치 경로는 OpenCodex 데스크톱 앱입니다. [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 macOS용 +`OpenCodex-<버전>-macos.dmg`를 내려받아 DMG를 열고 `OpenCodex.app`을 응용 프로그램 +폴더로 드래그하세요. Windows에서는 `OpenCodex-<버전>-windows-x64.msi`를 실행하고, +Linux에서는 AppImage 또는 `OpenCodex-<버전>-linux-amd64.deb`를 사용하세요. ```bash -shasum -a 256 -c OpenCodex-<버전>-macos-universal.zip.sha256 +chmod +x OpenCodex-<버전>-linux-x86_64.AppImage +sudo apt install ./OpenCodex-<버전>-linux-amd64.deb ``` +Windows SmartScreen 또는 macOS Gatekeeper 경고가 표시될 수 있습니다. 앱은 기존 `ocx` +프록시에 연결하고, 찾지 못하면 포함된 사이드카를 시작합니다. + ## 첫 실행: Gatekeeper 차단 **처음 실행하면 macOS가 막습니다.** 이런 메시지가 뜹니다. @@ -130,11 +139,13 @@ macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이 ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요: -`bash scripts/build-macos-app.sh`. +번들은 Tauri 릴리스 출력에 생성되며, WidgetKit 확장은 +`OpenCodex.app/Contents/PlugIns/` 아래에 포함됩니다. 유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools 에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 @@ -144,7 +155,7 @@ bun run build:macos 런타임으로 서명할 수 있습니다. ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## 삭제 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index e4f2913c6f..97048c3768 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -390,6 +390,8 @@ OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하 Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로그인 시 시작되며, 프록시를 원클릭으로 제어할 수 있습니다. `start`와 `stop`은 아이콘만 제어합니다. 프록시 제어는 메뉴를 사용하세요. `--no-start`는 `install`에 적용되며, 트레이를 바로 실행하지 않고 설치합니다. +지원 중단 예정: OpenCodex 데스크톱 앱이 Windows, macOS, Linux에서 트레이를 제공합니다. +`ocx tray`는 데스크톱 앱이 없는 설치를 위해 계속 사용할 수 있습니다. ## 대시보드 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e8380c73ca..07f6ca5853 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -611,6 +611,8 @@ file is not part of the injected `env_key` contract; the launching process must Install and control the Windows status tray icon. It starts at Windows login and provides one-click proxy controls. `start` and `stop` control the icon only; use its menu to control the proxy. `--no-start` applies to `install` and installs the tray without launching it immediately. +Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` +remains for installs without the desktop app. ## Dashboard diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index a1f3724a4b..ca0fe20b3e 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -45,6 +45,19 @@ ocx --version opencodex --version ``` +## Автономный бинарный файл (без npm) + +В релиз входят автономные бинарные файлы `ocx` для поддерживаемых macOS, Linux и Windows. +Они содержат рантайм Bun и дашборд, поэтому npm, Node и отдельная установка Bun не нужны. +Скачайте архив для своей платформы, распакуйте его и выполните: + +```bash +./ocx --version +./ocx start +``` + +Чтобы дашборд был доступен, оставьте распакованный каталог `gui/dist` рядом с бинарным файлом. + ### Каналы релизов Стабильный канал `latest` уже включает поддержку каталога GPT-5.6 Sol/Terra/Luna для маршрутов diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 1b086c7db2..088843f0f9 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Настройте первого провайдера и напр Это руководство проводит от чистой установки до запуска Codex с моделью не от OpenAI. +## Автономный бинарный файл (без npm) + +Можно также использовать архив с бинарным файлом `ocx` и рантаймом Bun без npm. +Распакуйте его, оставив каталог `gui/dist` рядом с бинарным файлом, и выполните `./ocx start`. + ## 1. Запустите мастер настройки ```bash diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index 30f54ad983..1d264a93c7 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -9,18 +9,28 @@ description: Нативное приложение, показывающее с Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — клиент, который обращается к локальному management API. -## Установка +## Настольное приложение (Tauri) + +Ту же панель можно открыть в настольном приложении OpenCodex. Панель компаньона показывает +шаги установки для выбранной ОС, а пункт **Открыть в браузере** открывает текущий экран +в обычном браузере, когда панель работает внутри desktop shell. -Скачайте `OpenCodex-<версия>-macos-universal.zip` со -[страницы релизов](https://github.com/lidge-jun/opencodex/releases), распакуйте и -переместите `OpenCodex.app` в папку «Программы». +## Установка -Если хотите проверить загрузку, к каждому релизу прилагается контрольная сумма: +Основной способ установки — настольное приложение OpenCodex. На +[странице релизов](https://github.com/lidge-jun/opencodex/releases) скачайте для macOS +`OpenCodex-<версия>-macos.dmg`, откройте DMG и перетащите `OpenCodex.app` в «Программы». +В Windows запустите `OpenCodex-<версия>-windows-x64.msi`, а в Linux используйте AppImage +или `OpenCodex-<версия>-linux-amd64.deb`. ```bash -shasum -a 256 -c OpenCodex-<версия>-macos-universal.zip.sha256 +chmod +x OpenCodex-<версия>-linux-x86_64.AppImage +sudo apt install ./OpenCodex-<версия>-linux-amd64.deb ``` +Windows SmartScreen и macOS Gatekeeper могут показать предупреждение. Приложение подключается +к существующему `ocx`, а если его нет — запускает встроенный sidecar. + ## Первый запуск: Gatekeeper **Первый запуск будет заблокирован.** macOS покажет: @@ -136,11 +146,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую: -`bash scripts/build-macos-app.sh`. +Бандл появится в выходных файлах Tauri, а расширение WidgetKit будет включено в +`OpenCodex.app/Contents/PlugIns/`. Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом @@ -150,7 +162,7 @@ bun run build:macos подписать с hardened runtime вместо ad-hoc: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## Удаление diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index f338d75388..6719e9cf2c 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -395,6 +395,8 @@ ocx codex-shim uninstall one-click управление прокси. `start` и `stop` управляют только иконкой; самим прокси нужно управлять из её меню. `--no-start` применяется к `install` и устанавливает tray, не запуская её немедленно. +Устарело: приложение OpenCodex для рабочего стола предоставляет трей в Windows, macOS и Linux; +`ocx tray` остаётся для установок без приложения для рабочего стола. ## Дашборд diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index ffa5b19df5..f1c3039260 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -470,6 +470,8 @@ Windows durum tepsisi simgesini kurun ve kontrol edin. Windows oturum açılış başlar ve tek tıklamayla proxy kontrolleri sağlar. `start` ve `stop` yalnızca simgeyi kontrol eder; proxy'yi kontrol etmek için menüsünü kullanın. `--no-start`, `install` için geçerlidir ve tepsiyi hemen başlatmadan kurar. +Kullanımdan kaldırıldı: OpenCodex masaüstü uygulaması Windows, macOS ve Linux'ta tepsi sağlar; +`ocx tray`, masaüstü uygulaması olmayan kurulumlar için kullanılmaya devam eder. ## Kontrol Paneli diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index eb5b02ec94..335debd845 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -42,6 +42,18 @@ ocx --version opencodex --version ``` +## 独立二进制文件(无需 npm) + +发布包还包含适用于 macOS、Linux 和 Windows 的独立 `ocx` 二进制文件。 +它内置 Bun 运行时和仪表盘,因此无需安装 npm、Node 或单独的 Bun。下载适合你平台的压缩包,解压后运行: + +```bash +./ocx --version +./ocx start +``` + +为了让仪表盘可用,请将解压后的 `gui/dist` 目录保留在二进制文件旁边。 + ### 发布渠道 稳定的 `latest` 渠道已经包含 ChatGPT、OpenAI API key、OpenRouter 以及实验性 Cursor 路由所需的 diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index e965b159a6..91ac4ba047 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: 配置你的第一个 provider,并在三条命令内让 OpenAI Co 本指南将带你从全新安装,一路走到用一个非 OpenAI 模型运行 Codex。 +## 独立二进制文件(无需 npm) + +你也可以使用包含 Bun 运行时的发布压缩包中的 `ocx`,无需 npm。 +解压时将 `gui/dist` 目录保留在二进制文件旁边,然后运行 `./ocx start`。 + ## 1. 运行设置向导 ```bash diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 50058524bf..2f3dc02616 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -7,17 +7,26 @@ description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供 它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 -## 安装 +## 桌面应用(Tauri) + +同一个仪表板也可以在 OpenCodex 桌面应用中运行。用量面板会显示匹配操作系统的安装步骤; +在桌面壳中选择**在浏览器中打开**,即可在普通浏览器中打开当前页面。 -从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 -`OpenCodex--macos-universal.zip`,解压后把 `OpenCodex.app` 移到「应用程序」文件夹。 +## 安装 -如果需要校验下载文件,每个版本都附带校验和: +推荐使用 OpenCodex 桌面应用安装。从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 macOS 的 +`OpenCodex--macos.dmg`,打开 DMG 后将 `OpenCodex.app` 拖到「应用程序」文件夹。 +Windows 运行 `OpenCodex--windows-x64.msi`,Linux 使用 AppImage 或 +`OpenCodex--linux-amd64.deb`。 ```bash -shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +chmod +x OpenCodex--linux-x86_64.AppImage +sudo apt install ./OpenCodex--linux-amd64.deb ``` +Windows SmartScreen 或 macOS Gatekeeper 可能显示警告。应用会连接现有的 `ocx` 代理; +找不到代理时则启动内置 sidecar。 + ## 首次启动:Gatekeeper **首次启动会被阻止。** macOS 会提示: @@ -117,11 +126,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本: -`bash scripts/build-macos-app.sh`。 +程序包会生成在 Tauri 的发布输出中,WidgetKit 扩展位于 +`OpenCodex.app/Contents/PlugIns/`。 构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift 兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 @@ -130,7 +141,7 @@ bun run build:macos 替代 ad-hoc 签名: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## 卸载 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 30687e2b81..b1fdef059a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -259,6 +259,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安装并控制 Windows 状态托盘图标。它会在 Windows 登录时启动,并提供一键代理控制。`start` 和 `stop` 只控制图标本身;要控制代理,请使用其菜单。`--no-start` 适用于 `install`,会安装托盘但不会立即启动。 +已弃用:OpenCodex 桌面应用在 Windows、macOS 和 Linux 上提供托盘;没有桌面应用的安装仍可使用 `ocx tray`。 ## 仪表盘 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index d9c104d3c9..4317ef37b3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -244,6 +244,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安裝並控制 Windows 狀態列圖示。它在 Windows 登入時啟動並提供一鍵代理控制。`start` 與 `stop` 僅控制圖示;請用其選單控制代理。`--no-start` 適用於 `install`,並在不立即啟動它的情況下安裝 tray。 +已淘汰:OpenCodex 桌面應用程式在 Windows、macOS 與 Linux 提供系統匣;沒有桌面應用程式的安裝仍可使用 `ocx tray`。 ## 儀表板 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index b7ec8f9db0..c53975f682 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -24,6 +24,7 @@ import { readModelsTab, type ModelsTab } from "./pages/models-tab"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; +import { isDesktopShell, isExternalLink } from "./lib/desktop-shell"; type Theme = "light" | "dark" | "system"; @@ -172,6 +173,23 @@ export default function App() { }; }, []); + useEffect(() => { + if (!isDesktopShell()) return; + const interceptExternalLinks = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) return; + const href = anchor.href; + if (!isExternalLink(href)) return; + event.preventDefault(); + // Rust denies external HTTP(S) navigation and opens it in the system browser. + window.location.assign(href); + }; + document.addEventListener("click", interceptExternalLinks, true); + return () => document.removeEventListener("click", interceptExternalLinks, true); + }, []); + useEffect(() => { const el = document.documentElement; if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0ed0d69611..c09ccbbb5e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -983,7 +983,7 @@ export const de: Record = { "usage.section.coverage": "Abdeckungs-Aufschlüsselung", "usage.section.companion": "Menüleiste & Widget", "usage.companion.title": "Menüleiste & Widget", - "usage.companion.description": "Diese Einstellungen steuern die OpenCodex-Menüleisten-App und ihr Widget.", + "usage.companion.description": "Diese Einstellungen steuern die OpenCodex Desktop-/Menüleisten-App und ihr Widget.", "usage.companion.installGuide": "Installationsanleitung", "usage.companion.loading": "Zeitachse wird geladen…", "usage.companion.timelineUnavailable": "Zeitachse nicht verfügbar", @@ -994,13 +994,26 @@ export const de: Record = { "usage.companion.corrupt": "Die Datei mit den Begleiteinstellungen ist beschädigt. Die Steuerelemente zeigen Standardwerte; das Speichern ist pausiert, bis Sie die Datei ersetzen.", "usage.companion.corruptReset": "Durch Standardwerte ersetzen", "usage.companion.connected": "Menüleisten-App verbunden · {age}", - "usage.companion.installTitle": "Menüleisten-App installieren", - "usage.companion.installStep1": "Laden Sie OpenCodex--macos-universal.zip aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", - "usage.companion.installStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (die App ist nur ad-hoc signiert, daher fragt Gatekeeper einmal).", - "usage.companion.installStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.connectedDesktop": "Desktop-App verbunden · {age}", + "usage.companion.installTitle": "Desktop-App installieren", + "usage.companion.installOs": "Betriebssystem", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "Du bist in der OpenCodex-Desktop-App {version}", + "usage.companion.openInBrowser": "Im Browser öffnen", + "usage.companion.installMacStep1": "Laden Sie OpenCodex--macos.dmg aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", + "usage.companion.installMacStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (Gatekeeper fragt einmal, bis die App notariell signiert ist).", + "usage.companion.installMacStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.installWinStep1": "Laden Sie OpenCodex--windows-x64.msi aus der neuesten Veröffentlichung herunter und führen Sie es aus.", + "usage.companion.installWinStep2": "Wenn SmartScreen warnt, wählen Sie Weitere Informationen → Trotzdem ausführen (der Installer ist noch nicht signiert).", + "usage.companion.installWinStep3": "OpenCodex erscheint in der Taskleiste und verbindet sich mit diesem Proxy oder startet den gebündelten Proxy.", + "usage.companion.installLinuxStep1": "Laden Sie OpenCodex--linux-x86_64.AppImage (oder die .deb-Datei) aus der neuesten Veröffentlichung herunter.", + "usage.companion.installLinuxStep2": "Führen Sie chmod +x für das AppImage aus und starten Sie es; ein Tray-Symbol benötigt einen AppIndicator-fähigen Desktop.", + "usage.companion.installLinuxStep3": "Die App verbindet sich mit diesem Proxy oder startet den gebündelten Proxy.", "usage.companion.notConnected": "Noch keine Menüleisten-App hat sich mit diesem Proxy verbunden.", "usage.companion.lastSeen": "Zuletzt gesehen {age}", - "usage.companion.installAnother": "Auf einem anderen Mac installieren", + "usage.companion.installAnother": "Auf einem anderen Gerät installieren", "usage.companion.saved": "Gespeichert · {time}", "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", "usage.companion.reset": "Auf Standardwerte zurücksetzen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 29202551e7..2c5b9f92a0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1036,7 +1036,7 @@ export const en = { "usage.section.coverage": "Coverage breakdown", "usage.section.companion": "Menu bar & widget", "usage.companion.title": "Menu bar & widget", - "usage.companion.description": "Settings here drive the OpenCodex menu bar app and its widget.", + "usage.companion.description": "Settings here drive the OpenCodex desktop/menu bar app and its widget.", "usage.companion.installGuide": "Install guide", "usage.companion.loading": "Loading timeline…", "usage.companion.timelineUnavailable": "Timeline unavailable", @@ -1047,13 +1047,26 @@ export const en = { "usage.companion.corrupt": "The companion settings file is corrupt. Controls show defaults; saving is paused until you replace the file.", "usage.companion.corruptReset": "Replace with defaults", "usage.companion.connected": "Menu bar app connected · {age}", - "usage.companion.installTitle": "Install the menu bar app", - "usage.companion.installStep1": "Download OpenCodex--macos-universal.zip from the latest release and drag OpenCodex.app to Applications.", - "usage.companion.installStep2": "First launch: right-click OpenCodex.app → Open (the app is ad-hoc signed, so Gatekeeper asks once).", - "usage.companion.installStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.connectedDesktop": "Desktop app connected · {age}", + "usage.companion.installTitle": "Install the desktop app", + "usage.companion.installOs": "Operating system", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "You’re in the OpenCodex desktop app {version}", + "usage.companion.openInBrowser": "Open in browser", + "usage.companion.installMacStep1": "Download OpenCodex--macos.dmg from the latest release and drag OpenCodex.app to Applications.", + "usage.companion.installMacStep2": "First launch: right-click OpenCodex.app → Open (Gatekeeper asks once until the app is notarized).", + "usage.companion.installMacStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.installWinStep1": "Download OpenCodex--windows-x64.msi from the latest release and run it.", + "usage.companion.installWinStep2": "If SmartScreen warns, choose More info → Run anyway (the installer is not yet code-signed).", + "usage.companion.installWinStep3": "OpenCodex appears in the system tray and attaches to this proxy, or starts its bundled one.", + "usage.companion.installLinuxStep1": "Download OpenCodex--linux-x86_64.AppImage (or the .deb) from the latest release.", + "usage.companion.installLinuxStep2": "chmod +x the AppImage and run it; a tray icon requires an AppIndicator-capable desktop.", + "usage.companion.installLinuxStep3": "The app attaches to this proxy, or starts its bundled one.", "usage.companion.notConnected": "No menu bar app has connected to this proxy yet.", "usage.companion.lastSeen": "Last seen {age}", - "usage.companion.installAnother": "Install on another Mac", + "usage.companion.installAnother": "Install on another device", "usage.companion.saved": "Saved · {time}", "usage.companion.saveFailed": "Couldn’t save: {error}", "usage.companion.reset": "Reset to defaults", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 715be09c70..348914135d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1013,7 +1013,7 @@ export const fr: Record = { "usage.section.coverage": "Répartition de la couverture", "usage.section.companion": "Barre des menus et widget", "usage.companion.title": "Barre des menus et widget", - "usage.companion.description": "Ces réglages contrôlent l’app OpenCodex de la barre des menus et son widget.", + "usage.companion.description": "Ces réglages pilotent l’application OpenCodex de bureau/barre des menus et son widget.", "usage.companion.installGuide": "Guide d’installation", "usage.companion.loading": "Chargement de la chronologie…", "usage.companion.timelineUnavailable": "Chronologie indisponible", @@ -1064,13 +1064,26 @@ export const fr: Record = { "usage.companion.placeholders": "Paramètres substituables :", "usage.companion.modelsOnChart": "Modèles du graphique", "usage.companion.connected": "App de barre des menus connectée · {age}", - "usage.companion.installTitle": "Installer l’app de barre des menus", - "usage.companion.installStep1": "Téléchargez OpenCodex--macos-universal.zip depuis la dernière version et faites glisser OpenCodex.app dans Applications.", - "usage.companion.installStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (l’app est signée ad hoc, Gatekeeper ne demande donc qu’une confirmation).", - "usage.companion.installStep3": "L’app trouve ce proxy automatiquement ; le widget apparaît dans la galerie de widgets après le lancement de l’app.", + "usage.companion.connectedDesktop": "Application de bureau connectée · {age}", + "usage.companion.installTitle": "Installer l’application de bureau", + "usage.companion.installOs": "Système d’exploitation", + "usage.companion.osMac": "Mac", + "usage.companion.osWindows": "Système Windows", + "usage.companion.osLinux": "Système Linux", + "usage.companion.runningInDesktop": "Vous êtes dans l’application de bureau OpenCodex {version}", + "usage.companion.openInBrowser": "Ouvrir dans le navigateur", + "usage.companion.installMacStep1": "Téléchargez OpenCodex--macos.dmg depuis la dernière version et faites glisser OpenCodex.app dans Applications.", + "usage.companion.installMacStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (Gatekeeper demande une confirmation jusqu’à la notarisation).", + "usage.companion.installMacStep3": "L’application trouve ce proxy automatiquement ; le widget apparaît dans la galerie après son lancement.", + "usage.companion.installWinStep1": "Téléchargez OpenCodex--windows-x64.msi depuis la dernière version et exécutez-le.", + "usage.companion.installWinStep2": "Si SmartScreen vous avertit, choisissez Informations supplémentaires → Exécuter quand même (l’installateur n’est pas encore signé).", + "usage.companion.installWinStep3": "OpenCodex apparaît dans la zone de notification et se connecte à ce proxy, ou démarre celui fourni.", + "usage.companion.installLinuxStep1": "Téléchargez OpenCodex--linux-x86_64.AppImage (ou le .deb) depuis la dernière version.", + "usage.companion.installLinuxStep2": "Exécutez chmod +x sur l’AppImage puis lancez-le ; une icône de zone de notification nécessite un bureau compatible AppIndicator.", + "usage.companion.installLinuxStep3": "L’application se connecte à ce proxy ou démarre celui fourni.", "usage.companion.notConnected": "Aucune app de barre des menus ne s’est encore connectée à ce proxy.", "usage.companion.lastSeen": "Dernière connexion {age}", - "usage.companion.installAnother": "Installer sur un autre Mac", + "usage.companion.installAnother": "Installer sur un autre appareil", "usage.companion.modelsCount": "{selected} sur {total} dans le graphique", "usage.companion.modelsShowAll": "Tout afficher", "usage.companion.hideProviders": "Masquer les fournisseurs", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d4eef7d803..d5920d7015 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -948,7 +948,7 @@ export const ja: Record = { "usage.section.coverage": "カバレッジ内訳", "usage.section.companion": "メニューバーとウィジェット", "usage.companion.title": "メニューバーとウィジェット", - "usage.companion.description": "ここでの設定は OpenCodex のメニューバーアプリとウィジェットを制御します。", + "usage.companion.description": "ここでの設定は OpenCodex デスクトップ/メニューバーアプリとウィジェットを制御します。", "usage.companion.installGuide": "インストールガイド", "usage.companion.loading": "タイムラインを読み込み中…", "usage.companion.timelineUnavailable": "タイムラインを利用できません", @@ -999,13 +999,26 @@ export const ja: Record = { "usage.companion.placeholders": "プレースホルダー:", "usage.companion.modelsOnChart": "グラフのモデル", "usage.companion.connected": "メニューバーアプリ接続済み · {age}", - "usage.companion.installTitle": "メニューバーアプリをインストール", - "usage.companion.installStep1": "最新リリースから OpenCodex--macos-universal.zip をダウンロードし、OpenCodex.app をアプリケーションに移動します。", - "usage.companion.installStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(アドホック署名のため、Gatekeeper の確認は一度だけです)。", - "usage.companion.installStep3": "アプリはこのプロキシを自動検出します。アプリを一度起動するとウィジェットギャラリーに表示されます。", + "usage.companion.connectedDesktop": "デスクトップアプリ接続済み · {age}", + "usage.companion.installTitle": "デスクトップアプリをインストール", + "usage.companion.installOs": "オペレーティングシステム", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "OpenCodex デスクトップアプリ {version} を使用中です", + "usage.companion.openInBrowser": "ブラウザで開く", + "usage.companion.installMacStep1": "最新リリースから OpenCodex--macos.dmg をダウンロードし、OpenCodex.app をアプリケーションへ移動します。", + "usage.companion.installMacStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(公証されるまで Gatekeeper が一度確認します)。", + "usage.companion.installMacStep3": "アプリはこのプロキシを自動検出します。アプリを起動するとウィジェットギャラリーに表示されます。", + "usage.companion.installWinStep1": "最新リリースから OpenCodex--windows-x64.msi をダウンロードして実行します。", + "usage.companion.installWinStep2": "SmartScreen が警告したら「詳細情報」→「実行」を選択します(インストーラーはまだコード署名されていません)。", + "usage.companion.installWinStep3": "OpenCodex はシステムトレイに表示され、このプロキシに接続するか、同梱のプロキシを起動します。", + "usage.companion.installLinuxStep1": "最新リリースから OpenCodex--linux-x86_64.AppImage(または .deb)をダウンロードします。", + "usage.companion.installLinuxStep2": "AppImage に chmod +x を実行して起動します。トレイアイコンには AppIndicator 対応デスクトップが必要です。", + "usage.companion.installLinuxStep3": "アプリはこのプロキシに接続するか、同梱のプロキシを起動します。", "usage.companion.notConnected": "このプロキシに接続したメニューバーアプリはまだありません。", "usage.companion.lastSeen": "最終接続 {age}", - "usage.companion.installAnother": "別の Mac にインストール", + "usage.companion.installAnother": "別のデバイスにインストール", "usage.companion.modelsCount": "{selected} / {total} がグラフに表示中", "usage.companion.modelsShowAll": "すべて表示", "usage.companion.hideProviders": "プロバイダーを非表示", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b2e8b94c50..6913c90317 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1017,7 +1017,7 @@ export const ko: Record = { "usage.section.coverage": "커버리지 상세", "usage.section.companion": "메뉴 막대 및 위젯", "usage.companion.title": "메뉴 막대 및 위젯", - "usage.companion.description": "여기 설정은 OpenCodex 메뉴 막대 앱과 위젯을 제어합니다.", + "usage.companion.description": "이 설정은 OpenCodex 데스크톱/메뉴 막대 앱과 위젯을 제어합니다.", "usage.companion.installGuide": "설치 안내", "usage.companion.loading": "타임라인 로드 중…", "usage.companion.timelineUnavailable": "타임라인을 사용할 수 없습니다", @@ -1068,13 +1068,26 @@ export const ko: Record = { "usage.companion.placeholders": "자리표시자:", "usage.companion.modelsOnChart": "차트의 모델", "usage.companion.connected": "메뉴 막대 앱 연결됨 · {age}", - "usage.companion.installTitle": "메뉴 막대 앱 설치", - "usage.companion.installStep1": "최신 릴리스에서 OpenCodex--macos-universal.zip을 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", - "usage.companion.installStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(앱이 애드혹 서명되어 Gatekeeper가 한 번 확인합니다).", - "usage.companion.installStep3": "앱이 이 프록시를 자동으로 찾습니다. 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.connectedDesktop": "데스크톱 앱 연결됨 · {age}", + "usage.companion.installTitle": "데스크톱 앱 설치", + "usage.companion.installOs": "운영 체제", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "OpenCodex 데스크톱 앱 {version}에서 실행 중입니다", + "usage.companion.openInBrowser": "브라우저에서 열기", + "usage.companion.installMacStep1": "최신 릴리스에서 OpenCodex--macos.dmg를 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", + "usage.companion.installMacStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(공증될 때까지 Gatekeeper가 한 번 확인합니다).", + "usage.companion.installMacStep3": "앱이 이 프록시를 자동으로 찾으며, 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.installWinStep1": "최신 릴리스에서 OpenCodex--windows-x64.msi를 다운로드하고 실행하세요.", + "usage.companion.installWinStep2": "SmartScreen 경고가 표시되면 추가 정보 → 실행을 선택하세요(설치 프로그램은 아직 코드 서명되지 않았습니다).", + "usage.companion.installWinStep3": "OpenCodex가 시스템 트레이에 나타나 이 프록시에 연결하거나 번들 프록시를 시작합니다.", + "usage.companion.installLinuxStep1": "최신 릴리스에서 OpenCodex--linux-x86_64.AppImage(또는 .deb)를 다운로드하세요.", + "usage.companion.installLinuxStep2": "AppImage에 chmod +x를 실행하고 시작하세요. 트레이 아이콘에는 AppIndicator를 지원하는 데스크톱이 필요합니다.", + "usage.companion.installLinuxStep3": "앱이 이 프록시에 연결하거나 번들 프록시를 시작합니다.", "usage.companion.notConnected": "아직 이 프록시에 연결한 메뉴 막대 앱이 없습니다.", "usage.companion.lastSeen": "마지막 연결 {age}", - "usage.companion.installAnother": "다른 Mac에 설치", + "usage.companion.installAnother": "다른 기기에 설치", "usage.companion.modelsCount": "{selected} / {total}개가 차트에 표시됨", "usage.companion.modelsShowAll": "모두 표시", "usage.companion.hideProviders": "공급자 숨기기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index af9681a2d8..c0002fc2ac 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1004,7 +1004,7 @@ export const ru: Record = { "usage.section.coverage": "Детализация покрытия", "usage.section.companion": "Строка меню и виджет", "usage.companion.title": "Строка меню и виджет", - "usage.companion.description": "Эти настройки управляют приложением OpenCodex в строке меню и его виджетом.", + "usage.companion.description": "Эти настройки управляют настольным приложением/приложением в строке меню OpenCodex и его виджетом.", "usage.companion.installGuide": "Руководство по установке", "usage.companion.loading": "Загрузка временной шкалы…", "usage.companion.timelineUnavailable": "Временная шкала недоступна", @@ -1055,13 +1055,26 @@ export const ru: Record = { "usage.companion.placeholders": "Заполнители:", "usage.companion.modelsOnChart": "Модели на графике", "usage.companion.connected": "Приложение в строке меню подключено · {age}", - "usage.companion.installTitle": "Установить приложение в строке меню", - "usage.companion.installStep1": "Скачайте OpenCodex--macos-universal.zip из последнего релиза и перетащите OpenCodex.app в Программы.", - "usage.companion.installStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (приложение подписано ad-hoc, поэтому Gatekeeper спросит один раз).", - "usage.companion.installStep3": "Приложение само найдёт этот прокси; виджет появится в галерее виджетов после запуска приложения.", + "usage.companion.connectedDesktop": "Настольное приложение подключено · {age}", + "usage.companion.installTitle": "Установить настольное приложение", + "usage.companion.installOs": "Операционная система", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "Вы находитесь в настольном приложении OpenCodex {version}", + "usage.companion.openInBrowser": "Открыть в браузере", + "usage.companion.installMacStep1": "Скачайте OpenCodex--macos.dmg из последнего релиза и перетащите OpenCodex.app в Программы.", + "usage.companion.installMacStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (до нотариальной заверки Gatekeeper спросит один раз).", + "usage.companion.installMacStep3": "Приложение само найдёт этот прокси; после запуска приложение появится в галерее виджетов.", + "usage.companion.installWinStep1": "Скачайте OpenCodex--windows-x64.msi из последнего релиза и запустите его.", + "usage.companion.installWinStep2": "Если SmartScreen предупредит, выберите Подробнее → Всё равно запустить (установщик ещё не подписан).", + "usage.companion.installWinStep3": "OpenCodex появится в системном трее и подключится к этому прокси или запустит встроенный.", + "usage.companion.installLinuxStep1": "Скачайте OpenCodex--linux-x86_64.AppImage (или .deb) из последнего релиза.", + "usage.companion.installLinuxStep2": "Выполните chmod +x для AppImage и запустите его; для значка в трее нужен рабочий стол с поддержкой AppIndicator.", + "usage.companion.installLinuxStep3": "Приложение подключится к этому прокси или запустит встроенный.", "usage.companion.notConnected": "К этому прокси ещё не подключалось приложение из строки меню.", "usage.companion.lastSeen": "Последнее подключение: {age}", - "usage.companion.installAnother": "Установить на другом Mac", + "usage.companion.installAnother": "Установить на другом устройстве", "usage.companion.modelsCount": "{selected} из {total} на графике", "usage.companion.modelsShowAll": "Показать все", "usage.companion.hideProviders": "Скрыть провайдеров", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index db62526abb..528387f39d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1023,7 +1023,7 @@ export const tr: Record = { "usage.section.coverage": "Kapsam dağılımı", "usage.section.companion": "Menü çubuğu ve widget", "usage.companion.title": "Menü çubuğu ve widget", - "usage.companion.description": "Buradaki ayarlar OpenCodex menü çubuğu uygulamasını ve widget'ını yönetir.", + "usage.companion.description": "Buradaki ayarlar OpenCodex masaüstü/menü çubuğu uygulamasını ve widget’ını yönetir.", "usage.companion.installGuide": "Kurulum rehberi", "usage.companion.loading": "Zaman çizelgesi yükleniyor…", "usage.companion.timelineUnavailable": "Zaman çizelgesi kullanılamıyor", @@ -1074,13 +1074,26 @@ export const tr: Record = { "usage.companion.placeholders": "Yer tutucular:", "usage.companion.modelsOnChart": "Grafikteki modeller", "usage.companion.connected": "Menü çubuğu uygulaması bağlı · {age}", - "usage.companion.installTitle": "Menü çubuğu uygulamasını yükle", - "usage.companion.installStep1": "En son sürümden OpenCodex--macos-universal.zip dosyasını indirin ve OpenCodex.app'i Uygulamalar'a sürükleyin.", - "usage.companion.installStep2": "İlk çalıştırma: OpenCodex.app'e sağ tıklayıp Aç'ı seçin (uygulama ad-hoc imzalıdır; Gatekeeper bir kez sorar).", - "usage.companion.installStep3": "Uygulama bu proxy'yi kendisi bulur; uygulama çalıştıktan sonra widget, widget galerisinde görünür.", + "usage.companion.connectedDesktop": "Masaüstü uygulaması bağlı · {age}", + "usage.companion.installTitle": "Masaüstü uygulamasını yükle", + "usage.companion.installOs": "İşletim sistemi", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "OpenCodex masaüstü uygulaması {version} içindesiniz", + "usage.companion.openInBrowser": "Tarayıcıda aç", + "usage.companion.installMacStep1": "En son sürümden OpenCodex--macos.dmg dosyasını indirin ve OpenCodex.app’i Uygulamalar’a sürükleyin.", + "usage.companion.installMacStep2": "İlk çalıştırma: OpenCodex.app’e sağ tıklayıp Aç’ı seçin (uygulama not edilene kadar Gatekeeper bir kez sorar).", + "usage.companion.installMacStep3": "Uygulama bu proxy’yi kendisi bulur; uygulama çalıştıktan sonra widget galeride görünür.", + "usage.companion.installWinStep1": "En son sürümden OpenCodex--windows-x64.msi dosyasını indirin ve çalıştırın.", + "usage.companion.installWinStep2": "SmartScreen uyarırsa Daha fazla bilgi → Yine de çalıştır seçeneğini seçin (yükleyici henüz kod imzalı değil).", + "usage.companion.installWinStep3": "OpenCodex sistem tepsisinde görünür ve bu proxy’ye bağlanır veya paketlenmiş olanı başlatır.", + "usage.companion.installLinuxStep1": "En son sürümden OpenCodex--linux-x86_64.AppImage (veya .deb) dosyasını indirin.", + "usage.companion.installLinuxStep2": "AppImage için chmod +x çalıştırıp başlatın; tepsi simgesi AppIndicator destekli bir masaüstü gerektirir.", + "usage.companion.installLinuxStep3": "Uygulama bu proxy’ye bağlanır veya paketlenmiş olanı başlatır.", "usage.companion.notConnected": "Bu proxy'ye henüz hiçbir menü çubuğu uygulaması bağlanmadı.", "usage.companion.lastSeen": "Son görülme {age}", - "usage.companion.installAnother": "Başka bir Mac'e yükle", + "usage.companion.installAnother": "Başka bir cihaza yükle", "usage.companion.modelsCount": "Grafikte {selected}/{total}", "usage.companion.modelsShowAll": "Tümünü göster", "usage.companion.hideProviders": "Sağlayıcıları gizle", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 749fde103c..337335fdbd 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1006,7 +1006,7 @@ export const vi: Record = { "usage.section.coverage": "Chi tiết độ phủ (Coverage breakdown)", "usage.section.companion": "Thanh menu và widget", "usage.companion.title": "Thanh menu và widget", - "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng thanh menu OpenCodex và widget.", + "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng máy tính/thanh menu OpenCodex và widget của ứng dụng.", "usage.companion.installGuide": "Hướng dẫn cài đặt", "usage.companion.loading": "Đang tải dòng thời gian…", "usage.companion.timelineUnavailable": "Không có dòng thời gian", @@ -1057,13 +1057,26 @@ export const vi: Record = { "usage.companion.placeholders": "Trình giữ chỗ:", "usage.companion.modelsOnChart": "Mô hình trên biểu đồ", "usage.companion.connected": "Ứng dụng trên thanh menu đã kết nối · {age}", - "usage.companion.installTitle": "Cài đặt ứng dụng trên thanh menu", - "usage.companion.installStep1": "Tải OpenCodex--macos-universal.zip từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", - "usage.companion.installStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (ứng dụng được ký ad-hoc nên Gatekeeper chỉ hỏi một lần).", - "usage.companion.installStep3": "Ứng dụng tự tìm proxy này; widget sẽ xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.connectedDesktop": "Ứng dụng máy tính đã kết nối · {age}", + "usage.companion.installTitle": "Cài đặt ứng dụng máy tính", + "usage.companion.installOs": "Hệ điều hành", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "Bạn đang ở trong ứng dụng máy tính OpenCodex {version}", + "usage.companion.openInBrowser": "Mở trong trình duyệt", + "usage.companion.installMacStep1": "Tải OpenCodex--macos.dmg từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", + "usage.companion.installMacStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (Gatekeeper sẽ hỏi một lần cho đến khi ứng dụng được công chứng).", + "usage.companion.installMacStep3": "Ứng dụng tự tìm proxy này; widget xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.installWinStep1": "Tải OpenCodex--windows-x64.msi từ bản phát hành mới nhất và chạy trình cài đặt.", + "usage.companion.installWinStep2": "Nếu SmartScreen cảnh báo, chọn Thông tin thêm → Vẫn chạy (trình cài đặt chưa được ký mã).", + "usage.companion.installWinStep3": "OpenCodex xuất hiện trong khay hệ thống và kết nối proxy này, hoặc khởi chạy proxy đi kèm.", + "usage.companion.installLinuxStep1": "Tải OpenCodex--linux-x86_64.AppImage (hoặc .deb) từ bản phát hành mới nhất.", + "usage.companion.installLinuxStep2": "Chạy chmod +x cho AppImage rồi mở; biểu tượng khay cần môi trường desktop hỗ trợ AppIndicator.", + "usage.companion.installLinuxStep3": "Ứng dụng kết nối proxy này hoặc khởi chạy proxy đi kèm.", "usage.companion.notConnected": "Chưa có ứng dụng trên thanh menu nào kết nối với proxy này.", "usage.companion.lastSeen": "Lần kết nối gần nhất {age}", - "usage.companion.installAnother": "Cài đặt trên máy Mac khác", + "usage.companion.installAnother": "Cài đặt trên thiết bị khác", "usage.companion.modelsCount": "{selected}/{total} trên biểu đồ", "usage.companion.modelsShowAll": "Hiện tất cả", "usage.companion.hideProviders": "Ẩn nhà cung cấp", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6201840100..28a7650bc1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -814,7 +814,7 @@ export const zhTW: Record = { "usage.section.coverage": "覆蓋率明細", "usage.section.companion": "選單列與小工具", "usage.companion.title": "選單列與小工具", - "usage.companion.description": "這裡的設定會控制 OpenCodex 選單列 App 與其小工具。", + "usage.companion.description": "這裡的設定會控制 OpenCodex 桌面/選單列 App 與其小工具。", "usage.companion.installGuide": "安裝指南", "usage.companion.loading": "正在載入時間軸…", "usage.companion.timelineUnavailable": "時間軸無法使用", @@ -865,13 +865,26 @@ export const zhTW: Record = { "usage.companion.placeholders": "預留位置:", "usage.companion.modelsOnChart": "圖表中的模型", "usage.companion.connected": "選單列 App 已連線 · {age}", - "usage.companion.installTitle": "安裝選單列 App", - "usage.companion.installStep1": "從最新版本下載 OpenCodex--macos-universal.zip,並將 OpenCodex.app 拖到應用程式。", - "usage.companion.installStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「打開」(App 使用臨時簽章,因此 Gatekeeper 只會詢問一次)。", - "usage.companion.installStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.connectedDesktop": "桌面 App 已連線 · {age}", + "usage.companion.installTitle": "安裝桌面 App", + "usage.companion.installOs": "作業系統", + "usage.companion.osMac": "macOS 系統", + "usage.companion.osWindows": "Windows 系統", + "usage.companion.osLinux": "Linux 系統", + "usage.companion.runningInDesktop": "你正在使用 OpenCodex 桌面 App {version}", + "usage.companion.openInBrowser": "在瀏覽器中開啟", + "usage.companion.installMacStep1": "從最新版本下載 OpenCodex--macos.dmg,並將 OpenCodex.app 拖到應用程式。", + "usage.companion.installMacStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「開啟」(App 完成公證前,Gatekeeper 只會詢問一次)。", + "usage.companion.installMacStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.installWinStep1": "從最新版本下載 OpenCodex--windows-x64.msi 並執行。", + "usage.companion.installWinStep2": "如果 SmartScreen 發出警告,請選擇「更多資訊」→「仍要執行」(安裝程式尚未完成程式碼簽署)。", + "usage.companion.installWinStep3": "OpenCodex 會出現在系統匣並連線此 Proxy,或啟動內建 Proxy。", + "usage.companion.installLinuxStep1": "從最新版本下載 OpenCodex--linux-x86_64.AppImage(或 .deb)。", + "usage.companion.installLinuxStep2": "對 AppImage 執行 chmod +x 後啟動;系統匣圖示需要支援 AppIndicator 的桌面環境。", + "usage.companion.installLinuxStep3": "App 會連線此 Proxy,或啟動內建 Proxy。", "usage.companion.notConnected": "尚未有選單列 App 連線到此 Proxy。", "usage.companion.lastSeen": "上次連線 {age}", - "usage.companion.installAnother": "在另一台 Mac 上安裝", + "usage.companion.installAnother": "在另一台裝置上安裝", "usage.companion.modelsCount": "圖表顯示 {selected}/{total}", "usage.companion.modelsShowAll": "顯示全部", "usage.companion.hideProviders": "隱藏提供者", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 17a93d78de..7caef60437 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -998,7 +998,7 @@ export const zh: Record = { "usage.section.coverage": "覆盖率明细", "usage.section.companion": "菜单栏与小组件", "usage.companion.title": "菜单栏与小组件", - "usage.companion.description": "此处设置会控制 OpenCodex 菜单栏应用及其小组件。", + "usage.companion.description": "这里的设置会控制 OpenCodex 桌面/菜单栏应用及其小组件。", "usage.companion.installGuide": "安装指南", "usage.companion.loading": "正在加载时间线…", "usage.companion.timelineUnavailable": "时间线不可用", @@ -1049,13 +1049,26 @@ export const zh: Record = { "usage.companion.placeholders": "占位符:", "usage.companion.modelsOnChart": "图表中的模型", "usage.companion.connected": "菜单栏应用已连接 · {age}", - "usage.companion.installTitle": "安装菜单栏应用", - "usage.companion.installStep1": "从最新版本下载 OpenCodex--macos-universal.zip,并将 OpenCodex.app 拖到应用程序。", - "usage.companion.installStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用使用临时签名,因此 Gatekeeper 只会询问一次)。", - "usage.companion.installStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.connectedDesktop": "桌面应用已连接 · {age}", + "usage.companion.installTitle": "安装桌面应用", + "usage.companion.installOs": "操作系统", + "usage.companion.osMac": "macOS", + "usage.companion.osWindows": "Windows", + "usage.companion.osLinux": "Linux", + "usage.companion.runningInDesktop": "你正在使用 OpenCodex 桌面应用 {version}", + "usage.companion.openInBrowser": "在浏览器中打开", + "usage.companion.installMacStep1": "从最新版本下载 OpenCodex--macos.dmg,并将 OpenCodex.app 拖到应用程序。", + "usage.companion.installMacStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用完成公证前,Gatekeeper 只会询问一次)。", + "usage.companion.installMacStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.installWinStep1": "从最新版本下载 OpenCodex--windows-x64.msi 并运行。", + "usage.companion.installWinStep2": "如果 SmartScreen 发出警告,请选择“更多信息”→“仍要运行”(安装程序尚未经过代码签名)。", + "usage.companion.installWinStep3": "OpenCodex 会出现在系统托盘中并连接此代理,或启动内置代理。", + "usage.companion.installLinuxStep1": "从最新版本下载 OpenCodex--linux-x86_64.AppImage(或 .deb)。", + "usage.companion.installLinuxStep2": "对 AppImage 执行 chmod +x 后运行;托盘图标需要支持 AppIndicator 的桌面环境。", + "usage.companion.installLinuxStep3": "应用会连接此代理,或启动内置代理。", "usage.companion.notConnected": "尚未有菜单栏应用连接到此代理。", "usage.companion.lastSeen": "上次连接 {age}", - "usage.companion.installAnother": "在另一台 Mac 上安装", + "usage.companion.installAnother": "在另一台设备上安装", "usage.companion.modelsCount": "图表显示 {selected}/{total}", "usage.companion.modelsShowAll": "显示全部", "usage.companion.hideProviders": "隐藏提供商", diff --git a/gui/src/lib/desktop-shell.ts b/gui/src/lib/desktop-shell.ts new file mode 100644 index 0000000000..f6301ee119 --- /dev/null +++ b/gui/src/lib/desktop-shell.ts @@ -0,0 +1,32 @@ +export type HostOs = "macos" | "windows" | "linux" | "unknown"; + +function currentUserAgent(): string { + return typeof navigator === "undefined" ? "" : navigator.userAgent; +} + +export function desktopShellVersion(ua = currentUserAgent()): string | null { + return ua.match(/OpenCodexDesktop\/(\S+)/)?.[1] ?? null; +} + +export function isDesktopShell(ua = currentUserAgent()): boolean { + return desktopShellVersion(ua) !== null; +} + +export function hostOs(ua = currentUserAgent()): HostOs { + if (/Windows/i.test(ua)) return "windows"; + if (/Mac OS X|Macintosh/i.test(ua)) return "macos"; + if (/Linux|X11/i.test(ua) && !/Android/i.test(ua)) return "linux"; + return "unknown"; +} + +export function isExternalLink( + href: string, + origin = typeof location === "undefined" ? "" : location.origin, +): boolean { + if (!/^https?:\/\//i.test(href)) return false; + try { + return new URL(href).origin !== origin; + } catch { + return false; + } +} diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index f3af2082de..44dd4f2dff 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { useI18n } from "../i18n/shared"; +import { useI18n, type TFn, type TKey } from "../i18n/shared"; import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage"; import { Switch } from "../ui"; import { UsageCompanionChart } from "./usage-companion-chart"; +import { desktopShellVersion, hostOs, isDesktopShell, type HostOs } from "../lib/desktop-shell"; import { bucketMinutesForWindow, buildCompanionSettingsPatch, @@ -24,6 +25,50 @@ const CHART_STYLES = ["line", "stackedBar"] as const; const TOKEN_METRICS = ["total", "input", "output", "cached"] as const; const AGGREGATIONS = ["sum", "average", "max"] as const; const GROUPINGS = ["model", "modelAccount"] as const; +const MENU_METRIC_KEYS: Record<(typeof MENU_METRICS)[number], TKey> = { + requests: "usage.companion.menuRequests", + tokens: "usage.companion.menuTokens", + cost: "usage.companion.menuCost", + quota: "usage.companion.menuQuota", + none: "usage.companion.menuNone", +}; +const WINDOW_KEYS: Record<(typeof WINDOWS)[number], TKey> = { + 6: "usage.companion.window6", + 24: "usage.companion.window24", + 72: "usage.companion.window72", + 168: "usage.companion.window168", +}; +const TOKEN_METRIC_KEYS: Record<(typeof TOKEN_METRICS)[number], TKey> = { + total: "usage.companion.metricTotal", + input: "usage.companion.metricInput", + output: "usage.companion.metricOutput", + cached: "usage.companion.metricCached", +}; +const SECTION_OPTIONS = [ + ["showToday", "usage.companion.sectionToday"], + ["showChart", "usage.companion.sectionChart"], + ["showModels", "usage.companion.sectionModels"], + ["showCost", "usage.companion.sectionCost"], + ["showAccounts", "usage.companion.sectionAccounts"], +] as const; +const AGGREGATION_KEYS: Record<(typeof AGGREGATIONS)[number], TKey> = { + sum: "usage.companion.aggregationSum", + average: "usage.companion.aggregationAverage", + max: "usage.companion.aggregationMax", +}; +type InstallOs = Exclude; + +const INSTALL_STEP_KEYS: Record = { + macos: ["usage.companion.installMacStep1", "usage.companion.installMacStep2", "usage.companion.installMacStep3"], + windows: ["usage.companion.installWinStep1", "usage.companion.installWinStep2", "usage.companion.installWinStep3"], + linux: ["usage.companion.installLinuxStep1", "usage.companion.installLinuxStep2", "usage.companion.installLinuxStep3"], +}; + +const OS_LABEL_KEYS: Record = { + macos: "usage.companion.osMac", + windows: "usage.companion.osWindows", + linux: "usage.companion.osLinux", +}; function formatSaveTime(value: number, locale: string): string { return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(value); @@ -34,6 +79,26 @@ function errorMessage(value: unknown): string { return String(value); } +function OsSelector({ + value, + onChange, + t, +}: { + value: InstallOs; + onChange: (value: InstallOs) => void; + t: TFn; +}) { + return ( +
+ {(Object.keys(OS_LABEL_KEYS) as InstallOs[]).map(os => ( + + ))} +
+ ); +} + function Segment({ label, value, @@ -129,6 +194,10 @@ export default function UsageCompanionPanel({ const settingsRef = useRef(settings); const knownTotalsRef = useRef(new Map()); const [knownTotals, setKnownTotals] = useState>(new Map()); + const [installOs, setInstallOs] = useState(() => { + const detected = hostOs(); + return detected === "unknown" ? "macos" : detected; + }); useEffect(() => { saveStateRef.current = saveState; @@ -275,6 +344,20 @@ export default function UsageCompanionPanel({ } }, [apiBase, loadSettings]); + const openInBrowser = useCallback(async () => { + try { + const path = location.hash ? `/${location.hash}` : "/#/usage"; + const result = await fetch(`${apiBase}/api/companion/open-in-browser`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + } catch (error) { + setSaveError(errorMessage(error)); + } + }, [apiBase]); + if (settingsError) { return

{t("usage.companion.settingsUnavailable")}

; } @@ -307,29 +390,51 @@ export default function UsageCompanionPanel({ const lastSeenAt = response?.companion?.lastSeenAt ?? null; const connected = lastSeenAt !== null && fetchedAt !== null && fetchedAt - lastSeenAt <= 10 * 60 * 1000; const age = lastSeenAt === null || fetchedAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), fetchedAt); + const shell = isDesktopShell(); + const shellVersion = desktopShellVersion() ?? __APP_VERSION__; + const stepKeys = INSTALL_STEP_KEYS[installOs]; const steps = (
    -
  1. {t("usage.companion.installStep1")} {t("common.github")}
  2. -
  3. {t("usage.companion.installStep2")}
  4. -
  5. {t("usage.companion.installStep3")}
  6. +
  7. {t(stepKeys[0])} {t("common.github")}
  8. +
  9. {t(stepKeys[1])}
  10. +
  11. {t(stepKeys[2])}
); + const installCommand = installOs === "macos" + ? xattr -d com.apple.quarantine /Applications/OpenCodex.app + : installOs === "linux" + ? chmod +x OpenCodex-*.AppImage + : null; + const installGuidance = ( +
+ {t("usage.companion.installAnother")} + + {steps} + {installCommand} +
+ ); + if (shell) { + return ( +
+

{t("usage.companion.runningInDesktop", { version: shellVersion })}

+ + {installGuidance} +
+ ); + } return connected ? (
-
-
- {t("usage.companion.installAnother")} - {steps} - xattr -d com.apple.quarantine /Applications/OpenCodex.app -
+
+ {installGuidance}
) : (
{t("usage.companion.installTitle")} {lastSeenAt !== null &&

{t("usage.companion.lastSeen", { age })}

} {lastSeenAt === null &&

{t("usage.companion.notConnected")}

} + {steps} - xattr -d com.apple.quarantine /Applications/OpenCodex.app + {installCommand}
); })()} @@ -378,30 +483,24 @@ export default function UsageCompanionPanel({ }
- t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> - t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> + t(MENU_METRIC_KEYS[value])} onChange={value => updateSettings({ menuBarMetric: value })} /> + t(WINDOW_KEYS[value])} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> - t(`usage.companion.metric${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ tokenMetric: value })} /> + t(TOKEN_METRIC_KEYS[value])} onChange={value => updateSettings({ tokenMetric: value })} /> value === "model" ? t("usage.companion.groupModel") : t("usage.companion.groupAccount")} onChange={value => updateSettings({ chartGrouping: value })} />
{t("usage.companion.popoverSections")} - {([ - ["showToday", "today"], - ["showChart", "chart"], - ["showModels", "models"], - ["showCost", "cost"], - ["showAccounts", "accounts"], - ] as const).map(([key, label]) => ( + {SECTION_OPTIONS.map(([key, label]) => (
- {t(`usage.companion.section${label[0]!.toUpperCase()}${label.slice(1)}` as never)} - + {t(label)} +
))}
{t("usage.companion.advanced")}
- t(`usage.companion.aggregation${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ aggregation: value })} /> + t(AGGREGATION_KEYS[value])} onChange={value => updateSettings({ aggregation: value })} />