diff --git a/.gitignore b/.gitignore index 0de6494..f57df8c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /dist/ *.tmp output +frontend/dist/ +frontend/node_modules/ diff --git a/.trellis/tasks/04-18-desktop-shell-gui/task.json b/.trellis/tasks/04-18-desktop-shell-gui/task.json index 707dc5c..0f676a2 100644 --- a/.trellis/tasks/04-18-desktop-shell-gui/task.json +++ b/.trellis/tasks/04-18-desktop-shell-gui/task.json @@ -63,12 +63,35 @@ "internal/cli/serve.go", "internal/desktop/app.go", "internal/desktop/bindings.go", + "internal/desktop/runtime_fallback.go", + "internal/desktop/runtime_wails.go", + "internal/desktop/http.go", + "internal/desktop/http_test.go", "internal/desktop/tray.go", "internal/desktop/notify.go", "internal/desktop/autostart.go", - "cmd/ocswitch-desktop/main.go" + "internal/desktop/autostart_test.go", + "internal/desktop/wails.go", + "cmd/ocswitch-desktop/main_fallback.go", + "cmd/ocswitch-desktop/main_wails.go", + "main_wails.go", + "frontend/assets.go", + "frontend/package.json", + "frontend/package-lock.json", + "frontend/tsconfig.json", + "frontend/tsconfig.node.json", + "frontend/vite.config.ts", + "frontend/index.html", + "frontend/src/main.tsx", + "frontend/src/App.tsx", + "frontend/src/api.ts", + "frontend/src/types.ts", + "frontend/src/env.d.ts", + "frontend/src/styles.css", + "frontend/dist/index.html", + "frontend/dist/assets/" ], - "notes": "Implemented the first desktop-shell skeleton instead of a full Wails UI: added config-backed desktop preferences, a shared internal/app service layer, CLI reuse of shared workflows for doctor/opencode sync/provider list/serve, a minimal internal/desktop composition root, and a cmd/ocswitch-desktop bootstrap entrypoint. Verification passed with gofmt, go test ./..., and go build ./....", + "notes": "Implemented the next desktop-shell iteration with a real Wails integration path and a formal React + TypeScript frontend. The browser fallback shell now serves the same frontend assets as the Wails build, Linux XDG launch-at-login is implemented for real, and close-to-background behavior is wired through the desktop preference model. Verification passed for `npm run build`, `go test ./...`, and `go test -tags desktop_wails ./...`. `wails build -tags desktop_wails -debug` now reaches native compilation and is blocked only by missing Linux system packages (`pkg-config`, plus GTK/WebKit dev packages). Stable public tray and native notification APIs remain unresolved in the pinned Wails version, so those desktop-only pieces are intentionally limited to lifecycle wiring/placeholders rather than private-API integrations.", "meta": { "desktopShellRequired": true, "recommendedShell": "wails", diff --git a/.trellis/workspace/Apale/index.md b/.trellis/workspace/Apale/index.md index a168d4f..1f68558 100644 --- a/.trellis/workspace/Apale/index.md +++ b/.trellis/workspace/Apale/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 10 +- **Total Sessions**: 12 - **Last Active**: 2026-04-18 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~420 | Active | +| `journal-1.md` | ~525 | Active | --- @@ -29,6 +29,8 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 12 | 2026-04-18 | Wails desktop shell integration | - | `master` | +| 11 | 2026-04-18 | Desktop control panel implementation | - | `master` | | 10 | 2026-04-18 | Desktop shell skeleton implementation | - | `master` | | 9 | 2026-04-18 | GUI desktop direction and architecture decision | - | `master` | | 8 | 2026-04-18 | Provider discovery and model ref hardening | `09d0c1f` | `master` | diff --git a/.trellis/workspace/Apale/journal-1.md b/.trellis/workspace/Apale/journal-1.md index 2673dc1..a9fdcfb 100644 --- a/.trellis/workspace/Apale/journal-1.md +++ b/.trellis/workspace/Apale/journal-1.md @@ -418,3 +418,108 @@ Implemented the first desktop-shell skeleton by introducing a shared internal/ap ### Next Steps - None - task complete + + +## Session 11: Desktop control panel implementation + +**Date**: 2026-04-18 +**Task**: Desktop control panel implementation +**Branch**: `master` + +### Summary + +Extended the desktop-shell skeleton into a minimal usable local control panel by adding an embedded web UI, desktop HTTP bindings, alias/provider overview surfaces, proxy controls, desktop preference editing, and OpenCode sync/doctor actions while keeping the shared Go application layer intact. + +### Main Changes + +- Upgraded `cmd/ocswitch-desktop/main.go` from a skeleton print-only bootstrap into a runnable local desktop control panel entrypoint with `--config`, `--listen`, and `--no-open` flags. +- Added `internal/desktop/http.go` as a lightweight desktop shell runtime that: + - serves embedded frontend assets + - exposes JSON endpoints for overview, providers, aliases, proxy status/start/stop, desktop prefs, doctor, and OpenCode sync preview/apply + - opens the browser by default and shuts down cleanly on SIGINT/SIGTERM +- Added `web/` embedded assets (`web/assets.go`, `web/dist/index.html`, `web/dist/app.css`, `web/dist/app.js`) to provide a minimal control-panel UI without introducing Wails/WebKit runtime dependencies into the current repo. +- Expanded `internal/app`/`internal/desktop` bindings with alias DTOs and alias listing so the GUI can show both routable provider state and alias routing state. +- Added `internal/desktop/http_test.go` to verify the desktop control panel serves the app shell, returns overview JSON, and persists desktop preferences through the HTTP API. +- Verified with: + - `gofmt -w cmd/ocswitch-desktop/main.go internal/app/service.go internal/app/types.go internal/desktop/bindings.go internal/desktop/http.go internal/desktop/http_test.go web/assets.go` + - `rtk go test ./...` + - `rtk go build ./...` + - `go run ./cmd/ocswitch-desktop --no-open` followed by live HTTP checks against `/` and `/api/overview` +- Constraint note: the PRD still recommends Wails as the long-term native shell, but the current environment lacks `wails` CLI and desktop system dependencies, so this implementation deliberately ships a dependency-light local control panel first while keeping `internal/app` and `internal/desktop` ready for a future Wails swap-in. + + +### Git Commits + +(No commits - planning session) + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete + + +## Session 12: Wails desktop shell integration + +**Date**: 2026-04-18 +**Task**: Wails desktop shell integration +**Branch**: `master` + +### Summary + +Migrated the desktop control panel onto a formal Wails + React/TypeScript structure while preserving a browser fallback shell, added real Linux XDG launch-at-login support, and verified both default and desktop_wails Go paths. Native desktop compilation now fails only on missing Linux system packages rather than repository structure issues. + +### Main Changes + +- Added Wails v2.12.0 to `go.mod` and introduced `wails.json` so the repository has a formal desktop-shell project structure. +- Split desktop entrypoints by build tag: + - `cmd/ocswitch-desktop/main_fallback.go` keeps the browser fallback shell for default builds. + - `cmd/ocswitch-desktop/main_wails.go` and root `main_wails.go` provide the real `desktop_wails` Wails entry path expected by the Wails CLI. +- Added `internal/desktop/wails.go` to centralize Wails startup configuration, bind the desktop app object, and serve `frontend/dist` assets from the same source used by the browser fallback shell. +- Expanded `internal/desktop/app.go` into a real desktop composition root with startup, shutdown, close-to-background, preference sync, metadata, and Wails-callable facade methods. +- Replaced the ad-hoc `web/` assets with a formal `frontend/` Vite + React + TypeScript app: + - `frontend/src/App.tsx` now owns the GUI. + - `frontend/src/api.ts` bridges both Wails-bound calls and fallback HTTP `/api/*` calls. + - `frontend/src/types.ts` mirrors the Go DTOs. + - `frontend/src/env.d.ts` declares the Wails bridge surface. + - `frontend/src/styles.css` carries forward the control-panel styling. +- Updated the fallback HTTP shell in `internal/desktop/http.go` to serve `frontendassets.DistFS()` and to align doctor/meta responses with the Wails bridge semantics. +- Implemented real Linux XDG launch-at-login handling in `internal/desktop/autostart.go` and added `internal/desktop/autostart_test.go` to cover write/remove behavior. +- Wired `MinimizeToTray` into close-to-background behavior through `internal/desktop/tray.go` using public Wails window lifecycle APIs only; intentionally did not depend on private Wails tray internals because the pinned Wails version does not expose a stable public tray API. +- Added tag-specific runtime helpers: + - `internal/desktop/runtime_wails.go` hides the Wails window. + - `internal/desktop/runtime_fallback.go` is a no-op for browser fallback mode. +- Removed the obsolete `web/` embedded assets directory after the frontend migration because all code now serves assets from `frontend/`. +- Verified with: + - `rtk npm install` (frontend dependencies) + - `rtk npm run build` + - `rtk go test ./...` + - `rtk go test -tags desktop_wails ./...` + - `wails build -tags desktop_wails -debug` +- Verification outcome: + - repository structure, bindings generation, frontend build, and tagged Go compilation all succeed + - `wails build` now reaches native Linux compilation and stops only at missing system packages (`pkg-config`, and likely GTK/WebKit dev packages) + - native tray menus and native notifications remain intentionally incomplete because the current pinned Wails release does not provide a stable public tray API and no notification backend has been integrated yet + + +### Git Commits + +(No commits - planning session) + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/cmd/ocswitch-desktop/main_fallback.go b/cmd/ocswitch-desktop/main_fallback.go new file mode 100644 index 0000000..ff77c20 --- /dev/null +++ b/cmd/ocswitch-desktop/main_fallback.go @@ -0,0 +1,32 @@ +//go:build !desktop_wails + +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/Apale7/opencode-provider-switch/internal/config" + "github.com/Apale7/opencode-provider-switch/internal/desktop" +) + +var version = "dev" + +func main() { + configPath := flag.String("config", "", fmt.Sprintf("path to %s config.json (default: %s)", config.AppName, config.DefaultPath())) + listenAddr := flag.String("listen", "127.0.0.1:0", "listen address for the local desktop control panel") + noOpen := flag.Bool("no-open", false, "do not open the control panel in the default browser") + flag.Parse() + + err := desktop.Run(desktop.RunOptions{ + ConfigPath: *configPath, + Version: version, + ListenAddr: *listenAddr, + OpenBrowser: !*noOpen, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/ocswitch-desktop/main_wails.go b/cmd/ocswitch-desktop/main_wails.go new file mode 100644 index 0000000..9315de0 --- /dev/null +++ b/cmd/ocswitch-desktop/main_wails.go @@ -0,0 +1,25 @@ +//go:build desktop_wails + +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/Apale7/opencode-provider-switch/internal/config" + "github.com/Apale7/opencode-provider-switch/internal/desktop" +) + +var version = "dev" + +func main() { + configPath := flag.String("config", "", fmt.Sprintf("path to %s config.json (default: %s)", config.AppName, config.DefaultPath())) + flag.Parse() + + err := desktop.RunWails(*configPath, version) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/frontend/assets.go b/frontend/assets.go new file mode 100644 index 0000000..aa767c3 --- /dev/null +++ b/frontend/assets.go @@ -0,0 +1,13 @@ +package frontend + +import ( + "embed" + "io/fs" +) + +//go:embed dist/* +var assets embed.FS + +func DistFS() (fs.FS, error) { + return fs.Sub(assets, "dist") +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..205d249 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + ocswitch desktop + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ef8fe66 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1846 @@ +{ + "name": "ocswitch-desktop-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ocswitch-desktop-frontend", + "version": "0.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..43d92f3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "ocswitch-desktop-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.5" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100755 index 0000000..77f4d31 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +2f0b51396a8b6c44ea214228c533bdbe \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..cf5092a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,358 @@ +import { FormEvent, useCallback, useEffect, useState } from 'react' +import { + applySync, + getMeta, + getOverview, + listAliases, + listProviders, + previewSync, + runDoctor, + saveDesktopPrefs, + startProxy, + stopProxy, +} from './api' +import type { + AliasView, + DesktopPrefsView, + DoctorRunResult, + Overview, + ProviderView, + SyncInput, + SyncPreview, + SyncResult, +} from './types' + +type MetaState = { + version: string + shell: string + url?: string +} + +const emptyPrefs: DesktopPrefsView = { + launchAtLogin: false, + minimizeToTray: false, + notifications: false, +} + +const emptySync: SyncInput = { + target: '', + setModel: '', + setSmallModel: '', +} + +function pretty(value: unknown): string { + return JSON.stringify(value, null, 2) +} + +export default function App() { + const [meta, setMeta] = useState({ version: 'dev', shell: 'loading' }) + const [overview, setOverview] = useState(null) + const [providers, setProviders] = useState([]) + const [aliases, setAliases] = useState([]) + const [prefs, setPrefs] = useState(emptyPrefs) + const [prefsStatus, setPrefsStatus] = useState('') + const [doctorStatus, setDoctorStatus] = useState('') + const [doctorResult, setDoctorResult] = useState(null) + const [syncStatus, setSyncStatus] = useState('') + const [syncInput, setSyncInput] = useState(emptySync) + const [syncOutput, setSyncOutput] = useState('') + const [loading, setLoading] = useState(false) + + const refreshAll = useCallback(async () => { + setLoading(true) + setPrefsStatus('Refreshing...') + try { + const [metaData, overviewData, providerData, aliasData] = await Promise.all([ + getMeta(), + getOverview(), + listProviders(), + listAliases(), + ]) + setMeta(metaData) + setOverview(overviewData) + setProviders(providerData) + setAliases(aliasData) + setPrefs(overviewData.desktop) + setPrefsStatus('Fresh') + } catch (error) { + setPrefsStatus(error instanceof Error ? error.message : String(error)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refreshAll() + }, [refreshAll]) + + async function onSavePrefs(event: FormEvent) { + event.preventDefault() + setPrefsStatus('Saving...') + try { + const saved = await saveDesktopPrefs(prefs) + setPrefs(saved) + setPrefsStatus('Saved') + await refreshAll() + } catch (error) { + setPrefsStatus(error instanceof Error ? error.message : String(error)) + } + } + + async function onRunDoctor() { + setDoctorStatus('Running...') + try { + const result = await runDoctor() + setDoctorResult(result) + setDoctorStatus(result.error || 'Doctor OK') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setDoctorResult(null) + setDoctorStatus(message) + } + } + + async function onStartProxy() { + try { + await startProxy() + await refreshAll() + } catch (error) { + setPrefsStatus(error instanceof Error ? error.message : String(error)) + } + } + + async function onStopProxy() { + try { + await stopProxy() + await refreshAll() + } catch (error) { + setPrefsStatus(error instanceof Error ? error.message : String(error)) + } + } + + async function onPreviewSync() { + setSyncStatus('Previewing...') + try { + const result = await previewSync(syncInput) + setSyncOutput(result) + setSyncStatus(result.wouldChange ? 'Preview shows changes' : 'Preview shows no changes') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setSyncOutput(message) + setSyncStatus(message) + } + } + + async function onApplySync(event: FormEvent) { + event.preventDefault() + setSyncStatus('Applying...') + try { + const result = await applySync(syncInput) + setSyncOutput(result) + setSyncStatus(result.changed ? 'Sync applied' : 'Already up to date') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + setSyncOutput(message) + setSyncStatus(message) + } + } + + const stats = overview + ? [ + ['Providers', String(overview.providerCount)], + ['Aliases', String(overview.aliasCount)], + ['Routable aliases', String(overview.availableAliases.length)], + ['Proxy', overview.proxy.running ? 'Running' : 'Idle'], + ] + : [] + + return ( +
+
+
+

ocswitch desktop

+

Native control panel

+

+ {meta.version || 'dev'} · {meta.shell} shell · {overview?.configPath || 'loading config'} +

+
+
+ + +
+
+ +
+
+
+

Overview

+ + {overview?.proxy.running ? 'Proxy running' : 'Proxy idle'} + +
+
+ {stats.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ + +
+
{overview ? pretty(overview) : 'Loading overview...'}
+
+ +
+
+

Desktop prefs

+ {prefsStatus} +
+
void onSavePrefs(event)}> + + + + +
+
+ +
+
+

OpenCode sync

+ {syncStatus} +
+
void onApplySync(event)}> + + + +
+ + +
+
+
{typeof syncOutput === 'string' ? syncOutput : pretty(syncOutput)}
+
+ +
+
+

Doctor report

+ {doctorStatus} +
+
{doctorResult ? pretty(doctorResult) : 'Run doctor to inspect config and OpenCode wiring.'}
+
+ +
+
+

Providers

+ {providers.length} total +
+
+ {providers.length === 0 ?

No providers configured yet.

: null} + {providers.map((provider) => ( +
+
+ {provider.name || provider.id} +
+ {provider.baseUrl} +
+
+ API key: {provider.apiKeyMasked || 'not set'} +
+ Models: {provider.models?.join(', ') || 'none'} +
+ Status: {provider.disabled ? 'disabled' : 'enabled'} +
+
+ ))} +
+
+ +
+
+

Aliases

+ {aliases.length} total +
+
+ {aliases.length === 0 ?

No aliases configured yet.

: null} + {aliases.map((alias) => ( +
+
+ {alias.displayName || alias.alias} +
+ {alias.alias} +
+
+ Targets: {alias.availableTargetCount}/{alias.targetCount} routable +
+ {alias.targets.map((target) => `${target.provider}/${target.model}${target.enabled ? '' : ' (disabled)'}`).join(', ') || 'No targets'} +
+ Status: {alias.enabled ? 'enabled' : 'disabled'} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..6c4b337 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,99 @@ +import type { + AliasView, + DesktopPrefsView, + DoctorRunResult, + MetaView, + Overview, + ProviderView, + ProxyStatusView, + SyncInput, + SyncPreview, + SyncResult, +} from './types' + +type ApiEnvelope = { + data: T + error?: string +} + +function isWails(): boolean { + return Boolean(window.__OCSWITCH_WAILS__ && window.go?.main?.App) +} + +async function http(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + const payload = (await response.json()) as ApiEnvelope + if (!response.ok) { + throw new Error(payload.error || 'request failed') + } + return payload.data +} + +function bridge() { + const app = window.go?.main?.App + if (!app) { + throw new Error('Wails bridge unavailable') + } + return app +} + +export async function getMeta(): Promise { + if (isWails()) { + const data = await bridge().Meta() + return { version: data.version || 'dev', shell: data.shell || 'wails' } + } + return http('/api/meta') +} + +export function getOverview(): Promise { + return isWails() ? bridge().Overview() : http('/api/overview') +} + +export function listProviders(): Promise { + return isWails() ? bridge().Providers() : http('/api/providers') +} + +export function listAliases(): Promise { + return isWails() ? bridge().Aliases() : http('/api/aliases') +} + +export function getDesktopPrefs(): Promise { + return isWails() ? bridge().DesktopPrefs() : http('/api/desktop-prefs') +} + +export function saveDesktopPrefs(input: DesktopPrefsView): Promise { + return isWails() + ? bridge().SavePrefs(input) + : http('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) }) +} + +export function runDoctor(): Promise { + return isWails() ? bridge().DoctorRun() : http('/api/doctor', { method: 'POST' }) +} + +export function getProxyStatus(): Promise { + return isWails() ? bridge().ProxyStatus() : http('/api/proxy/status') +} + +export function startProxy(): Promise { + return isWails() ? bridge().StartProxy() : http('/api/proxy/start', { method: 'POST' }) +} + +export function stopProxy(): Promise { + return isWails() ? bridge().StopProxy() : http('/api/proxy/stop', { method: 'POST' }) +} + +export function previewSync(input: SyncInput): Promise { + return isWails() + ? bridge().PreviewSync(input) + : http('/api/opencode-sync/preview', { method: 'POST', body: JSON.stringify(input) }) +} + +export function applySync(input: SyncInput): Promise { + return isWails() + ? bridge().ApplySync(input) + : http('/api/opencode-sync/apply', { method: 'POST', body: JSON.stringify(input) }) +} diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..f4439b4 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,27 @@ +/// + +declare global { + interface Window { + __OCSWITCH_WAILS__?: boolean + go?: { + main?: { + App?: { + Meta: () => Promise> + Overview: () => Promise + Providers: () => Promise + Aliases: () => Promise + DoctorRun: () => Promise + ProxyStatus: () => Promise + StartProxy: () => Promise + StopProxy: () => Promise + DesktopPrefs: () => Promise + SavePrefs: (input: import('./types').DesktopPrefsView) => Promise + PreviewSync: (input: import('./types').SyncInput) => Promise + ApplySync: (input: import('./types').SyncInput) => Promise + } + } + } + } +} + +export {} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6906b28 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..ec5f4d0 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,232 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #0b1020; + color: #eef2ff; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(96, 165, 250, 0.18), transparent 28%), + linear-gradient(180deg, #0b1020 0%, #0f172a 100%); +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 12px; + background: rgba(15, 23, 42, 0.85); + color: #eef2ff; + padding: 0.75rem 1rem; +} + +button.primary { + background: linear-gradient(135deg, #2563eb, #22c55e); + border: none; +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +input[type='text'] { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 10px; + background: rgba(15, 23, 42, 0.75); + color: #eef2ff; + padding: 0.75rem 0.9rem; +} + +.shell { + max-width: 1280px; + margin: 0 auto; + padding: 1.25rem; +} + +.hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.hero h1, +.panel h2 { + margin: 0; +} + +.eyebrow { + margin: 0 0 0.35rem; + font-size: 0.75rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #93c5fd; +} + +.subtle { + color: #94a3b8; +} + +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel { + background: rgba(15, 23, 42, 0.82); + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 18px; + padding: 1rem; + box-shadow: 0 18px 60px rgba(15, 23, 42, 0.35); +} + +.wide, +.overview-panel { + grid-column: 1 / -1; +} + +.panel-header, +.toolbar, +.hero-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; +} + +.hero-actions, +.toolbar { + flex-wrap: wrap; +} + +.stats { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 1rem 0; +} + +.stat { + padding: 0.9rem; + border-radius: 14px; + background: rgba(30, 41, 59, 0.88); +} + +.stat-label { + display: block; + font-size: 0.8rem; + color: #94a3b8; +} + +.stat-value { + display: block; + margin-top: 0.25rem; + font-size: 1.45rem; + font-weight: 600; +} + +.badge { + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.82rem; +} + +.badge.live { + background: rgba(34, 197, 94, 0.18); + color: #86efac; +} + +.badge.idle { + background: rgba(148, 163, 184, 0.14); + color: #cbd5e1; +} + +.stack { + display: grid; + gap: 0.85rem; +} + +.stack label { + display: grid; + gap: 0.4rem; +} + +.checkbox-row { + display: flex !important; + align-items: center; + gap: 0.65rem; +} + +.details { + margin: 0; + padding: 0.9rem; + border-radius: 14px; + background: rgba(2, 6, 23, 0.72); + color: #cbd5e1; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.list { + display: grid; + gap: 0.75rem; + margin-top: 0.9rem; +} + +.item-card { + display: grid; + gap: 0.45rem; + padding: 0.9rem; + border-radius: 14px; + background: rgba(30, 41, 59, 0.82); +} + +.item-card strong { + font-size: 1rem; +} + +.item-card code { + font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.item-meta { + color: #cbd5e1; +} + +.tone-error { + color: #fca5a5; +} + +.tone-ok { + color: #86efac; +} + +@media (max-width: 860px) { + .hero, + .panel-header, + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .grid, + .stats { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..9bb0d63 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,98 @@ +export type DesktopPrefsView = { + launchAtLogin: boolean + minimizeToTray: boolean + notifications: boolean +} + +export type ProxyStatusView = { + running: boolean + bindAddress: string + startedAt?: string + lastError?: string +} + +export type Overview = { + configPath: string + providerCount: number + aliasCount: number + availableAliases: string[] + proxy: ProxyStatusView + desktop: DesktopPrefsView +} + +export type ProviderView = { + id: string + name?: string + baseUrl: string + apiKeySet: boolean + apiKeyMasked?: string + headers?: Record + models?: string[] + modelsSource?: string + disabled: boolean +} + +export type AliasTargetView = { + provider: string + model: string + enabled: boolean +} + +export type AliasView = { + alias: string + displayName?: string + enabled: boolean + targetCount: number + availableTargetCount: number + targets: AliasTargetView[] +} + +export type DoctorIssue = { + message: string +} + +export type DoctorReport = { + ok: boolean + issues: DoctorIssue[] + configPath: string + providerCount: number + aliasCount: number + proxyBindAddress: string + openCodeTargetPath: string + openCodeTargetFound: boolean +} + +export type DoctorRunResult = { + report: DoctorReport + error?: string +} + +export type SyncInput = { + target?: string + setModel?: string + setSmallModel?: string + dryRun?: boolean +} + +export type SyncPreview = { + targetPath: string + aliasNames: string[] + setModel?: string + setSmallModel?: string + wouldChange: boolean +} + +export type SyncResult = { + targetPath: string + aliasNames: string[] + changed: boolean + dryRun: boolean + setModel?: string + setSmallModel?: string +} + +export type MetaView = { + version: string + shell: string + url?: string +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5b4f50b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..9d31e2a --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..927fd48 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/frontend/wailsjs/go/desktop/App.d.ts b/frontend/wailsjs/go/desktop/App.d.ts new file mode 100755 index 0000000..a07f9c6 --- /dev/null +++ b/frontend/wailsjs/go/desktop/App.d.ts @@ -0,0 +1,49 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {app} from '../models'; +import {desktop} from '../models'; +import {context} from '../models'; + +export function Aliases():Promise>; + +export function ApplySync(arg1:app.SyncInput):Promise; + +export function AutoStart():Promise; + +export function BeforeClose(arg1:context.Context):Promise; + +export function Bindings():Promise; + +export function DesktopPrefs():Promise; + +export function DoctorRun():Promise; + +export function Meta():Promise>; + +export function Overview():Promise; + +export function PreviewSync(arg1:app.SyncInput):Promise; + +export function Providers():Promise>; + +export function ProxyStatus():Promise; + +export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise; + +export function SavePrefs(arg1:app.DesktopPrefsInput):Promise; + +export function Service():Promise; + +export function SetVersion(arg1:string):Promise; + +export function Shutdown(arg1:context.Context):Promise; + +export function StartProxy():Promise; + +export function Startup(arg1:context.Context):Promise; + +export function StopProxy():Promise; + +export function SyncDesktopPreferences(arg1:context.Context):Promise; + +export function Tray():Promise; diff --git a/frontend/wailsjs/go/desktop/App.js b/frontend/wailsjs/go/desktop/App.js new file mode 100755 index 0000000..3b3eb42 --- /dev/null +++ b/frontend/wailsjs/go/desktop/App.js @@ -0,0 +1,91 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function Aliases() { + return window['go']['desktop']['App']['Aliases'](); +} + +export function ApplySync(arg1) { + return window['go']['desktop']['App']['ApplySync'](arg1); +} + +export function AutoStart() { + return window['go']['desktop']['App']['AutoStart'](); +} + +export function BeforeClose(arg1) { + return window['go']['desktop']['App']['BeforeClose'](arg1); +} + +export function Bindings() { + return window['go']['desktop']['App']['Bindings'](); +} + +export function DesktopPrefs() { + return window['go']['desktop']['App']['DesktopPrefs'](); +} + +export function DoctorRun() { + return window['go']['desktop']['App']['DoctorRun'](); +} + +export function Meta() { + return window['go']['desktop']['App']['Meta'](); +} + +export function Overview() { + return window['go']['desktop']['App']['Overview'](); +} + +export function PreviewSync(arg1) { + return window['go']['desktop']['App']['PreviewSync'](arg1); +} + +export function Providers() { + return window['go']['desktop']['App']['Providers'](); +} + +export function ProxyStatus() { + return window['go']['desktop']['App']['ProxyStatus'](); +} + +export function SaveDesktopPrefs(arg1, arg2) { + return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2); +} + +export function SavePrefs(arg1) { + return window['go']['desktop']['App']['SavePrefs'](arg1); +} + +export function Service() { + return window['go']['desktop']['App']['Service'](); +} + +export function SetVersion(arg1) { + return window['go']['desktop']['App']['SetVersion'](arg1); +} + +export function Shutdown(arg1) { + return window['go']['desktop']['App']['Shutdown'](arg1); +} + +export function StartProxy() { + return window['go']['desktop']['App']['StartProxy'](); +} + +export function Startup(arg1) { + return window['go']['desktop']['App']['Startup'](arg1); +} + +export function StopProxy() { + return window['go']['desktop']['App']['StopProxy'](); +} + +export function SyncDesktopPreferences(arg1) { + return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1); +} + +export function Tray() { + return window['go']['desktop']['App']['Tray'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100755 index 0000000..bc15a33 --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,400 @@ +export namespace app { + + export class AliasTargetView { + provider: string; + model: string; + enabled: boolean; + + static createFrom(source: any = {}) { + return new AliasTargetView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.provider = source["provider"]; + this.model = source["model"]; + this.enabled = source["enabled"]; + } + } + export class AliasView { + alias: string; + displayName?: string; + enabled: boolean; + targetCount: number; + availableTargetCount: number; + targets: AliasTargetView[]; + + static createFrom(source: any = {}) { + return new AliasView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.alias = source["alias"]; + this.displayName = source["displayName"]; + this.enabled = source["enabled"]; + this.targetCount = source["targetCount"]; + this.availableTargetCount = source["availableTargetCount"]; + this.targets = this.convertValues(source["targets"], AliasTargetView); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class DesktopPrefsInput { + launchAtLogin: boolean; + minimizeToTray: boolean; + notifications: boolean; + + static createFrom(source: any = {}) { + return new DesktopPrefsInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.launchAtLogin = source["launchAtLogin"]; + this.minimizeToTray = source["minimizeToTray"]; + this.notifications = source["notifications"]; + } + } + export class DesktopPrefsView { + launchAtLogin: boolean; + minimizeToTray: boolean; + notifications: boolean; + + static createFrom(source: any = {}) { + return new DesktopPrefsView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.launchAtLogin = source["launchAtLogin"]; + this.minimizeToTray = source["minimizeToTray"]; + this.notifications = source["notifications"]; + } + } + export class DoctorIssue { + message: string; + + static createFrom(source: any = {}) { + return new DoctorIssue(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.message = source["message"]; + } + } + export class DoctorReport { + ok: boolean; + issues: DoctorIssue[]; + configPath: string; + providerCount: number; + aliasCount: number; + proxyBindAddress: string; + openCodeTargetPath: string; + openCodeTargetFound: boolean; + + static createFrom(source: any = {}) { + return new DoctorReport(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ok = source["ok"]; + this.issues = this.convertValues(source["issues"], DoctorIssue); + this.configPath = source["configPath"]; + this.providerCount = source["providerCount"]; + this.aliasCount = source["aliasCount"]; + this.proxyBindAddress = source["proxyBindAddress"]; + this.openCodeTargetPath = source["openCodeTargetPath"]; + this.openCodeTargetFound = source["openCodeTargetFound"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class DoctorRunResult { + report: DoctorReport; + error?: string; + + static createFrom(source: any = {}) { + return new DoctorRunResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.report = this.convertValues(source["report"], DoctorReport); + this.error = source["error"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class ProxyStatusView { + running: boolean; + bindAddress: string; + // Go type: time + startedAt?: any; + lastError?: string; + + static createFrom(source: any = {}) { + return new ProxyStatusView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.running = source["running"]; + this.bindAddress = source["bindAddress"]; + this.startedAt = this.convertValues(source["startedAt"], null); + this.lastError = source["lastError"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Overview { + configPath: string; + providerCount: number; + aliasCount: number; + availableAliases: string[]; + proxy: ProxyStatusView; + desktop: DesktopPrefsView; + + static createFrom(source: any = {}) { + return new Overview(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.configPath = source["configPath"]; + this.providerCount = source["providerCount"]; + this.aliasCount = source["aliasCount"]; + this.availableAliases = source["availableAliases"]; + this.proxy = this.convertValues(source["proxy"], ProxyStatusView); + this.desktop = this.convertValues(source["desktop"], DesktopPrefsView); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class ProviderView { + id: string; + name?: string; + baseUrl: string; + apiKeySet: boolean; + apiKeyMasked?: string; + headers?: Record; + models?: string[]; + modelsSource?: string; + disabled: boolean; + + static createFrom(source: any = {}) { + return new ProviderView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.baseUrl = source["baseUrl"]; + this.apiKeySet = source["apiKeySet"]; + this.apiKeyMasked = source["apiKeyMasked"]; + this.headers = source["headers"]; + this.models = source["models"]; + this.modelsSource = source["modelsSource"]; + this.disabled = source["disabled"]; + } + } + + export class Service { + + + static createFrom(source: any = {}) { + return new Service(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + export class SyncInput { + target?: string; + setModel?: string; + setSmallModel?: string; + dryRun: boolean; + + static createFrom(source: any = {}) { + return new SyncInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.target = source["target"]; + this.setModel = source["setModel"]; + this.setSmallModel = source["setSmallModel"]; + this.dryRun = source["dryRun"]; + } + } + export class SyncPreview { + targetPath: string; + aliasNames: string[]; + setModel?: string; + setSmallModel?: string; + wouldChange: boolean; + + static createFrom(source: any = {}) { + return new SyncPreview(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.targetPath = source["targetPath"]; + this.aliasNames = source["aliasNames"]; + this.setModel = source["setModel"]; + this.setSmallModel = source["setSmallModel"]; + this.wouldChange = source["wouldChange"]; + } + } + export class SyncResult { + targetPath: string; + aliasNames: string[]; + changed: boolean; + dryRun: boolean; + setModel?: string; + setSmallModel?: string; + + static createFrom(source: any = {}) { + return new SyncResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.targetPath = source["targetPath"]; + this.aliasNames = source["aliasNames"]; + this.changed = source["changed"]; + this.dryRun = source["dryRun"]; + this.setModel = source["setModel"]; + this.setSmallModel = source["setSmallModel"]; + } + } + +} + +export namespace desktop { + + export class AutoStart { + + + static createFrom(source: any = {}) { + return new AutoStart(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + export class Bindings { + + + static createFrom(source: any = {}) { + return new Bindings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + export class Tray { + + + static createFrom(source: any = {}) { + return new Tray(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + +} + diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/go.mod b/go.mod index 52a97e1..59a1ba0 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,38 @@ go 1.22.2 require ( github.com/spf13/cobra v1.8.1 github.com/tidwall/jsonc v0.3.2 + github.com/wailsapp/wails/v2 v2.12.0 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index a230ae4..227d651 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,94 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/service.go b/internal/app/service.go new file mode 100644 index 0000000..6ca907d --- /dev/null +++ b/internal/app/service.go @@ -0,0 +1,447 @@ +package app + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/Apale7/opencode-provider-switch/internal/config" + "github.com/Apale7/opencode-provider-switch/internal/opencode" + "github.com/Apale7/opencode-provider-switch/internal/proxy" +) + +type Service struct { + configPath string + + mu sync.Mutex + proxyCancel context.CancelFunc + proxyDone chan struct{} + proxyErr error + proxyStatus ProxyStatusView +} + +func NewService(configPath string) *Service { + return &Service{configPath: strings.TrimSpace(configPath)} +} + +func (s *Service) ConfigPath() string { + if s.configPath != "" { + return s.configPath + } + return config.DefaultPath() +} + +func (s *Service) GetOverview(ctx context.Context) (Overview, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return Overview{}, err + } + aliases := cfg.AvailableAliasNames() + sort.Strings(aliases) + status := s.currentProxyStatus(proxyBindAddress(cfg)) + return Overview{ + ConfigPath: cfg.Path(), + ProviderCount: len(cfg.Providers), + AliasCount: len(cfg.Aliases), + AvailableAliases: aliases, + Proxy: status, + Desktop: desktopPrefsView(cfg.Desktop), + }, nil +} + +func (s *Service) ListProviders(ctx context.Context) ([]ProviderView, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return nil, err + } + providers := append([]config.Provider(nil), cfg.Providers...) + sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) + views := make([]ProviderView, 0, len(providers)) + for _, provider := range providers { + views = append(views, providerView(provider)) + } + return views, nil +} + +func (s *Service) ListAliases(ctx context.Context) ([]AliasView, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return nil, err + } + aliases := append([]config.Alias(nil), cfg.Aliases...) + sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) + views := make([]AliasView, 0, len(aliases)) + for _, alias := range aliases { + views = append(views, aliasView(cfg, alias)) + } + return views, nil +} + +func (s *Service) RunDoctor(ctx context.Context) (DoctorReport, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return DoctorReport{}, err + } + issues := cfg.Validate() + path, existed := opencode.ResolveGlobalConfigPath() + raw, err := opencode.Load(path) + if err != nil { + issues = append(issues, fmt.Errorf("load opencode config target: %w", err)) + } else { + aliasNames := cfg.AvailableAliasNames() + baseURL := proxyBaseURL(cfg) + opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) + if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil { + issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err)) + } + } + report := DoctorReport{ + OK: len(issues) == 0, + Issues: doctorIssues(issues), + ConfigPath: cfg.Path(), + ProviderCount: len(cfg.Providers), + AliasCount: len(cfg.Aliases), + ProxyBindAddress: proxyBindAddress(cfg), + OpenCodeTargetPath: path, + OpenCodeTargetFound: existed, + } + if report.OK { + return report, nil + } + return report, fmt.Errorf("%d config issue(s)", len(issues)) +} + +func (s *Service) PreviewOpenCodeSync(ctx context.Context, in SyncInput) (SyncPreview, error) { + prepared, err := s.prepareSync(ctx, in) + if err != nil { + return SyncPreview{}, err + } + return SyncPreview{ + TargetPath: prepared.targetPath, + AliasNames: append([]string(nil), prepared.aliasNames...), + SetModel: in.SetModel, + SetSmallModel: in.SetSmallModel, + WouldChange: prepared.changed, + }, nil +} + +func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResult, error) { + prepared, err := s.prepareSync(ctx, in) + if err != nil { + return SyncResult{}, err + } + result := SyncResult{ + TargetPath: prepared.targetPath, + AliasNames: append([]string(nil), prepared.aliasNames...), + Changed: prepared.changed, + DryRun: in.DryRun, + SetModel: in.SetModel, + SetSmallModel: in.SetSmallModel, + } + if !prepared.changed || in.DryRun { + return result, nil + } + if err := opencode.Save(prepared.targetPath, prepared.raw); err != nil { + return SyncResult{}, err + } + return result, nil +} + +func (s *Service) StartProxy(ctx context.Context) error { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return err + } + if errs := cfg.Validate(); len(errs) > 0 { + return errs[0] + } + + bindAddress := proxyBindAddress(cfg) + started := false + + s.mu.Lock() + if s.proxyCancel == nil { + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + s.proxyCancel = cancel + s.proxyDone = done + s.proxyErr = nil + s.proxyStatus = ProxyStatusView{ + Running: true, + BindAddress: bindAddress, + StartedAt: time.Now(), + } + started = true + go s.runProxy(runCtx, cancel, done, cfg, bindAddress) + } + s.mu.Unlock() + + if !started { + return nil + } + return nil +} + +func (s *Service) StopProxy(ctx context.Context) error { + s.mu.Lock() + cancel := s.proxyCancel + done := s.proxyDone + s.mu.Unlock() + if cancel == nil || done == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Service) WaitProxy(ctx context.Context) error { + s.mu.Lock() + done := s.proxyDone + proxyErr := s.proxyErr + s.mu.Unlock() + if done == nil { + return proxyErr + } + select { + case <-done: + s.mu.Lock() + defer s.mu.Unlock() + return s.proxyErr + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return ProxyStatusView{}, err + } + return s.currentProxyStatus(proxyBindAddress(cfg)), nil +} + +func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return DesktopPrefsView{}, err + } + return desktopPrefsView(cfg.Desktop), nil +} + +func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (DesktopPrefsView, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return DesktopPrefsView{}, err + } + cfg.Desktop = config.Desktop{ + LaunchAtLogin: in.LaunchAtLogin, + MinimizeToTray: in.MinimizeToTray, + Notifications: in.Notifications, + } + if err := cfg.Save(); err != nil { + return DesktopPrefsView{}, err + } + return desktopPrefsView(cfg.Desktop), nil +} + +type preparedSync struct { + targetPath string + aliasNames []string + raw opencode.Raw + changed bool +} + +func (s *Service) prepareSync(ctx context.Context, in SyncInput) (preparedSync, error) { + _ = ctx + cfg, err := s.loadConfig() + if err != nil { + return preparedSync{}, err + } + if errs := cfg.Validate(); len(errs) > 0 { + return preparedSync{}, errs[0] + } + targetPath := strings.TrimSpace(in.Target) + if targetPath == "" { + resolved, _ := opencode.ResolveGlobalConfigPath() + targetPath = resolved + } + raw, err := opencode.Load(targetPath) + if err != nil { + return preparedSync{}, err + } + aliasNames := cfg.AvailableAliasNames() + sort.Strings(aliasNames) + if err := validateSyncedModelSelection(in.SetModel, aliasNames, "--set-model"); err != nil { + return preparedSync{}, err + } + if err := validateSyncedModelSelection(in.SetSmallModel, aliasNames, "--set-small-model"); err != nil { + return preparedSync{}, err + } + baseURL := proxyBaseURL(cfg) + changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) + if in.SetModel != "" && raw["model"] != in.SetModel { + raw["model"] = in.SetModel + changed = true + } + if in.SetSmallModel != "" && raw["small_model"] != in.SetSmallModel { + raw["small_model"] = in.SetSmallModel + changed = true + } + return preparedSync{targetPath: targetPath, aliasNames: aliasNames, raw: raw, changed: changed}, nil +} + +func (s *Service) loadConfig() (*config.Config, error) { + return config.Load(s.configPath) +} + +func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView { + s.mu.Lock() + defer s.mu.Unlock() + status := s.proxyStatus + if status.BindAddress == "" { + status.BindAddress = bindAddress + } + return status +} + +func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) { + err := proxy.New(cfg).ListenAndServe(runCtx) + s.mu.Lock() + defer s.mu.Unlock() + if s.proxyDone == done { + s.proxyCancel = nil + s.proxyDone = nil + } + s.proxyErr = err + status := s.proxyStatus + status.Running = false + status.BindAddress = bindAddress + if err != nil { + status.LastError = err.Error() + } else { + status.LastError = "" + } + s.proxyStatus = status + close(done) +} + +func providerView(provider config.Provider) ProviderView { + return ProviderView{ + ID: provider.ID, + Name: provider.Name, + BaseURL: provider.BaseURL, + APIKeySet: provider.APIKey != "", + APIKeyMasked: maskKey(provider.APIKey), + Headers: cloneHeaders(provider.Headers), + Models: append([]string(nil), provider.Models...), + ModelsSource: provider.ModelsSource, + Disabled: provider.Disabled, + } +} + +func aliasView(cfg *config.Config, alias config.Alias) AliasView { + targets := make([]AliasTargetView, 0, len(alias.Targets)) + for _, target := range alias.Targets { + targets = append(targets, AliasTargetView{ + Provider: target.Provider, + Model: target.Model, + Enabled: target.Enabled, + }) + } + return AliasView{ + Alias: alias.Alias, + DisplayName: alias.DisplayName, + Enabled: alias.Enabled, + TargetCount: len(alias.Targets), + AvailableTargetCount: len(cfg.AvailableTargets(alias)), + Targets: targets, + } +} + +func doctorIssues(errs []error) []DoctorIssue { + issues := make([]DoctorIssue, 0, len(errs)) + for _, err := range errs { + issues = append(issues, DoctorIssue{Message: err.Error()}) + } + return issues +} + +func desktopPrefsView(prefs config.Desktop) DesktopPrefsView { + return DesktopPrefsView{ + LaunchAtLogin: prefs.LaunchAtLogin, + MinimizeToTray: prefs.MinimizeToTray, + Notifications: prefs.Notifications, + } +} + +func validateSyncedModelSelection(value string, aliases []string, flagName string) error { + if value == "" { + return nil + } + const prefix = "ocswitch/" + if !strings.HasPrefix(value, prefix) { + return fmt.Errorf("%s must use the ocswitch/ form", flagName) + } + alias := strings.TrimPrefix(value, prefix) + if alias == "" { + return fmt.Errorf("%s must use the ocswitch/ form", flagName) + } + for _, name := range aliases { + if name == alias { + return nil + } + } + if len(aliases) == 0 { + return fmt.Errorf("%s requires at least one routable alias; run ocswitch alias list or doctor first", flagName) + } + choices := make([]string, 0, len(aliases)) + for _, name := range aliases { + choices = append(choices, prefix+name) + } + return fmt.Errorf("%s %q is not a routable alias; available: %s", flagName, value, strings.Join(choices, ", ")) +} + +func proxyBindAddress(cfg *config.Config) string { + return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) +} + +func proxyBaseURL(cfg *config.Config) string { + return fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) +} + +func cloneHeaders(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func maskKey(k string) string { + if k == "" { + return "" + } + if len(k) <= 8 { + return "***" + } + return k[:4] + "…" + k[len(k)-4:] +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go new file mode 100644 index 0000000..d1f5910 --- /dev/null +++ b/internal/app/service_test.go @@ -0,0 +1,120 @@ +package app + +import ( + "context" + "net" + "net/http" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Apale7/opencode-provider-switch/internal/config" +) + +func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "ocswitch.json") + svc := NewService(path) + + prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{ + LaunchAtLogin: true, + MinimizeToTray: true, + Notifications: true, + }) + if err != nil { + t.Fatalf("SaveDesktopPrefs() error = %v", err) + } + if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications { + t.Fatalf("SaveDesktopPrefs() = %#v", prefs) + } + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications { + t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop) + } +} + +func TestStartStopProxyUpdatesStatus(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "ocswitch.json") + cfgPathPort := freePort(t) + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + cfg.Server.Port = cfgPathPort + cfg.Server.Host = "127.0.0.1" + cfg.Server.APIKey = config.DefaultLocalAPIKey + if err := cfg.Save(); err != nil { + t.Fatalf("cfg.Save() error = %v", err) + } + + svc := NewService(path) + if err := svc.StartProxy(context.Background()); err != nil { + t.Fatalf("StartProxy() error = %v", err) + } + t.Cleanup(func() { + _ = svc.StopProxy(context.Background()) + }) + + status, err := svc.GetProxyStatus(context.Background()) + if err != nil { + t.Fatalf("GetProxyStatus() error = %v", err) + } + if !status.Running { + t.Fatalf("status.Running = false, want true") + } + + assertEventually(t, func() bool { + resp, err := http.Get("http://127.0.0.1:" + itoa(cfgPathPort) + "/healthz") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK + }) + + if err := svc.StopProxy(context.Background()); err != nil { + t.Fatalf("StopProxy() error = %v", err) + } + + status, err = svc.GetProxyStatus(context.Background()) + if err != nil { + t.Fatalf("GetProxyStatus() after stop error = %v", err) + } + if status.Running { + t.Fatalf("status.Running = true, want false") + } +} + +func assertEventually(t *testing.T, fn func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("condition not satisfied before timeout") +} + +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error = %v", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +func itoa(v int) string { + return strconv.Itoa(v) +} diff --git a/internal/app/types.go b/internal/app/types.go new file mode 100644 index 0000000..bba3417 --- /dev/null +++ b/internal/app/types.go @@ -0,0 +1,102 @@ +package app + +import "time" + +type Overview struct { + ConfigPath string `json:"configPath"` + ProviderCount int `json:"providerCount"` + AliasCount int `json:"aliasCount"` + AvailableAliases []string `json:"availableAliases"` + Proxy ProxyStatusView `json:"proxy"` + Desktop DesktopPrefsView `json:"desktop"` +} + +type ProxyStatusView struct { + Running bool `json:"running"` + BindAddress string `json:"bindAddress"` + StartedAt time.Time `json:"startedAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +type ProviderView struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + BaseURL string `json:"baseUrl"` + APIKeySet bool `json:"apiKeySet"` + APIKeyMasked string `json:"apiKeyMasked,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Models []string `json:"models,omitempty"` + ModelsSource string `json:"modelsSource,omitempty"` + Disabled bool `json:"disabled"` +} + +type AliasTargetView struct { + Provider string `json:"provider"` + Model string `json:"model"` + Enabled bool `json:"enabled"` +} + +type AliasView struct { + Alias string `json:"alias"` + DisplayName string `json:"displayName,omitempty"` + Enabled bool `json:"enabled"` + TargetCount int `json:"targetCount"` + AvailableTargetCount int `json:"availableTargetCount"` + Targets []AliasTargetView `json:"targets"` +} + +type DoctorIssue struct { + Message string `json:"message"` +} + +type DoctorReport struct { + OK bool `json:"ok"` + Issues []DoctorIssue `json:"issues"` + ConfigPath string `json:"configPath"` + ProviderCount int `json:"providerCount"` + AliasCount int `json:"aliasCount"` + ProxyBindAddress string `json:"proxyBindAddress"` + OpenCodeTargetPath string `json:"openCodeTargetPath"` + OpenCodeTargetFound bool `json:"openCodeTargetFound"` +} + +type DoctorRunResult struct { + Report DoctorReport `json:"report"` + Error string `json:"error,omitempty"` +} + +type SyncInput struct { + Target string `json:"target,omitempty"` + SetModel string `json:"setModel,omitempty"` + SetSmallModel string `json:"setSmallModel,omitempty"` + DryRun bool `json:"dryRun"` +} + +type SyncPreview struct { + TargetPath string `json:"targetPath"` + AliasNames []string `json:"aliasNames"` + SetModel string `json:"setModel,omitempty"` + SetSmallModel string `json:"setSmallModel,omitempty"` + WouldChange bool `json:"wouldChange"` +} + +type SyncResult struct { + TargetPath string `json:"targetPath"` + AliasNames []string `json:"aliasNames"` + Changed bool `json:"changed"` + DryRun bool `json:"dryRun"` + SetModel string `json:"setModel,omitempty"` + SetSmallModel string `json:"setSmallModel,omitempty"` +} + +type DesktopPrefsView struct { + LaunchAtLogin bool `json:"launchAtLogin"` + MinimizeToTray bool `json:"minimizeToTray"` + Notifications bool `json:"notifications"` +} + +type DesktopPrefsInput struct { + LaunchAtLogin bool `json:"launchAtLogin"` + MinimizeToTray bool `json:"minimizeToTray"` + Notifications bool `json:"notifications"` +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 099b955..a4f44f9 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -4,8 +4,6 @@ import ( "fmt" "github.com/spf13/cobra" - - "github.com/Apale7/opencode-provider-switch/internal/opencode" ) func newDoctorCmd() *cobra.Command { @@ -25,48 +23,32 @@ aliases, or local server settings.`, Example: ` ocswitch doctor ocswitch --config /path/to/config.json doctor`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadCfg() + report, err := appService().RunDoctor(cmd.Context()) if err != nil { + for _, issue := range report.Issues { + fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", issue.Message) + } + fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount) + fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount) + marker := "(will be created)" + if report.OpenCodeTargetFound { + marker = "(exists)" + } + fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker) + fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK) + fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress) return err } - issues := cfg.Validate() - - path, existed := opencode.ResolveGlobalConfigPath() - raw, err := opencode.Load(path) - if err != nil { - issues = append(issues, fmt.Errorf("load opencode config target: %w", err)) - } else { - aliasNames := cfg.AvailableAliasNames() - baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) - opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) - if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil { - issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err)) - } - } - ok := len(issues) == 0 - if ok { - fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", cfg.Path()) - } else { - fmt.Fprintf(cmd.OutOrStdout(), "✗ config has %d issue(s):\n", len(issues)) - for _, e := range issues { - fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", e) - } - } - fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", len(cfg.Providers)) - fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", len(cfg.Aliases)) - - // Preview resolved opencode config target + fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", report.ConfigPath) + fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount) + fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount) marker := "(will be created)" - if existed { + if report.OpenCodeTargetFound { marker = "(exists)" } - fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker) - fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", ok) - - fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port) - if !ok { - return fmt.Errorf("%d config issue(s)", len(issues)) - } + fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker) + fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK) + fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress) return nil }, } diff --git a/internal/cli/opencode.go b/internal/cli/opencode.go index 31c4411..d943962 100644 --- a/internal/cli/opencode.go +++ b/internal/cli/opencode.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/Apale7/opencode-provider-switch/internal/opencode" + "github.com/Apale7/opencode-provider-switch/internal/app" ) func newOpencodeCmd() *cobra.Command { @@ -61,58 +61,24 @@ needed.`, ocswitch opencode sync --set-model ocswitch/gpt-5.4 --set-small-model ocswitch/gpt-5.4-mini ocswitch opencode sync --target /path/to/opencode.jsonc`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadCfg() + result, err := appService().ApplyOpenCodeSync(cmd.Context(), app.SyncInput{ + Target: target, + SetModel: setModel, + SetSmallModel: setSmallModel, + DryRun: dryRun, + }) if err != nil { return err } - if errs := cfg.Validate(); len(errs) > 0 { - for _, e := range errs { - cmd.PrintErrln("config error:", e) - } - return errs[0] - } - path := target - if path == "" { - p, _ := opencode.ResolveGlobalConfigPath() - path = p - } - raw, err := opencode.Load(path) - if err != nil { - return err - } - aliasNames := cfg.AvailableAliasNames() - if setModel != "" { - if err := validateSyncedModelSelection(setModel, aliasNames, "--set-model"); err != nil { - return err - } - } - if setSmallModel != "" { - if err := validateSyncedModelSelection(setSmallModel, aliasNames, "--set-small-model"); err != nil { - return err - } - } - baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) - changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) - if setModel != "" && raw["model"] != setModel { - raw["model"] = setModel - changed = true - } - if setSmallModel != "" && raw["small_model"] != setSmallModel { - raw["small_model"] = setSmallModel - changed = true - } - if !changed { - fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", path) + if !result.Changed { + fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", result.TargetPath) return nil } - if dryRun { - fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", path) + if result.DryRun { + fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", result.TargetPath) return nil } - if err := opencode.Save(path, raw); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", path, len(aliasNames)) + fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", result.TargetPath, len(result.AliasNames)) if setModel != "" { fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel) } diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 174b411..07add4d 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "reflect" - "sort" "strings" "github.com/spf13/cobra" @@ -211,23 +210,21 @@ This command does not modify config and does not contact upstream providers.`, Example: ` ocswitch provider list ocswitch --config /path/to/config.json provider list`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadCfg() + providers, err := appService().ListProviders(cmd.Context()) if err != nil { return err } - providers := append([]config.Provider(nil), cfg.Providers...) - sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) if len(providers) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "(no providers)") return nil } for _, p := range providers { key := "(none)" - if p.APIKey != "" { - key = maskKey(p.APIKey) + if p.APIKeySet { + key = p.APIKeyMasked } state := "enabled" - if !p.IsEnabled() { + if p.Disabled { state = "disabled" } fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key) diff --git a/internal/cli/root.go b/internal/cli/root.go index 4c01513..fbc2df5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + "github.com/Apale7/opencode-provider-switch/internal/app" "github.com/Apale7/opencode-provider-switch/internal/config" ) @@ -18,6 +19,10 @@ func loadCfg() (*config.Config, error) { return config.Load(configPath) } +func appService() *app.Service { + return app.NewService(configPath) +} + // NewRootCmd builds the root ocswitch command. func NewRootCmd(version string) *cobra.Command { root := &cobra.Command{ diff --git a/internal/cli/serve.go b/internal/cli/serve.go index b22d3ad..c913f01 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -1,12 +1,11 @@ package cli import ( + "context" "os/signal" "syscall" "github.com/spf13/cobra" - - "github.com/Apale7/opencode-provider-switch/internal/proxy" ) func newServeCmd() *cobra.Command { @@ -25,20 +24,17 @@ OpenCode can see the same aliases that the proxy can route.`, Example: ` ocswitch serve ocswitch --config /path/to/config.json serve`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadCfg() - if err != nil { - return err - } - if errs := cfg.Validate(); len(errs) > 0 { - for _, e := range errs { - cmd.PrintErrln("config error:", e) - } - return errs[0] - } - srv := proxy.New(cfg) ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) defer stop() - return srv.ListenAndServe(ctx) + svc := appService() + if err := svc.StartProxy(context.Background()); err != nil { + return err + } + go func() { + <-ctx.Done() + _ = svc.StopProxy(context.Background()) + }() + return svc.WaitProxy(context.Background()) }, } } diff --git a/internal/config/config.go b/internal/config/config.go index 6624996..1a28307 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,9 +56,17 @@ type Server struct { APIKey string `json:"api_key"` } +// Desktop holds desktop-shell user preferences. +type Desktop struct { + LaunchAtLogin bool `json:"launch_at_login,omitempty"` + MinimizeToTray bool `json:"minimize_to_tray,omitempty"` + Notifications bool `json:"notifications,omitempty"` +} + // Config is the on-disk ocswitch config. type Config struct { Server Server `json:"server"` + Desktop Desktop `json:"desktop,omitempty"` Providers []Provider `json:"providers"` Aliases []Alias `json:"aliases"` @@ -98,6 +106,7 @@ func Default() *Config { Port: 9982, APIKey: DefaultLocalAPIKey, }, + Desktop: Desktop{}, Providers: []Provider{}, Aliases: []Alias{}, } @@ -176,9 +185,10 @@ func (c *Config) Save() error { sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) snap := struct { Server Server `json:"server"` + Desktop Desktop `json:"desktop,omitempty"` Providers []Provider `json:"providers"` Aliases []Alias `json:"aliases"` - }{c.Server, providers, aliases} + }{c.Server, c.Desktop, providers, aliases} data, err := json.MarshalIndent(snap, "", " ") if err != nil { return fmt.Errorf("marshal: %w", err) diff --git a/internal/desktop/app.go b/internal/desktop/app.go new file mode 100644 index 0000000..edce758 --- /dev/null +++ b/internal/desktop/app.go @@ -0,0 +1,167 @@ +package desktop + +import ( + "context" + "time" + + "github.com/Apale7/opencode-provider-switch/internal/app" +) + +// App is the desktop-shell composition root. Native shell integrations are kept +// out of internal/app so CLI and GUI can share the same workflows. +type App struct { + service *app.Service + bindings *Bindings + tray *Tray + notify *Notifier + auto *AutoStart + + ctx context.Context + version string +} + +func New(configPath string) *App { + svc := app.NewService(configPath) + instance := &App{service: svc} + instance.bindings = NewBindings(svc) + instance.tray = NewTray(svc) + instance.notify = NewNotifier(svc) + instance.auto = NewAutoStart(svc) + return instance +} + +func (a *App) SetVersion(version string) { + a.version = version +} + +func (a *App) Startup(ctx context.Context) { + a.ctx = ctx + a.tray.Attach(ctx) + a.notify.Attach(ctx) + a.auto.Attach(ctx) + _ = a.SyncDesktopPreferences(ctx) +} + +func (a *App) BeforeClose(ctx context.Context) bool { + a.ctx = ctx + prevent, _ := a.tray.BeforeClose(ctx) + return prevent +} + +func (a *App) Shutdown(ctx context.Context) { + a.ctx = ctx + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = a.service.StopProxy(shutdownCtx) + a.tray.Detach() + a.notify.Detach() + a.auto.Detach() +} + +func (a *App) SyncDesktopPreferences(ctx context.Context) error { + prefs, err := a.bindings.GetDesktopPrefs(ctx) + if err != nil { + return err + } + if err := a.auto.Sync(ctx, prefs); err != nil { + return err + } + a.tray.Sync(ctx, prefs) + return nil +} + +func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) { + prefs, err := a.bindings.SaveDesktopPrefs(ctx, in) + if err != nil { + return app.DesktopPrefsView{}, err + } + if err := a.auto.Sync(ctx, prefs); err != nil { + return app.DesktopPrefsView{}, err + } + a.tray.Sync(ctx, prefs) + return prefs, nil +} + +func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) { + return a.SaveDesktopPrefs(a.callContext(), in) +} + +func (a *App) Meta() map[string]string { + return map[string]string{ + "version": a.version, + "shell": a.shellName(), + } +} + +func (a *App) Overview() (app.Overview, error) { + return a.bindings.GetOverview(a.callContext()) +} + +func (a *App) Providers() ([]app.ProviderView, error) { + return a.bindings.ListProviders(a.callContext()) +} + +func (a *App) Aliases() ([]app.AliasView, error) { + return a.bindings.ListAliases(a.callContext()) +} + +func (a *App) DoctorRun() (app.DoctorRunResult, error) { + report, err := a.bindings.RunDoctor(a.callContext()) + return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil +} + +func (a *App) ProxyStatus() (app.ProxyStatusView, error) { + return a.bindings.GetProxyStatus(a.callContext()) +} + +func (a *App) StartProxy() (app.ProxyStatusView, error) { + return a.bindings.StartProxy(a.callContext()) +} + +func (a *App) StopProxy() (app.ProxyStatusView, error) { + ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second) + defer cancel() + return a.bindings.StopProxy(ctx) +} + +func (a *App) DesktopPrefs() (app.DesktopPrefsView, error) { + return a.bindings.GetDesktopPrefs(a.callContext()) +} + +func (a *App) PreviewSync(in app.SyncInput) (app.SyncPreview, error) { + return a.bindings.PreviewOpenCodeSync(a.callContext(), in) +} + +func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) { + return a.bindings.SyncOpenCode(a.callContext(), in) +} + +func (a *App) callContext() context.Context { + if a.ctx != nil { + return a.ctx + } + return context.Background() +} + +func (a *App) shellName() string { + if a.ctx != nil { + return "wails" + } + return "browser" +} + +func (a *App) Service() *app.Service { + return a.service +} + +func (a *App) Bindings() *Bindings { + return a.bindings +} + +func (a *App) Tray() *Tray { + return a.tray +} + +func (a *App) AutoStart() *AutoStart { + return a.auto +} diff --git a/internal/desktop/autostart.go b/internal/desktop/autostart.go new file mode 100644 index 0000000..2ffcc9f --- /dev/null +++ b/internal/desktop/autostart.go @@ -0,0 +1,98 @@ +package desktop + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/Apale7/opencode-provider-switch/internal/app" + "github.com/Apale7/opencode-provider-switch/internal/config" +) + +// AutoStart manages real launch-at-login integration where the platform permits +// it. This implementation currently targets Linux XDG autostart. +type AutoStart struct { + service *app.Service + ctx context.Context +} + +func NewAutoStart(service *app.Service) *AutoStart { + return &AutoStart{service: service} +} + +func (a *AutoStart) Attach(ctx context.Context) { + a.ctx = ctx +} + +func (a *AutoStart) Detach() { + a.ctx = nil +} + +func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error { + _ = ctx + if runtime.GOOS != "linux" { + return nil + } + entryPath, err := a.entryPath() + if err != nil { + return err + } + if prefs.LaunchAtLogin { + return a.writeLinuxEntry(entryPath) + } + if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove autostart entry: %w", err) + } + return nil +} + +func (a *AutoStart) entryPath() (string, error) { + base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home for autostart: %w", err) + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil +} + +func (a *AutoStart) writeLinuxEntry(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir autostart dir: %w", err) + } + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve desktop executable: %w", err) + } + configPath := config.DefaultPath() + if a.service != nil { + configPath = a.service.ConfigPath() + } + content := strings.Join([]string{ + "[Desktop Entry]", + "Type=Application", + "Version=1.0", + "Name=ocswitch desktop", + "Comment=OpenCode provider switch desktop shell", + fmt.Sprintf("Exec=%s --config %s", shellQuote(execPath), shellQuote(configPath)), + "Terminal=false", + "X-GNOME-Autostart-enabled=true", + "Categories=Network;Development;", + "StartupNotify=false", + "", + }, "\n") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write autostart entry: %w", err) + } + return nil +} + +func shellQuote(value string) string { + replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`) + return `"` + replacer.Replace(value) + `"` +} diff --git a/internal/desktop/autostart_test.go b/internal/desktop/autostart_test.go new file mode 100644 index 0000000..91b0054 --- /dev/null +++ b/internal/desktop/autostart_test.go @@ -0,0 +1,59 @@ +package desktop + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Apale7/opencode-provider-switch/internal/app" +) + +func TestAutoStartSyncLinuxWritesDesktopEntry(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only autostart behavior") + } + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + auto := NewAutoStart(nil) + if err := auto.Sync(context.Background(), app.DesktopPrefsView{LaunchAtLogin: true}); err != nil { + t.Fatalf("Sync() error = %v", err) + } + + entry, err := auto.entryPath() + if err != nil { + t.Fatalf("entryPath() error = %v", err) + } + data, err := os.ReadFile(entry) + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + text := string(data) + if !strings.Contains(text, "[Desktop Entry]") { + t.Fatalf("desktop entry missing header: %q", text) + } + if !strings.Contains(text, "Name=ocswitch desktop") { + t.Fatalf("desktop entry missing app name: %q", text) + } + + if err := auto.Sync(context.Background(), app.DesktopPrefsView{}); err != nil { + t.Fatalf("Sync(remove) error = %v", err) + } + if _, err := os.Stat(entry); !os.IsNotExist(err) { + t.Fatalf("autostart entry still exists at %s", entry) + } +} + +func TestShellQuoteEscapesDoubleQuotes(t *testing.T) { + t.Parallel() + quoted := shellQuote(filepath.Join(`/tmp/demo"path`, `bin`)) + if !strings.HasPrefix(quoted, `"`) || !strings.HasSuffix(quoted, `"`) { + t.Fatalf("shellQuote() = %q", quoted) + } + if !strings.Contains(quoted, `\\"`) { + t.Fatalf("shellQuote() did not escape quotes: %q", quoted) + } +} diff --git a/internal/desktop/bindings.go b/internal/desktop/bindings.go new file mode 100644 index 0000000..6f96d64 --- /dev/null +++ b/internal/desktop/bindings.go @@ -0,0 +1,114 @@ +package desktop + +import ( + "context" + "time" + + "github.com/Apale7/opencode-provider-switch/internal/app" +) + +// Bindings is the thin desktop-callable facade shared by the fallback HTTP shell +// and the Wails bridge. +type Bindings struct { + service *app.Service +} + +func NewBindings(service *app.Service) *Bindings { + return &Bindings{service: service} +} + +func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) { + return b.service.GetOverview(ctx) +} + +func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) { + return b.service.ListProviders(ctx) +} + +func (b *Bindings) ListAliases(ctx context.Context) ([]app.AliasView, error) { + return b.service.ListAliases(ctx) +} + +func (b *Bindings) RunDoctor(ctx context.Context) (app.DoctorReport, error) { + return b.service.RunDoctor(ctx) +} + +func (b *Bindings) SyncOpenCode(ctx context.Context, in app.SyncInput) (app.SyncResult, error) { + return b.service.ApplyOpenCodeSync(ctx, in) +} + +func (b *Bindings) PreviewOpenCodeSync(ctx context.Context, in app.SyncInput) (app.SyncPreview, error) { + return b.service.PreviewOpenCodeSync(ctx, in) +} + +func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, error) { + return b.service.GetProxyStatus(ctx) +} + +func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) { + if err := b.service.StartProxy(ctx); err != nil { + return app.ProxyStatusView{}, err + } + return b.service.GetProxyStatus(ctx) +} + +func (b *Bindings) StopProxy(ctx context.Context) (app.ProxyStatusView, error) { + if err := b.service.StopProxy(ctx); err != nil { + return app.ProxyStatusView{}, err + } + return b.service.GetProxyStatus(ctx) +} + +func (b *Bindings) Overview() (app.Overview, error) { + return b.GetOverview(context.Background()) +} + +func (b *Bindings) Providers() ([]app.ProviderView, error) { + return b.ListProviders(context.Background()) +} + +func (b *Bindings) Aliases() ([]app.AliasView, error) { + return b.ListAliases(context.Background()) +} + +func (b *Bindings) Doctor() (app.DoctorReport, error) { + return b.RunDoctor(context.Background()) +} + +func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) { + return b.GetProxyStatus(context.Background()) +} + +func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) { + return b.StartProxy(context.Background()) +} + +func (b *Bindings) StopProxyNow() (app.ProxyStatusView, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return b.StopProxy(ctx) +} + +func (b *Bindings) DesktopPrefs() (app.DesktopPrefsView, error) { + return b.GetDesktopPrefs(context.Background()) +} + +func (b *Bindings) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) { + return b.SaveDesktopPrefs(context.Background(), in) +} + +func (b *Bindings) PreviewSync(in app.SyncInput) (app.SyncPreview, error) { + return b.PreviewOpenCodeSync(context.Background(), in) +} + +func (b *Bindings) ApplySync(in app.SyncInput) (app.SyncResult, error) { + return b.SyncOpenCode(context.Background(), in) +} + +func (b *Bindings) GetDesktopPrefs(ctx context.Context) (app.DesktopPrefsView, error) { + return b.service.GetDesktopPrefs(ctx) +} + +func (b *Bindings) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) { + return b.service.SaveDesktopPrefs(ctx, in) +} diff --git a/internal/desktop/http.go b/internal/desktop/http.go new file mode 100644 index 0000000..8646f6b --- /dev/null +++ b/internal/desktop/http.go @@ -0,0 +1,323 @@ +package desktop + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "runtime" + "strings" + "syscall" + "time" + + frontendassets "github.com/Apale7/opencode-provider-switch/frontend" + appcore "github.com/Apale7/opencode-provider-switch/internal/app" + "github.com/Apale7/opencode-provider-switch/internal/config" +) + +type RunOptions struct { + ConfigPath string + Version string + ListenAddr string + OpenBrowser bool + ShutdownWait time.Duration +} + +type apiEnvelope struct { + Data any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type metaView struct { + Version string `json:"version"` + Shell string `json:"shell"` + URL string `json:"url"` +} + +func Run(opts RunOptions) error { + if strings.TrimSpace(opts.ConfigPath) == "" { + opts.ConfigPath = config.DefaultPath() + } + if strings.TrimSpace(opts.ListenAddr) == "" { + opts.ListenAddr = "127.0.0.1:0" + } + if opts.ShutdownWait <= 0 { + opts.ShutdownWait = 5 * time.Second + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + instance := New(opts.ConfigPath) + listener, err := net.Listen("tcp", opts.ListenAddr) + if err != nil { + return fmt.Errorf("listen desktop control panel: %w", err) + } + defer listener.Close() + + url := "http://" + listener.Addr().String() + handler, err := newHandler(instance, opts.Version, url) + if err != nil { + return err + } + + srv := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + errCh := make(chan error, 1) + go func() { + errCh <- srv.Serve(listener) + }() + + fmt.Printf("ocswitch desktop control panel: %s\n", url) + if opts.OpenBrowser { + if err := openBrowser(url); err != nil { + fmt.Fprintf(os.Stderr, "warning: open browser: %v\n", err) + } + } + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), opts.ShutdownWait) + defer cancel() + _ = instance.Service().StopProxy(shutdownCtx) + return srv.Shutdown(shutdownCtx) + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +func newHandler(instance *App, version string, baseURL string) (http.Handler, error) { + assets, err := frontendassets.DistFS() + if err != nil { + return nil, fmt.Errorf("load web assets: %w", err) + } + api := http.NewServeMux() + b := instance.Bindings() + + api.HandleFunc("/api/meta", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + writeJSON(w, http.StatusOK, apiEnvelope{Data: metaView{Version: version, Shell: instance.shellName(), URL: baseURL}}) + }) + + api.HandleFunc("/api/overview", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + data, err := b.GetOverview(r.Context()) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + data, err := b.ListProviders(r.Context()) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + data, err := b.ListAliases(r.Context()) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/desktop-prefs", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + data, err := b.GetDesktopPrefs(r.Context()) + writeResult(w, data, err) + case http.MethodPost: + var in appcore.DesktopPrefsInput + if !decodeJSONBody(w, r, &in) { + return + } + data, err := b.SaveDesktopPrefs(r.Context(), in) + writeResult(w, data, err) + default: + writeMethodNotAllowed(w, http.MethodGet, http.MethodPost) + } + }) + + api.HandleFunc("/api/proxy/status", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + data, err := b.GetProxyStatus(r.Context()) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + data, err := b.StartProxy(r.Context()) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/proxy/stop", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + data, err := b.StopProxy(ctx) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/doctor", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + data, err := b.RunDoctor(r.Context()) + writeJSON(w, http.StatusOK, apiEnvelope{Data: appcore.DoctorRunResult{Report: data, Error: errorString(err)}}) + }) + + api.HandleFunc("/api/opencode-sync/preview", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + var in appcore.SyncInput + if !decodeJSONBody(w, r, &in) { + return + } + data, err := b.PreviewOpenCodeSync(r.Context(), in) + writeResult(w, data, err) + }) + + api.HandleFunc("/api/opencode-sync/apply", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + var in appcore.SyncInput + if !decodeJSONBody(w, r, &in) { + return + } + data, err := b.SyncOpenCode(r.Context(), in) + writeResult(w, data, err) + }) + + fileServer := http.FileServer(http.FS(assets)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + api.ServeHTTP(w, r) + return + } + serveSPA(w, r, assets, fileServer) + }), nil +} + +func serveSPA(w http.ResponseWriter, r *http.Request, assets fs.FS, next http.Handler) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, err := fs.Stat(assets, path); err == nil { + next.ServeHTTP(w, r) + return + } + r = r.Clone(r.Context()) + r.URL.Path = "/index.html" + next.ServeHTTP(w, r) +} + +func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool { + defer r.Body.Close() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()}) + return false + } + if len(strings.TrimSpace(string(body))) == 0 { + return true + } + if err := json.Unmarshal(body, dst); err != nil { + writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: "invalid json: " + err.Error()}) + return false + } + return true +} + +func writeResult(w http.ResponseWriter, data any, err error) { + if err != nil { + writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, apiEnvelope{Data: data}) +} + +func writeMethodNotAllowed(w http.ResponseWriter, allowed ...string) { + if len(allowed) > 0 { + w.Header().Set("Allow", strings.Join(allowed, ", ")) + } + writeJSON(w, http.StatusMethodNotAllowed, apiEnvelope{Error: "method not allowed"}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func openBrowser(url string) error { + commands := browserCommands(url) + var errs []string + for _, args := range commands { + if _, err := exec.LookPath(args[0]); err != nil { + err = nil + continue + } + if err := exec.Command(args[0], args[1:]...).Start(); err == nil { + return nil + } else { + errs = append(errs, err.Error()) + } + } + if len(errs) == 0 { + return fmt.Errorf("no browser launcher found") + } + return fmt.Errorf(strings.Join(errs, "; ")) +} + +func browserCommands(url string) [][]string { + switch runtime.GOOS { + case "darwin": + return [][]string{{"open", url}} + case "windows": + return [][]string{{"rundll32", "url.dll,FileProtocolHandler", url}} + default: + return [][]string{{"xdg-open", url}, {"gio", "open", url}} + } +} diff --git a/internal/desktop/http_test.go b/internal/desktop/http_test.go new file mode 100644 index 0000000..8b5eb1c --- /dev/null +++ b/internal/desktop/http_test.go @@ -0,0 +1,104 @@ +package desktop + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/Apale7/opencode-provider-switch/internal/config" +) + +func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "ocswitch.json") + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "demo", + Name: "Demo", + BaseURL: "https://example.com/v1", + APIKey: "sk-demo-12345678", + Models: []string{"gpt-4.1-mini"}, + }) + cfg.UpsertAlias(config.Alias{ + Alias: "chat", + DisplayName: "Chat", + Enabled: true, + Targets: []config.Target{{ + Provider: "demo", + Model: "gpt-4.1-mini", + Enabled: true, + }}, + }) + if err := cfg.Save(); err != nil { + t.Fatalf("cfg.Save() error = %v", err) + } + + h, err := newHandler(New(path), "test", "http://127.0.0.1:9982") + if err != nil { + t.Fatalf("newHandler() error = %v", err) + } + + t.Run("overview api", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/overview", nil) + resp := httptest.NewRecorder() + h.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK) + } + var payload struct { + Data struct { + ProviderCount int `json:"providerCount"` + AliasCount int `json:"aliasCount"` + Aliases []string `json:"availableAliases"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if payload.Data.ProviderCount != 1 || payload.Data.AliasCount != 1 { + t.Fatalf("unexpected counts: %#v", payload.Data) + } + if len(payload.Data.Aliases) != 1 || payload.Data.Aliases[0] != "chat" { + t.Fatalf("unexpected aliases: %#v", payload.Data.Aliases) + } + }) + + t.Run("save desktop prefs", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + h.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK) + } + loaded, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load() error = %v", err) + } + if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications { + t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop) + } + }) + + t.Run("serves app shell", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp := httptest.NewRecorder() + h.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK) + } + if !strings.Contains(resp.Body.String(), "ocswitch desktop") { + t.Fatalf("unexpected body = %q", resp.Body.String()) + } + }) +} diff --git a/internal/desktop/notify.go b/internal/desktop/notify.go new file mode 100644 index 0000000..de27e00 --- /dev/null +++ b/internal/desktop/notify.go @@ -0,0 +1,25 @@ +package desktop + +import ( + "context" + + "github.com/Apale7/opencode-provider-switch/internal/app" +) + +// Notifier is the future native notification adapter. +type Notifier struct { + service *app.Service + ctx context.Context +} + +func NewNotifier(service *app.Service) *Notifier { + return &Notifier{service: service} +} + +func (n *Notifier) Attach(ctx context.Context) { + n.ctx = ctx +} + +func (n *Notifier) Detach() { + n.ctx = nil +} diff --git a/internal/desktop/runtime_fallback.go b/internal/desktop/runtime_fallback.go new file mode 100644 index 0000000..b0c9c89 --- /dev/null +++ b/internal/desktop/runtime_fallback.go @@ -0,0 +1,10 @@ +//go:build !desktop_wails + +package desktop + +import "context" + +func hideWindow(ctx context.Context) error { + _ = ctx + return nil +} diff --git a/internal/desktop/runtime_wails.go b/internal/desktop/runtime_wails.go new file mode 100644 index 0000000..84d42bc --- /dev/null +++ b/internal/desktop/runtime_wails.go @@ -0,0 +1,14 @@ +//go:build desktop_wails + +package desktop + +import ( + "context" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func hideWindow(ctx context.Context) error { + wruntime.Hide(ctx) + return nil +} diff --git a/internal/desktop/tray.go b/internal/desktop/tray.go new file mode 100644 index 0000000..3002a86 --- /dev/null +++ b/internal/desktop/tray.go @@ -0,0 +1,41 @@ +package desktop + +import ( + "context" + + "github.com/Apale7/opencode-provider-switch/internal/app" +) + +// Tray holds desktop resident-mode preferences. Wails v2 does not expose a +// stable public tray API in the current pinned version, so this type currently +// manages close-to-background behavior without depending on private APIs. +type Tray struct { + service *app.Service + ctx context.Context + prefs app.DesktopPrefsView +} + +func NewTray(service *app.Service) *Tray { + return &Tray{service: service} +} + +func (t *Tray) Attach(ctx context.Context) { + t.ctx = ctx +} + +func (t *Tray) Detach() { + t.ctx = nil +} + +func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) { + _ = ctx + t.prefs = prefs +} + +func (t *Tray) BeforeClose(ctx context.Context) (bool, error) { + _ = ctx + if !t.prefs.MinimizeToTray { + return false, nil + } + return true, hideWindow(ctx) +} diff --git a/internal/desktop/wails.go b/internal/desktop/wails.go new file mode 100644 index 0000000..6a1fbe0 --- /dev/null +++ b/internal/desktop/wails.go @@ -0,0 +1,58 @@ +//go:build desktop_wails + +package desktop + +import ( + "context" + "fmt" + "io/fs" + + frontendassets "github.com/Apale7/opencode-provider-switch/frontend" + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/linux" +) + +func RunWails(configPath string, version string) error { + assets, err := frontendassets.DistFS() + if err != nil { + return err + } + + instance := New(configPath) + instance.SetVersion(version) + + return wails.Run(&options.App{ + Title: "ocswitch desktop", + Width: 1280, + Height: 880, + MinWidth: 980, + MinHeight: 720, + HideWindowOnClose: true, + AssetServer: &assetserver.Options{ + Assets: mustFS(assets), + }, + Bind: []any{instance}, + OnStartup: func(ctx context.Context) { + instance.Startup(ctx) + }, + OnBeforeClose: func(ctx context.Context) bool { + return instance.BeforeClose(ctx) + }, + OnShutdown: func(ctx context.Context) { + instance.Shutdown(ctx) + }, + Linux: &linux.Options{ + ProgramName: "ocswitch-desktop", + }, + }) +} + +func mustFS(assets fs.FS) fs.FS { + return assets +} + +func WailsProjectName() string { + return fmt.Sprintf("%s desktop", "ocswitch") +} diff --git a/main_wails.go b/main_wails.go new file mode 100644 index 0000000..f6d95f8 --- /dev/null +++ b/main_wails.go @@ -0,0 +1,24 @@ +//go:build desktop_wails + +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/Apale7/opencode-provider-switch/internal/config" + "github.com/Apale7/opencode-provider-switch/internal/desktop" +) + +var version = "dev" + +func main() { + configPath := flag.String("config", "", fmt.Sprintf("path to %s config.json (default: %s)", config.AppName, config.DefaultPath())) + flag.Parse() + + if err := desktop.RunWails(*configPath, version); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..1992c78 --- /dev/null +++ b/wails.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "ocswitch-desktop", + "outputfilename": "ocswitch-desktop", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "Apale" + } +}