gui
This commit is contained in:
parent
29d0cabbd1
commit
cc3359f773
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@
|
|||||||
/dist/
|
/dist/
|
||||||
*.tmp
|
*.tmp
|
||||||
output
|
output
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|||||||
@ -63,12 +63,35 @@
|
|||||||
"internal/cli/serve.go",
|
"internal/cli/serve.go",
|
||||||
"internal/desktop/app.go",
|
"internal/desktop/app.go",
|
||||||
"internal/desktop/bindings.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/tray.go",
|
||||||
"internal/desktop/notify.go",
|
"internal/desktop/notify.go",
|
||||||
"internal/desktop/autostart.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": {
|
"meta": {
|
||||||
"desktopShellRequired": true,
|
"desktopShellRequired": true,
|
||||||
"recommendedShell": "wails",
|
"recommendedShell": "wails",
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
<!-- @@@auto:current-status -->
|
||||||
- **Active File**: `journal-1.md`
|
- **Active File**: `journal-1.md`
|
||||||
- **Total Sessions**: 10
|
- **Total Sessions**: 12
|
||||||
- **Last Active**: 2026-04-18
|
- **Last Active**: 2026-04-18
|
||||||
<!-- @@@/auto:current-status -->
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
@ -19,7 +19,7 @@
|
|||||||
<!-- @@@auto:active-documents -->
|
<!-- @@@auto:active-documents -->
|
||||||
| File | Lines | Status |
|
| File | Lines | Status |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| `journal-1.md` | ~420 | Active |
|
| `journal-1.md` | ~525 | Active |
|
||||||
<!-- @@@/auto:active-documents -->
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -29,6 +29,8 @@
|
|||||||
<!-- @@@auto:session-history -->
|
<!-- @@@auto:session-history -->
|
||||||
| # | Date | Title | Commits | Branch |
|
| # | 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` |
|
| 10 | 2026-04-18 | Desktop shell skeleton implementation | - | `master` |
|
||||||
| 9 | 2026-04-18 | GUI desktop direction and architecture decision | - | `master` |
|
| 9 | 2026-04-18 | GUI desktop direction and architecture decision | - | `master` |
|
||||||
| 8 | 2026-04-18 | Provider discovery and model ref hardening | `09d0c1f` | `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
|
### Next Steps
|
||||||
|
|
||||||
- None - task complete
|
- 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")
|
||||||
|
}
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!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>
|
||||||
|
<script>
|
||||||
|
window.__OCSWITCH_WAILS__ = typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined'
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1846
frontend/package-lock.json
generated
Normal file
1846
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
|
||||||
358
frontend/src/App.tsx
Normal file
358
frontend/src/App.tsx
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
applySync,
|
||||||
|
getMeta,
|
||||||
|
getOverview,
|
||||||
|
listAliases,
|
||||||
|
listProviders,
|
||||||
|
previewSync,
|
||||||
|
runDoctor,
|
||||||
|
saveDesktopPrefs,
|
||||||
|
startProxy,
|
||||||
|
stopProxy,
|
||||||
|
} from './api'
|
||||||
|
import type {
|
||||||
|
AliasView,
|
||||||
|
DesktopPrefsView,
|
||||||
|
DoctorRunResult,
|
||||||
|
Overview,
|
||||||
|
ProviderView,
|
||||||
|
SyncInput,
|
||||||
|
SyncPreview,
|
||||||
|
SyncResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type MetaState = {
|
||||||
|
version: string
|
||||||
|
shell: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyPrefs: DesktopPrefsView = {
|
||||||
|
launchAtLogin: false,
|
||||||
|
minimizeToTray: false,
|
||||||
|
notifications: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptySync: SyncInput = {
|
||||||
|
target: '',
|
||||||
|
setModel: '',
|
||||||
|
setSmallModel: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function pretty(value: unknown): string {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [meta, setMeta] = useState<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 [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const refreshAll = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setPrefsStatus('Refreshing...')
|
||||||
|
try {
|
||||||
|
const [metaData, overviewData, providerData, aliasData] = await Promise.all([
|
||||||
|
getMeta(),
|
||||||
|
getOverview(),
|
||||||
|
listProviders(),
|
||||||
|
listAliases(),
|
||||||
|
])
|
||||||
|
setMeta(metaData)
|
||||||
|
setOverview(overviewData)
|
||||||
|
setProviders(providerData)
|
||||||
|
setAliases(aliasData)
|
||||||
|
setPrefs(overviewData.desktop)
|
||||||
|
setPrefsStatus('Fresh')
|
||||||
|
} catch (error) {
|
||||||
|
setPrefsStatus(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshAll()
|
||||||
|
}, [refreshAll])
|
||||||
|
|
||||||
|
async function onSavePrefs(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setPrefsStatus('Saving...')
|
||||||
|
try {
|
||||||
|
const saved = await saveDesktopPrefs(prefs)
|
||||||
|
setPrefs(saved)
|
||||||
|
setPrefsStatus('Saved')
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
setPrefsStatus(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunDoctor() {
|
||||||
|
setDoctorStatus('Running...')
|
||||||
|
try {
|
||||||
|
const result = await runDoctor()
|
||||||
|
setDoctorResult(result)
|
||||||
|
setDoctorStatus(result.error || 'Doctor OK')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
setDoctorResult(null)
|
||||||
|
setDoctorStatus(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStartProxy() {
|
||||||
|
try {
|
||||||
|
await startProxy()
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
setPrefsStatus(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStopProxy() {
|
||||||
|
try {
|
||||||
|
await stopProxy()
|
||||||
|
await refreshAll()
|
||||||
|
} catch (error) {
|
||||||
|
setPrefsStatus(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPreviewSync() {
|
||||||
|
setSyncStatus('Previewing...')
|
||||||
|
try {
|
||||||
|
const result = await previewSync(syncInput)
|
||||||
|
setSyncOutput(result)
|
||||||
|
setSyncStatus(result.wouldChange ? 'Preview shows changes' : 'Preview shows no changes')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
setSyncOutput(message)
|
||||||
|
setSyncStatus(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onApplySync(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSyncStatus('Applying...')
|
||||||
|
try {
|
||||||
|
const result = await applySync(syncInput)
|
||||||
|
setSyncOutput(result)
|
||||||
|
setSyncStatus(result.changed ? 'Sync applied' : 'Already up to date')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
setSyncOutput(message)
|
||||||
|
setSyncStatus(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = overview
|
||||||
|
? [
|
||||||
|
['Providers', String(overview.providerCount)],
|
||||||
|
['Aliases', String(overview.aliasCount)],
|
||||||
|
['Routable aliases', String(overview.availableAliases.length)],
|
||||||
|
['Proxy', overview.proxy.running ? 'Running' : 'Idle'],
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{providers.length} total</span>
|
||||||
|
</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 />
|
||||||
|
Models: {provider.models?.join(', ') || 'none'}
|
||||||
|
<br />
|
||||||
|
Status: {provider.disabled ? 'disabled' : 'enabled'}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel wide">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Aliases</h2>
|
||||||
|
<span className="subtle">{aliases.length} total</span>
|
||||||
|
</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 />
|
||||||
|
{alias.targets.map((target) => `${target.provider}/${target.model}${target.enabled ? '' : ' (disabled)'}`).join(', ') || 'No targets'}
|
||||||
|
<br />
|
||||||
|
Status: {alias.enabled ? 'enabled' : 'disabled'}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
frontend/src/api.ts
Normal file
99
frontend/src/api.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
AliasView,
|
||||||
|
DesktopPrefsView,
|
||||||
|
DoctorRunResult,
|
||||||
|
MetaView,
|
||||||
|
Overview,
|
||||||
|
ProviderView,
|
||||||
|
ProxyStatusView,
|
||||||
|
SyncInput,
|
||||||
|
SyncPreview,
|
||||||
|
SyncResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type ApiEnvelope<T> = {
|
||||||
|
data: T
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWails(): boolean {
|
||||||
|
return Boolean(window.__OCSWITCH_WAILS__ && window.go?.main?.App)
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.main?.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 getDesktopPrefs(): Promise<DesktopPrefsView> {
|
||||||
|
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsView> {
|
||||||
|
return isWails()
|
||||||
|
? bridge().SavePrefs(input)
|
||||||
|
: http<DesktopPrefsView>('/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) })
|
||||||
|
}
|
||||||
27
frontend/src/env.d.ts
vendored
Normal file
27
frontend/src/env.d.ts
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OCSWITCH_WAILS__?: boolean
|
||||||
|
go?: {
|
||||||
|
main?: {
|
||||||
|
App?: {
|
||||||
|
Meta: () => Promise<Record<string, string>>
|
||||||
|
Overview: () => Promise<import('./types').Overview>
|
||||||
|
Providers: () => Promise<import('./types').ProviderView[]>
|
||||||
|
Aliases: () => 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').DesktopPrefsView>
|
||||||
|
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>,
|
||||||
|
)
|
||||||
232
frontend/src/styles.css
Normal file
232
frontend/src/styles.css
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0b1020;
|
||||||
|
color: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(96, 165, 250, 0.18), transparent 28%),
|
||||||
|
linear-gradient(180deg, #0b1020 0%, #0f172a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
color: #eef2ff;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(135deg, #2563eb, #22c55e);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
color: #eef2ff;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1,
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide,
|
||||||
|
.overview-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header,
|
||||||
|
.toolbar,
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions,
|
||||||
|
.toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(30, 41, 59, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.live {
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.idle {
|
||||||
|
background: rgba(148, 163, 184, 0.14);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(2, 6, 23, 0.72);
|
||||||
|
color: #cbd5e1;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(30, 41, 59, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card code {
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-error {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-ok {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.hero,
|
||||||
|
.panel-header,
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid,
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
frontend/src/types.ts
Normal file
98
frontend/src/types.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
export type DesktopPrefsView = {
|
||||||
|
launchAtLogin: boolean
|
||||||
|
minimizeToTray: boolean
|
||||||
|
notifications: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyStatusView = {
|
||||||
|
running: boolean
|
||||||
|
bindAddress: string
|
||||||
|
startedAt?: string
|
||||||
|
lastError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Overview = {
|
||||||
|
configPath: string
|
||||||
|
providerCount: number
|
||||||
|
aliasCount: number
|
||||||
|
availableAliases: string[]
|
||||||
|
proxy: ProxyStatusView
|
||||||
|
desktop: DesktopPrefsView
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderView = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
baseUrl: string
|
||||||
|
apiKeySet: boolean
|
||||||
|
apiKeyMasked?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
models?: string[]
|
||||||
|
modelsSource?: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AliasTargetView = {
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AliasView = {
|
||||||
|
alias: string
|
||||||
|
displayName?: string
|
||||||
|
enabled: boolean
|
||||||
|
targetCount: number
|
||||||
|
availableTargetCount: number
|
||||||
|
targets: AliasTargetView[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DoctorIssue = {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DoctorReport = {
|
||||||
|
ok: boolean
|
||||||
|
issues: DoctorIssue[]
|
||||||
|
configPath: string
|
||||||
|
providerCount: number
|
||||||
|
aliasCount: number
|
||||||
|
proxyBindAddress: string
|
||||||
|
openCodeTargetPath: string
|
||||||
|
openCodeTargetFound: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DoctorRunResult = {
|
||||||
|
report: DoctorReport
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncInput = {
|
||||||
|
target?: string
|
||||||
|
setModel?: string
|
||||||
|
setSmallModel?: string
|
||||||
|
dryRun?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncPreview = {
|
||||||
|
targetPath: string
|
||||||
|
aliasNames: string[]
|
||||||
|
setModel?: string
|
||||||
|
setSmallModel?: string
|
||||||
|
wouldChange: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncResult = {
|
||||||
|
targetPath: string
|
||||||
|
aliasNames: string[]
|
||||||
|
changed: boolean
|
||||||
|
dryRun: boolean
|
||||||
|
setModel?: string
|
||||||
|
setSmallModel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetaView = {
|
||||||
|
version: string
|
||||||
|
shell: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
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": "Node",
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
})
|
||||||
49
frontend/wailsjs/go/desktop/App.d.ts
vendored
Executable file
49
frontend/wailsjs/go/desktop/App.d.ts
vendored
Executable file
@ -0,0 +1,49 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {app} from '../models';
|
||||||
|
import {desktop} from '../models';
|
||||||
|
import {context} from '../models';
|
||||||
|
|
||||||
|
export function Aliases():Promise<Array<app.AliasView>>;
|
||||||
|
|
||||||
|
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
|
||||||
|
|
||||||
|
export function AutoStart():Promise<desktop.AutoStart>;
|
||||||
|
|
||||||
|
export function BeforeClose(arg1:context.Context):Promise<boolean>;
|
||||||
|
|
||||||
|
export function Bindings():Promise<desktop.Bindings>;
|
||||||
|
|
||||||
|
export function DesktopPrefs():Promise<app.DesktopPrefsView>;
|
||||||
|
|
||||||
|
export function DoctorRun():Promise<app.DoctorRunResult>;
|
||||||
|
|
||||||
|
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 SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
|
||||||
|
|
||||||
|
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
|
||||||
|
|
||||||
|
export function Service():Promise<app.Service>;
|
||||||
|
|
||||||
|
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 Tray():Promise<desktop.Tray>;
|
||||||
91
frontend/wailsjs/go/desktop/App.js
Executable file
91
frontend/wailsjs/go/desktop/App.js
Executable file
@ -0,0 +1,91 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function Aliases() {
|
||||||
|
return window['go']['desktop']['App']['Aliases']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApplySync(arg1) {
|
||||||
|
return window['go']['desktop']['App']['ApplySync'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoStart() {
|
||||||
|
return window['go']['desktop']['App']['AutoStart']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BeforeClose(arg1) {
|
||||||
|
return window['go']['desktop']['App']['BeforeClose'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Bindings() {
|
||||||
|
return window['go']['desktop']['App']['Bindings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopPrefs() {
|
||||||
|
return window['go']['desktop']['App']['DesktopPrefs']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DoctorRun() {
|
||||||
|
return window['go']['desktop']['App']['DoctorRun']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Meta() {
|
||||||
|
return window['go']['desktop']['App']['Meta']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Overview() {
|
||||||
|
return window['go']['desktop']['App']['Overview']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewSync(arg1) {
|
||||||
|
return window['go']['desktop']['App']['PreviewSync'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers() {
|
||||||
|
return window['go']['desktop']['App']['Providers']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProxyStatus() {
|
||||||
|
return window['go']['desktop']['App']['ProxyStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveDesktopPrefs(arg1, arg2) {
|
||||||
|
return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavePrefs(arg1) {
|
||||||
|
return window['go']['desktop']['App']['SavePrefs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Service() {
|
||||||
|
return window['go']['desktop']['App']['Service']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetVersion(arg1) {
|
||||||
|
return window['go']['desktop']['App']['SetVersion'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shutdown(arg1) {
|
||||||
|
return window['go']['desktop']['App']['Shutdown'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StartProxy() {
|
||||||
|
return window['go']['desktop']['App']['StartProxy']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Startup(arg1) {
|
||||||
|
return window['go']['desktop']['App']['Startup'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopProxy() {
|
||||||
|
return window['go']['desktop']['App']['StopProxy']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncDesktopPreferences(arg1) {
|
||||||
|
return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tray() {
|
||||||
|
return window['go']['desktop']['App']['Tray']();
|
||||||
|
}
|
||||||
400
frontend/wailsjs/go/models.ts
Executable file
400
frontend/wailsjs/go/models.ts
Executable file
@ -0,0 +1,400 @@
|
|||||||
|
export namespace app {
|
||||||
|
|
||||||
|
export class AliasTargetView {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AliasTargetView(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.provider = source["provider"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AliasView {
|
||||||
|
alias: string;
|
||||||
|
displayName?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
targetCount: number;
|
||||||
|
availableTargetCount: number;
|
||||||
|
targets: AliasTargetView[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AliasView(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.alias = source["alias"];
|
||||||
|
this.displayName = source["displayName"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.targetCount = source["targetCount"];
|
||||||
|
this.availableTargetCount = source["availableTargetCount"];
|
||||||
|
this.targets = this.convertValues(source["targets"], AliasTargetView);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DesktopPrefsInput {
|
||||||
|
launchAtLogin: boolean;
|
||||||
|
minimizeToTray: boolean;
|
||||||
|
notifications: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DesktopPrefsInput(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.launchAtLogin = source["launchAtLogin"];
|
||||||
|
this.minimizeToTray = source["minimizeToTray"];
|
||||||
|
this.notifications = source["notifications"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DesktopPrefsView {
|
||||||
|
launchAtLogin: boolean;
|
||||||
|
minimizeToTray: boolean;
|
||||||
|
notifications: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DesktopPrefsView(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.launchAtLogin = source["launchAtLogin"];
|
||||||
|
this.minimizeToTray = source["minimizeToTray"];
|
||||||
|
this.notifications = source["notifications"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DoctorIssue {
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DoctorIssue(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.message = source["message"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DoctorReport {
|
||||||
|
ok: boolean;
|
||||||
|
issues: DoctorIssue[];
|
||||||
|
configPath: string;
|
||||||
|
providerCount: number;
|
||||||
|
aliasCount: number;
|
||||||
|
proxyBindAddress: string;
|
||||||
|
openCodeTargetPath: string;
|
||||||
|
openCodeTargetFound: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DoctorReport(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ok = source["ok"];
|
||||||
|
this.issues = this.convertValues(source["issues"], DoctorIssue);
|
||||||
|
this.configPath = source["configPath"];
|
||||||
|
this.providerCount = source["providerCount"];
|
||||||
|
this.aliasCount = source["aliasCount"];
|
||||||
|
this.proxyBindAddress = source["proxyBindAddress"];
|
||||||
|
this.openCodeTargetPath = source["openCodeTargetPath"];
|
||||||
|
this.openCodeTargetFound = source["openCodeTargetFound"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DoctorRunResult {
|
||||||
|
report: DoctorReport;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DoctorRunResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.report = this.convertValues(source["report"], DoctorReport);
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ProxyStatusView {
|
||||||
|
running: boolean;
|
||||||
|
bindAddress: string;
|
||||||
|
// Go type: time
|
||||||
|
startedAt?: any;
|
||||||
|
lastError?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ProxyStatusView(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.running = source["running"];
|
||||||
|
this.bindAddress = source["bindAddress"];
|
||||||
|
this.startedAt = this.convertValues(source["startedAt"], null);
|
||||||
|
this.lastError = source["lastError"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Overview {
|
||||||
|
configPath: string;
|
||||||
|
providerCount: number;
|
||||||
|
aliasCount: number;
|
||||||
|
availableAliases: string[];
|
||||||
|
proxy: ProxyStatusView;
|
||||||
|
desktop: DesktopPrefsView;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Overview(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.configPath = source["configPath"];
|
||||||
|
this.providerCount = source["providerCount"];
|
||||||
|
this.aliasCount = source["aliasCount"];
|
||||||
|
this.availableAliases = source["availableAliases"];
|
||||||
|
this.proxy = this.convertValues(source["proxy"], ProxyStatusView);
|
||||||
|
this.desktop = this.convertValues(source["desktop"], DesktopPrefsView);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ProviderView {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKeySet: boolean;
|
||||||
|
apiKeyMasked?: string;
|
||||||
|
headers?: Record<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 Service {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Service(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SyncInput {
|
||||||
|
target?: string;
|
||||||
|
setModel?: string;
|
||||||
|
setSmallModel?: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SyncInput(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.target = source["target"];
|
||||||
|
this.setModel = source["setModel"];
|
||||||
|
this.setSmallModel = source["setSmallModel"];
|
||||||
|
this.dryRun = source["dryRun"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SyncPreview {
|
||||||
|
targetPath: string;
|
||||||
|
aliasNames: string[];
|
||||||
|
setModel?: string;
|
||||||
|
setSmallModel?: string;
|
||||||
|
wouldChange: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SyncPreview(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.targetPath = source["targetPath"];
|
||||||
|
this.aliasNames = source["aliasNames"];
|
||||||
|
this.setModel = source["setModel"];
|
||||||
|
this.setSmallModel = source["setSmallModel"];
|
||||||
|
this.wouldChange = source["wouldChange"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SyncResult {
|
||||||
|
targetPath: string;
|
||||||
|
aliasNames: string[];
|
||||||
|
changed: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
setModel?: string;
|
||||||
|
setSmallModel?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SyncResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.targetPath = source["targetPath"];
|
||||||
|
this.aliasNames = source["aliasNames"];
|
||||||
|
this.changed = source["changed"];
|
||||||
|
this.dryRun = source["dryRun"];
|
||||||
|
this.setModel = source["setModel"];
|
||||||
|
this.setSmallModel = source["setSmallModel"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace desktop {
|
||||||
|
|
||||||
|
export class AutoStart {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoStart(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Bindings {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Bindings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Tray {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Tray(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
29
go.mod
29
go.mod
@ -5,9 +5,38 @@ go 1.22.2
|
|||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/tidwall/jsonc v0.3.2
|
github.com/tidwall/jsonc v0.3.2
|
||||||
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
82
go.sum
82
go.sum
@ -1,12 +1,94 @@
|
|||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
|
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
|
||||||
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
|
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
|
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/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=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
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:]
|
||||||
|
}
|
||||||
120
internal/app/service_test.go
Normal file
120
internal/app/service_test.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
svc := NewService(path)
|
||||||
|
|
||||||
|
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
||||||
|
LaunchAtLogin: true,
|
||||||
|
MinimizeToTray: true,
|
||||||
|
Notifications: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
||||||
|
}
|
||||||
|
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications {
|
||||||
|
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications {
|
||||||
|
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
cfgPathPort := freePort(t)
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Port = cfgPathPort
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(path)
|
||||||
|
if err := svc.StartProxy(context.Background()); err != nil {
|
||||||
|
t.Fatalf("StartProxy() error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = svc.StopProxy(context.Background())
|
||||||
|
})
|
||||||
|
|
||||||
|
status, err := svc.GetProxyStatus(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if !status.Running {
|
||||||
|
t.Fatalf("status.Running = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEventually(t, func() bool {
|
||||||
|
resp, err := http.Get("http://127.0.0.1:" + itoa(cfgPathPort) + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := svc.StopProxy(context.Background()); err != nil {
|
||||||
|
t.Fatalf("StopProxy() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err = svc.GetProxyStatus(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() after stop error = %v", err)
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
t.Fatalf("status.Running = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEventually(t *testing.T, fn func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if fn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("condition not satisfied before timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("net.Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
return listener.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
}
|
||||||
102
internal/app/types.go
Normal file
102
internal/app/types.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Overview struct {
|
||||||
|
ConfigPath string `json:"configPath"`
|
||||||
|
ProviderCount int `json:"providerCount"`
|
||||||
|
AliasCount int `json:"aliasCount"`
|
||||||
|
AvailableAliases []string `json:"availableAliases"`
|
||||||
|
Proxy ProxyStatusView `json:"proxy"`
|
||||||
|
Desktop DesktopPrefsView `json:"desktop"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyStatusView struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
BindAddress string `json:"bindAddress"`
|
||||||
|
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
APIKeySet bool `json:"apiKeySet"`
|
||||||
|
APIKeyMasked string `json:"apiKeyMasked,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
|
ModelsSource string `json:"modelsSource,omitempty"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AliasTargetView struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AliasView struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
TargetCount int `json:"targetCount"`
|
||||||
|
AvailableTargetCount int `json:"availableTargetCount"`
|
||||||
|
Targets []AliasTargetView `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DoctorIssue struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DoctorReport struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issues []DoctorIssue `json:"issues"`
|
||||||
|
ConfigPath string `json:"configPath"`
|
||||||
|
ProviderCount int `json:"providerCount"`
|
||||||
|
AliasCount int `json:"aliasCount"`
|
||||||
|
ProxyBindAddress string `json:"proxyBindAddress"`
|
||||||
|
OpenCodeTargetPath string `json:"openCodeTargetPath"`
|
||||||
|
OpenCodeTargetFound bool `json:"openCodeTargetFound"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DoctorRunResult struct {
|
||||||
|
Report DoctorReport `json:"report"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncInput struct {
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
SetModel string `json:"setModel,omitempty"`
|
||||||
|
SetSmallModel string `json:"setSmallModel,omitempty"`
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncPreview struct {
|
||||||
|
TargetPath string `json:"targetPath"`
|
||||||
|
AliasNames []string `json:"aliasNames"`
|
||||||
|
SetModel string `json:"setModel,omitempty"`
|
||||||
|
SetSmallModel string `json:"setSmallModel,omitempty"`
|
||||||
|
WouldChange bool `json:"wouldChange"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncResult struct {
|
||||||
|
TargetPath string `json:"targetPath"`
|
||||||
|
AliasNames []string `json:"aliasNames"`
|
||||||
|
Changed bool `json:"changed"`
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
SetModel string `json:"setModel,omitempty"`
|
||||||
|
SetSmallModel string `json:"setSmallModel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DesktopPrefsView struct {
|
||||||
|
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||||
|
MinimizeToTray bool `json:"minimizeToTray"`
|
||||||
|
Notifications bool `json:"notifications"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DesktopPrefsInput struct {
|
||||||
|
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||||
|
MinimizeToTray bool `json:"minimizeToTray"`
|
||||||
|
Notifications bool `json:"notifications"`
|
||||||
|
}
|
||||||
@ -4,8 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newDoctorCmd() *cobra.Command {
|
func newDoctorCmd() *cobra.Command {
|
||||||
@ -25,48 +23,32 @@ aliases, or local server settings.`,
|
|||||||
Example: ` ocswitch doctor
|
Example: ` ocswitch doctor
|
||||||
ocswitch --config /path/to/config.json doctor`,
|
ocswitch --config /path/to/config.json doctor`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := loadCfg()
|
report, err := appService().RunDoctor(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
for _, issue := range report.Issues {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", issue.Message)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
|
||||||
|
marker := "(will be created)"
|
||||||
|
if report.OpenCodeTargetFound {
|
||||||
|
marker = "(exists)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
issues := cfg.Validate()
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", report.ConfigPath)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
|
||||||
path, existed := opencode.ResolveGlobalConfigPath()
|
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
|
||||||
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
|
|
||||||
marker := "(will be created)"
|
marker := "(will be created)"
|
||||||
if existed {
|
if report.OpenCodeTargetFound {
|
||||||
marker = "(exists)"
|
marker = "(exists)"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker)
|
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker)
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", ok)
|
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress)
|
||||||
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))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newOpencodeCmd() *cobra.Command {
|
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 --set-model ocswitch/gpt-5.4 --set-small-model ocswitch/gpt-5.4-mini
|
||||||
ocswitch opencode sync --target /path/to/opencode.jsonc`,
|
ocswitch opencode sync --target /path/to/opencode.jsonc`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if errs := cfg.Validate(); len(errs) > 0 {
|
if !result.Changed {
|
||||||
for _, e := range errs {
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", result.TargetPath)
|
||||||
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)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if dryRun {
|
if result.DryRun {
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", path)
|
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", result.TargetPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := opencode.Save(path, raw); err != nil {
|
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", result.TargetPath, len(result.AliasNames))
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", path, len(aliasNames))
|
|
||||||
if setModel != "" {
|
if setModel != "" {
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
|
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@ -211,23 +210,21 @@ This command does not modify config and does not contact upstream providers.`,
|
|||||||
Example: ` ocswitch provider list
|
Example: ` ocswitch provider list
|
||||||
ocswitch --config /path/to/config.json provider list`,
|
ocswitch --config /path/to/config.json provider list`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := loadCfg()
|
providers, err := appService().ListProviders(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if len(providers) == 0 {
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
|
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, p := range providers {
|
for _, p := range providers {
|
||||||
key := "(none)"
|
key := "(none)"
|
||||||
if p.APIKey != "" {
|
if p.APIKeySet {
|
||||||
key = maskKey(p.APIKey)
|
key = p.APIKeyMasked
|
||||||
}
|
}
|
||||||
state := "enabled"
|
state := "enabled"
|
||||||
if !p.IsEnabled() {
|
if p.Disabled {
|
||||||
state = "disabled"
|
state = "disabled"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key)
|
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/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -18,6 +19,10 @@ func loadCfg() (*config.Config, error) {
|
|||||||
return config.Load(configPath)
|
return config.Load(configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appService() *app.Service {
|
||||||
|
return app.NewService(configPath)
|
||||||
|
}
|
||||||
|
|
||||||
// NewRootCmd builds the root ocswitch command.
|
// NewRootCmd builds the root ocswitch command.
|
||||||
func NewRootCmd(version string) *cobra.Command {
|
func NewRootCmd(version string) *cobra.Command {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/proxy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newServeCmd() *cobra.Command {
|
func newServeCmd() *cobra.Command {
|
||||||
@ -25,20 +24,17 @@ OpenCode can see the same aliases that the proxy can route.`,
|
|||||||
Example: ` ocswitch serve
|
Example: ` ocswitch serve
|
||||||
ocswitch --config /path/to/config.json serve`,
|
ocswitch --config /path/to/config.json serve`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
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)
|
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
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"`
|
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.
|
// Config is the on-disk ocswitch config.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server Server `json:"server"`
|
Server Server `json:"server"`
|
||||||
|
Desktop Desktop `json:"desktop,omitempty"`
|
||||||
Providers []Provider `json:"providers"`
|
Providers []Provider `json:"providers"`
|
||||||
Aliases []Alias `json:"aliases"`
|
Aliases []Alias `json:"aliases"`
|
||||||
|
|
||||||
@ -98,6 +106,7 @@ func Default() *Config {
|
|||||||
Port: 9982,
|
Port: 9982,
|
||||||
APIKey: DefaultLocalAPIKey,
|
APIKey: DefaultLocalAPIKey,
|
||||||
},
|
},
|
||||||
|
Desktop: Desktop{},
|
||||||
Providers: []Provider{},
|
Providers: []Provider{},
|
||||||
Aliases: []Alias{},
|
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 })
|
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
||||||
snap := struct {
|
snap := struct {
|
||||||
Server Server `json:"server"`
|
Server Server `json:"server"`
|
||||||
|
Desktop Desktop `json:"desktop,omitempty"`
|
||||||
Providers []Provider `json:"providers"`
|
Providers []Provider `json:"providers"`
|
||||||
Aliases []Alias `json:"aliases"`
|
Aliases []Alias `json:"aliases"`
|
||||||
}{c.Server, providers, aliases}
|
}{c.Server, c.Desktop, providers, aliases}
|
||||||
data, err := json.MarshalIndent(snap, "", " ")
|
data, err := json.MarshalIndent(snap, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal: %w", err)
|
return fmt.Errorf("marshal: %w", err)
|
||||||
|
|||||||
167
internal/desktop/app.go
Normal file
167
internal/desktop/app.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the desktop-shell composition root. Native shell integrations are kept
|
||||||
|
// out of internal/app so CLI and GUI can share the same workflows.
|
||||||
|
type App struct {
|
||||||
|
service *app.Service
|
||||||
|
bindings *Bindings
|
||||||
|
tray *Tray
|
||||||
|
notify *Notifier
|
||||||
|
auto *AutoStart
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(configPath string) *App {
|
||||||
|
svc := app.NewService(configPath)
|
||||||
|
instance := &App{service: svc}
|
||||||
|
instance.bindings = NewBindings(svc)
|
||||||
|
instance.tray = NewTray(svc)
|
||||||
|
instance.notify = NewNotifier(svc)
|
||||||
|
instance.auto = NewAutoStart(svc)
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetVersion(version string) {
|
||||||
|
a.version = version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
a.tray.Attach(ctx)
|
||||||
|
a.notify.Attach(ctx)
|
||||||
|
a.auto.Attach(ctx)
|
||||||
|
_ = a.SyncDesktopPreferences(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) BeforeClose(ctx context.Context) bool {
|
||||||
|
a.ctx = ctx
|
||||||
|
prevent, _ := a.tray.BeforeClose(ctx)
|
||||||
|
return prevent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Shutdown(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = a.service.StopProxy(shutdownCtx)
|
||||||
|
a.tray.Detach()
|
||||||
|
a.notify.Detach()
|
||||||
|
a.auto.Detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SyncDesktopPreferences(ctx context.Context) error {
|
||||||
|
prefs, err := a.bindings.GetDesktopPrefs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.auto.Sync(ctx, prefs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.tray.Sync(ctx, prefs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||||
|
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return app.DesktopPrefsView{}, err
|
||||||
|
}
|
||||||
|
if err := a.auto.Sync(ctx, prefs); err != nil {
|
||||||
|
return app.DesktopPrefsView{}, err
|
||||||
|
}
|
||||||
|
a.tray.Sync(ctx, prefs)
|
||||||
|
return prefs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||||
|
return a.SaveDesktopPrefs(a.callContext(), in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Meta() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"version": a.version,
|
||||||
|
"shell": a.shellName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Overview() (app.Overview, error) {
|
||||||
|
return a.bindings.GetOverview(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Providers() ([]app.ProviderView, error) {
|
||||||
|
return a.bindings.ListProviders(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Aliases() ([]app.AliasView, error) {
|
||||||
|
return a.bindings.ListAliases(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DoctorRun() (app.DoctorRunResult, error) {
|
||||||
|
report, err := a.bindings.RunDoctor(a.callContext())
|
||||||
|
return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
|
||||||
|
return a.bindings.GetProxyStatus(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) StartProxy() (app.ProxyStatusView, error) {
|
||||||
|
return a.bindings.StartProxy(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) StopProxy() (app.ProxyStatusView, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return a.bindings.StopProxy(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DesktopPrefs() (app.DesktopPrefsView, error) {
|
||||||
|
return a.bindings.GetDesktopPrefs(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
|
||||||
|
return a.bindings.PreviewOpenCodeSync(a.callContext(), in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) {
|
||||||
|
return a.bindings.SyncOpenCode(a.callContext(), in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) callContext() context.Context {
|
||||||
|
if a.ctx != nil {
|
||||||
|
return a.ctx
|
||||||
|
}
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) shellName() string {
|
||||||
|
if a.ctx != nil {
|
||||||
|
return "wails"
|
||||||
|
}
|
||||||
|
return "browser"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Service() *app.Service {
|
||||||
|
return a.service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Bindings() *Bindings {
|
||||||
|
return a.bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Tray() *Tray {
|
||||||
|
return a.tray
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AutoStart() *AutoStart {
|
||||||
|
return a.auto
|
||||||
|
}
|
||||||
98
internal/desktop/autostart.go
Normal file
98
internal/desktop/autostart.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoStart manages real launch-at-login integration where the platform permits
|
||||||
|
// it. This implementation currently targets Linux XDG autostart.
|
||||||
|
type AutoStart struct {
|
||||||
|
service *app.Service
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAutoStart(service *app.Service) *AutoStart {
|
||||||
|
return &AutoStart{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoStart) Attach(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoStart) Detach() {
|
||||||
|
a.ctx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error {
|
||||||
|
_ = ctx
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entryPath, err := a.entryPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prefs.LaunchAtLogin {
|
||||||
|
return a.writeLinuxEntry(entryPath)
|
||||||
|
}
|
||||||
|
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove autostart entry: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoStart) entryPath() (string, error) {
|
||||||
|
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
|
||||||
|
if base == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home for autostart: %w", err)
|
||||||
|
}
|
||||||
|
base = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AutoStart) writeLinuxEntry(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir autostart dir: %w", err)
|
||||||
|
}
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve desktop executable: %w", err)
|
||||||
|
}
|
||||||
|
configPath := config.DefaultPath()
|
||||||
|
if a.service != nil {
|
||||||
|
configPath = a.service.ConfigPath()
|
||||||
|
}
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"[Desktop Entry]",
|
||||||
|
"Type=Application",
|
||||||
|
"Version=1.0",
|
||||||
|
"Name=ocswitch desktop",
|
||||||
|
"Comment=OpenCode provider switch desktop shell",
|
||||||
|
fmt.Sprintf("Exec=%s --config %s", shellQuote(execPath), shellQuote(configPath)),
|
||||||
|
"Terminal=false",
|
||||||
|
"X-GNOME-Autostart-enabled=true",
|
||||||
|
"Categories=Network;Development;",
|
||||||
|
"StartupNotify=false",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write autostart entry: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
|
||||||
|
return `"` + replacer.Replace(value) + `"`
|
||||||
|
}
|
||||||
59
internal/desktop/autostart_test.go
Normal file
59
internal/desktop/autostart_test.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAutoStartSyncLinuxWritesDesktopEntry(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("linux-only autostart behavior")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
|
||||||
|
auto := NewAutoStart(nil)
|
||||||
|
if err := auto.Sync(context.Background(), app.DesktopPrefsView{LaunchAtLogin: true}); err != nil {
|
||||||
|
t.Fatalf("Sync() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := auto.entryPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entryPath() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, "[Desktop Entry]") {
|
||||||
|
t.Fatalf("desktop entry missing header: %q", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "Name=ocswitch desktop") {
|
||||||
|
t.Fatalf("desktop entry missing app name: %q", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auto.Sync(context.Background(), app.DesktopPrefsView{}); err != nil {
|
||||||
|
t.Fatalf("Sync(remove) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(entry); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("autostart entry still exists at %s", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuoteEscapesDoubleQuotes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
quoted := shellQuote(filepath.Join(`/tmp/demo"path`, `bin`))
|
||||||
|
if !strings.HasPrefix(quoted, `"`) || !strings.HasSuffix(quoted, `"`) {
|
||||||
|
t.Fatalf("shellQuote() = %q", quoted)
|
||||||
|
}
|
||||||
|
if !strings.Contains(quoted, `\\"`) {
|
||||||
|
t.Fatalf("shellQuote() did not escape quotes: %q", quoted)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/desktop/bindings.go
Normal file
114
internal/desktop/bindings.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bindings is the thin desktop-callable facade shared by the fallback HTTP shell
|
||||||
|
// and the Wails bridge.
|
||||||
|
type Bindings struct {
|
||||||
|
service *app.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBindings(service *app.Service) *Bindings {
|
||||||
|
return &Bindings{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) {
|
||||||
|
return b.service.GetOverview(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
|
||||||
|
return b.service.ListProviders(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ListAliases(ctx context.Context) ([]app.AliasView, error) {
|
||||||
|
return b.service.ListAliases(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) RunDoctor(ctx context.Context) (app.DoctorReport, error) {
|
||||||
|
return b.service.RunDoctor(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) SyncOpenCode(ctx context.Context, in app.SyncInput) (app.SyncResult, error) {
|
||||||
|
return b.service.ApplyOpenCodeSync(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) PreviewOpenCodeSync(ctx context.Context, in app.SyncInput) (app.SyncPreview, error) {
|
||||||
|
return b.service.PreviewOpenCodeSync(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, error) {
|
||||||
|
return b.service.GetProxyStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
|
||||||
|
if err := b.service.StartProxy(ctx); err != nil {
|
||||||
|
return app.ProxyStatusView{}, err
|
||||||
|
}
|
||||||
|
return b.service.GetProxyStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) StopProxy(ctx context.Context) (app.ProxyStatusView, error) {
|
||||||
|
if err := b.service.StopProxy(ctx); err != nil {
|
||||||
|
return app.ProxyStatusView{}, err
|
||||||
|
}
|
||||||
|
return b.service.GetProxyStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) Overview() (app.Overview, error) {
|
||||||
|
return b.GetOverview(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) Providers() ([]app.ProviderView, error) {
|
||||||
|
return b.ListProviders(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) Aliases() ([]app.AliasView, error) {
|
||||||
|
return b.ListAliases(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) Doctor() (app.DoctorReport, error) {
|
||||||
|
return b.RunDoctor(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
|
||||||
|
return b.GetProxyStatus(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
|
||||||
|
return b.StartProxy(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) StopProxyNow() (app.ProxyStatusView, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return b.StopProxy(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) DesktopPrefs() (app.DesktopPrefsView, error) {
|
||||||
|
return b.GetDesktopPrefs(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||||
|
return b.SaveDesktopPrefs(context.Background(), in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
|
||||||
|
return b.PreviewOpenCodeSync(context.Background(), in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ApplySync(in app.SyncInput) (app.SyncResult, error) {
|
||||||
|
return b.SyncOpenCode(context.Background(), in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) GetDesktopPrefs(ctx context.Context) (app.DesktopPrefsView, error) {
|
||||||
|
return b.service.GetDesktopPrefs(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||||
|
return b.service.SaveDesktopPrefs(ctx, in)
|
||||||
|
}
|
||||||
323
internal/desktop/http.go
Normal file
323
internal/desktop/http.go
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
|
||||||
|
appcore "github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunOptions struct {
|
||||||
|
ConfigPath string
|
||||||
|
Version string
|
||||||
|
ListenAddr string
|
||||||
|
OpenBrowser bool
|
||||||
|
ShutdownWait time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiEnvelope struct {
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type metaView struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Shell string `json:"shell"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(opts RunOptions) error {
|
||||||
|
if strings.TrimSpace(opts.ConfigPath) == "" {
|
||||||
|
opts.ConfigPath = config.DefaultPath()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.ListenAddr) == "" {
|
||||||
|
opts.ListenAddr = "127.0.0.1:0"
|
||||||
|
}
|
||||||
|
if opts.ShutdownWait <= 0 {
|
||||||
|
opts.ShutdownWait = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
instance := New(opts.ConfigPath)
|
||||||
|
listener, err := net.Listen("tcp", opts.ListenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen desktop control panel: %w", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
url := "http://" + listener.Addr().String()
|
||||||
|
handler, err := newHandler(instance, opts.Version, url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- srv.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Printf("ocswitch desktop control panel: %s\n", url)
|
||||||
|
if opts.OpenBrowser {
|
||||||
|
if err := openBrowser(url); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: open browser: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), opts.ShutdownWait)
|
||||||
|
defer cancel()
|
||||||
|
_ = instance.Service().StopProxy(shutdownCtx)
|
||||||
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
case err := <-errCh:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandler(instance *App, version string, baseURL string) (http.Handler, error) {
|
||||||
|
assets, err := frontendassets.DistFS()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load web assets: %w", err)
|
||||||
|
}
|
||||||
|
api := http.NewServeMux()
|
||||||
|
b := instance.Bindings()
|
||||||
|
|
||||||
|
api.HandleFunc("/api/meta", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, apiEnvelope{Data: metaView{Version: version, Shell: instance.shellName(), URL: baseURL}})
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/overview", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.GetOverview(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.ListProviders(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.ListAliases(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/desktop-prefs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
data, err := b.GetDesktopPrefs(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
case http.MethodPost:
|
||||||
|
var in appcore.DesktopPrefsInput
|
||||||
|
if !decodeJSONBody(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.SaveDesktopPrefs(r.Context(), in)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
default:
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/proxy/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.GetProxyStatus(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.StartProxy(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/proxy/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
data, err := b.StopProxy(ctx)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/doctor", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.RunDoctor(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, apiEnvelope{Data: appcore.DoctorRunResult{Report: data, Error: errorString(err)}})
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/opencode-sync/preview", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in appcore.SyncInput
|
||||||
|
if !decodeJSONBody(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.PreviewOpenCodeSync(r.Context(), in)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/opencode-sync/apply", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in appcore.SyncInput
|
||||||
|
if !decodeJSONBody(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.SyncOpenCode(r.Context(), in)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
fileServer := http.FileServer(http.FS(assets))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
api.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveSPA(w, r, assets, fileServer)
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveSPA(w http.ResponseWriter, r *http.Request, assets fs.FS, next http.Handler) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(assets, path); err == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r = r.Clone(r.Context())
|
||||||
|
r.URL.Path = "/index.html"
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(string(body))) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, dst); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: "invalid json: " + err.Error()})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResult(w http.ResponseWriter, data any, err error) {
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, apiEnvelope{Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMethodNotAllowed(w http.ResponseWriter, allowed ...string) {
|
||||||
|
if len(allowed) > 0 {
|
||||||
|
w.Header().Set("Allow", strings.Join(allowed, ", "))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, apiEnvelope{Error: "method not allowed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
commands := browserCommands(url)
|
||||||
|
var errs []string
|
||||||
|
for _, args := range commands {
|
||||||
|
if _, err := exec.LookPath(args[0]); err != nil {
|
||||||
|
err = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := exec.Command(args[0], args[1:]...).Start(); err == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) == 0 {
|
||||||
|
return fmt.Errorf("no browser launcher found")
|
||||||
|
}
|
||||||
|
return fmt.Errorf(strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserCommands(url string) [][]string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return [][]string{{"open", url}}
|
||||||
|
case "windows":
|
||||||
|
return [][]string{{"rundll32", "url.dll,FileProtocolHandler", url}}
|
||||||
|
default:
|
||||||
|
return [][]string{{"xdg-open", url}, {"gio", "open", url}}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
internal/desktop/http_test.go
Normal file
104
internal/desktop/http_test.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.UpsertProvider(config.Provider{
|
||||||
|
ID: "demo",
|
||||||
|
Name: "Demo",
|
||||||
|
BaseURL: "https://example.com/v1",
|
||||||
|
APIKey: "sk-demo-12345678",
|
||||||
|
Models: []string{"gpt-4.1-mini"},
|
||||||
|
})
|
||||||
|
cfg.UpsertAlias(config.Alias{
|
||||||
|
Alias: "chat",
|
||||||
|
DisplayName: "Chat",
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []config.Target{{
|
||||||
|
Provider: "demo",
|
||||||
|
Model: "gpt-4.1-mini",
|
||||||
|
Enabled: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := newHandler(New(path), "test", "http://127.0.0.1:9982")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newHandler() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("overview api", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/overview", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data struct {
|
||||||
|
ProviderCount int `json:"providerCount"`
|
||||||
|
AliasCount int `json:"aliasCount"`
|
||||||
|
Aliases []string `json:"availableAliases"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data.ProviderCount != 1 || payload.Data.AliasCount != 1 {
|
||||||
|
t.Fatalf("unexpected counts: %#v", payload.Data)
|
||||||
|
}
|
||||||
|
if len(payload.Data.Aliases) != 1 || payload.Data.Aliases[0] != "chat" {
|
||||||
|
t.Fatalf("unexpected aliases: %#v", payload.Data.Aliases)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("save desktop prefs", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications {
|
||||||
|
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves app shell", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ocswitch desktop") {
|
||||||
|
t.Fatalf("unexpected body = %q", resp.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
25
internal/desktop/notify.go
Normal file
25
internal/desktop/notify.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier is the future native notification adapter.
|
||||||
|
type Notifier struct {
|
||||||
|
service *app.Service
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotifier(service *app.Service) *Notifier {
|
||||||
|
return &Notifier{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) Attach(ctx context.Context) {
|
||||||
|
n.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) Detach() {
|
||||||
|
n.ctx = nil
|
||||||
|
}
|
||||||
10
internal/desktop/runtime_fallback.go
Normal file
10
internal/desktop/runtime_fallback.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
//go:build !desktop_wails
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func hideWindow(ctx context.Context) error {
|
||||||
|
_ = ctx
|
||||||
|
return nil
|
||||||
|
}
|
||||||
14
internal/desktop/runtime_wails.go
Normal file
14
internal/desktop/runtime_wails.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//go:build desktop_wails
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hideWindow(ctx context.Context) error {
|
||||||
|
wruntime.Hide(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
internal/desktop/tray.go
Normal file
41
internal/desktop/tray.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tray holds desktop resident-mode preferences. Wails v2 does not expose a
|
||||||
|
// stable public tray API in the current pinned version, so this type currently
|
||||||
|
// manages close-to-background behavior without depending on private APIs.
|
||||||
|
type Tray struct {
|
||||||
|
service *app.Service
|
||||||
|
ctx context.Context
|
||||||
|
prefs app.DesktopPrefsView
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTray(service *app.Service) *Tray {
|
||||||
|
return &Tray{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tray) Attach(ctx context.Context) {
|
||||||
|
t.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tray) Detach() {
|
||||||
|
t.ctx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
||||||
|
_ = ctx
|
||||||
|
t.prefs = prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
||||||
|
_ = ctx
|
||||||
|
if !t.prefs.MinimizeToTray {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, hideWindow(ctx)
|
||||||
|
}
|
||||||
58
internal/desktop/wails.go
Normal file
58
internal/desktop/wails.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
//go:build desktop_wails
|
||||||
|
|
||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RunWails(configPath string, version string) error {
|
||||||
|
assets, err := frontendassets.DistFS()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
instance := New(configPath)
|
||||||
|
instance.SetVersion(version)
|
||||||
|
|
||||||
|
return wails.Run(&options.App{
|
||||||
|
Title: "ocswitch desktop",
|
||||||
|
Width: 1280,
|
||||||
|
Height: 880,
|
||||||
|
MinWidth: 980,
|
||||||
|
MinHeight: 720,
|
||||||
|
HideWindowOnClose: true,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Assets: mustFS(assets),
|
||||||
|
},
|
||||||
|
Bind: []any{instance},
|
||||||
|
OnStartup: func(ctx context.Context) {
|
||||||
|
instance.Startup(ctx)
|
||||||
|
},
|
||||||
|
OnBeforeClose: func(ctx context.Context) bool {
|
||||||
|
return instance.BeforeClose(ctx)
|
||||||
|
},
|
||||||
|
OnShutdown: func(ctx context.Context) {
|
||||||
|
instance.Shutdown(ctx)
|
||||||
|
},
|
||||||
|
Linux: &linux.Options{
|
||||||
|
ProgramName: "ocswitch-desktop",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustFS(assets fs.FS) fs.FS {
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
|
func WailsProjectName() string {
|
||||||
|
return fmt.Sprintf("%s desktop", "ocswitch")
|
||||||
|
}
|
||||||
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