feat(desktop): finish Windows GUI integration
Bring the Wails desktop shell to feature parity with the CLI for provider and alias management while keeping the browser fallback working. Wire tray, notifications, autostart, and GUI warnings so desktop flows behave predictably.
This commit is contained in:
parent
29d0cabbd1
commit
5c4f60f2b7
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@
|
||||
/dist/
|
||||
*.tmp
|
||||
output
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
@ -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",
|
||||
|
||||
100
.trellis/tasks/04-18-windows-desktop-integrations/prd.md
Normal file
100
.trellis/tasks/04-18-windows-desktop-integrations/prd.md
Normal file
@ -0,0 +1,100 @@
|
||||
# Windows Desktop Integrations
|
||||
|
||||
## Summary
|
||||
|
||||
`ocswitch desktop` already has shared Go application services, a React control panel, a browser fallback shell, and a Wails window wrapper. What is still missing is the set of desktop-native integrations that make the Windows build behave like a real resident utility instead of only a packaged webview.
|
||||
|
||||
This task completes that gap with a minimal extension to the current architecture:
|
||||
|
||||
- fix the Wails frontend bridge so the GUI actually talks to Go bindings
|
||||
- add native tray integration for the Wails desktop build
|
||||
- add native notification delivery for desktop events
|
||||
- add launch-at-login support for Windows
|
||||
- preserve the existing browser fallback shell and shared service layer
|
||||
|
||||
## Product Goal
|
||||
|
||||
Provide a Windows-friendly desktop shell for `ocswitch` that can stay resident, expose quick tray controls, surface important events through native notifications, and reopen automatically at login without rewriting existing business logic.
|
||||
|
||||
## Current State
|
||||
|
||||
### Already done
|
||||
|
||||
- `internal/app` owns shared workflows for overview, proxy control, doctor, desktop preferences, and OpenCode sync.
|
||||
- `internal/desktop/http.go` already provides a browser fallback shell with the same workflows.
|
||||
- `frontend/src/App.tsx` already renders the main control panel for overview, desktop prefs, sync, doctor, providers, and aliases.
|
||||
- `internal/desktop/wails.go` already boots a Wails window and hides on close.
|
||||
|
||||
### Missing
|
||||
|
||||
- frontend Wails bridge still points at `window.go.main.App` even though generated bindings live at `window.go.desktop.App`
|
||||
- tray support is only a placeholder and does not expose resident controls
|
||||
- notifications are only a placeholder and do not use Wails runtime APIs
|
||||
- launch-at-login only supports Linux XDG autostart today
|
||||
|
||||
## Requirements
|
||||
|
||||
### Desktop integration
|
||||
|
||||
- Wails desktop build must expose a real system tray on Windows.
|
||||
- Tray menu must at least support opening the window, hiding the window, starting the proxy, stopping the proxy, and quitting the app.
|
||||
- Close-to-background behavior must continue to respect `minimizeToTray` preference.
|
||||
- Native notifications must be available when `notifications` preference is enabled.
|
||||
- Native launch-at-login must be available on Windows through a practical low-complexity mechanism.
|
||||
|
||||
### Reuse and layering
|
||||
|
||||
- Keep `internal/app` as the owner of shared workflows and state transitions.
|
||||
- Keep `internal/desktop` focused on shell integration only.
|
||||
- Do not duplicate proxy, config, or sync business rules inside tray or frontend code.
|
||||
- Browser fallback shell must remain operational.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Tray implementation
|
||||
|
||||
Wails v2 does not provide a stable public tray API, so tray support should use `github.com/getlantern/systray` inside the `desktop_wails` build.
|
||||
|
||||
Why this path:
|
||||
|
||||
- lowest implementation cost in current Go codebase
|
||||
- already present in module graph
|
||||
- works alongside Wails window lifecycle
|
||||
- avoids replacing the desktop shell framework
|
||||
|
||||
### Notification implementation
|
||||
|
||||
Use Wails runtime notification APIs for the desktop build. This keeps notifications native and avoids introducing another platform adapter.
|
||||
|
||||
Expected event coverage:
|
||||
|
||||
- proxy started
|
||||
- proxy stopped
|
||||
- OpenCode sync applied
|
||||
- notifications preference enabled
|
||||
|
||||
### Windows launch-at-login
|
||||
|
||||
Implement Windows startup integration by writing a `.cmd` launcher into the user Startup folder.
|
||||
|
||||
Why this path:
|
||||
|
||||
- much simpler than generating `.lnk`
|
||||
- good enough for a local utility
|
||||
- easy to inspect and delete
|
||||
|
||||
## Non-Goals
|
||||
|
||||
1. No migration from Wails to Fyne unless current approach proves unworkable.
|
||||
2. No tray support requirement for browser fallback mode.
|
||||
3. No redesign of the React control panel information architecture.
|
||||
4. No expansion into advanced desktop telemetry or background sync daemons.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Wails frontend can successfully call Go bindings through generated `desktop` namespace.
|
||||
2. Windows desktop build can be hidden and restored through tray controls.
|
||||
3. Tray can start and stop proxy without reopening main window.
|
||||
4. Enabling notifications allows native desktop alerts for key lifecycle events.
|
||||
5. Enabling launch at login creates Windows startup entry; disabling removes it.
|
||||
6. Existing browser fallback flow and current tests remain green.
|
||||
80
.trellis/tasks/04-18-windows-desktop-integrations/task.json
Normal file
80
.trellis/tasks/04-18-windows-desktop-integrations/task.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"id": "windows-desktop-integrations",
|
||||
"name": "windows-desktop-integrations",
|
||||
"title": "Finish Windows desktop integrations",
|
||||
"description": "Finish the desktop-shell implementation for Windows by wiring the Wails bridge correctly, adding tray controls, native notifications, and launch-at-login support without breaking the browser fallback shell.",
|
||||
"status": "completed",
|
||||
"dev_type": "feature",
|
||||
"scope": "desktop",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "OpenCode",
|
||||
"assignee": "OpenCode",
|
||||
"createdAt": "2026-04-18",
|
||||
"completedAt": "2026-04-18",
|
||||
"branch": null,
|
||||
"base_branch": "master",
|
||||
"worktree_path": null,
|
||||
"current_phase": 6,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "brainstorm"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "research"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"action": "update-spec"
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"action": "record-session"
|
||||
}
|
||||
],
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/tasks/04-18-windows-desktop-integrations/prd.md",
|
||||
"internal/desktop/app.go",
|
||||
"internal/desktop/tray.go",
|
||||
"internal/desktop/tray_wails.go",
|
||||
"internal/desktop/notify.go",
|
||||
"internal/desktop/autostart.go",
|
||||
"internal/desktop/autostart_test.go",
|
||||
"internal/desktop/runtime_wails.go",
|
||||
"internal/desktop/runtime_fallback.go",
|
||||
"internal/fileutil/fileutil.go",
|
||||
"internal/fileutil/lock_unix.go",
|
||||
"internal/fileutil/lock_windows.go",
|
||||
"internal/config/config_test.go",
|
||||
"internal/config/file_lock_testhelper_unix_test.go",
|
||||
"internal/config/file_lock_testhelper_windows_test.go",
|
||||
"frontend/index.html",
|
||||
"frontend/src/api.ts",
|
||||
"frontend/src/env.d.ts",
|
||||
"frontend/tsconfig.json",
|
||||
"frontend/dist/index.html",
|
||||
"frontend/dist/assets/"
|
||||
],
|
||||
"notes": "Completed the Windows desktop integration pass on top of the existing desktop shell GUI. Fixed the Wails frontend bridge namespace mismatch, added a real Wails-only tray implementation with systray, wired native notifications through Wails runtime, added Windows Startup-folder launch-at-login support, and fixed file locking so Windows desktop/config flows can build and test. Verification passed for `npm run build`, `go test ./internal/desktop`, `go test -tags desktop_wails ./internal/desktop`, and `go test ./internal/config ./internal/opencode`.",
|
||||
"meta": {
|
||||
"wailsBridgeNamespace": "window.go.desktop.App",
|
||||
"trayImplementation": "github.com/getlantern/systray",
|
||||
"notificationImplementation": "wails runtime notifications",
|
||||
"windowsAutostart": "Startup folder .cmd launcher"
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 10
|
||||
- **Total Sessions**: 12
|
||||
- **Last Active**: 2026-04-18
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~420 | Active |
|
||||
| `journal-1.md` | ~525 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@ -29,6 +29,8 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | 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` |
|
||||
|
||||
@ -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
|
||||
|
||||
32
cmd/ocswitch-desktop/main_fallback.go
Normal file
32
cmd/ocswitch-desktop/main_fallback.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
25
cmd/ocswitch-desktop/main_wails.go
Normal file
25
cmd/ocswitch-desktop/main_wails.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
13
frontend/assets.go
Normal file
13
frontend/assets.go
Normal file
@ -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")
|
||||
}
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ocswitch desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1852
frontend/package-lock.json
generated
Normal file
1852
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
1
frontend/package.json.md5
Executable file
1
frontend/package.json.md5
Executable file
@ -0,0 +1 @@
|
||||
2f0b51396a8b6c44ea214228c533bdbe
|
||||
881
frontend/src/App.tsx
Normal file
881
frontend/src/App.tsx
Normal file
@ -0,0 +1,881 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
applySync,
|
||||
bindAliasTarget,
|
||||
deleteAlias,
|
||||
deleteProvider,
|
||||
getMeta,
|
||||
getOverview,
|
||||
importProviders,
|
||||
listAliases,
|
||||
listProviders,
|
||||
previewSync,
|
||||
runDoctor,
|
||||
saveAlias,
|
||||
saveDesktopPrefs,
|
||||
saveProvider,
|
||||
setAliasTargetState,
|
||||
setProviderState,
|
||||
startProxy,
|
||||
stopProxy,
|
||||
unbindAliasTarget,
|
||||
} from './api'
|
||||
import type {
|
||||
AliasTargetInput,
|
||||
AliasView,
|
||||
AliasUpsertInput,
|
||||
DesktopPrefsSaveResult,
|
||||
DesktopPrefsView,
|
||||
DoctorRunResult,
|
||||
Overview,
|
||||
ProviderImportInput,
|
||||
ProviderImportResult,
|
||||
ProviderSaveResult,
|
||||
ProviderUpsertInput,
|
||||
ProviderView,
|
||||
SyncInput,
|
||||
SyncPreview,
|
||||
SyncResult,
|
||||
} from './types'
|
||||
|
||||
type MetaState = {
|
||||
version: string
|
||||
shell: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
type ProviderFormState = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
headersText: string
|
||||
disabled: boolean
|
||||
skipModels: boolean
|
||||
clearHeaders: boolean
|
||||
}
|
||||
|
||||
type AliasFormState = {
|
||||
alias: string
|
||||
displayName: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const emptyPrefs: DesktopPrefsView = {
|
||||
launchAtLogin: false,
|
||||
minimizeToTray: false,
|
||||
notifications: false,
|
||||
}
|
||||
|
||||
const emptySync: SyncInput = {
|
||||
target: '',
|
||||
setModel: '',
|
||||
setSmallModel: '',
|
||||
}
|
||||
|
||||
const emptyProviderForm: ProviderFormState = {
|
||||
id: '',
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
headersText: '',
|
||||
disabled: false,
|
||||
skipModels: false,
|
||||
clearHeaders: false,
|
||||
}
|
||||
|
||||
const emptyProviderImport: ProviderImportInput = {
|
||||
sourcePath: '',
|
||||
overwrite: false,
|
||||
}
|
||||
|
||||
const emptyAliasForm: AliasFormState = {
|
||||
alias: '',
|
||||
displayName: '',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
const emptyTargetForm: AliasTargetInput = {
|
||||
alias: '',
|
||||
provider: '',
|
||||
model: '',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
function pretty(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function headersTextFromMap(headers?: Record<string, string>): string {
|
||||
if (!headers || Object.keys(headers).length === 0) {
|
||||
return ''
|
||||
}
|
||||
return Object.entries(headers)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function parseHeadersText(input: string): Record<string, string> | undefined {
|
||||
const lines = input
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
for (const line of lines) {
|
||||
const index = line.indexOf('=')
|
||||
if (index <= 0) {
|
||||
throw new Error(`invalid header ${JSON.stringify(line)}; use KEY=VALUE`)
|
||||
}
|
||||
const key = line.slice(0, index).trim().toLowerCase()
|
||||
const value = line.slice(index + 1).trim()
|
||||
if (!key) {
|
||||
throw new Error(`invalid header ${JSON.stringify(line)}; header name must not be empty`)
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function joinWarnings(warnings?: string[]): string {
|
||||
if (!warnings || warnings.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return warnings.join(' | ')
|
||||
}
|
||||
|
||||
function providerSaveStatus(result: ProviderSaveResult): string {
|
||||
const base = `Saved provider ${result.provider.id}`
|
||||
const warnings = joinWarnings(result.warnings)
|
||||
return warnings ? `${base}. Warnings: ${warnings}` : base
|
||||
}
|
||||
|
||||
function providerImportStatus(result: ProviderImportResult): string {
|
||||
const base = `Import done: imported=${result.imported}, skipped=${result.skipped}`
|
||||
const warnings = joinWarnings(result.warnings)
|
||||
return warnings ? `${base}. Warnings: ${warnings}` : base
|
||||
}
|
||||
|
||||
function desktopPrefsSaveStatus(result: DesktopPrefsSaveResult): string {
|
||||
const warnings = joinWarnings(result.warnings)
|
||||
return warnings ? `Saved. Warnings: ${warnings}` : 'Saved'
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [meta, setMeta] = useState<MetaState>({ version: 'dev', shell: 'loading' })
|
||||
const [overview, setOverview] = useState<Overview | null>(null)
|
||||
const [providers, setProviders] = useState<ProviderView[]>([])
|
||||
const [aliases, setAliases] = useState<AliasView[]>([])
|
||||
const [prefs, setPrefs] = useState<DesktopPrefsView>(emptyPrefs)
|
||||
const [prefsStatus, setPrefsStatus] = useState('')
|
||||
const [doctorStatus, setDoctorStatus] = useState('')
|
||||
const [doctorResult, setDoctorResult] = useState<DoctorRunResult | null>(null)
|
||||
const [syncStatus, setSyncStatus] = useState('')
|
||||
const [syncInput, setSyncInput] = useState<SyncInput>(emptySync)
|
||||
const [syncOutput, setSyncOutput] = useState<SyncPreview | SyncResult | string>('')
|
||||
const [providerStatus, setProviderStatus] = useState('')
|
||||
const [providerForm, setProviderForm] = useState<ProviderFormState>(emptyProviderForm)
|
||||
const [providerImportForm, setProviderImportForm] = useState<ProviderImportInput>(emptyProviderImport)
|
||||
const [aliasStatus, setAliasStatus] = useState('')
|
||||
const [aliasForm, setAliasForm] = useState<AliasFormState>(emptyAliasForm)
|
||||
const [targetForm, setTargetForm] = useState<AliasTargetInput>(emptyTargetForm)
|
||||
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.prefs)
|
||||
await refreshAll()
|
||||
setPrefsStatus(desktopPrefsSaveStatus(saved))
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProvider(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setProviderStatus('Saving provider...')
|
||||
try {
|
||||
const input: ProviderUpsertInput = {
|
||||
id: providerForm.id.trim(),
|
||||
name: providerForm.name.trim(),
|
||||
baseUrl: providerForm.baseUrl.trim(),
|
||||
apiKey: providerForm.apiKey,
|
||||
headers: parseHeadersText(providerForm.headersText),
|
||||
disabled: providerForm.disabled,
|
||||
skipModels: providerForm.skipModels,
|
||||
clearHeaders: providerForm.clearHeaders,
|
||||
}
|
||||
const result = await saveProvider(input)
|
||||
setProviderForm(emptyProviderForm)
|
||||
setProviderStatus(providerSaveStatus(result))
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setProviderStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onImportProviders(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setProviderStatus('Importing providers...')
|
||||
try {
|
||||
const result = await importProviders({
|
||||
sourcePath: providerImportForm.sourcePath?.trim(),
|
||||
overwrite: providerImportForm.overwrite,
|
||||
})
|
||||
setProviderStatus(providerImportStatus(result))
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setProviderStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function onEditProvider(provider: ProviderView) {
|
||||
setProviderForm({
|
||||
id: provider.id,
|
||||
name: provider.name || '',
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: '',
|
||||
headersText: headersTextFromMap(provider.headers),
|
||||
disabled: provider.disabled,
|
||||
skipModels: false,
|
||||
clearHeaders: false,
|
||||
})
|
||||
setProviderStatus(`Editing provider ${provider.id}. Leave API key blank to keep current value.`)
|
||||
}
|
||||
|
||||
async function onToggleProvider(provider: ProviderView) {
|
||||
setProviderStatus(`${provider.disabled ? 'Enabling' : 'Disabling'} provider ${provider.id}...`)
|
||||
try {
|
||||
await setProviderState({ id: provider.id, disabled: !provider.disabled })
|
||||
setProviderStatus(`${provider.disabled ? 'Enabled' : 'Disabled'} provider ${provider.id}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setProviderStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteProvider(id: string) {
|
||||
setProviderStatus(`Deleting provider ${id}...`)
|
||||
try {
|
||||
await deleteProvider(id)
|
||||
setProviderStatus(`Deleted provider ${id}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setProviderStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveAlias(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setAliasStatus('Saving alias...')
|
||||
try {
|
||||
const input: AliasUpsertInput = {
|
||||
alias: aliasForm.alias.trim(),
|
||||
displayName: aliasForm.displayName.trim(),
|
||||
disabled: aliasForm.disabled,
|
||||
}
|
||||
await saveAlias(input)
|
||||
setAliasForm(emptyAliasForm)
|
||||
setAliasStatus(`Saved alias ${input.alias}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setAliasStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function onEditAlias(alias: AliasView) {
|
||||
setAliasForm({
|
||||
alias: alias.alias,
|
||||
displayName: alias.displayName || '',
|
||||
disabled: !alias.enabled,
|
||||
})
|
||||
setTargetForm((current) => ({ ...current, alias: alias.alias }))
|
||||
setAliasStatus(`Editing alias ${alias.alias}`)
|
||||
}
|
||||
|
||||
async function onDeleteAlias(alias: string) {
|
||||
setAliasStatus(`Deleting alias ${alias}...`)
|
||||
try {
|
||||
await deleteAlias(alias)
|
||||
setAliasStatus(`Deleted alias ${alias}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setAliasStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBindTarget(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setAliasStatus('Binding target...')
|
||||
try {
|
||||
const input: AliasTargetInput = {
|
||||
alias: targetForm.alias.trim(),
|
||||
provider: targetForm.provider.trim(),
|
||||
model: targetForm.model.trim(),
|
||||
disabled: targetForm.disabled,
|
||||
}
|
||||
await bindAliasTarget(input)
|
||||
setTargetForm((current) => ({ ...emptyTargetForm, alias: current.alias }))
|
||||
setAliasStatus(`Bound ${input.provider}/${input.model} to ${input.alias}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setAliasStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnbindTarget(alias: string, provider: string, model: string) {
|
||||
setAliasStatus(`Removing ${provider}/${model} from ${alias}...`)
|
||||
try {
|
||||
await unbindAliasTarget({ alias, provider, model, disabled: false })
|
||||
setAliasStatus(`Removed ${provider}/${model} from ${alias}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setAliasStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleTarget(alias: string, provider: string, model: string, currentlyDisabled: boolean) {
|
||||
setAliasStatus(`${currentlyDisabled ? 'Enabling' : 'Disabling'} ${provider}/${model} on ${alias}...`)
|
||||
try {
|
||||
await setAliasTargetState({ alias, provider, model, disabled: !currentlyDisabled })
|
||||
setAliasStatus(`${currentlyDisabled ? 'Enabled' : 'Disabled'} ${provider}/${model} on ${alias}`)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
setAliasStatus(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const stats = overview
|
||||
? [
|
||||
['Providers', String(overview.providerCount)],
|
||||
['Aliases', String(overview.aliasCount)],
|
||||
['Routable aliases', String(overview.availableAliases.length)],
|
||||
['Proxy', overview.proxy.running ? 'Running' : 'Idle'],
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">ocswitch desktop</p>
|
||||
<h1>Native control panel</h1>
|
||||
<p className="subtle">
|
||||
{meta.version || 'dev'} · {meta.shell} shell · {overview?.configPath || 'loading config'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<button type="button" className="primary" onClick={() => void refreshAll()} disabled={loading}>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" onClick={() => void onRunDoctor()}>
|
||||
Run doctor
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid">
|
||||
<section className="panel overview-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Overview</h2>
|
||||
<span className={`badge ${overview?.proxy.running ? 'live' : 'idle'}`}>
|
||||
{overview?.proxy.running ? 'Proxy running' : 'Proxy idle'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stats">
|
||||
{stats.map(([label, value]) => (
|
||||
<div className="stat" key={label}>
|
||||
<span className="stat-label">{label}</span>
|
||||
<span className="stat-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button type="button" className="primary" onClick={() => void onStartProxy()}>
|
||||
Start proxy
|
||||
</button>
|
||||
<button type="button" onClick={() => void onStopProxy()}>
|
||||
Stop proxy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="details">{overview ? pretty(overview) : 'Loading overview...'}</pre>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Desktop prefs</h2>
|
||||
<span className="subtle">{prefsStatus}</span>
|
||||
</div>
|
||||
<form className="stack" onSubmit={(event) => void onSavePrefs(event)}>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.launchAtLogin}
|
||||
onChange={(event) => setPrefs((current) => ({ ...current, launchAtLogin: event.target.checked }))}
|
||||
/>
|
||||
<span>Launch at login</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.minimizeToTray}
|
||||
onChange={(event) => setPrefs((current) => ({ ...current, minimizeToTray: event.target.checked }))}
|
||||
/>
|
||||
<span>Minimize to tray / close to background</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.notifications}
|
||||
onChange={(event) => setPrefs((current) => ({ ...current, notifications: event.target.checked }))}
|
||||
/>
|
||||
<span>Native notifications</span>
|
||||
</label>
|
||||
<button type="submit" className="primary">
|
||||
Save settings
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>OpenCode sync</h2>
|
||||
<span className="subtle">{syncStatus}</span>
|
||||
</div>
|
||||
<form className="stack" onSubmit={(event) => void onApplySync(event)}>
|
||||
<label>
|
||||
<span>Target path</span>
|
||||
<input
|
||||
type="text"
|
||||
value={syncInput.target || ''}
|
||||
onChange={(event) => setSyncInput((current) => ({ ...current, target: event.target.value }))}
|
||||
placeholder="Use default OpenCode config"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={syncInput.setModel || ''}
|
||||
onChange={(event) => setSyncInput((current) => ({ ...current, setModel: event.target.value }))}
|
||||
placeholder="ocswitch/<alias>"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>small_model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={syncInput.setSmallModel || ''}
|
||||
onChange={(event) => setSyncInput((current) => ({ ...current, setSmallModel: event.target.value }))}
|
||||
placeholder="ocswitch/<alias>"
|
||||
/>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button type="button" onClick={() => void onPreviewSync()}>
|
||||
Preview
|
||||
</button>
|
||||
<button type="submit" className="primary">
|
||||
Apply sync
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<pre className="details">{typeof syncOutput === 'string' ? syncOutput : pretty(syncOutput)}</pre>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Doctor report</h2>
|
||||
<span className={`subtle ${doctorResult?.error ? 'tone-error' : 'tone-ok'}`}>{doctorStatus}</span>
|
||||
</div>
|
||||
<pre className="details">{doctorResult ? pretty(doctorResult) : 'Run doctor to inspect config and OpenCode wiring.'}</pre>
|
||||
</section>
|
||||
|
||||
<section className="panel wide">
|
||||
<div className="panel-header">
|
||||
<h2>Providers</h2>
|
||||
<span className="subtle">{providerStatus || `${providers.length} total`}</span>
|
||||
</div>
|
||||
<div className="split-layout">
|
||||
<div className="stack">
|
||||
<form className="stack" onSubmit={(event) => void onSaveProvider(event)}>
|
||||
<label>
|
||||
<span>Provider id</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerForm.id}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, id: event.target.value }))}
|
||||
placeholder="su8"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerForm.name}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="SU8"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Base URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerForm.baseUrl}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, baseUrl: event.target.value }))}
|
||||
placeholder="https://example.com/v1"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>API key</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerForm.apiKey}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, apiKey: event.target.value }))}
|
||||
placeholder="Leave blank to keep existing key when editing"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Headers</span>
|
||||
<textarea
|
||||
value={providerForm.headersText}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, headersText: event.target.value }))}
|
||||
placeholder={'X-Token=abc\nX-Workspace=my-team'}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={providerForm.disabled}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, disabled: event.target.checked }))}
|
||||
/>
|
||||
<span>Save as disabled</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={providerForm.skipModels}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, skipModels: event.target.checked }))}
|
||||
/>
|
||||
<span>Skip /v1/models discovery</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={providerForm.clearHeaders}
|
||||
onChange={(event) => setProviderForm((current) => ({ ...current, clearHeaders: event.target.checked }))}
|
||||
/>
|
||||
<span>Clear saved headers before update</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button type="submit" className="primary">
|
||||
Save provider
|
||||
</button>
|
||||
<button type="button" onClick={() => setProviderForm(emptyProviderForm)}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form className="stack" onSubmit={(event) => void onImportProviders(event)}>
|
||||
<label>
|
||||
<span>Import from OpenCode config</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerImportForm.sourcePath || ''}
|
||||
onChange={(event) => setProviderImportForm((current) => ({ ...current, sourcePath: event.target.value }))}
|
||||
placeholder="Leave blank to use global OpenCode config"
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={providerImportForm.overwrite}
|
||||
onChange={(event) => setProviderImportForm((current) => ({ ...current, overwrite: event.target.checked }))}
|
||||
/>
|
||||
<span>Overwrite existing providers</span>
|
||||
</label>
|
||||
<button type="submit">Import providers</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="list">
|
||||
{providers.length === 0 ? <p className="subtle">No providers configured yet.</p> : null}
|
||||
{providers.map((provider) => (
|
||||
<article className="item-card" key={provider.id}>
|
||||
<div>
|
||||
<strong>{provider.name || provider.id}</strong>
|
||||
<br />
|
||||
<code>{provider.baseUrl}</code>
|
||||
</div>
|
||||
<div className="item-meta">
|
||||
API key: {provider.apiKeyMasked || 'not set'}
|
||||
<br />
|
||||
Headers: {headersTextFromMap(provider.headers) || 'none'}
|
||||
<br />
|
||||
Models: {provider.models?.join(', ') || 'none'}
|
||||
<br />
|
||||
Status: {provider.disabled ? 'disabled' : 'enabled'}
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button type="button" onClick={() => onEditProvider(provider)}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => void onToggleProvider(provider)}>
|
||||
{provider.disabled ? 'Enable' : 'Disable'}
|
||||
</button>
|
||||
<button type="button" onClick={() => void onDeleteProvider(provider.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel wide">
|
||||
<div className="panel-header">
|
||||
<h2>Aliases</h2>
|
||||
<span className="subtle">{aliasStatus || `${aliases.length} total`}</span>
|
||||
</div>
|
||||
<div className="split-layout">
|
||||
<div className="stack">
|
||||
<form className="stack" onSubmit={(event) => void onSaveAlias(event)}>
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
<input
|
||||
type="text"
|
||||
value={aliasForm.alias}
|
||||
onChange={(event) => setAliasForm((current) => ({ ...current, alias: event.target.value }))}
|
||||
placeholder="gpt-5.4"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={aliasForm.displayName}
|
||||
onChange={(event) => setAliasForm((current) => ({ ...current, displayName: event.target.value }))}
|
||||
placeholder="GPT 5.4"
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aliasForm.disabled}
|
||||
onChange={(event) => setAliasForm((current) => ({ ...current, disabled: event.target.checked }))}
|
||||
/>
|
||||
<span>Create or keep disabled</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button type="submit" className="primary">
|
||||
Save alias
|
||||
</button>
|
||||
<button type="button" onClick={() => setAliasForm(emptyAliasForm)}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form className="stack" onSubmit={(event) => void onBindTarget(event)}>
|
||||
<label>
|
||||
<span>Alias for binding</span>
|
||||
<input
|
||||
type="text"
|
||||
value={targetForm.alias}
|
||||
onChange={(event) => setTargetForm((current) => ({ ...current, alias: event.target.value }))}
|
||||
placeholder="Existing alias or new alias"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Provider id</span>
|
||||
<input
|
||||
type="text"
|
||||
value={targetForm.provider}
|
||||
onChange={(event) => setTargetForm((current) => ({ ...current, provider: event.target.value }))}
|
||||
placeholder="su8"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={targetForm.model}
|
||||
onChange={(event) => setTargetForm((current) => ({ ...current, model: event.target.value }))}
|
||||
placeholder="gpt-5.4"
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targetForm.disabled}
|
||||
onChange={(event) => setTargetForm((current) => ({ ...current, disabled: event.target.checked }))}
|
||||
/>
|
||||
<span>Add target disabled</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button type="submit">Bind target</button>
|
||||
<button type="button" onClick={() => setTargetForm(emptyTargetForm)}>
|
||||
Reset target form
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="list">
|
||||
{aliases.length === 0 ? <p className="subtle">No aliases configured yet.</p> : null}
|
||||
{aliases.map((alias) => (
|
||||
<article className="item-card" key={alias.alias}>
|
||||
<div>
|
||||
<strong>{alias.displayName || alias.alias}</strong>
|
||||
<br />
|
||||
<code>{alias.alias}</code>
|
||||
</div>
|
||||
<div className="item-meta">
|
||||
Targets: {alias.availableTargetCount}/{alias.targetCount} routable
|
||||
<br />
|
||||
Status: {alias.enabled ? 'enabled' : 'disabled'}
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button type="button" onClick={() => onEditAlias(alias)}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => setTargetForm((current) => ({ ...current, alias: alias.alias }))}>
|
||||
Use in bind form
|
||||
</button>
|
||||
<button type="button" onClick={() => void onDeleteAlias(alias.alias)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="list compact-list">
|
||||
{alias.targets.length === 0 ? <p className="subtle">No targets bound.</p> : null}
|
||||
{alias.targets.map((target) => (
|
||||
<div className="item-card nested-card" key={`${alias.alias}-${target.provider}-${target.model}`}>
|
||||
<div>
|
||||
<code>
|
||||
{target.provider}/{target.model}
|
||||
</code>
|
||||
{!target.enabled ? ' (disabled)' : ''}
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onToggleTarget(alias.alias, target.provider, target.model, !target.enabled)
|
||||
}
|
||||
>
|
||||
{target.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onUnbindTarget(alias.alias, target.provider, target.model)
|
||||
}
|
||||
>
|
||||
Unbind
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
frontend/src/api.ts
Normal file
165
frontend/src/api.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import type {
|
||||
AliasTargetInput,
|
||||
AliasUpsertInput,
|
||||
DesktopPrefsSaveResult,
|
||||
AliasView,
|
||||
DesktopPrefsView,
|
||||
DoctorRunResult,
|
||||
MetaView,
|
||||
Overview,
|
||||
ProviderImportInput,
|
||||
ProviderImportResult,
|
||||
ProviderSaveResult,
|
||||
ProviderStateInput,
|
||||
ProviderUpsertInput,
|
||||
ProviderView,
|
||||
ProxyStatusView,
|
||||
SyncInput,
|
||||
SyncPreview,
|
||||
SyncResult,
|
||||
} from './types'
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
data: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
function isWails(): boolean {
|
||||
return typeof window.go?.desktop?.App !== 'undefined'
|
||||
}
|
||||
|
||||
async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
const payload = (await response.json()) as ApiEnvelope<T>
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'request failed')
|
||||
}
|
||||
return payload.data
|
||||
}
|
||||
|
||||
function bridge() {
|
||||
const app = window.go?.desktop?.App
|
||||
if (!app) {
|
||||
throw new Error('Wails bridge unavailable')
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
export async function getMeta(): Promise<MetaView> {
|
||||
if (isWails()) {
|
||||
const data = await bridge().Meta()
|
||||
return { version: data.version || 'dev', shell: data.shell || 'wails' }
|
||||
}
|
||||
return http<MetaView>('/api/meta')
|
||||
}
|
||||
|
||||
export function getOverview(): Promise<Overview> {
|
||||
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
|
||||
}
|
||||
|
||||
export function listProviders(): Promise<ProviderView[]> {
|
||||
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
|
||||
}
|
||||
|
||||
export function listAliases(): Promise<AliasView[]> {
|
||||
return isWails() ? bridge().Aliases() : http<AliasView[]>('/api/aliases')
|
||||
}
|
||||
|
||||
export function saveProvider(input: ProviderUpsertInput): Promise<ProviderSaveResult> {
|
||||
return isWails()
|
||||
? bridge().SaveProvider(input)
|
||||
: http<ProviderSaveResult>('/api/providers', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function setProviderState(input: ProviderStateInput): Promise<ProviderView> {
|
||||
return isWails()
|
||||
? bridge().SetProviderState(input)
|
||||
: http<ProviderView>('/api/providers/state', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: string): Promise<void> {
|
||||
if (isWails()) {
|
||||
await bridge().DeleteProvider(id)
|
||||
return
|
||||
}
|
||||
await http<{ ok: boolean }>('/api/providers/delete', { method: 'POST', body: JSON.stringify({ id }) })
|
||||
}
|
||||
|
||||
export function importProviders(input: ProviderImportInput): Promise<ProviderImportResult> {
|
||||
return isWails()
|
||||
? bridge().ImportProviders(input)
|
||||
: http<ProviderImportResult>('/api/providers/import', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function saveAlias(input: AliasUpsertInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().SaveAlias(input)
|
||||
: http<AliasView>('/api/aliases', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export async function deleteAlias(alias: string): Promise<void> {
|
||||
if (isWails()) {
|
||||
await bridge().DeleteAlias(alias)
|
||||
return
|
||||
}
|
||||
await http<{ ok: boolean }>('/api/aliases/delete', { method: 'POST', body: JSON.stringify({ alias }) })
|
||||
}
|
||||
|
||||
export function bindAliasTarget(input: AliasTargetInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().BindTarget(input)
|
||||
: http<AliasView>('/api/aliases/bind', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function setAliasTargetState(input: AliasTargetInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().SetTargetState(input)
|
||||
: http<AliasView>('/api/aliases/state', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function unbindAliasTarget(input: AliasTargetInput): Promise<AliasView> {
|
||||
return isWails()
|
||||
? bridge().UnbindTarget(input)
|
||||
: http<AliasView>('/api/aliases/unbind', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function getDesktopPrefs(): Promise<DesktopPrefsView> {
|
||||
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
|
||||
}
|
||||
|
||||
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsSaveResult> {
|
||||
return isWails()
|
||||
? bridge().SavePrefs(input)
|
||||
: http<DesktopPrefsSaveResult>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function runDoctor(): Promise<DoctorRunResult> {
|
||||
return isWails() ? bridge().DoctorRun() : http<DoctorRunResult>('/api/doctor', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getProxyStatus(): Promise<ProxyStatusView> {
|
||||
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
|
||||
}
|
||||
|
||||
export function startProxy(): Promise<ProxyStatusView> {
|
||||
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function stopProxy(): Promise<ProxyStatusView> {
|
||||
return isWails() ? bridge().StopProxy() : http<ProxyStatusView>('/api/proxy/stop', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function previewSync(input: SyncInput): Promise<SyncPreview> {
|
||||
return isWails()
|
||||
? bridge().PreviewSync(input)
|
||||
: http<SyncPreview>('/api/opencode-sync/preview', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
|
||||
export function applySync(input: SyncInput): Promise<SyncResult> {
|
||||
return isWails()
|
||||
? bridge().ApplySync(input)
|
||||
: http<SyncResult>('/api/opencode-sync/apply', { method: 'POST', body: JSON.stringify(input) })
|
||||
}
|
||||
35
frontend/src/env.d.ts
vendored
Normal file
35
frontend/src/env.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
go?: {
|
||||
desktop?: {
|
||||
App?: {
|
||||
Meta: () => Promise<Record<string, string>>
|
||||
Overview: () => Promise<import('./types').Overview>
|
||||
Providers: () => Promise<import('./types').ProviderView[]>
|
||||
Aliases: () => Promise<import('./types').AliasView[]>
|
||||
SaveProvider: (input: import('./types').ProviderUpsertInput) => Promise<import('./types').ProviderSaveResult>
|
||||
SetProviderState: (input: import('./types').ProviderStateInput) => Promise<import('./types').ProviderView>
|
||||
DeleteProvider: (id: string) => Promise<void>
|
||||
ImportProviders: (input: import('./types').ProviderImportInput) => Promise<import('./types').ProviderImportResult>
|
||||
SaveAlias: (input: import('./types').AliasUpsertInput) => Promise<import('./types').AliasView>
|
||||
DeleteAlias: (alias: string) => Promise<void>
|
||||
BindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||
SetTargetState: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||
UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
|
||||
DoctorRun: () => Promise<import('./types').DoctorRunResult>
|
||||
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
||||
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
||||
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
||||
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
||||
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
|
||||
PreviewSync: (input: import('./types').SyncInput) => Promise<import('./types').SyncPreview>
|
||||
ApplySync: (input: import('./types').SyncInput) => Promise<import('./types').SyncResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
259
frontend/src/styles.css
Normal file
259
frontend/src/styles.css
Normal file
@ -0,0 +1,259 @@
|
||||
: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,
|
||||
textarea {
|
||||
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'],
|
||||
textarea {
|
||||
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;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 5.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.nested-card {
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.split-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
149
frontend/src/types.ts
Normal file
149
frontend/src/types.ts
Normal file
@ -0,0 +1,149 @@
|
||||
export type DesktopPrefsView = {
|
||||
launchAtLogin: boolean
|
||||
minimizeToTray: boolean
|
||||
notifications: boolean
|
||||
}
|
||||
|
||||
export type DesktopPrefsSaveResult = {
|
||||
prefs: DesktopPrefsView
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
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<string, string>
|
||||
models?: string[]
|
||||
modelsSource?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type ProviderSaveResult = {
|
||||
provider: ProviderView
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type ProviderUpsertInput = {
|
||||
id: string
|
||||
name?: string
|
||||
baseUrl: string
|
||||
apiKey?: string
|
||||
headers?: Record<string, string>
|
||||
disabled: boolean
|
||||
skipModels: boolean
|
||||
clearHeaders: boolean
|
||||
}
|
||||
|
||||
export type ProviderStateInput = {
|
||||
id: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type ProviderImportInput = {
|
||||
sourcePath?: string
|
||||
overwrite: boolean
|
||||
}
|
||||
|
||||
export type ProviderImportResult = {
|
||||
sourcePath: string
|
||||
imported: number
|
||||
skipped: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
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 AliasUpsertInput = {
|
||||
alias: string
|
||||
displayName?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type AliasTargetInput = {
|
||||
alias: string
|
||||
provider: string
|
||||
model: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@ -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": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
63
frontend/wailsjs/go/desktop/App.d.ts
vendored
Executable file
63
frontend/wailsjs/go/desktop/App.d.ts
vendored
Executable file
@ -0,0 +1,63 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {app} from '../models';
|
||||
import {context} from '../models';
|
||||
import {desktop} from '../models';
|
||||
|
||||
export function Aliases():Promise<Array<app.AliasView>>;
|
||||
|
||||
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
|
||||
|
||||
export function BeforeClose(arg1:context.Context):Promise<boolean>;
|
||||
|
||||
export function BindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
||||
|
||||
export function Bindings():Promise<desktop.Bindings>;
|
||||
|
||||
export function DeleteAlias(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function DesktopPrefs():Promise<app.DesktopPrefsView>;
|
||||
|
||||
export function DoctorRun():Promise<app.DoctorRunResult>;
|
||||
|
||||
export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.ProviderImportResult>;
|
||||
|
||||
export function Meta():Promise<Record<string, string>>;
|
||||
|
||||
export function Overview():Promise<app.Overview>;
|
||||
|
||||
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
|
||||
|
||||
export function Providers():Promise<Array<app.ProviderView>>;
|
||||
|
||||
export function ProxyStatus():Promise<app.ProxyStatusView>;
|
||||
|
||||
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
|
||||
|
||||
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
|
||||
|
||||
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
|
||||
|
||||
export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>;
|
||||
|
||||
export function Service():Promise<app.Service>;
|
||||
|
||||
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;
|
||||
|
||||
export function SetTargetState(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
||||
|
||||
export function SetVersion(arg1:string):Promise<void>;
|
||||
|
||||
export function Shutdown(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function StartProxy():Promise<app.ProxyStatusView>;
|
||||
|
||||
export function Startup(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function StopProxy():Promise<app.ProxyStatusView>;
|
||||
|
||||
export function SyncDesktopPreferences(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function UnbindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
||||
119
frontend/wailsjs/go/desktop/App.js
Executable file
119
frontend/wailsjs/go/desktop/App.js
Executable file
@ -0,0 +1,119 @@
|
||||
// @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 BeforeClose(arg1) {
|
||||
return window['go']['desktop']['App']['BeforeClose'](arg1);
|
||||
}
|
||||
|
||||
export function BindTarget(arg1) {
|
||||
return window['go']['desktop']['App']['BindTarget'](arg1);
|
||||
}
|
||||
|
||||
export function Bindings() {
|
||||
return window['go']['desktop']['App']['Bindings']();
|
||||
}
|
||||
|
||||
export function DeleteAlias(arg1) {
|
||||
return window['go']['desktop']['App']['DeleteAlias'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteProvider(arg1) {
|
||||
return window['go']['desktop']['App']['DeleteProvider'](arg1);
|
||||
}
|
||||
|
||||
export function DesktopPrefs() {
|
||||
return window['go']['desktop']['App']['DesktopPrefs']();
|
||||
}
|
||||
|
||||
export function DoctorRun() {
|
||||
return window['go']['desktop']['App']['DoctorRun']();
|
||||
}
|
||||
|
||||
export function ImportProviders(arg1) {
|
||||
return window['go']['desktop']['App']['ImportProviders'](arg1);
|
||||
}
|
||||
|
||||
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 SaveAlias(arg1) {
|
||||
return window['go']['desktop']['App']['SaveAlias'](arg1);
|
||||
}
|
||||
|
||||
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 SaveProvider(arg1) {
|
||||
return window['go']['desktop']['App']['SaveProvider'](arg1);
|
||||
}
|
||||
|
||||
export function Service() {
|
||||
return window['go']['desktop']['App']['Service']();
|
||||
}
|
||||
|
||||
export function SetProviderState(arg1) {
|
||||
return window['go']['desktop']['App']['SetProviderState'](arg1);
|
||||
}
|
||||
|
||||
export function SetTargetState(arg1) {
|
||||
return window['go']['desktop']['App']['SetTargetState'](arg1);
|
||||
}
|
||||
|
||||
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 UnbindTarget(arg1) {
|
||||
return window['go']['desktop']['App']['UnbindTarget'](arg1);
|
||||
}
|
||||
548
frontend/wailsjs/go/models.ts
Executable file
548
frontend/wailsjs/go/models.ts
Executable file
@ -0,0 +1,548 @@
|
||||
export namespace app {
|
||||
|
||||
export class AliasTargetInput {
|
||||
alias: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
disabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AliasTargetInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.alias = source["alias"];
|
||||
this.provider = source["provider"];
|
||||
this.model = source["model"];
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
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 AliasUpsertInput {
|
||||
alias: string;
|
||||
displayName?: string;
|
||||
disabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AliasUpsertInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.alias = source["alias"];
|
||||
this.displayName = source["displayName"];
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
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 DesktopPrefsSaveResult {
|
||||
prefs: DesktopPrefsView;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DesktopPrefsSaveResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.prefs = this.convertValues(source["prefs"], DesktopPrefsView);
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
|
||||
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 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 ProviderImportInput {
|
||||
sourcePath?: string;
|
||||
overwrite: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderImportInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourcePath = source["sourcePath"];
|
||||
this.overwrite = source["overwrite"];
|
||||
}
|
||||
}
|
||||
export class ProviderImportResult {
|
||||
sourcePath: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderImportResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.sourcePath = source["sourcePath"];
|
||||
this.imported = source["imported"];
|
||||
this.skipped = source["skipped"];
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
}
|
||||
export class ProviderView {
|
||||
id: string;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
apiKeySet: boolean;
|
||||
apiKeyMasked?: string;
|
||||
headers?: Record<string, string>;
|
||||
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 ProviderSaveResult {
|
||||
provider: ProviderView;
|
||||
warnings?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderSaveResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.provider = this.convertValues(source["provider"], ProviderView);
|
||||
this.warnings = source["warnings"];
|
||||
}
|
||||
|
||||
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 ProviderStateInput {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderStateInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.disabled = source["disabled"];
|
||||
}
|
||||
}
|
||||
export class ProviderUpsertInput {
|
||||
id: string;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
disabled: boolean;
|
||||
skipModels: boolean;
|
||||
clearHeaders: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProviderUpsertInput(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.apiKey = source["apiKey"];
|
||||
this.headers = source["headers"];
|
||||
this.disabled = source["disabled"];
|
||||
this.skipModels = source["skipModels"];
|
||||
this.clearHeaders = source["clearHeaders"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 Bindings {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Bindings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
frontend/wailsjs/runtime/package.json
Normal file
24
frontend/wailsjs/runtime/package.json
Normal file
@ -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 <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
330
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
330
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
@ -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<boolean>;
|
||||
|
||||
// [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<Size>;
|
||||
|
||||
// [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<Position>;
|
||||
|
||||
// [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<boolean>;
|
||||
|
||||
// [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<boolean>;
|
||||
|
||||
// [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<boolean>;
|
||||
|
||||
// [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<Screen[]>;
|
||||
|
||||
// [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<EnvironmentInfo>;
|
||||
|
||||
// [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<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [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<void>;
|
||||
|
||||
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
|
||||
// Cleans up notification resources and releases any held connections.
|
||||
export function CleanupNotifications(): Promise<void>;
|
||||
|
||||
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
|
||||
// Checks if notifications are available on the current platform.
|
||||
export function IsNotificationAvailable(): Promise<boolean>;
|
||||
|
||||
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
|
||||
// Requests notification authorization from the user (macOS only).
|
||||
export function RequestNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
|
||||
// Checks the current notification authorization status (macOS only).
|
||||
export function CheckNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
|
||||
// Sends a basic notification with the given options.
|
||||
export function SendNotification(options: NotificationOptions): Promise<void>;
|
||||
|
||||
// [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<void>;
|
||||
|
||||
// [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<void>;
|
||||
|
||||
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
|
||||
// Removes a previously registered notification category.
|
||||
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
|
||||
|
||||
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
|
||||
// Removes all pending notifications from the notification center.
|
||||
export function RemoveAllPendingNotifications(): Promise<void>;
|
||||
|
||||
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
|
||||
// Removes a specific pending notification by its identifier.
|
||||
export function RemovePendingNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
|
||||
// Removes all delivered notifications from the notification center.
|
||||
export function RemoveAllDeliveredNotifications(): Promise<void>;
|
||||
|
||||
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
|
||||
// Removes a specific delivered notification by its identifier.
|
||||
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [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<void>;
|
||||
298
frontend/wailsjs/runtime/runtime.js
Normal file
298
frontend/wailsjs/runtime/runtime.js
Normal file
@ -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);
|
||||
}
|
||||
38
go.mod
38
go.mod
@ -3,11 +3,49 @@ module github.com/Apale7/opencode-provider-switch
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/getlantern/systray v1.2.2
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/tidwall/jsonc v0.3.2
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/sys v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-stack/stack v1.8.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/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // 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/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
108
go.sum
108
go.sum
@ -1,12 +1,120 @@
|
||||
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||
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/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
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/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
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/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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-20201018230417-eeed37f84f13/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/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
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=
|
||||
|
||||
380
internal/app/manage.go
Normal file
380
internal/app/manage.go
Normal file
@ -0,0 +1,380 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
||||
)
|
||||
|
||||
func (s *Service) UpsertProvider(ctx context.Context, in ProviderUpsertInput) (ProviderSaveResult, error) {
|
||||
_ = ctx
|
||||
if strings.TrimSpace(in.ID) == "" {
|
||||
return ProviderSaveResult{}, fmt.Errorf("provider id is required")
|
||||
}
|
||||
if err := config.ValidateProviderBaseURL(in.BaseURL); err != nil {
|
||||
return ProviderSaveResult{}, fmt.Errorf("invalid baseUrl: %w", err)
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProviderSaveResult{}, err
|
||||
}
|
||||
warnings := []string{}
|
||||
provider := config.Provider{
|
||||
ID: strings.TrimSpace(in.ID),
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
BaseURL: config.NormalizeProviderBaseURL(in.BaseURL),
|
||||
APIKey: in.APIKey,
|
||||
Headers: normalizeProviderHeaders(in.Headers),
|
||||
Disabled: in.Disabled,
|
||||
}
|
||||
var existing *config.Provider
|
||||
if cur := cfg.FindProvider(provider.ID); cur != nil {
|
||||
existing = cur
|
||||
if provider.Name == "" {
|
||||
provider.Name = cur.Name
|
||||
}
|
||||
if provider.APIKey == "" {
|
||||
provider.APIKey = cur.APIKey
|
||||
}
|
||||
if len(provider.Headers) == 0 && !in.ClearHeaders && len(cur.Headers) > 0 {
|
||||
provider.Headers = cloneHeaders(cur.Headers)
|
||||
}
|
||||
provider.Models = append([]string(nil), cur.Models...)
|
||||
provider.ModelsSource = cur.ModelsSource
|
||||
if !providerConnectionEqual(*cur, provider) {
|
||||
provider.ModelsSource = ""
|
||||
}
|
||||
}
|
||||
if !in.SkipModels {
|
||||
models, err := opencode.FetchProviderModels(provider.BaseURL, provider.APIKey, provider.Headers)
|
||||
if err != nil {
|
||||
if existing != nil && !providerConnectionEqual(*existing, provider) {
|
||||
provider.Models = append([]string(nil), existing.Models...)
|
||||
provider.ModelsSource = ""
|
||||
warnings = append(warnings, "provider connection changed and model discovery failed; keeping existing model catalog as untrusted")
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("could not discover provider models: %v", err))
|
||||
} else if normalized := config.NormalizeProviderModels(models); len(normalized) > 0 {
|
||||
provider.Models = normalized
|
||||
provider.ModelsSource = "discovered"
|
||||
} else if existing != nil && !providerConnectionEqual(*existing, provider) {
|
||||
provider.Models = append([]string(nil), existing.Models...)
|
||||
provider.ModelsSource = ""
|
||||
warnings = append(warnings, "provider connection changed and model discovery returned no models; keeping existing model catalog as untrusted")
|
||||
} else {
|
||||
warnings = append(warnings, "provider model discovery returned no models; keeping existing model catalog")
|
||||
}
|
||||
} else if existing != nil && !providerConnectionEqual(*existing, provider) {
|
||||
provider.Models = append([]string(nil), existing.Models...)
|
||||
provider.ModelsSource = ""
|
||||
warnings = append(warnings, "provider connection changed with skip models enabled; keeping existing model catalog as untrusted")
|
||||
}
|
||||
cfg.UpsertProvider(provider)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProviderSaveResult{}, err
|
||||
}
|
||||
return ProviderSaveResult{Provider: providerView(provider), Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveProvider(ctx context.Context, id string) error {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.RemoveProvider(strings.TrimSpace(id)) {
|
||||
return fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
return cfg.Save()
|
||||
}
|
||||
|
||||
func (s *Service) SetAliasTargetDisabled(ctx context.Context, in AliasTargetInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
alias := strings.TrimSpace(in.Alias)
|
||||
providerID := strings.TrimSpace(in.Provider)
|
||||
model := strings.TrimSpace(in.Model)
|
||||
if alias == "" || providerID == "" || model == "" {
|
||||
return AliasView{}, fmt.Errorf("alias, provider and model are required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
current := cfg.FindAlias(alias)
|
||||
if current == nil {
|
||||
return AliasView{}, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
updated := *current
|
||||
found := false
|
||||
for i := range updated.Targets {
|
||||
if updated.Targets[i].Provider == providerID && updated.Targets[i].Model == model {
|
||||
updated.Targets[i].Enabled = !in.Disabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return AliasView{}, fmt.Errorf("target %s/%s not found on alias %s", providerID, model, alias)
|
||||
}
|
||||
cfg.UpsertAlias(updated)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
return aliasView(cfg, updated), nil
|
||||
}
|
||||
|
||||
func (s *Service) SetProviderDisabled(ctx context.Context, in ProviderStateInput) (ProviderView, error) {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProviderView{}, err
|
||||
}
|
||||
existing := cfg.FindProvider(strings.TrimSpace(in.ID))
|
||||
if existing == nil {
|
||||
return ProviderView{}, fmt.Errorf("provider %q not found", in.ID)
|
||||
}
|
||||
updated := *existing
|
||||
updated.Disabled = in.Disabled
|
||||
cfg.UpsertProvider(updated)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProviderView{}, err
|
||||
}
|
||||
return providerView(updated), nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportProviders(ctx context.Context, in ProviderImportInput) (ProviderImportResult, error) {
|
||||
_ = ctx
|
||||
sourcePath := strings.TrimSpace(in.SourcePath)
|
||||
if sourcePath == "" {
|
||||
p, existed := opencode.ResolveGlobalConfigPath()
|
||||
if !existed {
|
||||
return ProviderImportResult{}, fmt.Errorf("no OpenCode config found at %s; use sourcePath to specify", p)
|
||||
}
|
||||
sourcePath = p
|
||||
}
|
||||
raw, err := opencode.Load(sourcePath)
|
||||
if err != nil {
|
||||
return ProviderImportResult{}, err
|
||||
}
|
||||
imports := opencode.ImportCustomProviders(raw)
|
||||
result := ProviderImportResult{SourcePath: sourcePath}
|
||||
if len(imports) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return ProviderImportResult{}, err
|
||||
}
|
||||
for _, ip := range imports {
|
||||
if !in.Overwrite && cfg.FindProvider(ip.ID) != nil {
|
||||
result.Skipped++
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("skip %q (already exists, enable overwrite to replace it)", ip.ID))
|
||||
continue
|
||||
}
|
||||
baseURL := config.NormalizeProviderBaseURL(ip.BaseURL)
|
||||
if err := config.ValidateProviderBaseURL(baseURL); err != nil {
|
||||
result.Skipped++
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("skip %q (invalid baseURL %q: %v)", ip.ID, ip.BaseURL, err))
|
||||
continue
|
||||
}
|
||||
merged := mergeImportedProvider(cfg.FindProvider(ip.ID), opencode.ImportableProvider{
|
||||
ID: ip.ID,
|
||||
Name: ip.Name,
|
||||
BaseURL: baseURL,
|
||||
APIKey: ip.APIKey,
|
||||
Models: ip.Models,
|
||||
})
|
||||
cfg.UpsertProvider(merged)
|
||||
result.Imported++
|
||||
}
|
||||
if result.Imported > 0 {
|
||||
if err := cfg.Save(); err != nil {
|
||||
return ProviderImportResult{}, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpsertAlias(ctx context.Context, in AliasUpsertInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
name := strings.TrimSpace(in.Alias)
|
||||
if name == "" {
|
||||
return AliasView{}, fmt.Errorf("alias name is required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
a := config.Alias{Alias: name, DisplayName: strings.TrimSpace(in.DisplayName), Enabled: !in.Disabled}
|
||||
if existing := cfg.FindAlias(name); existing != nil {
|
||||
if a.DisplayName == "" {
|
||||
a.DisplayName = existing.DisplayName
|
||||
}
|
||||
a.Targets = existing.Targets
|
||||
}
|
||||
cfg.UpsertAlias(a)
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
return aliasView(cfg, a), nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveAlias(ctx context.Context, name string) error {
|
||||
_ = ctx
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.RemoveAlias(strings.TrimSpace(name)) {
|
||||
return fmt.Errorf("alias %q not found", name)
|
||||
}
|
||||
return cfg.Save()
|
||||
}
|
||||
|
||||
func (s *Service) BindAliasTarget(ctx context.Context, in AliasTargetInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
alias := strings.TrimSpace(in.Alias)
|
||||
providerID := strings.TrimSpace(in.Provider)
|
||||
model := strings.TrimSpace(in.Model)
|
||||
if alias == "" || providerID == "" || model == "" {
|
||||
return AliasView{}, fmt.Errorf("alias, provider and model are required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
p := cfg.FindProvider(providerID)
|
||||
if p == nil {
|
||||
return AliasView{}, fmt.Errorf("provider %q does not exist; add it first", providerID)
|
||||
}
|
||||
if err := validateProviderModelKnown(providerID, p.Models, p.ModelsSource, model); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if cfg.FindAlias(alias) == nil {
|
||||
cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true})
|
||||
}
|
||||
if err := cfg.AddTarget(alias, config.Target{Provider: providerID, Model: model, Enabled: !in.Disabled}); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
current := cfg.FindAlias(alias)
|
||||
if current == nil {
|
||||
return AliasView{}, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
return aliasView(cfg, *current), nil
|
||||
}
|
||||
|
||||
func (s *Service) UnbindAliasTarget(ctx context.Context, in AliasTargetInput) (AliasView, error) {
|
||||
_ = ctx
|
||||
alias := strings.TrimSpace(in.Alias)
|
||||
providerID := strings.TrimSpace(in.Provider)
|
||||
model := strings.TrimSpace(in.Model)
|
||||
if alias == "" || providerID == "" || model == "" {
|
||||
return AliasView{}, fmt.Errorf("alias, provider and model are required")
|
||||
}
|
||||
cfg, err := s.loadConfig()
|
||||
if err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if err := cfg.RemoveTarget(alias, providerID, model); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return AliasView{}, err
|
||||
}
|
||||
current := cfg.FindAlias(alias)
|
||||
if current == nil {
|
||||
return AliasView{}, fmt.Errorf("alias %q not found", alias)
|
||||
}
|
||||
return aliasView(cfg, *current), nil
|
||||
}
|
||||
|
||||
func providerConnectionEqual(a, b config.Provider) bool {
|
||||
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
|
||||
a.APIKey == b.APIKey &&
|
||||
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
|
||||
}
|
||||
|
||||
func normalizeProviderHeaders(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 {
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = strings.TrimSpace(v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeImportedProvider(existing *config.Provider, ip opencode.ImportableProvider) config.Provider {
|
||||
importedModels := config.NormalizeProviderModels(ip.Models)
|
||||
merged := config.Provider{
|
||||
ID: ip.ID,
|
||||
Name: ip.Name,
|
||||
BaseURL: config.NormalizeProviderBaseURL(ip.BaseURL),
|
||||
APIKey: ip.APIKey,
|
||||
Models: importedModels,
|
||||
ModelsSource: "imported",
|
||||
}
|
||||
if len(importedModels) == 0 {
|
||||
merged.ModelsSource = ""
|
||||
}
|
||||
if existing == nil {
|
||||
return merged
|
||||
}
|
||||
merged.Headers = cloneHeaders(existing.Headers)
|
||||
merged.Disabled = existing.Disabled
|
||||
if merged.Name == "" {
|
||||
merged.Name = existing.Name
|
||||
}
|
||||
if existing.ModelsSource == "discovered" {
|
||||
prospective := merged
|
||||
prospective.Headers = cloneHeaders(existing.Headers)
|
||||
prospective.Disabled = existing.Disabled
|
||||
if providerConnectionEqual(*existing, prospective) {
|
||||
merged.Models = append([]string(nil), existing.Models...)
|
||||
merged.ModelsSource = existing.ModelsSource
|
||||
return merged
|
||||
}
|
||||
if len(importedModels) == 0 {
|
||||
merged.Models = append([]string(nil), existing.Models...)
|
||||
merged.ModelsSource = ""
|
||||
return merged
|
||||
}
|
||||
}
|
||||
if len(importedModels) == 0 {
|
||||
merged.Models = nil
|
||||
merged.ModelsSource = ""
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func validateProviderModelKnown(providerID string, known []string, source string, model string) error {
|
||||
if source != "discovered" || len(known) == 0 {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(known, model) {
|
||||
return nil
|
||||
}
|
||||
choices := make([]string, 0, len(known))
|
||||
for _, item := range known {
|
||||
choices = append(choices, providerID+"/"+item)
|
||||
}
|
||||
sort.Strings(choices)
|
||||
return fmt.Errorf("model %q is not in provider %q discovered models; available: %s", model, providerID, strings.Join(choices, ", "))
|
||||
}
|
||||
447
internal/app/service.go
Normal file
447
internal/app/service.go
Normal file
@ -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/<alias> form", flagName)
|
||||
}
|
||||
alias := strings.TrimPrefix(value, prefix)
|
||||
if alias == "" {
|
||||
return fmt.Errorf("%s must use the ocswitch/<alias> 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:]
|
||||
}
|
||||
351
internal/app/service_test.go
Normal file
351
internal/app/service_test.go
Normal file
@ -0,0 +1,351 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 TestUpsertProviderReturnsWarningsAndKeepsCatalog(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: "relay",
|
||||
BaseURL: "https://old.example.com/v1",
|
||||
APIKey: "sk-old",
|
||||
Models: []string{"gpt-4.1"},
|
||||
ModelsSource: "discovered",
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := NewService(path)
|
||||
result, err := svc.UpsertProvider(context.Background(), ProviderUpsertInput{
|
||||
ID: "relay",
|
||||
BaseURL: upstream.URL + "/v1",
|
||||
APIKey: "sk-new",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertProvider() error = %v", err)
|
||||
}
|
||||
if result.Provider.BaseURL != upstream.URL+"/v1" {
|
||||
t.Fatalf("saved baseUrl = %q, want %q", result.Provider.BaseURL, upstream.URL+"/v1")
|
||||
}
|
||||
if !containsWarning(result.Warnings, "model discovery failed") {
|
||||
t.Fatalf("warnings %#v do not mention stale catalog preservation", result.Warnings)
|
||||
}
|
||||
if !containsWarning(result.Warnings, "could not discover provider models") {
|
||||
t.Fatalf("warnings %#v do not mention discovery failure", result.Warnings)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
provider := reloaded.FindProvider("relay")
|
||||
if provider == nil {
|
||||
t.Fatal("provider relay not found after save")
|
||||
}
|
||||
if provider.ModelsSource != "" {
|
||||
t.Fatalf("provider.ModelsSource = %q, want empty", provider.ModelsSource)
|
||||
}
|
||||
if len(provider.Models) != 1 || provider.Models[0] != "gpt-4.1" {
|
||||
t.Fatalf("provider.Models = %#v, want existing catalog kept", provider.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportProvidersReturnsWarnings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ocswitch.json")
|
||||
sourcePath := filepath.Join(dir, "opencode.json")
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{ID: "keep", BaseURL: "https://existing.example.com/v1"})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, []byte(`{
|
||||
"provider": {
|
||||
"keep": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
|
||||
},
|
||||
"broken": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
|
||||
},
|
||||
"fresh": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"name": "Fresh",
|
||||
"options": {"baseURL": "https://fresh.example.com/v1", "apiKey": "sk-fresh"},
|
||||
"models": {"gpt-4.1": {}}
|
||||
}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
result, err := svc.ImportProviders(context.Background(), ProviderImportInput{SourcePath: sourcePath})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProviders() error = %v", err)
|
||||
}
|
||||
if result.Imported != 1 || result.Skipped != 2 {
|
||||
t.Fatalf("ImportProviders() = %#v, want imported=1 skipped=2", result)
|
||||
}
|
||||
if !containsWarning(result.Warnings, `skip "keep"`) {
|
||||
t.Fatalf("warnings %#v do not mention duplicate provider", result.Warnings)
|
||||
}
|
||||
if !containsWarning(result.Warnings, `skip "broken"`) {
|
||||
t.Fatalf("warnings %#v do not mention invalid provider", result.Warnings)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if provider := reloaded.FindProvider("fresh"); provider == nil {
|
||||
t.Fatal("provider fresh not imported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAliasTargetDisabledPersistsState(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: "relay", BaseURL: "https://relay.example.com/v1"})
|
||||
cfg.UpsertAlias(config.Alias{
|
||||
Alias: "chat",
|
||||
Enabled: true,
|
||||
Targets: []config.Target{{Provider: "relay", Model: "gpt-4.1", Enabled: true}},
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
disabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
|
||||
Alias: "chat",
|
||||
Provider: "relay",
|
||||
Model: "gpt-4.1",
|
||||
Disabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAliasTargetDisabled(disable) error = %v", err)
|
||||
}
|
||||
if disabled.AvailableTargetCount != 0 || disabled.Targets[0].Enabled {
|
||||
t.Fatalf("disabled alias view = %#v", disabled)
|
||||
}
|
||||
|
||||
enabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
|
||||
Alias: "chat",
|
||||
Provider: "relay",
|
||||
Model: "gpt-4.1",
|
||||
Disabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAliasTargetDisabled(enable) error = %v", err)
|
||||
}
|
||||
if enabled.AvailableTargetCount != 1 || !enabled.Targets[0].Enabled {
|
||||
t.Fatalf("enabled alias view = %#v", enabled)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
alias := reloaded.FindAlias("chat")
|
||||
if alias == nil {
|
||||
t.Fatal("alias chat not found after update")
|
||||
}
|
||||
if len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
|
||||
t.Fatalf("persisted alias targets = %#v", alias.Targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertAliasCanReEnableExistingAlias(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.UpsertAlias(config.Alias{Alias: "chat", DisplayName: "Chat", Enabled: false})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(path)
|
||||
alias, err := svc.UpsertAlias(context.Background(), AliasUpsertInput{
|
||||
Alias: "chat",
|
||||
DisplayName: "Chat enabled",
|
||||
Disabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertAlias() error = %v", err)
|
||||
}
|
||||
if !alias.Enabled {
|
||||
t.Fatalf("alias.Enabled = false, want true: %#v", alias)
|
||||
}
|
||||
if alias.DisplayName != "Chat enabled" {
|
||||
t.Fatalf("alias.DisplayName = %q, want %q", alias.DisplayName, "Chat enabled")
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
persisted := reloaded.FindAlias("chat")
|
||||
if persisted == nil || !persisted.Enabled {
|
||||
t.Fatalf("persisted alias = %#v, want enabled", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func containsWarning(warnings []string, want string) bool {
|
||||
for _, warning := range warnings {
|
||||
if strings.Contains(warning, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return 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)
|
||||
}
|
||||
153
internal/app/types.go
Normal file
153
internal/app/types.go
Normal file
@ -0,0 +1,153 @@
|
||||
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 ProviderSaveResult struct {
|
||||
Provider ProviderView `json:"provider"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
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 DesktopPrefsSaveResult struct {
|
||||
Prefs DesktopPrefsView `json:"prefs"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type DesktopPrefsInput struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
MinimizeToTray bool `json:"minimizeToTray"`
|
||||
Notifications bool `json:"notifications"`
|
||||
}
|
||||
|
||||
type ProviderUpsertInput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
SkipModels bool `json:"skipModels"`
|
||||
ClearHeaders bool `json:"clearHeaders"`
|
||||
}
|
||||
|
||||
type ProviderImportInput struct {
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type ProviderImportResult struct {
|
||||
SourcePath string `json:"sourcePath"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderStateInput struct {
|
||||
ID string `json:"id"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type AliasUpsertInput struct {
|
||||
Alias string `json:"alias"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type AliasTargetInput struct {
|
||||
Alias string `json:"alias"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
@ -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 {
|
||||
return err
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", issue.Message)
|
||||
}
|
||||
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(), " 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 err
|
||||
}
|
||||
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 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 nil
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@ -219,7 +218,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
|
||||
t.Fatalf("OpenFile(lock): %v", err)
|
||||
}
|
||||
defer lockFile.Close()
|
||||
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil {
|
||||
if err := lockTestFile(lockFile); err != nil {
|
||||
t.Fatalf("Flock(lock): %v", err)
|
||||
}
|
||||
|
||||
@ -255,7 +254,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil {
|
||||
if err := unlockTestFile(lockFile); err != nil {
|
||||
t.Fatalf("Flock(unlock): %v", err)
|
||||
}
|
||||
|
||||
|
||||
23
internal/config/file_lock_testhelper_unix_test.go
Normal file
23
internal/config/file_lock_testhelper_unix_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockTestFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return fmt.Errorf("flock lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockTestFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil {
|
||||
return fmt.Errorf("flock unlock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
26
internal/config/file_lock_testhelper_windows_test.go
Normal file
26
internal/config/file_lock_testhelper_windows_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func lockTestFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("lock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockTestFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("unlock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
244
internal/desktop/app.go
Normal file
244
internal/desktop/app.go
Normal file
@ -0,0 +1,244 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
type trayAdapter interface {
|
||||
Attach(context.Context)
|
||||
Detach()
|
||||
Sync(context.Context, app.DesktopPrefsView)
|
||||
RefreshProxyStatus(context.Context)
|
||||
BeforeClose(context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type notifierAdapter interface {
|
||||
Attach(context.Context)
|
||||
Detach()
|
||||
Sync(context.Context, app.DesktopPrefsView)
|
||||
Send(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type autoStartAdapter interface {
|
||||
Attach(context.Context)
|
||||
Detach()
|
||||
Sync(context.Context, app.DesktopPrefsView) error
|
||||
}
|
||||
|
||||
// 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 trayAdapter
|
||||
notify notifierAdapter
|
||||
auto autoStartAdapter
|
||||
|
||||
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)
|
||||
a.tray.RefreshProxyStatus(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)
|
||||
a.notify.Sync(ctx, prefs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
|
||||
previous, _ := a.bindings.GetDesktopPrefs(ctx)
|
||||
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
|
||||
if err != nil {
|
||||
return app.DesktopPrefsSaveResult{}, err
|
||||
}
|
||||
warnings := []string{}
|
||||
if err := a.auto.Sync(ctx, prefs); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("saved preferences but could not update launch-at-login integration: %v", err))
|
||||
}
|
||||
a.tray.Sync(ctx, prefs)
|
||||
a.notify.Sync(ctx, prefs)
|
||||
if prefs.Notifications && !previous.Notifications {
|
||||
_ = a.notify.Send(ctx, "Notifications enabled", "ocswitch desktop will now show native alerts.")
|
||||
}
|
||||
return app.DesktopPrefsSaveResult{Prefs: prefs, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, 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) SaveProvider(in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
|
||||
return a.bindings.UpsertProvider(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) SetProviderState(in app.ProviderStateInput) (app.ProviderView, error) {
|
||||
return a.bindings.SetProviderDisabled(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) DeleteProvider(id string) error {
|
||||
return a.bindings.RemoveProvider(a.callContext(), id)
|
||||
}
|
||||
|
||||
func (a *App) ImportProviders(in app.ProviderImportInput) (app.ProviderImportResult, error) {
|
||||
return a.bindings.ImportProviders(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) SaveAlias(in app.AliasUpsertInput) (app.AliasView, error) {
|
||||
return a.bindings.UpsertAlias(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) DeleteAlias(name string) error {
|
||||
return a.bindings.RemoveAlias(a.callContext(), name)
|
||||
}
|
||||
|
||||
func (a *App) BindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return a.bindings.BindAliasTarget(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) SetTargetState(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return a.bindings.SetAliasTargetDisabled(a.callContext(), in)
|
||||
}
|
||||
|
||||
func (a *App) UnbindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return a.bindings.UnbindAliasTarget(a.callContext(), in)
|
||||
}
|
||||
|
||||
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) {
|
||||
status, err := a.bindings.StartProxy(a.callContext())
|
||||
if err != nil {
|
||||
return app.ProxyStatusView{}, err
|
||||
}
|
||||
a.tray.RefreshProxyStatus(a.callContext())
|
||||
_ = a.notify.Send(a.callContext(), "Proxy started", status.BindAddress)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (a *App) StopProxy() (app.ProxyStatusView, error) {
|
||||
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
|
||||
defer cancel()
|
||||
status, err := a.bindings.StopProxy(ctx)
|
||||
if err != nil {
|
||||
return app.ProxyStatusView{}, err
|
||||
}
|
||||
a.tray.RefreshProxyStatus(a.callContext())
|
||||
_ = a.notify.Send(a.callContext(), "Proxy stopped", status.BindAddress)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
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) {
|
||||
result, err := a.bindings.SyncOpenCode(a.callContext(), in)
|
||||
if err != nil {
|
||||
return app.SyncResult{}, err
|
||||
}
|
||||
if result.Changed && !result.DryRun {
|
||||
_ = a.notify.Send(a.callContext(), "OpenCode sync applied", result.TargetPath)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
151
internal/desktop/autostart.go
Normal file
151
internal/desktop/autostart.go
Normal file
@ -0,0 +1,151 @@
|
||||
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. Linux uses XDG autostart; Windows uses Startup folder scripts.
|
||||
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" && runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
entryPath, err := a.entryPathForOS(runtime.GOOS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prefs.LaunchAtLogin {
|
||||
return a.writeEntry(entryPath, runtime.GOOS)
|
||||
}
|
||||
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) {
|
||||
return a.entryPathForOS(runtime.GOOS)
|
||||
}
|
||||
|
||||
func (a *AutoStart) entryPathForOS(goos string) (string, error) {
|
||||
switch goos {
|
||||
case "linux":
|
||||
return a.linuxEntryPath()
|
||||
case "windows":
|
||||
return a.windowsEntryPath()
|
||||
default:
|
||||
return "", fmt.Errorf("autostart unsupported on %s", goos)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AutoStart) linuxEntryPath() (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) windowsEntryPath() (string, error) {
|
||||
base := strings.TrimSpace(os.Getenv("APPDATA"))
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home for autostart: %w", err)
|
||||
}
|
||||
base = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(base, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "ocswitch-desktop.cmd"), nil
|
||||
}
|
||||
|
||||
func (a *AutoStart) writeEntry(path string, goos 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()
|
||||
}
|
||||
var content string
|
||||
switch goos {
|
||||
case "linux":
|
||||
content = linuxAutostartEntry(execPath, configPath)
|
||||
case "windows":
|
||||
content = windowsAutostartScript(execPath, configPath)
|
||||
default:
|
||||
return fmt.Errorf("autostart unsupported on %s", goos)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("write autostart entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func linuxAutostartEntry(execPath string, configPath string) string {
|
||||
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")
|
||||
return content
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
|
||||
return `"` + replacer.Replace(value) + `"`
|
||||
}
|
||||
|
||||
func windowsAutostartScript(execPath string, configPath string) string {
|
||||
return strings.Join([]string{
|
||||
"@echo off",
|
||||
fmt.Sprintf("start \"\" %s --config %s", cmdQuote(execPath), cmdQuote(configPath)),
|
||||
"",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
func cmdQuote(value string) string {
|
||||
replacer := strings.NewReplacer(`"`, `""`)
|
||||
return `"` + replacer.Replace(value) + `"`
|
||||
}
|
||||
82
internal/desktop/autostart_test.go
Normal file
82
internal/desktop/autostart_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryPathForWindowsUsesStartupFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("APPDATA", root)
|
||||
|
||||
auto := NewAutoStart(nil)
|
||||
path, err := auto.entryPathForOS("windows")
|
||||
if err != nil {
|
||||
t.Fatalf("entryPathForOS(windows) error = %v", err)
|
||||
}
|
||||
want := filepath.Join(root, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "ocswitch-desktop.cmd")
|
||||
if path != want {
|
||||
t.Fatalf("entryPathForOS(windows) = %q, want %q", path, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsAutostartScriptQuotesArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
script := windowsAutostartScript(`C:\Program Files\ocswitch\ocswitch-desktop.exe`, `C:\Users\demo\.config\ocswitch\config.json`)
|
||||
if !strings.Contains(script, `start "" "C:\Program Files\ocswitch\ocswitch-desktop.exe" --config "C:\Users\demo\.config\ocswitch\config.json"`) {
|
||||
t.Fatalf("windowsAutostartScript() missing command: %q", script)
|
||||
}
|
||||
}
|
||||
186
internal/desktop/bindings.go
Normal file
186
internal/desktop/bindings.go
Normal file
@ -0,0 +1,186 @@
|
||||
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) UpsertProvider(ctx context.Context, in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
|
||||
return b.service.UpsertProvider(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetProviderDisabled(ctx context.Context, in app.ProviderStateInput) (app.ProviderView, error) {
|
||||
return b.service.SetProviderDisabled(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) RemoveProvider(ctx context.Context, id string) error {
|
||||
return b.service.RemoveProvider(ctx, id)
|
||||
}
|
||||
|
||||
func (b *Bindings) ImportProviders(ctx context.Context, in app.ProviderImportInput) (app.ProviderImportResult, error) {
|
||||
return b.service.ImportProviders(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) UpsertAlias(ctx context.Context, in app.AliasUpsertInput) (app.AliasView, error) {
|
||||
return b.service.UpsertAlias(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) RemoveAlias(ctx context.Context, name string) error {
|
||||
return b.service.RemoveAlias(ctx, name)
|
||||
}
|
||||
|
||||
func (b *Bindings) BindAliasTarget(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.service.BindAliasTarget(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetAliasTargetDisabled(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.service.SetAliasTargetDisabled(ctx, in)
|
||||
}
|
||||
|
||||
func (b *Bindings) UnbindAliasTarget(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.service.UnbindAliasTarget(ctx, in)
|
||||
}
|
||||
|
||||
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) SaveProvider(in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
|
||||
return b.UpsertProvider(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetProviderState(in app.ProviderStateInput) (app.ProviderView, error) {
|
||||
return b.SetProviderDisabled(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) DeleteProvider(id string) error {
|
||||
return b.RemoveProvider(context.Background(), id)
|
||||
}
|
||||
|
||||
func (b *Bindings) ImportProviderSet(in app.ProviderImportInput) (app.ProviderImportResult, error) {
|
||||
return b.ImportProviders(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SaveAlias(in app.AliasUpsertInput) (app.AliasView, error) {
|
||||
return b.UpsertAlias(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) DeleteAlias(name string) error {
|
||||
return b.RemoveAlias(context.Background(), name)
|
||||
}
|
||||
|
||||
func (b *Bindings) BindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.BindAliasTarget(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) SetTargetState(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.SetAliasTargetDisabled(context.Background(), in)
|
||||
}
|
||||
|
||||
func (b *Bindings) UnbindTarget(in app.AliasTargetInput) (app.AliasView, error) {
|
||||
return b.UnbindAliasTarget(context.Background(), in)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
432
internal/desktop/http.go
Normal file
432
internal/desktop/http.go
Normal file
@ -0,0 +1,432 @@
|
||||
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) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := b.ListProviders(r.Context())
|
||||
writeResult(w, data, err)
|
||||
case http.MethodPost:
|
||||
var in appcore.ProviderUpsertInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.UpsertProvider(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||
}
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers/import", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.ProviderImportInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.ImportProviders(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.ProviderStateInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.SetProviderDisabled(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/providers/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if !decodeJSONBody(w, r, &payload) {
|
||||
return
|
||||
}
|
||||
writeResult(w, map[string]bool{"ok": true}, b.RemoveProvider(r.Context(), payload.ID))
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := b.ListAliases(r.Context())
|
||||
writeResult(w, data, err)
|
||||
case http.MethodPost:
|
||||
var in appcore.AliasUpsertInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.UpsertAlias(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
default:
|
||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||
}
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
if !decodeJSONBody(w, r, &payload) {
|
||||
return
|
||||
}
|
||||
writeResult(w, map[string]bool{"ok": true}, b.RemoveAlias(r.Context(), payload.Alias))
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/bind", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.AliasTargetInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.BindAliasTarget(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.AliasTargetInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.SetAliasTargetDisabled(r.Context(), in)
|
||||
writeResult(w, data, err)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/aliases/unbind", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var in appcore.AliasTargetInput
|
||||
if !decodeJSONBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
data, err := b.UnbindAliasTarget(r.Context(), in)
|
||||
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 := instance.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}}
|
||||
}
|
||||
}
|
||||
297
internal/desktop/http_test.go
Normal file
297
internal/desktop/http_test.go
Normal file
@ -0,0 +1,297 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
"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)
|
||||
}
|
||||
|
||||
instance := New(path)
|
||||
h, err := newHandler(instance, "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("save desktop prefs keeps success with integration warning", func(t *testing.T) {
|
||||
originalAuto := instance.auto
|
||||
instance.auto = failingAutoStart{message: "startup folder unavailable"}
|
||||
defer func() {
|
||||
instance.auto = originalAuto
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":false,"notifications":false}`))
|
||||
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, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Prefs struct {
|
||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||
} `json:"prefs"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !payload.Data.Prefs.LaunchAtLogin {
|
||||
t.Fatalf("prefs payload = %#v", payload.Data.Prefs)
|
||||
}
|
||||
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "launch-at-login integration") {
|
||||
t.Fatalf("warnings = %#v, want integration warning", payload.Data.Warnings)
|
||||
}
|
||||
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if !loaded.Desktop.LaunchAtLogin {
|
||||
t.Fatalf("persisted desktop prefs = %#v, want saved despite warning", loaded.Desktop)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider save exposes warnings", func(t *testing.T) {
|
||||
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":"bad upstream"}`))
|
||||
}))
|
||||
defer providerServer.Close()
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
cfg.UpsertProvider(config.Provider{
|
||||
ID: "warnme",
|
||||
BaseURL: "https://prior.example.com/v1",
|
||||
APIKey: "sk-prior",
|
||||
Models: []string{"gpt-4.1"},
|
||||
ModelsSource: "discovered",
|
||||
})
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("cfg.Save() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/providers", strings.NewReader(`{"id":"warnme","baseUrl":"`+providerServer.URL+`/v1","apiKey":"sk-new"}`))
|
||||
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, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Provider struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"provider"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.Provider.ID != "warnme" {
|
||||
t.Fatalf("saved provider = %#v", payload.Data.Provider)
|
||||
}
|
||||
if len(payload.Data.Warnings) == 0 {
|
||||
t.Fatalf("warnings = %#v, want non-empty", payload.Data.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias target state route toggles enabled flag", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/aliases/state", strings.NewReader(`{"alias":"chat","provider":"demo","model":"gpt-4.1-mini","disabled":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("disable status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
AvailableTargetCount int `json:"availableTargetCount"`
|
||||
Targets []struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"targets"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.AvailableTargetCount != 0 || len(payload.Data.Targets) != 1 || payload.Data.Targets[0].Enabled {
|
||||
t.Fatalf("disabled payload = %#v", payload.Data)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/aliases/state", strings.NewReader(`{"alias":"chat","provider":"demo","model":"gpt-4.1-mini","disabled":false}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
h.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("enable status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
|
||||
loaded, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
alias := loaded.FindAlias("chat")
|
||||
if alias == nil || len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
|
||||
t.Fatalf("persisted alias = %#v", alias)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider import exposes warnings", func(t *testing.T) {
|
||||
sourcePath := filepath.Join(t.TempDir(), "opencode.json")
|
||||
if err := os.WriteFile(sourcePath, []byte(`{
|
||||
"provider": {
|
||||
"demo": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
|
||||
},
|
||||
"broken": {
|
||||
"npm": "@ai-sdk/openai",
|
||||
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
|
||||
}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/providers/import", strings.NewReader(`{"sourcePath":"`+strings.ReplaceAll(sourcePath, "\\", "\\\\")+`"}`))
|
||||
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, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Data.Imported != 0 || payload.Data.Skipped != 2 {
|
||||
t.Fatalf("import payload = %#v", payload.Data)
|
||||
}
|
||||
if len(payload.Data.Warnings) != 2 {
|
||||
t.Fatalf("warnings = %#v, want 2 entries", payload.Data.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type failingAutoStart struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (f failingAutoStart) Attach(_ context.Context) {}
|
||||
|
||||
func (f failingAutoStart) Detach() {}
|
||||
|
||||
func (f failingAutoStart) Sync(_ context.Context, _ app.DesktopPrefsView) error {
|
||||
return fmt.Errorf(f.message)
|
||||
}
|
||||
103
internal/desktop/notify.go
Normal file
103
internal/desktop/notify.go
Normal file
@ -0,0 +1,103 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
// Notifier bridges desktop preference state to native notifications.
|
||||
type Notifier struct {
|
||||
service *app.Service
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
prefs app.DesktopPrefsView
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func NewNotifier(service *app.Service) *Notifier {
|
||||
return &Notifier{service: service}
|
||||
}
|
||||
|
||||
func (n *Notifier) Attach(ctx context.Context) {
|
||||
n.mu.Lock()
|
||||
n.ctx = ctx
|
||||
n.mu.Unlock()
|
||||
_ = n.ensureInitialized(ctx)
|
||||
}
|
||||
|
||||
func (n *Notifier) Detach() {
|
||||
n.mu.Lock()
|
||||
n.ctx = nil
|
||||
n.initialized = false
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Notifier) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
||||
n.mu.Lock()
|
||||
if ctx != nil {
|
||||
n.ctx = ctx
|
||||
}
|
||||
n.prefs = prefs
|
||||
n.mu.Unlock()
|
||||
if prefs.Notifications {
|
||||
_ = n.ensureInitialized(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) Send(ctx context.Context, title string, body string) error {
|
||||
n.mu.Lock()
|
||||
if !n.prefs.Notifications {
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = n.ctx
|
||||
}
|
||||
n.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if err := n.ensureInitialized(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !desktopNotificationsAvailable(ctx) {
|
||||
return nil
|
||||
}
|
||||
return sendDesktopNotification(ctx, desktopNotification{
|
||||
ID: fmt.Sprintf("ocswitch-%d", time.Now().UnixNano()),
|
||||
Title: title,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notifier) ensureInitialized(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
n.mu.Lock()
|
||||
ctx = n.ctx
|
||||
n.mu.Unlock()
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
if n.initialized {
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
n.mu.Unlock()
|
||||
|
||||
if err := initDesktopNotifications(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
n.initialized = true
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
42
internal/desktop/runtime_fallback.go
Normal file
42
internal/desktop/runtime_fallback.go
Normal file
@ -0,0 +1,42 @@
|
||||
//go:build !desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import "context"
|
||||
|
||||
type desktopNotification struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
func hideWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func showWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func quitWindow(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDesktopNotifications(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func desktopNotificationsAvailable(ctx context.Context) bool {
|
||||
_ = ctx
|
||||
return false
|
||||
}
|
||||
|
||||
func sendDesktopNotification(ctx context.Context, notification desktopNotification) error {
|
||||
_ = ctx
|
||||
_ = notification
|
||||
return nil
|
||||
}
|
||||
46
internal/desktop/runtime_wails.go
Normal file
46
internal/desktop/runtime_wails.go
Normal file
@ -0,0 +1,46 @@
|
||||
//go:build desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type desktopNotification struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
func hideWindow(ctx context.Context) error {
|
||||
wruntime.Hide(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func showWindow(ctx context.Context) error {
|
||||
wruntime.Show(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func quitWindow(ctx context.Context) error {
|
||||
wruntime.Quit(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDesktopNotifications(ctx context.Context) error {
|
||||
return wruntime.InitializeNotifications(ctx)
|
||||
}
|
||||
|
||||
func desktopNotificationsAvailable(ctx context.Context) bool {
|
||||
return wruntime.IsNotificationAvailable(ctx)
|
||||
}
|
||||
|
||||
func sendDesktopNotification(ctx context.Context, notification desktopNotification) error {
|
||||
return wruntime.SendNotification(ctx, wruntime.NotificationOptions{
|
||||
ID: notification.ID,
|
||||
Title: notification.Title,
|
||||
Body: notification.Body,
|
||||
})
|
||||
}
|
||||
45
internal/desktop/tray.go
Normal file
45
internal/desktop/tray.go
Normal file
@ -0,0 +1,45 @@
|
||||
//go:build !desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
)
|
||||
|
||||
// Tray is a no-op shell adapter outside the Wails desktop build.
|
||||
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) RefreshProxyStatus(ctx context.Context) {
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
||||
_ = ctx
|
||||
if !t.prefs.MinimizeToTray {
|
||||
return false, nil
|
||||
}
|
||||
return true, hideWindow(ctx)
|
||||
}
|
||||
209
internal/desktop/tray_wails.go
Normal file
209
internal/desktop/tray_wails.go
Normal file
@ -0,0 +1,209 @@
|
||||
//go:build desktop_wails
|
||||
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||
"github.com/getlantern/systray"
|
||||
)
|
||||
|
||||
// Tray wires resident-mode controls into a native system tray.
|
||||
type Tray struct {
|
||||
service *app.Service
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
prefs app.DesktopPrefsView
|
||||
registered bool
|
||||
ready bool
|
||||
quitting bool
|
||||
|
||||
statusItem *systray.MenuItem
|
||||
showItem *systray.MenuItem
|
||||
hideItem *systray.MenuItem
|
||||
startItem *systray.MenuItem
|
||||
stopItem *systray.MenuItem
|
||||
quitItem *systray.MenuItem
|
||||
}
|
||||
|
||||
func NewTray(service *app.Service) *Tray {
|
||||
return &Tray{service: service}
|
||||
}
|
||||
|
||||
func (t *Tray) Attach(ctx context.Context) {
|
||||
t.mu.Lock()
|
||||
t.ctx = ctx
|
||||
if t.registered {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.registered = true
|
||||
t.mu.Unlock()
|
||||
systray.Register(t.onReady, nil)
|
||||
}
|
||||
|
||||
func (t *Tray) Detach() {
|
||||
t.mu.Lock()
|
||||
t.ctx = nil
|
||||
registered := t.registered
|
||||
t.mu.Unlock()
|
||||
if registered {
|
||||
systray.Quit()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
||||
t.mu.Lock()
|
||||
if ctx != nil {
|
||||
t.ctx = ctx
|
||||
}
|
||||
t.prefs = prefs
|
||||
t.mu.Unlock()
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
|
||||
t.mu.Lock()
|
||||
if ctx != nil {
|
||||
t.ctx = ctx
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
||||
t.mu.Lock()
|
||||
t.ctx = ctx
|
||||
quitting := t.quitting
|
||||
minimize := t.prefs.MinimizeToTray
|
||||
t.mu.Unlock()
|
||||
if quitting || !minimize {
|
||||
return false, nil
|
||||
}
|
||||
return true, hideWindow(ctx)
|
||||
}
|
||||
|
||||
func (t *Tray) onReady() {
|
||||
systray.SetTitle("ocswitch")
|
||||
systray.SetTooltip("ocswitch desktop")
|
||||
|
||||
t.mu.Lock()
|
||||
t.ready = true
|
||||
t.statusItem = systray.AddMenuItem("Proxy: checking...", "Current proxy status")
|
||||
t.statusItem.Disable()
|
||||
systray.AddSeparator()
|
||||
t.showItem = systray.AddMenuItem("Open window", "Show desktop window")
|
||||
t.hideItem = systray.AddMenuItem("Hide window", "Hide desktop window")
|
||||
systray.AddSeparator()
|
||||
t.startItem = systray.AddMenuItem("Start proxy", "Start local proxy")
|
||||
t.stopItem = systray.AddMenuItem("Stop proxy", "Stop local proxy")
|
||||
systray.AddSeparator()
|
||||
t.quitItem = systray.AddMenuItem("Quit", "Exit application")
|
||||
t.mu.Unlock()
|
||||
|
||||
go t.loop()
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) loop() {
|
||||
for {
|
||||
t.mu.Lock()
|
||||
showItem := t.showItem
|
||||
hideItem := t.hideItem
|
||||
startItem := t.startItem
|
||||
stopItem := t.stopItem
|
||||
quitItem := t.quitItem
|
||||
t.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-showItem.ClickedCh:
|
||||
_ = t.withContext(showWindow)
|
||||
case <-hideItem.ClickedCh:
|
||||
_ = t.withContext(hideWindow)
|
||||
case <-startItem.ClickedCh:
|
||||
t.startProxy()
|
||||
case <-stopItem.ClickedCh:
|
||||
t.stopProxy()
|
||||
case <-quitItem.ClickedCh:
|
||||
t.requestQuit()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tray) startProxy() {
|
||||
if t.service == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = t.service.StartProxy(ctx)
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) stopProxy() {
|
||||
if t.service == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = t.service.StopProxy(ctx)
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *Tray) requestQuit() {
|
||||
t.mu.Lock()
|
||||
t.quitting = true
|
||||
ctx := t.ctx
|
||||
t.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
_ = quitWindow(ctx)
|
||||
}
|
||||
|
||||
func (t *Tray) withContext(fn func(context.Context) error) error {
|
||||
t.mu.Lock()
|
||||
ctx := t.ctx
|
||||
t.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
func (t *Tray) refresh() {
|
||||
t.mu.Lock()
|
||||
ready := t.ready
|
||||
statusItem := t.statusItem
|
||||
startItem := t.startItem
|
||||
stopItem := t.stopItem
|
||||
t.mu.Unlock()
|
||||
if !ready || statusItem == nil || startItem == nil || stopItem == nil || t.service == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := t.service.GetProxyStatus(context.Background())
|
||||
if err != nil {
|
||||
statusItem.SetTitle("Proxy: unavailable")
|
||||
startItem.Enable()
|
||||
stopItem.Disable()
|
||||
return
|
||||
}
|
||||
|
||||
if status.Running {
|
||||
statusItem.SetTitle(fmt.Sprintf("Proxy: running (%s)", status.BindAddress))
|
||||
startItem.Disable()
|
||||
stopItem.Enable()
|
||||
return
|
||||
}
|
||||
|
||||
statusItem.SetTitle(fmt.Sprintf("Proxy: stopped (%s)", status.BindAddress))
|
||||
startItem.Enable()
|
||||
stopItem.Disable()
|
||||
}
|
||||
57
internal/desktop/wails.go
Normal file
57
internal/desktop/wails.go
Normal file
@ -0,0 +1,57 @@
|
||||
//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,
|
||||
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")
|
||||
}
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type LockedFile struct {
|
||||
@ -61,7 +60,7 @@ func acquireLock(path string) (*LockedFile, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open lock: %w", err)
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
if err := lockFile(file); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("lock file: %w", err)
|
||||
}
|
||||
@ -72,7 +71,7 @@ func (l *LockedFile) Close() error {
|
||||
if l == nil || l.file == nil {
|
||||
return nil
|
||||
}
|
||||
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
||||
unlockErr := unlockFile(l.file)
|
||||
closeErr := l.file.Close()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("unlock file: %w", unlockErr)
|
||||
@ -82,15 +81,3 @@ func (l *LockedFile) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dir: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
35
internal/fileutil/lock_unix.go
Normal file
35
internal/fileutil/lock_unix.go
Normal file
@ -0,0 +1,35 @@
|
||||
//go:build !windows
|
||||
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return fmt.Errorf("flock lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockFile(file *os.File) error {
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil {
|
||||
return fmt.Errorf("flock unlock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dir: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/fileutil/lock_windows.go
Normal file
31
internal/fileutil/lock_windows.go
Normal file
@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func lockFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("lock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unlockFile(file *os.File) error {
|
||||
var overlapped windows.Overlapped
|
||||
if err := windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped); err != nil {
|
||||
return fmt.Errorf("unlock file ex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
_ = dir
|
||||
return nil
|
||||
}
|
||||
24
main_wails.go
Normal file
24
main_wails.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
12
wails.json
Normal file
12
wails.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user