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 (
+
+
+
+
+
+
+
Overview
+
+ {overview?.proxy.running ? 'Proxy running' : 'Proxy idle'}
+
+
+
+ {stats.map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+ void onStartProxy()}>
+ Start proxy
+
+ void onStopProxy()}>
+ Stop proxy
+
+
+ {overview ? pretty(overview) : 'Loading overview...'}
+
+
+
+
+
Desktop prefs
+ {prefsStatus}
+
+
+
+
+
+
+
OpenCode sync
+ {syncStatus}
+
+
+ {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"
+ }
+}