This commit is contained in:
apale7 2026-04-18 20:49:54 +08:00
parent 29d0cabbd1
commit cc3359f773
50 changed files with 6037 additions and 110 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
/dist/
*.tmp
output
frontend/dist/
frontend/node_modules/

View File

@ -63,12 +63,35 @@
"internal/cli/serve.go",
"internal/desktop/app.go",
"internal/desktop/bindings.go",
"internal/desktop/runtime_fallback.go",
"internal/desktop/runtime_wails.go",
"internal/desktop/http.go",
"internal/desktop/http_test.go",
"internal/desktop/tray.go",
"internal/desktop/notify.go",
"internal/desktop/autostart.go",
"cmd/ocswitch-desktop/main.go"
"internal/desktop/autostart_test.go",
"internal/desktop/wails.go",
"cmd/ocswitch-desktop/main_fallback.go",
"cmd/ocswitch-desktop/main_wails.go",
"main_wails.go",
"frontend/assets.go",
"frontend/package.json",
"frontend/package-lock.json",
"frontend/tsconfig.json",
"frontend/tsconfig.node.json",
"frontend/vite.config.ts",
"frontend/index.html",
"frontend/src/main.tsx",
"frontend/src/App.tsx",
"frontend/src/api.ts",
"frontend/src/types.ts",
"frontend/src/env.d.ts",
"frontend/src/styles.css",
"frontend/dist/index.html",
"frontend/dist/assets/"
],
"notes": "Implemented the first desktop-shell skeleton instead of a full Wails UI: added config-backed desktop preferences, a shared internal/app service layer, CLI reuse of shared workflows for doctor/opencode sync/provider list/serve, a minimal internal/desktop composition root, and a cmd/ocswitch-desktop bootstrap entrypoint. Verification passed with gofmt, go test ./..., and go build ./....",
"notes": "Implemented the next desktop-shell iteration with a real Wails integration path and a formal React + TypeScript frontend. The browser fallback shell now serves the same frontend assets as the Wails build, Linux XDG launch-at-login is implemented for real, and close-to-background behavior is wired through the desktop preference model. Verification passed for `npm run build`, `go test ./...`, and `go test -tags desktop_wails ./...`. `wails build -tags desktop_wails -debug` now reaches native compilation and is blocked only by missing Linux system packages (`pkg-config`, plus GTK/WebKit dev packages). Stable public tray and native notification APIs remain unresolved in the pinned Wails version, so those desktop-only pieces are intentionally limited to lifecycle wiring/placeholders rather than private-API integrations.",
"meta": {
"desktopShellRequired": true,
"recommendedShell": "wails",

View File

@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 10
- **Total Sessions**: 12
- **Last Active**: 2026-04-18
<!-- @@@/auto:current-status -->
@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~420 | Active |
| `journal-1.md` | ~525 | Active |
<!-- @@@/auto:active-documents -->
---
@ -29,6 +29,8 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 12 | 2026-04-18 | Wails desktop shell integration | - | `master` |
| 11 | 2026-04-18 | Desktop control panel implementation | - | `master` |
| 10 | 2026-04-18 | Desktop shell skeleton implementation | - | `master` |
| 9 | 2026-04-18 | GUI desktop direction and architecture decision | - | `master` |
| 8 | 2026-04-18 | Provider discovery and model ref hardening | `09d0c1f` | `master` |

View File

@ -418,3 +418,108 @@ Implemented the first desktop-shell skeleton by introducing a shared internal/ap
### Next Steps
- None - task complete
## Session 11: Desktop control panel implementation
**Date**: 2026-04-18
**Task**: Desktop control panel implementation
**Branch**: `master`
### Summary
Extended the desktop-shell skeleton into a minimal usable local control panel by adding an embedded web UI, desktop HTTP bindings, alias/provider overview surfaces, proxy controls, desktop preference editing, and OpenCode sync/doctor actions while keeping the shared Go application layer intact.
### Main Changes
- Upgraded `cmd/ocswitch-desktop/main.go` from a skeleton print-only bootstrap into a runnable local desktop control panel entrypoint with `--config`, `--listen`, and `--no-open` flags.
- Added `internal/desktop/http.go` as a lightweight desktop shell runtime that:
- serves embedded frontend assets
- exposes JSON endpoints for overview, providers, aliases, proxy status/start/stop, desktop prefs, doctor, and OpenCode sync preview/apply
- opens the browser by default and shuts down cleanly on SIGINT/SIGTERM
- Added `web/` embedded assets (`web/assets.go`, `web/dist/index.html`, `web/dist/app.css`, `web/dist/app.js`) to provide a minimal control-panel UI without introducing Wails/WebKit runtime dependencies into the current repo.
- Expanded `internal/app`/`internal/desktop` bindings with alias DTOs and alias listing so the GUI can show both routable provider state and alias routing state.
- Added `internal/desktop/http_test.go` to verify the desktop control panel serves the app shell, returns overview JSON, and persists desktop preferences through the HTTP API.
- Verified with:
- `gofmt -w cmd/ocswitch-desktop/main.go internal/app/service.go internal/app/types.go internal/desktop/bindings.go internal/desktop/http.go internal/desktop/http_test.go web/assets.go`
- `rtk go test ./...`
- `rtk go build ./...`
- `go run ./cmd/ocswitch-desktop --no-open` followed by live HTTP checks against `/` and `/api/overview`
- Constraint note: the PRD still recommends Wails as the long-term native shell, but the current environment lacks `wails` CLI and desktop system dependencies, so this implementation deliberately ships a dependency-light local control panel first while keeping `internal/app` and `internal/desktop` ready for a future Wails swap-in.
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 12: Wails desktop shell integration
**Date**: 2026-04-18
**Task**: Wails desktop shell integration
**Branch**: `master`
### Summary
Migrated the desktop control panel onto a formal Wails + React/TypeScript structure while preserving a browser fallback shell, added real Linux XDG launch-at-login support, and verified both default and desktop_wails Go paths. Native desktop compilation now fails only on missing Linux system packages rather than repository structure issues.
### Main Changes
- Added Wails v2.12.0 to `go.mod` and introduced `wails.json` so the repository has a formal desktop-shell project structure.
- Split desktop entrypoints by build tag:
- `cmd/ocswitch-desktop/main_fallback.go` keeps the browser fallback shell for default builds.
- `cmd/ocswitch-desktop/main_wails.go` and root `main_wails.go` provide the real `desktop_wails` Wails entry path expected by the Wails CLI.
- Added `internal/desktop/wails.go` to centralize Wails startup configuration, bind the desktop app object, and serve `frontend/dist` assets from the same source used by the browser fallback shell.
- Expanded `internal/desktop/app.go` into a real desktop composition root with startup, shutdown, close-to-background, preference sync, metadata, and Wails-callable facade methods.
- Replaced the ad-hoc `web/` assets with a formal `frontend/` Vite + React + TypeScript app:
- `frontend/src/App.tsx` now owns the GUI.
- `frontend/src/api.ts` bridges both Wails-bound calls and fallback HTTP `/api/*` calls.
- `frontend/src/types.ts` mirrors the Go DTOs.
- `frontend/src/env.d.ts` declares the Wails bridge surface.
- `frontend/src/styles.css` carries forward the control-panel styling.
- Updated the fallback HTTP shell in `internal/desktop/http.go` to serve `frontendassets.DistFS()` and to align doctor/meta responses with the Wails bridge semantics.
- Implemented real Linux XDG launch-at-login handling in `internal/desktop/autostart.go` and added `internal/desktop/autostart_test.go` to cover write/remove behavior.
- Wired `MinimizeToTray` into close-to-background behavior through `internal/desktop/tray.go` using public Wails window lifecycle APIs only; intentionally did not depend on private Wails tray internals because the pinned Wails version does not expose a stable public tray API.
- Added tag-specific runtime helpers:
- `internal/desktop/runtime_wails.go` hides the Wails window.
- `internal/desktop/runtime_fallback.go` is a no-op for browser fallback mode.
- Removed the obsolete `web/` embedded assets directory after the frontend migration because all code now serves assets from `frontend/`.
- Verified with:
- `rtk npm install` (frontend dependencies)
- `rtk npm run build`
- `rtk go test ./...`
- `rtk go test -tags desktop_wails ./...`
- `wails build -tags desktop_wails -debug`
- Verification outcome:
- repository structure, bindings generation, frontend build, and tagged Go compilation all succeed
- `wails build` now reaches native Linux compilation and stops only at missing system packages (`pkg-config`, and likely GTK/WebKit dev packages)
- native tray menus and native notifications remain intentionally incomplete because the current pinned Wails release does not provide a stable public tray API and no notification backend has been integrated yet
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

View 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)
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View 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
View File

@ -0,0 +1 @@
2f0b51396a8b6c44ea214228c533bdbe

358
frontend/src/App.tsx Normal file
View 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/&lt;alias&gt;"
/>
</label>
<label>
<span>small_model</span>
<input
type="text"
value={syncInput.setSmallModel || ''}
onChange={(event) => setSyncInput((current) => ({ ...current, setSmallModel: event.target.value }))}
placeholder="ocswitch/&lt;alias&gt;"
/>
</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
View 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
View 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
View 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
View 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
View 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
View 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" }]
}

View 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
View 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
View 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>;

View 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
View 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);
}
}
}

View 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
View 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>;

View 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
View File

@ -5,9 +5,38 @@ go 1.22.2
require (
github.com/spf13/cobra v1.8.1
github.com/tidwall/jsonc v0.3.2
github.com/wailsapp/wails/v2 v2.12.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

82
go.sum
View File

@ -1,12 +1,94 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

447
internal/app/service.go Normal file
View 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:]
}

View 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
View 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"`
}

View File

@ -4,8 +4,6 @@ import (
"fmt"
"github.com/spf13/cobra"
"github.com/Apale7/opencode-provider-switch/internal/opencode"
)
func newDoctorCmd() *cobra.Command {
@ -25,48 +23,32 @@ aliases, or local server settings.`,
Example: ` ocswitch doctor
ocswitch --config /path/to/config.json doctor`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
report, err := appService().RunDoctor(cmd.Context())
if err != nil {
return err
for _, issue := range report.Issues {
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", issue.Message)
}
issues := cfg.Validate()
path, existed := opencode.ResolveGlobalConfigPath()
raw, err := opencode.Load(path)
if err != nil {
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
} else {
aliasNames := cfg.AvailableAliasNames()
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err))
}
}
ok := len(issues) == 0
if ok {
fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", cfg.Path())
} else {
fmt.Fprintf(cmd.OutOrStdout(), "✗ config has %d issue(s):\n", len(issues))
for _, e := range issues {
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", e)
}
}
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", len(cfg.Providers))
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", len(cfg.Aliases))
// Preview resolved opencode config target
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
marker := "(will be created)"
if existed {
if report.OpenCodeTargetFound {
marker = "(exists)"
}
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", ok)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
if !ok {
return fmt.Errorf("%d config issue(s)", len(issues))
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress)
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", report.ConfigPath)
fmt.Fprintf(cmd.OutOrStdout(), " providers: %d\n", report.ProviderCount)
fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", report.AliasCount)
marker := "(will be created)"
if report.OpenCodeTargetFound {
marker = "(exists)"
}
fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", report.OpenCodeTargetPath, marker)
fmt.Fprintf(cmd.OutOrStdout(), " provider.ocswitch preview: valid=%v\n", report.OK)
fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s\n", report.ProxyBindAddress)
return nil
},
}

View File

@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra"
"github.com/Apale7/opencode-provider-switch/internal/opencode"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
func newOpencodeCmd() *cobra.Command {
@ -61,58 +61,24 @@ needed.`,
ocswitch opencode sync --set-model ocswitch/gpt-5.4 --set-small-model ocswitch/gpt-5.4-mini
ocswitch opencode sync --target /path/to/opencode.jsonc`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
result, err := appService().ApplyOpenCodeSync(cmd.Context(), app.SyncInput{
Target: target,
SetModel: setModel,
SetSmallModel: setSmallModel,
DryRun: dryRun,
})
if err != nil {
return err
}
if errs := cfg.Validate(); len(errs) > 0 {
for _, e := range errs {
cmd.PrintErrln("config error:", e)
}
return errs[0]
}
path := target
if path == "" {
p, _ := opencode.ResolveGlobalConfigPath()
path = p
}
raw, err := opencode.Load(path)
if err != nil {
return err
}
aliasNames := cfg.AvailableAliasNames()
if setModel != "" {
if err := validateSyncedModelSelection(setModel, aliasNames, "--set-model"); err != nil {
return err
}
}
if setSmallModel != "" {
if err := validateSyncedModelSelection(setSmallModel, aliasNames, "--set-small-model"); err != nil {
return err
}
}
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
if setModel != "" && raw["model"] != setModel {
raw["model"] = setModel
changed = true
}
if setSmallModel != "" && raw["small_model"] != setSmallModel {
raw["small_model"] = setSmallModel
changed = true
}
if !changed {
fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", path)
if !result.Changed {
fmt.Fprintf(cmd.OutOrStdout(), "✓ no changes required at %s\n", result.TargetPath)
return nil
}
if dryRun {
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", path)
if result.DryRun {
fmt.Fprintf(cmd.OutOrStdout(), "would write %s (dry-run)\n", result.TargetPath)
return nil
}
if err := opencode.Save(path, raw); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", path, len(aliasNames))
fmt.Fprintf(cmd.OutOrStdout(), "synced provider.ocswitch into %s (%d alias(es))\n", result.TargetPath, len(result.AliasNames))
if setModel != "" {
fmt.Fprintf(cmd.OutOrStdout(), " model = %s\n", setModel)
}

View File

@ -4,7 +4,6 @@ import (
"fmt"
"os"
"reflect"
"sort"
"strings"
"github.com/spf13/cobra"
@ -211,23 +210,21 @@ This command does not modify config and does not contact upstream providers.`,
Example: ` ocswitch provider list
ocswitch --config /path/to/config.json provider list`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
providers, err := appService().ListProviders(cmd.Context())
if err != nil {
return err
}
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
if len(providers) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no providers)")
return nil
}
for _, p := range providers {
key := "(none)"
if p.APIKey != "" {
key = maskKey(p.APIKey)
if p.APIKeySet {
key = p.APIKeyMasked
}
state := "enabled"
if !p.IsEnabled() {
if p.Disabled {
state = "disabled"
}
fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key)

View File

@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
@ -18,6 +19,10 @@ func loadCfg() (*config.Config, error) {
return config.Load(configPath)
}
func appService() *app.Service {
return app.NewService(configPath)
}
// NewRootCmd builds the root ocswitch command.
func NewRootCmd(version string) *cobra.Command {
root := &cobra.Command{

View File

@ -1,12 +1,11 @@
package cli
import (
"context"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/Apale7/opencode-provider-switch/internal/proxy"
)
func newServeCmd() *cobra.Command {
@ -25,20 +24,17 @@ OpenCode can see the same aliases that the proxy can route.`,
Example: ` ocswitch serve
ocswitch --config /path/to/config.json serve`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if errs := cfg.Validate(); len(errs) > 0 {
for _, e := range errs {
cmd.PrintErrln("config error:", e)
}
return errs[0]
}
srv := proxy.New(cfg)
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
return srv.ListenAndServe(ctx)
svc := appService()
if err := svc.StartProxy(context.Background()); err != nil {
return err
}
go func() {
<-ctx.Done()
_ = svc.StopProxy(context.Background())
}()
return svc.WaitProxy(context.Background())
},
}
}

View File

@ -56,9 +56,17 @@ type Server struct {
APIKey string `json:"api_key"`
}
// Desktop holds desktop-shell user preferences.
type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"`
}
// Config is the on-disk ocswitch config.
type Config struct {
Server Server `json:"server"`
Desktop Desktop `json:"desktop,omitempty"`
Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"`
@ -98,6 +106,7 @@ func Default() *Config {
Port: 9982,
APIKey: DefaultLocalAPIKey,
},
Desktop: Desktop{},
Providers: []Provider{},
Aliases: []Alias{},
}
@ -176,9 +185,10 @@ func (c *Config) Save() error {
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
snap := struct {
Server Server `json:"server"`
Desktop Desktop `json:"desktop,omitempty"`
Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"`
}{c.Server, providers, aliases}
}{c.Server, c.Desktop, providers, aliases}
data, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)

167
internal/desktop/app.go Normal file
View 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
}

View 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) + `"`
}

View 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)
}
}

View 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
View 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}}
}
}

View 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())
}
})
}

View 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
}

View File

@ -0,0 +1,10 @@
//go:build !desktop_wails
package desktop
import "context"
func hideWindow(ctx context.Context) error {
_ = ctx
return nil
}

View 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
View 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
View 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
View 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
View 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"
}
}