Compare commits

...

19 Commits

Author SHA1 Message Date
eb46fa3bd3 chore: record journal
Some checks failed
release-please / release-please (push) Has been cancelled
2026-04-20 03:47:07 +08:00
a82754eb40 fix(desktop): unify log and network layouts 2026-04-20 03:46:30 +08:00
08343ad576 chore: record journal 2026-04-20 03:23:47 +08:00
b00ee4c8dc docs(readme): add WebView2 runtime note 2026-04-20 03:22:19 +08:00
63f7de7cb6 chore(task): archive 04-19-desktop-log-network-views 2026-04-20 02:45:26 +08:00
a9448125f1 fix(desktop): stabilize provider list cards 2026-04-20 02:45:26 +08:00
f3de590e1b chore(task): mark windows desktop done
Record the completed Windows desktop integration task and link it to the shipped desktop polish commit.
2026-04-20 02:37:33 +08:00
8fc72e87e4 fix(desktop): finish Windows shell polish
Stabilize the tray icon and locale handling on Windows while aligning provider and alias management panels with the refreshed desktop branding.
2026-04-20 02:36:54 +08:00
b70f49cbef chore(release): automate changelog and assets 2026-04-20 01:44:27 +08:00
c3e622be5d chore(task): mark desktop views done 2026-04-20 01:28:09 +08:00
5bd04b6a07 fix(desktop): streamline detail drawers 2026-04-20 01:27:00 +08:00
a1eb4e244c fix(desktop): stabilize proxy and trace flows 2026-04-20 00:45:21 +08:00
bbe21e14df fix(desktop): smooth settings and config flows 2026-04-19 19:03:44 +08:00
0b3098ccf6 chore(task): record proxy SSE fix session 2026-04-19 11:25:14 +08:00
e4789d0539 fix(proxy): harden SSE failover handling 2026-04-19 11:24:44 +08:00
136912d0ef chore(task): add gui refresh artifacts
Record the source PRD, copy inventory, task metadata, and agent run logs for the desktop GUI i18n and UX refresh so the Trellis task history stays complete alongside the implemented desktop changes.
2026-04-19 10:36:34 +08:00
f3b211f745 feat(desktop): refresh localized control panel
Rebuild the Wails desktop shell into a localized tabbed control panel so provider, alias, sync, and settings workflows are easier to manage in one place. Extend desktop preferences with theme and language, document the desktop build flow, and keep the browser fallback shell aligned with the same frontend.
2026-04-19 10:07:46 +08:00
31f25b54ab ui finish 2026-04-19 01:05:49 +08:00
5c4f60f2b7 feat(desktop): finish Windows GUI integration
Bring the Wails desktop shell to feature parity with the CLI for provider and alias management while keeping the browser fallback working. Wire tray, notifications, autostart, and GUI warnings so desktop flows behave predictably.
2026-04-19 01:02:16 +08:00
97 changed files with 17221 additions and 164 deletions

138
.github/workflows/release-assets.yml vendored Normal file
View File

@ -0,0 +1,138 @@
name: release-assets
on:
release:
types:
- published
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- name: windows
runs-on: windows-latest
artifact_name: ocswitch-desktop-windows-amd64
package: release/ocswitch-desktop-windows-amd64.zip
output: build/bin/ocswitch-desktop.exe
- name: macos
runs-on: macos-13
artifact_name: ocswitch-desktop-darwin-amd64
package: release/ocswitch-desktop-darwin-amd64.zip
output: build/bin/ocswitch-desktop.app
- name: linux
runs-on: ubuntu-22.04
artifact_name: ocswitch-desktop-linux-amd64
package: release/ocswitch-desktop-linux-amd64.zip
output: build/bin/ocswitch-desktop
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install frontend deps
working-directory: frontend
run: npm install
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
- name: Install Linux deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev build-essential pkg-config
- name: Verify Wails output name
shell: bash
env:
EXPECTED_OUTPUT: ${{ matrix.output }}
run: |
python - <<'PY'
import os
import pathlib
expected = pathlib.Path(os.environ["EXPECTED_OUTPUT"])
print(f"expected output: {expected.as_posix()}")
PY
- name: Build desktop app
run: wails build -tags desktop_wails
- name: Package artifact
shell: bash
env:
SOURCE_PATH: ${{ matrix.output }}
DEST_PATH: ${{ matrix.package }}
run: |
mkdir -p release
python - <<'PY'
import hashlib
import os
import pathlib
import zipfile
source = pathlib.Path(os.environ["SOURCE_PATH"])
dest = pathlib.Path(os.environ["DEST_PATH"])
dest.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
if source.is_dir():
for path in source.rglob("*"):
if path.is_file():
zf.write(path, arcname=f"{source.name}/{path.relative_to(source).as_posix()}")
else:
zf.write(source, arcname=source.name)
digest = hashlib.sha256(dest.read_bytes()).hexdigest()
dest.with_suffix(dest.suffix + ".sha256").write_text(f"{digest} {dest.name}\n", encoding="ascii")
PY
- name: List packaged files
shell: bash
run: |
python - <<'PY'
import pathlib
for path in sorted(pathlib.Path("release").glob("**/*")):
if path.is_file():
print(path.as_posix())
PY
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
${{ matrix.package }}
${{ matrix.package }}.sha256
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-assets/**

22
.github/workflows/release-please.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: release-please
on:
push:
branches:
- master
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Release Please
uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

15
.github/workflows/release-precheck.md vendored Normal file
View File

@ -0,0 +1,15 @@
# Release Workflow Precheck
1. Confirm release-please is enabled on `master`.
2. Confirm conventional commits are used for release notes grouping.
3. Confirm `release-please-config.json` targets the repo root and `CHANGELOG.md`.
4. Confirm Wails output name matches `wails.json`:
- `build/bin/ocswitch-desktop.exe` on Windows
- `build/bin/ocswitch-desktop.app` on macOS
- `build/bin/ocswitch-desktop` on Linux
5. Confirm Linux runner has the GTK/WebKit packages needed by Wails.
6. Confirm `npm install` is sufficient for frontend build in CI.
7. Confirm release assets are uploaded only after the GitHub Release is published.
8. Confirm each asset has a matching `.sha256` file.
9. Confirm tag format is `v*`.
10. Confirm the generated release contains the three platform archives.

3
.gitignore vendored
View File

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

View File

@ -1,5 +1,5 @@
{ {
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.4.3" "@opencode-ai/plugin": "1.4.6"
} }
} }

View File

@ -0,0 +1,3 @@
{
".": "0.0.0"
}

View File

@ -0,0 +1,78 @@
{
"id": "04-18-finish-windows-desktop",
"name": "04-18-finish-windows-desktop",
"title": "Finish Windows desktop integration",
"description": "",
"status": "completed",
"dev_type": "feature",
"scope": "desktop",
"package": null,
"priority": "P2",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-18",
"completedAt": "2026-04-20",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": "8fc72e8",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"build/windows/icon.ico",
"frontend/assets.go",
"frontend/src/App.tsx",
"frontend/src/api.ts",
"frontend/src/i18n/locales/en.json",
"frontend/src/i18n/locales/zh-CN.json",
"frontend/src/styles.css",
"internal/desktop/app.go",
"internal/desktop/runtime_wails.go",
"internal/desktop/tray_wails.go",
"internal/desktop/tray_language.go",
"internal/desktop/tray_language_windows.go",
"internal/desktop/wails.go"
],
"notes": "Finished the Windows desktop integration pass. Reworked tray startup onto a stable systray event loop with icon embedding, click-to-open behavior, localized menu labels, and Windows system-language fallback. Replaced desktop/window branding assets, wired repository opening through the system browser, and refreshed the providers and aliases list/detail panels into a denser, product-style UI with updated i18n strings.",
"meta": {
"tests": [
"npm run build",
"go test -tags desktop_wails ./internal/desktop/..."
],
"desktopAreas": [
"windows tray",
"window icon and branding",
"providers panel",
"aliases panel"
]
}
}

View File

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

View File

@ -0,0 +1,100 @@
# Windows Desktop Integrations
## Summary
`ocswitch desktop` already has shared Go application services, a React control panel, a browser fallback shell, and a Wails window wrapper. What is still missing is the set of desktop-native integrations that make the Windows build behave like a real resident utility instead of only a packaged webview.
This task completes that gap with a minimal extension to the current architecture:
- fix the Wails frontend bridge so the GUI actually talks to Go bindings
- add native tray integration for the Wails desktop build
- add native notification delivery for desktop events
- add launch-at-login support for Windows
- preserve the existing browser fallback shell and shared service layer
## Product Goal
Provide a Windows-friendly desktop shell for `ocswitch` that can stay resident, expose quick tray controls, surface important events through native notifications, and reopen automatically at login without rewriting existing business logic.
## Current State
### Already done
- `internal/app` owns shared workflows for overview, proxy control, doctor, desktop preferences, and OpenCode sync.
- `internal/desktop/http.go` already provides a browser fallback shell with the same workflows.
- `frontend/src/App.tsx` already renders the main control panel for overview, desktop prefs, sync, doctor, providers, and aliases.
- `internal/desktop/wails.go` already boots a Wails window and hides on close.
### Missing
- frontend Wails bridge still points at `window.go.main.App` even though generated bindings live at `window.go.desktop.App`
- tray support is only a placeholder and does not expose resident controls
- notifications are only a placeholder and do not use Wails runtime APIs
- launch-at-login only supports Linux XDG autostart today
## Requirements
### Desktop integration
- Wails desktop build must expose a real system tray on Windows.
- Tray menu must at least support opening the window, hiding the window, starting the proxy, stopping the proxy, and quitting the app.
- Close-to-background behavior must continue to respect `minimizeToTray` preference.
- Native notifications must be available when `notifications` preference is enabled.
- Native launch-at-login must be available on Windows through a practical low-complexity mechanism.
### Reuse and layering
- Keep `internal/app` as the owner of shared workflows and state transitions.
- Keep `internal/desktop` focused on shell integration only.
- Do not duplicate proxy, config, or sync business rules inside tray or frontend code.
- Browser fallback shell must remain operational.
## Design Decisions
### Tray implementation
Wails v2 does not provide a stable public tray API, so tray support should use `github.com/getlantern/systray` inside the `desktop_wails` build.
Why this path:
- lowest implementation cost in current Go codebase
- already present in module graph
- works alongside Wails window lifecycle
- avoids replacing the desktop shell framework
### Notification implementation
Use Wails runtime notification APIs for the desktop build. This keeps notifications native and avoids introducing another platform adapter.
Expected event coverage:
- proxy started
- proxy stopped
- OpenCode sync applied
- notifications preference enabled
### Windows launch-at-login
Implement Windows startup integration by writing a `.cmd` launcher into the user Startup folder.
Why this path:
- much simpler than generating `.lnk`
- good enough for a local utility
- easy to inspect and delete
## Non-Goals
1. No migration from Wails to Fyne unless current approach proves unworkable.
2. No tray support requirement for browser fallback mode.
3. No redesign of the React control panel information architecture.
4. No expansion into advanced desktop telemetry or background sync daemons.
## Acceptance Criteria
1. Wails frontend can successfully call Go bindings through generated `desktop` namespace.
2. Windows desktop build can be hidden and restored through tray controls.
3. Tray can start and stop proxy without reopening main window.
4. Enabling notifications allows native desktop alerts for key lifecycle events.
5. Enabling launch at login creates Windows startup entry; disabling removes it.
6. Existing browser fallback flow and current tests remain green.

View File

@ -0,0 +1,80 @@
{
"id": "windows-desktop-integrations",
"name": "windows-desktop-integrations",
"title": "Finish Windows desktop integrations",
"description": "Finish the desktop-shell implementation for Windows by wiring the Wails bridge correctly, adding tray controls, native notifications, and launch-at-login support without breaking the browser fallback shell.",
"status": "completed",
"dev_type": "feature",
"scope": "desktop",
"package": null,
"priority": "P2",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-18",
"completedAt": "2026-04-18",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": "5c4f60f",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-18-windows-desktop-integrations/prd.md",
"internal/desktop/app.go",
"internal/desktop/tray.go",
"internal/desktop/tray_wails.go",
"internal/desktop/notify.go",
"internal/desktop/autostart.go",
"internal/desktop/autostart_test.go",
"internal/desktop/runtime_wails.go",
"internal/desktop/runtime_fallback.go",
"internal/fileutil/fileutil.go",
"internal/fileutil/lock_unix.go",
"internal/fileutil/lock_windows.go",
"internal/config/config_test.go",
"internal/config/file_lock_testhelper_unix_test.go",
"internal/config/file_lock_testhelper_windows_test.go",
"frontend/index.html",
"frontend/src/api.ts",
"frontend/src/env.d.ts",
"frontend/tsconfig.json",
"frontend/dist/index.html",
"frontend/dist/assets/"
],
"notes": "Completed the Windows desktop integration pass on top of the existing desktop shell GUI. Fixed the Wails frontend bridge namespace mismatch, added a real Wails-only tray implementation with systray, wired native notifications through Wails runtime, added Windows Startup-folder launch-at-login support, and fixed file locking so Windows desktop/config flows can build and test. Verification passed for `npm run build`, `go test ./internal/desktop`, `go test -tags desktop_wails ./internal/desktop`, and `go test ./internal/config ./internal/opencode`.",
"meta": {
"wailsBridgeNamespace": "window.go.desktop.App",
"trayImplementation": "github.com/getlantern/systray",
"notificationImplementation": "wails runtime notifications",
"windowsAutostart": "Startup folder .cmd launcher"
}
}

View File

@ -0,0 +1,26 @@
# Proxy SSE Minimal Fix
## Summary
The local Responses proxy can report `upstream_status=200` while still handing OpenCode a broken stream in two narrow cases inside `internal/proxy/server.go`:
- the upstream returns `200` with `text/event-stream` but closes before sending any bytes
- the upstream sends one SSE chunk and then stays silent longer than `streamIdleTimeout`, causing the proxy to terminate a still-valid SSE stream
This task applies the smallest possible fix in the proxy layer only, without changing CLI, desktop, app, config, or public interfaces.
## Requirements
- Keep the existing `firstByteTimeout` behavior.
- Treat `readFirstChunk()` returning `io.EOF` with zero bytes as a retryable pre-first-byte failure.
- Do not write downstream `200` for that empty first-read case.
- Preserve existing failover behavior for transport errors and `429`/`5xx` responses.
- After the first chunk succeeds, bypass `streamIdleTimeout` only for `text/event-stream` responses.
- Keep non-SSE responses on the existing idle-timeout path.
- Add regression coverage for empty-200-SSE failover and long-idle SSE continuation.
## Non-Goals
1. No proxy refactor.
2. No new config flags.
3. No changes outside `internal/proxy/server.go` and `internal/proxy/server_test.go`.

View File

@ -0,0 +1,65 @@
{
"id": "04-19-proxy-sse-minimal-fix",
"name": "04-19-proxy-sse-minimal-fix",
"title": "Fix proxy SSE minimal failure handling",
"description": "Minimal proxy fix for empty 200 SSE failover and SSE idle timeout handling",
"status": "completed",
"dev_type": "feature",
"scope": "proxy",
"package": null,
"priority": "P1",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-19",
"completedAt": "2026-04-19",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": "e4789d0",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-19-04-19-proxy-sse-minimal-fix/prd.md",
"internal/proxy/server.go",
"internal/proxy/server_test.go"
],
"notes": "Applied a minimal SSE reliability fix in the local Responses proxy. Empty `200 text/event-stream` first reads are now treated as retryable pre-first-byte failures so failover can continue before any downstream status is written. After the first SSE chunk arrives, the proxy no longer applies `streamIdleTimeout` to later SSE reads, while non-SSE responses keep the existing timeout behavior. Added regression coverage for empty-200 SSE failover and long-idle SSE continuation, and verified with `go test ./internal/proxy`.",
"meta": {
"tests": [
"go test ./internal/proxy"
],
"regressions": [
"empty 200 SSE failover",
"SSE bypasses idle timeout after first chunk"
]
}
}

View File

@ -0,0 +1,72 @@
{
"id": "desktop-gui-i18n-ux-refresh-impl",
"name": "desktop-gui-i18n-ux-refresh-impl",
"title": "Implement desktop GUI i18n and UX refresh",
"description": "",
"status": "completed",
"dev_type": "feature",
"scope": "desktop",
"package": null,
"priority": "P2",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-19",
"completedAt": "2026-04-19",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "Completed the desktop GUI i18n and UX refresh. Extended desktop preferences with theme and language across Go and frontend, added i18next-based localization with en-US/zh-CN/system support, rebuilt the desktop shell into a tabbed Wails/browser-shared control panel, refreshed layout and theme tokens, updated the generated Wails desktop prefs models, documented desktop build and usage in the README files, and verified with `npm run build`, `go test ./internal/app ./internal/desktop`, and `wails build -tags desktop_wails`.",
"meta": {
"desktopTabs": [
"overview",
"providers",
"aliases",
"sync",
"settings"
],
"languages": [
"en-US",
"zh-CN",
"system"
],
"themes": [
"light",
"dark",
"system"
],
"desktopExe": "build/bin/ocswitch-desktop.exe"
}
}

View File

@ -0,0 +1,2 @@
{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"}
{"file": ".opencode/commands/trellis/check.md", "reason": "Code quality check spec"}

View File

@ -0,0 +1,127 @@
# GUI 文案收口规划
## 目的
这份文件不是最终翻译稿,而是为后续“人工精细翻译”做结构准备。
本轮建议先把所有可见文案按命名空间收口到 i18n 资源中,再由人工直接维护 `en.json` / `zh-CN.json`
## 建议命名空间
### app
- 应用标题
- 副标题
- 版本 / shell / 配置路径说明
### nav
- 左侧 tab 名称
- tab 简短描述
### overview
- 概览标题
- 代理状态
- 统计卡标题
- 主操作按钮
- 调试详情标题
### providers
- provider 列表空态
- 创建 / 编辑表单标签
- 导入区文案
- 列表项操作按钮
- 保存 / 删除 / 启停状态消息
### aliases
- alias 列表空态
- alias 表单标签
- target 绑定表单标签
- target 行操作按钮
- 绑定 / 解绑 / 启停状态消息
### sync
- sync 标题
- target path / model / small_model 标签
- preview / apply 按钮
- preview 结果摘要
- apply 结果摘要
### doctor
- doctor 标题
- 检查中 / 成功 / 失败状态
- 问题列表
- 空态与说明文案
### settings
- 主题设置
- 语言设置
- 桌面行为设置
- 关于信息
### actions
- refresh
- save
- reset
- edit
- delete
- enable
- disable
- preview
- apply
- runDoctor
### messages
- refreshing
- saving
- saved
- applying
- previewing
- importing
- loading
- noData
### errors
- requestFailed
- bridgeUnavailable
- invalidHeader
## 需要纳入翻译的文案类型
1. 页面标题与模块标题
2. 按钮文本
3. 表单标签
4. placeholder
5. 空态提示
6. 成功 / 失败 / 进行中状态文案
7. 折叠区标题与说明性文字
## 不建议翻译的内容
1. provider id
2. alias 原始值
3. model id
4. 文件路径
5. URL
6. 原始技术错误详情
## 初始覆盖源文件
- `frontend/src/App.tsx`
- `frontend/src/api.ts`
## 实施建议
1. 先把现有硬编码文案替换为 key
2. `en.json` 先完整收口
3. `zh-CN.json` 先给出可用翻译
4. 人工精修阶段只改 locale 文件,不再回头搜 JSX

View File

@ -0,0 +1 @@
{"file": ".opencode/commands/trellis/check.md", "reason": "Code quality check spec"}

View File

@ -0,0 +1 @@
{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"}

View File

@ -0,0 +1,563 @@
# Desktop GUI i18n / UI-UX / Theme Refresh
## 概要
当前桌面 GUI 已具备可用的基础功能,但前端还停留在“单文件控制台”形态:
- `frontend/src/App.tsx` 单文件承载全部模块和交互,页面信息密度高但层次弱
- 当前所有主内容都堆在一个长页面里,模块边界不清晰
- 文案基本为英文硬编码,尚未建立 i18n 机制
- `frontend/src/styles.css` 仅支持一套暗色视觉,缺少主题切换能力
- 桌面偏好当前只包含 `launchAtLogin` / `minimizeToTray` / `notifications`
本任务的目标不是直接做完全部 GUI 重构,而是输出一版可以直接进入实现阶段的方案设计,并把范围、分层、数据契约、实施顺序和验收标准先固定下来。
## 产品目标
这轮 GUI 优化聚焦三个结果:
1. 建立正式的 i18n 基础设施,至少支持 `zh-CN``en-US`
2. 将当前单页大杂烩改为左侧纵向 tab 的模块化信息架构
3. 支持 `system / light / dark` 三档主题切换
同时满足以下使用体验要求:
- 桌面主窗口默认信息组织清晰,不需要依赖通篇向下滚动来完成主要操作
- 尽量实现“页面不滚动、局部区域滚动”
- 保持 Wails 桌面壳与浏览器 fallback 共用同一套前端
- 不重写现有 Go 侧业务逻辑,只补齐桌面偏好与前端展示能力
## 当前事实与约束
### 现有前端事实
- 前端入口很轻:`frontend/src/main.tsx` 仅挂载 `App`
- 当前无路由库、无全局状态库、无 i18n 依赖
- 当前 API 通过 `frontend/src/api.ts` 在 Wails bridge 与 HTTP fallback 间切换
- 当前页面结构全部写在 `frontend/src/App.tsx`内容包含概览、桌面偏好、OpenCode 同步、Doctor、Providers、Aliases
- 当前视觉完全由 `frontend/src/styles.css` 管理,且根节点声明为暗色 `color-scheme: dark`
### 现有跨层契约事实
- `internal/config/config.go``config.Desktop` 只有三个布尔字段
- `internal/app/types.go``DesktopPrefsView` / `DesktopPrefsInput` 同样只有三个布尔字段
- `internal/app/service.go``SaveDesktopPrefs()` 目前直接把这三个字段写回配置文件
- `internal/app/service_test.go``internal/desktop/http_test.go` 已覆盖桌面偏好保存路径,适合作为后续扩展 `theme` / `language` 的回归测试入口
### 现有窗口约束
- `internal/desktop/wails.go` 当前窗口尺寸为 `1280 x 880`
- 最小尺寸为 `980 x 720`
这个尺寸对“左侧纵向 tab + 主内容双栏”是偏紧的,尤其是 Providers / Aliases 两个高密度模块,因此布局方案需要同时考虑:
- 默认窗口尺寸下尽量不出现整页纵向滚动
- 窄窗口时允许局部折叠或内部滚动,但仍保持结构稳定
## 非目标
1. 不引入新的后端业务层或重写 `internal/app`、`internal/config`、`internal/opencode`、`internal/proxy` 的业务归属
2. 不为了 tab 导航引入完整路由系统,除非 hash 方案无法满足需要
3. 不在这一轮引入大型 UI 框架或设计系统迁移
4. 不在这一轮扩展日志分析、历史记录、远程控制等新产品面
5. 不在这一轮做复杂动画、图标系统或品牌视觉重塑
## 核心设计决策
### 1. i18n 方案
推荐使用:`i18next + react-i18next`
选择原因:
- 对 React 生态成熟,后续扩展语言成本低
- 支持命名空间、fallback、插值、运行时切换
- 比手写 context 字典更适合后续“人工精细翻译”与增量维护
- 与当前前端体量相匹配,不需要引入更重的国际化平台
推荐落地结构:
```text
frontend/src/i18n/
index.ts
locales/
en.json
zh-CN.json
```
推荐规则:
- `en.json` 作为 source of truth
- `zh-CN.json` 先提供可用翻译,后续再人工精修
- 所有可见文案统一改为 key不再直接在 JSX 中写死英文
- 所有状态文案、按钮文案、空态文案、表单标签、placeholder、错误提示都纳入翻译
- 不翻译配置路径、provider id、model id、URL、技术错误原文等数据性内容
推荐 key 命名方式:
```text
app.title
nav.overview
overview.proxyRunning
providers.form.baseUrl
aliases.empty
sync.previewChanges
settings.theme.system
messages.refreshing
errors.bridgeUnavailable
```
### 2. 语言偏好持久化
推荐把语言偏好纳入桌面偏好,而不是只放前端本地状态。
建议新增字段:
- `language`: `system | en-US | zh-CN`
对应变更位置:
- `internal/config/config.go``config.Desktop`
- `internal/app/types.go``DesktopPrefsView` / `DesktopPrefsInput`
- `internal/app/service.go` 的读写映射
- `frontend/src/types.ts`
语言决议顺序:
1. 用户显式选择的 `language`
2. 若为 `system`,使用 `navigator.language`
3. 若系统语言不在支持列表,则回退到 `en-US`
这样做的原因:
- 桌面应用重启后保留用户偏好
- 浏览器 fallback 与 Wails 行为一致
- 后续新增日语等语言时不需要重构偏好模型
### 3. 主题方案
推荐继续使用原生 CSS不引入 Tailwind 或 CSS-in-JS 重构。
建议新增字段:
- `theme`: `system | light | dark`
实现原则:
- 前端只维护一个“用户选择主题”字段
- 运行时计算一个 `resolvedTheme`,结果为 `light``dark`
- 把结果写到 `document.documentElement.dataset.theme`
- 用 CSS 变量承载所有颜色 token
推荐样式组织:
```css
:root {
--bg: ...;
--panel: ...;
--text: ...;
--muted: ...;
--border: ...;
--accent: ...;
}
html[data-theme='dark'] { ... }
html[data-theme='light'] { ... }
```
注意事项:
- 现在的 `:root { color-scheme: dark; }` 需要改为按主题动态切换
- 表单控件、滚动条、badge、panel、背景渐变都需要转为 token 化
- 不要在各组件里散落 `if (theme === 'dark')` 之类分支,主题只通过 CSS token 生效
### 4. 导航与信息架构
推荐使用左侧纵向 tab且 tab 状态优先使用 hash 管理,而不是新增路由依赖。
推荐 tab
1. `overview` 概览
2. `providers` 提供商
3. `aliases` 别名路由
4. `sync` 同步与体检
5. `settings` 设置
推荐原因:
- 这 5 个模块正好对应当前功能边界
- hash 方案足够轻量Wails 与 HTTP fallback 都可直接工作
- 可支持 `#providers` 这类深链接,便于问题定位与后续文档引用
不建议保留当前结构:
- 概览、设置、同步、Doctor、Providers、Aliases 在单页堆叠
- 大量 `pre` 原始 JSON 长块直接暴露在主流程中
### 5. 页面滚动策略
目标不是“任何情况下零滚动”,而是:
- 主页面整体尽量不产生纵向长滚动
- 长列表和明细列表在局部 pane 内滚动
- 表单和操作区域尽量固定在视口中
推荐布局原则:
- 应用根容器使用接近全高布局
- 左侧 sidebar 固定宽度
- 右侧内容区使用 tab 面板切换
- Providers / Aliases 使用左右 split-pane
- 列表区独立 `overflow: auto`
- 原始 JSON / 诊断详情放入折叠区或次级详情区,不占主操作流
## 推荐信息架构
### Overview Tab
目标:进入应用后 5 秒内看懂当前状态,并完成最常见的启停操作。
推荐内容:
- 顶部状态卡代理状态、provider 数、alias 数、可路由 alias 数
- 主操作区:`Refresh`、`Start proxy`、`Stop proxy`、`Run doctor`
- 环境信息版本、shell、配置路径
- 简版健康摘要:最近一次 doctor 状态或提示入口
不推荐继续保留:
- 默认展示完整 `overview` JSON 大块文本
建议:
- 原始 JSON 仅在“调试详情”折叠区显示
### Providers Tab
目标:高频管理 provider避免当前“左边长表单 + 右边长列表 + 整页继续往下”的阅读负担。
推荐结构:
- 左侧provider 列表、搜索、状态筛选、`新建 provider`
- 右侧:当前选中 provider 的编辑表单
- 右上或右下:导入 OpenCode provider 的辅助卡片
交互原则:
- “创建”与“编辑”共用同一表单
- 点击列表项进入编辑态
- 删除、启用、禁用作为列表项级操作
- `Headers``Models` 属于细节信息,不要默认铺得过长
列表过长时:
- 仅让左侧列表内部滚动
- 右侧表单固定显示
### Aliases Tab
目标把“别名编辑”和“target 绑定管理”从当前混排状态拆开。
推荐结构:
- 左侧alias 列表、状态摘要、`新建 alias`
- 右侧上半区alias 基本信息编辑
- 右侧下半区targets 表格或卡片列表
交互原则:
- `Bind target` 不再作为全局独立表单漂浮存在,而是明确附着到当前 alias
- 当未选中 alias 时,右侧给出“请选择或新建 alias”空态
- target 的启用/停用/解绑应靠近 target 行,不要再依赖跨区域操作
### Sync Tab
目标:把 OpenCode 同步与 Doctor 合并成“维护与诊断”模块,但在视觉上清楚分区。
推荐结构:
- 左列OpenCode sync 表单与 preview / apply 结果
- 右列Doctor 摘要、问题列表、重新检查按钮
展示方式:
- preview / apply 结果先展示结构化摘要
- 原始结果放在折叠详情中
- doctor 结果优先展示 `OK / 有问题`、问题数量和核心问题列表
### Settings Tab
目标:把“桌面偏好”和“外观语言偏好”统一收口。
推荐内容:
- Appearance
- Language
- Desktop behavior
- About / debug info
建议顺序:
1. 主题选择:`跟随系统 / 浅色 / 深色`
2. 语言选择:`跟随系统 / 中文 / English`
3. 桌面行为:开机启动、最小化到托盘、通知
4. 应用信息版本、shell、配置路径
这会把原本散落在首页的桌面偏好移到更符合用户心智的位置。
## 推荐前端结构调整
当前 `App.tsx` 已达到必须拆分的规模。推荐最小可控拆分如下:
```text
frontend/src/
App.tsx
api.ts
main.tsx
types.ts
styles.css
i18n/
index.ts
locales/
en.json
zh-CN.json
app/
tabs.ts
useDesktopPreferences.ts
useResolvedTheme.ts
features/
overview/OverviewTab.tsx
providers/ProvidersTab.tsx
aliases/AliasesTab.tsx
sync/SyncTab.tsx
settings/SettingsTab.tsx
shared/
AppSidebar.tsx
AppHeader.tsx
Panel.tsx
```
拆分原则:
- API 访问仍保留在 `api.ts`
- 跨模块共享状态仍由 `App.tsx` 统一拥有
- tab 组件只接收明确 props不私自重复请求数据
- theme / language 的解析逻辑抽到 hook 或 util避免每个 tab 重复判断
## 跨层数据契约变更
### 配置层
`internal/config/config.go`
建议将:
```go
type Desktop struct {
LaunchAtLogin bool
MinimizeToTray bool
Notifications bool
}
```
扩展为:
```go
type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"`
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
}
```
允许值建议:
- `Theme`: `system | light | dark`
- `Language`: `system | en-US | zh-CN`
### 应用层 DTO
`internal/app/types.go`
- `DesktopPrefsView`
- `DesktopPrefsInput`
- 如需保证兼容性,可继续让 `DesktopPrefsSaveResult` 结构不变,只扩展 `prefs` 内容
### 应用层服务
`internal/app/service.go`
需要新增:
- 对 `theme` / `language` 的合法值归一化
- 对未知值回退到 `system`
- `desktopPrefsView()` 的新字段映射
建议不要把合法性校验放到前端单独维护,前后端都应该认同同一套枚举语义。
### 前端类型
`frontend/src/types.ts`
需要扩展:
- `DesktopPrefsView`
- `DesktopPrefsSaveResult`
建议新增:
- `type ThemePreference = 'system' | 'light' | 'dark'`
- `type LanguagePreference = 'system' | 'en-US' | 'zh-CN'`
## 实施顺序
推荐拆成 4 个小阶段,按风险从低到高推进。
### Phase 1: 补齐桌面偏好契约
目标:先把 `theme` / `language` 变成可保存、可读取、可测试的数据。
涉及:
- `internal/config/config.go`
- `internal/app/types.go`
- `internal/app/service.go`
- `internal/app/service_test.go`
- `internal/desktop/http_test.go`
- `frontend/src/types.ts`
验收:
- 保存桌面偏好时可写入 `theme` / `language`
- 旧配置不报错,缺省值按 `system` 处理
- 现有桌面偏好测试继续通过,并增加新字段断言
### Phase 2: 搭 i18n 与主题基础设施
目标:先把底座搭起来,再替换可见文案。
涉及:
- 新增 i18n 初始化
- 新增中英文资源文件
- 把现有根样式改为 token 化
- 新增主题解析与 DOM 同步逻辑
- Settings 中先提供主题 / 语言切换控件
验收:
- 应用启动后可按配置或系统语言切换文案
- 应用启动后可按配置或系统主题切换样式
- Wails 与 HTTP fallback 行为一致
### Phase 3: 重构为左侧纵向 tab 布局
目标:把当前单页拆成清晰模块,不改变业务能力。
涉及:
- 提炼 `Sidebar` 与 tab metadata
- 将当前大页面拆为 5 个 tab 内容区
- 将 Provider / Alias 改为 split-pane 布局
- 将 JSON 大块输出收进折叠区或调试区
验收:
- 默认视图下无需整页向下滚动才能完成主要操作
- 大部分高频操作都能在当前 tab 内完成
- hash 切换 tab 正常,可直接进入某个模块
### Phase 4: 文案收口与中文首轮翻译
目标:把所有硬编码文案收口到资源文件,并提供首轮中文。
涉及:
- 清理残余硬编码字符串
- 生成文案清单供人工精修
- 完成 `zh-CN` 首轮翻译
验收:
- 主界面无裸露英文硬编码
- 翻译 key 命名稳定、按模块组织
- 人工精修时只需要维护 locale 文件,不需要再搜 JSX
## 测试与验证建议
### Go 侧
- `go test ./internal/app`
- `go test ./internal/desktop`
重点校验:
- 旧配置读取兼容
- 新偏好字段保存正确
- HTTP 接口序列化包含新字段
### 前端侧
当前仓库未引入测试框架,本轮至少应保留:
- `npm run build`
手动验证清单:
- 语言切换后 tab、表单、状态文案、空态是否同步变化
- 主题切换后按钮、输入框、badge、panel、背景是否都正确
- 窗口宽度在默认值与最小值附近时,布局是否稳定
- hash 切换 tab 与刷新后定位是否符合预期
- Wails / 浏览器 fallback 两种壳下切换行为是否一致
## 风险与缓解
### 风险 1: `App.tsx` 一次性重构过大
缓解:
- 先抽 tab 壳与 shared 组件
- 每拆一个 tab 就保留原有业务处理函数,不同时改业务与 UI
### 风险 2: i18n key 粒度不稳定,后续翻译返工
缓解:
- 先按模块命名空间统一 key
- 避免把整句状态文案拼接在多个地方
- 动态文案尽量通过插值模板统一
### 风险 3: 主题切换改动大,容易出现漏色
缓解:
- 先把当前样式中的颜色集中成 token
- 搜索所有十六进制颜色和 `rgba()`,逐步收口
### 风险 4: 窄窗口下 tab 布局拥挤
缓解:
- 默认保持左侧 tab
- 在低于阈值时允许 sidebar 收缩
- 若验证后仍过于拥挤,再上调 `MinWidth`,建议目标区间为 `1100-1120`
## 验收标准
1. `DesktopPrefs` 契约中包含 `theme``language`,并可在配置中持久化
2. 前端引入正式 i18n 机制,至少可在 `en-US``zh-CN` 间切换
3. 主界面切换为左侧纵向 tab至少包含概览、提供商、别名路由、同步与体检、设置五个模块
4. 主题支持 `system / light / dark`
5. 主流程页面不再依赖整页长滚动;长列表改为局部 pane 内滚动
6. 主要可见文案已从 JSX 抽离到翻译资源文件
7. 构建与现有相关测试路径不回归
## 建议的下一步
1. 先实现 Phase 1补齐 `theme` / `language` 跨层契约
2. 再实现 Phase 2搭建 i18n 与 CSS token 底座
3. 最后进入 tab 布局重构,避免同时改数据层与视觉层
这是当前仓库里风险最低、可持续迭代的推进顺序。

View File

@ -0,0 +1,87 @@
{
"id": "desktop-gui-i18n-ux-refresh",
"name": "desktop-gui-i18n-ux-refresh",
"title": "Plan desktop GUI i18n and UX refresh",
"description": "Produce an execution-ready design for desktop GUI i18n, left-side tab information architecture, and light/dark theme support based on the current Wails + React implementation.",
"status": "completed",
"dev_type": "docs",
"scope": "desktop",
"package": null,
"priority": "P1",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-19",
"completedAt": "2026-04-19",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/prd.md",
".trellis/tasks/04-19-desktop-gui-i18n-ux-refresh/copy-inventory.md",
"frontend/src/App.tsx",
"frontend/src/main.tsx",
"frontend/src/api.ts",
"frontend/src/types.ts",
"frontend/src/styles.css",
"internal/app/types.go",
"internal/app/service.go",
"internal/app/service_test.go",
"internal/config/config.go",
"internal/desktop/http_test.go",
"internal/desktop/wails.go"
],
"notes": "完成桌面 GUI 后续改造方案设计,明确了 i18n、左侧纵向 tab 信息架构、system/light/dark 主题切换、DesktopPrefs 新增 theme/language 契约、阶段化实施顺序与测试策略。当前建议先实现偏好契约与 i18n/theme 底座,再进入页面拆 tab。",
"meta": {
"i18nFramework": "i18next + react-i18next",
"navigationModel": "left-side vertical tabs with hash state",
"themeModes": [
"system",
"light",
"dark"
],
"languageModes": [
"system",
"en-US",
"zh-CN"
],
"primaryTabs": [
"overview",
"providers",
"aliases",
"sync",
"settings"
]
}
}

View File

@ -0,0 +1,53 @@
{
"id": "04-20-readme-webview2-note",
"name": "04-20-readme-webview2-note",
"title": "Add WebView2 runtime note to bilingual README",
"description": "Add Windows desktop runtime note to README.md and README_EN.md",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P3",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-20",
"completedAt": "2026-04-20",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": "b00ee4c8dc2256a18dd155d5da619b48e0d722cc",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "Added bilingual README notes clarifying that the Windows desktop binary is distributed as a single-file artifact and may require WebView2 Runtime on a small subset of systems.",
"meta": {}
}

View File

@ -0,0 +1,93 @@
# Desktop Log, Network, And List-First Forms
## Summary
Desktop shell already contains provider and alias management pages with inline split-view editing, plus dedicated log and network tabs. Current provider and alias pages always reserve detail-pane space and automatically select first list item. This makes browsing feel heavy and wastes space when user only wants to inspect list.
This task keeps current data flow and form logic, but changes page behavior to a list-first layout:
- provider page shows only list by default
- alias route page shows only list by default
- clicking a list item opens edit panel
- clicking new opens create form panel
- import and bind target flows stay modal-based
## Product Goal
Make provider and alias route management feel lighter and more focused by separating browsing from editing, while preserving existing CRUD behavior and minimizing architectural churn.
## Current State
### Already done
- `frontend/src/App.tsx` contains provider and alias list, search, filter, create, edit, delete, import, and bind-target flows.
- provider and alias forms already reuse local state and existing API calls.
- desktop shell already uses modal overlays for provider import, alias target binding, and confirm dialogs.
### Problems
- provider and alias tabs default to split-view with always-visible detail panel.
- first filtered item is auto-selected, so page never rests in pure list mode.
- empty detail area still occupies layout width before user chooses an action.
## Requirements
### Provider page
- Default state shows only provider list and list controls.
- No provider detail panel should be visible until user clicks a provider row or `New provider`.
- Clicking provider row opens edit panel for that provider.
- Clicking `New provider` opens create panel with empty form.
### Alias route page
- Default state shows only alias list and list controls.
- No alias detail panel should be visible until user clicks alias row or `New alias`.
- Clicking alias row opens edit panel for that alias.
- Clicking `New alias` opens create panel with empty form.
### Interaction and reuse
- Reuse existing provider and alias form logic as much as possible.
- Reuse current modal/backdrop interaction patterns already used in desktop shell.
- Keep import-provider modal and bind-target modal unchanged unless minor adjustments are required for consistency.
- Keep existing backend/API contracts unchanged.
## Design Decisions
### Detail presentation
Use conditional overlay detail panel instead of always-mounted split-view pane.
Why:
- smallest frontend-only change that matches requested behavior
- consistent with existing modal/backdrop interaction already present in app
- preserves current form structure, submit handlers, and confirmation flows
### Selection behavior
Do not auto-select first filtered provider or alias.
Why:
- page should remain in pure list mode until explicit user action
- prevents accidental context switching when search/filter changes
## Non-Goals
1. No backend changes for providers, aliases, logs, or network.
2. No redesign of import-provider or bind-target workflows.
3. No new global state layer or routing changes.
4. No visual redesign outside provider and alias page layout adjustments needed for list-first behavior.
## Acceptance Criteria
1. Provider tab initially renders list without visible edit pane.
2. Alias tab initially renders list without visible edit pane.
3. Clicking provider row opens provider edit panel.
4. Clicking alias row opens alias edit panel.
5. Clicking provider create button opens provider create panel.
6. Clicking alias create button opens alias create panel.
7. Search/filter changes do not auto-open a detail panel.
8. Existing save, delete, import, bind, and toggle actions still work.

View File

@ -0,0 +1,69 @@
{
"id": "desktop-log-network-views",
"name": "desktop-log-network-views",
"title": "Design and implement desktop log and network views",
"description": "",
"status": "completed",
"dev_type": "feature",
"scope": "desktop",
"package": null,
"priority": "P2",
"creator": "OpenCode",
"assignee": "OpenCode",
"createdAt": "2026-04-19",
"completedAt": "2026-04-20",
"branch": "master",
"base_branch": "master",
"worktree_path": null,
"current_phase": 6,
"next_action": [
{
"phase": 1,
"action": "brainstorm"
},
{
"phase": 2,
"action": "research"
},
{
"phase": 3,
"action": "implement"
},
{
"phase": 4,
"action": "check"
},
{
"phase": 5,
"action": "update-spec"
},
{
"phase": 6,
"action": "record-session"
}
],
"commit": "5bd04b6",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
".trellis/tasks/04-19-desktop-log-network-views/prd.md",
"frontend/src/App.tsx",
"frontend/src/styles.css"
],
"notes": "Completed desktop provider, alias, log, and network view refresh to use list-first layouts with on-demand detail drawers instead of default split panes. Added keyboard-accessible drawer close flows, automatic first-field focus, shell scroll locking while drawers are open, list-header status/close affordances, and independent log/network detail selection. Fixed a GUI theme regression by preventing routine data refresh after proxy start/stop from overwriting in-memory desktop preferences. Verified with `npm run build` in `frontend`.",
"meta": {
"tests": [
"npm run build"
],
"uiPatterns": [
"list-first detail drawers",
"shared drawer close interactions",
"drawer-only scrolling"
],
"regressions": [
"proxy start/stop no longer resets theme presentation"
]
}
}

View File

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

View File

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

View File

@ -0,0 +1,44 @@
# Workspace Index - OpenCode
> Journal tracking for AI development sessions.
---
## Current Status
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 4
- **Last Active**: 2026-04-20
<!-- @@@/auto:current-status -->
---
## Active Documents
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~200 | Active |
<!-- @@@/auto:active-documents -->
---
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 4 | 2026-04-20 | Unify log and network layouts | `a82754e` | `master` |
| 3 | 2026-04-20 | Add WebView2 runtime note to README | `b00ee4c8dc2256a18dd155d5da619b48e0d722cc` | `master` |
| 2 | 2026-04-19 | Proxy SSE minimal fix | `e4789d0` | `master` |
| 1 | 2026-04-19 | Desktop GUI i18n and UX refresh | - | `master` |
<!-- @@@/auto:session-history -->
---
## Notes
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions

View File

@ -0,0 +1,200 @@
# Journal - OpenCode (Part 1)
> AI development session journal
> Started: 2026-04-18
---
## Session 1: Finish Windows desktop integrations
**Date**: 2026-04-18
**Task**: Finish Windows desktop integrations
**Branch**: `master`
### Summary
Completed the Windows-focused desktop integration pass for `ocswitch desktop` by fixing the Wails bridge path, adding native tray controls, wiring desktop notifications, implementing Windows launch-at-login, and fixing cross-platform file locking that previously blocked Windows builds.
### Main Changes
| Area | Description |
|------|-------------|
| Wails bridge | Corrected frontend bridge detection and access from `window.go.main.App` to `window.go.desktop.App`. |
| Tray | Added Wails-only native tray integration via `github.com/getlantern/systray` with open/hide/start/stop/quit actions and proxy status display. |
| Notifications | Hooked desktop preference state to Wails runtime notifications for proxy start/stop, sync apply, and notification enablement. |
| Launch at login | Added Windows Startup-folder `.cmd` launcher generation while preserving existing Linux XDG autostart flow. |
| Windows compatibility | Replaced direct `syscall.Flock` usage with platform-specific file locking helpers and aligned config tests with cross-platform lock helpers. |
| Task docs | Added implementation PRD for `.trellis/tasks/04-18-windows-desktop-integrations/`. |
**Updated Files**:
- `.trellis/tasks/04-18-windows-desktop-integrations/prd.md`
- `.trellis/tasks/04-18-windows-desktop-integrations/task.json`
- `.trellis/workspace/OpenCode/journal-1.md`
- `internal/desktop/app.go`
- `internal/desktop/tray.go`
- `internal/desktop/tray_wails.go`
- `internal/desktop/notify.go`
- `internal/desktop/autostart.go`
- `internal/desktop/autostart_test.go`
- `internal/desktop/runtime_wails.go`
- `internal/desktop/runtime_fallback.go`
- `internal/fileutil/fileutil.go`
- `internal/fileutil/lock_unix.go`
- `internal/fileutil/lock_windows.go`
- `internal/config/config_test.go`
- `internal/config/file_lock_testhelper_unix_test.go`
- `internal/config/file_lock_testhelper_windows_test.go`
- `frontend/index.html`
- `frontend/src/api.ts`
- `frontend/src/env.d.ts`
- `frontend/tsconfig.json`
### Git Commits
(No commits - implementation left in worktree)
### Testing
- [OK] `npm run build` (in `frontend/`)
- [OK] `go test ./internal/desktop`
- [OK] `go test -tags desktop_wails ./internal/desktop`
- [OK] `go test ./internal/config ./internal/opencode`
### Status
[OK] **Completed**
### Next Steps
- If needed, run a full desktop binary build path (`wails build` or Windows packaging) on a machine with final packaging prerequisites.
## Session 1: Desktop GUI i18n and UX refresh
**Date**: 2026-04-19
**Task**: Desktop GUI i18n and UX refresh
**Branch**: `master`
### Summary
Added desktop theme/language preferences across Go and frontend, rebuilt the app into a tabbed localized shell, refreshed CSS theming/layout, updated Wails desktop prefs models, and verified with npm run build plus go test ./internal/app ./internal/desktop.
### Main Changes
(Add details)
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 2: Proxy SSE minimal fix
**Date**: 2026-04-19
**Task**: Proxy SSE minimal fix
**Branch**: `master`
### Summary
Fixed empty-200 SSE failover and bypassed idle timeout after SSE start in the local Responses proxy; added targeted regression tests.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `e4789d0` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 3: Add WebView2 runtime note to README
**Date**: 2026-04-20
**Task**: Add WebView2 runtime note to README
**Branch**: `master`
### Summary
Updated README.md and README_EN.md with a Windows desktop runtime note covering single-file distribution and the fallback WebView2 installation link.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `b00ee4c8dc2256a18dd155d5da619b48e0d722cc` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 4: Unify log and network layouts
**Date**: 2026-04-20
**Task**: Unify log and network layouts
**Branch**: `master`
### Summary
Refactored the desktop log and network pages to reuse the alias full-width card layout, removed the old narrow trace list styles, added the missing network count i18n key, and verified with frontend build plus a tracked-file secret scan before push.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `a82754e` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete

View File

@ -54,6 +54,70 @@ go build -o ocswitch ./cmd/ocswitch
go run ./cmd/ocswitch --help go run ./cmd/ocswitch --help
``` ```
## 桌面 GUI
仓库同时包含一个基于 Wails 的桌面控制台,适合直接在 Windows 上管理 provider、alias、同步和桌面偏好。
当前桌面界面提供:
- 左侧导航页签:`Overview` / `Providers` / `Aliases` / `Sync` / `Settings`
- 中英文界面切换:`en-US` / `zh-CN` / `system`
- 主题偏好:`light` / `dark` / `system`
- 与浏览器 fallback shell 共用同一套前端
### 构建桌面可执行文件
先安装前端依赖并确认前端能正常构建:
```bash
cd frontend
npm install
npm run build
```
然后在仓库根目录构建桌面应用:
```bash
wails build -tags desktop_wails
```
Windows 下默认产物路径:
```text
build/bin/ocswitch-desktop.exe
```
提示:
- Windows 11 一般已内置 WebView2 Runtime主流 Windows 10 设备通常也已经安装。
- `ocswitch-desktop.exe` 按单文件产物分发,不需要额外同目录依赖文件。
- 如果桌面应用在 Windows 上无法启动,常见原因之一是系统缺少 WebView2 Runtime这时请先安装 Microsoft Edge WebView2 Runtime 再重试:
https://developer.microsoft.com/microsoft-edge/webview2/
### 开发模式
如果你想本地调试桌面 GUI
```bash
wails dev -tags desktop_wails
```
### 使用方式
启动桌面应用后,可以直接在界面里完成这些操作:
- 查看当前代理状态、配置路径和 Doctor 摘要
- 管理 provider包括搜索、筛选、编辑和从 OpenCode 导入
- 管理 alias 和 target 绑定关系
- 预览并应用 `ocswitch opencode sync`
- 保存桌面偏好,包括开机启动、托盘行为、通知、主题和语言
如果你已经构建出可执行文件,也可以直接运行:
```bash
./build/bin/ocswitch-desktop.exe
```
## 5 分钟快速上手 ## 5 分钟快速上手
### 1. 添加上游 provider ### 1. 添加上游 provider

View File

@ -21,6 +21,72 @@ Protocol: OpenAI Responses (`POST /v1/responses`) only. Streaming supported.
go build -o ocswitch ./cmd/ocswitch go build -o ocswitch ./cmd/ocswitch
``` ```
## Desktop GUI
The repository also ships a Wails-based desktop control panel for managing
providers, aliases, sync flows, and desktop preferences on Windows.
Current desktop capabilities:
- Sidebar tabs: `Overview` / `Providers` / `Aliases` / `Sync` / `Settings`
- UI language preference: `en-US` / `zh-CN` / `system`
- Theme preference: `light` / `dark` / `system`
- Shared frontend between the desktop shell and the browser fallback shell
### Build the desktop executable
Install frontend dependencies and verify the frontend build first:
```bash
cd frontend
npm install
npm run build
```
Then build the desktop app from the repository root:
```bash
wails build -tags desktop_wails
```
On Windows, the default output path is:
```text
build/bin/ocswitch-desktop.exe
```
Note:
- Windows 11 generally includes the WebView2 Runtime, and most mainstream Windows 10 devices already have it installed.
- `ocswitch-desktop.exe` is distributed as a single-file artifact and does not require extra sidecar files in the same directory.
- If the desktop app fails to start on Windows, one common cause is a missing WebView2 Runtime. Install the Microsoft Edge WebView2 Runtime and try again:
https://developer.microsoft.com/microsoft-edge/webview2/
### Development mode
To run the desktop GUI in local development mode:
```bash
wails dev -tags desktop_wails
```
### Usage
After launching the desktop app, you can:
- inspect proxy state, config path, and doctor summary
- manage providers with search, filtering, editing, and OpenCode import
- manage aliases and target bindings
- preview and apply `ocswitch opencode sync`
- save desktop preferences including launch-at-login, tray behavior,
notifications, theme, and language
If you already built the executable, you can run it directly:
```bash
./build/bin/ocswitch-desktop.exe
```
## Quick start ## Quick start
```bash ```bash

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

66
frontend/assets.go Normal file
View File

@ -0,0 +1,66 @@
package frontend
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"sync"
)
//go:embed dist/*
var assets embed.FS
//go:embed src/i18n/locales/*.json
var localeAssets embed.FS
var (
localeCache = map[string]map[string]any{}
localeCacheMu sync.RWMutex
)
func DistFS() (fs.FS, error) {
return fs.Sub(assets, "dist")
}
func LocaleValue(language string, path ...string) (string, bool, error) {
locale, err := loadLocale(language)
if err != nil {
return "", false, err
}
var current any = locale
for _, key := range path {
next, ok := current.(map[string]any)
if !ok {
return "", false, nil
}
current, ok = next[key]
if !ok {
return "", false, nil
}
}
value, ok := current.(string)
return value, ok, nil
}
func loadLocale(language string) (map[string]any, error) {
localeCacheMu.RLock()
if locale, ok := localeCache[language]; ok {
localeCacheMu.RUnlock()
return locale, nil
}
localeCacheMu.RUnlock()
data, err := localeAssets.ReadFile(fmt.Sprintf("src/i18n/locales/%s.json", language))
if err != nil {
return nil, err
}
var locale map[string]any
if err := json.Unmarshal(data, &locale); err != nil {
return nil, err
}
localeCacheMu.Lock()
localeCache[language] = locale
localeCacheMu.Unlock()
return locale, nil
}

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ocswitch desktop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1940
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "ocswitch-desktop-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"i18next": "^25.0.2",
"react": "^18.3.1",
"react-i18next": "^15.5.3",
"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 @@
30ca7bcbca69c2b717e8a2e8cbadd5c5

2778
frontend/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

207
frontend/src/api.ts Normal file
View File

@ -0,0 +1,207 @@
import type {
AliasTargetInput,
AliasUpsertInput,
ConfigExportView,
ConfigImportInput,
ConfigImportResult,
DesktopPrefsSaveResult,
AliasView,
DesktopPrefsView,
DoctorRunResult,
MetaView,
Overview,
ProviderImportInput,
ProviderImportResult,
ProviderSaveResult,
ProviderStateInput,
ProviderUpsertInput,
ProviderView,
ProxySettingsSaveResult,
ProxySettingsView,
ProxyStatusView,
RequestTrace,
SyncInput,
SyncPreview,
SyncResult,
} from './types'
type ApiEnvelope<T> = {
data: T
error?: string
}
function isWails(): boolean {
return typeof window.go?.desktop?.App !== 'undefined'
}
async function http<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...init,
})
const payload = (await response.json()) as ApiEnvelope<T>
if (!response.ok) {
throw new Error(payload.error || 'request failed')
}
return payload.data
}
function bridge() {
const app = window.go?.desktop?.App
if (!app) {
throw new Error('Wails bridge unavailable')
}
return app
}
export async function getMeta(): Promise<MetaView> {
if (isWails()) {
const data = await bridge().Meta()
return { version: data.version || 'dev', shell: data.shell || 'wails' }
}
return http<MetaView>('/api/meta')
}
export async function openExternalURL(url: string): Promise<void> {
if (isWails()) {
await bridge().OpenExternalURL(url)
return
}
const target = url.trim()
if (!target) {
return
}
window.open(target, '_blank', 'noopener,noreferrer')
}
export function getOverview(): Promise<Overview> {
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
}
export function exportConfig(): Promise<ConfigExportView> {
return isWails() ? bridge().ExportConfig() : http<ConfigExportView>('/api/config/export')
}
export function importConfig(input: ConfigImportInput): Promise<ConfigImportResult> {
return isWails()
? bridge().ImportConfig(input)
: http<ConfigImportResult>('/api/config/import', { method: 'POST', body: JSON.stringify(input) })
}
export function listProviders(): Promise<ProviderView[]> {
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
}
export function listAliases(): Promise<AliasView[]> {
return isWails() ? bridge().Aliases() : http<AliasView[]>('/api/aliases')
}
export function saveProvider(input: ProviderUpsertInput): Promise<ProviderSaveResult> {
return isWails()
? bridge().SaveProvider(input)
: http<ProviderSaveResult>('/api/providers', { method: 'POST', body: JSON.stringify(input) })
}
export function setProviderState(input: ProviderStateInput): Promise<ProviderView> {
return isWails()
? bridge().SetProviderState(input)
: http<ProviderView>('/api/providers/state', { method: 'POST', body: JSON.stringify(input) })
}
export async function deleteProvider(id: string): Promise<void> {
if (isWails()) {
await bridge().DeleteProvider(id)
return
}
await http<{ ok: boolean }>('/api/providers/delete', { method: 'POST', body: JSON.stringify({ id }) })
}
export function importProviders(input: ProviderImportInput): Promise<ProviderImportResult> {
return isWails()
? bridge().ImportProviders(input)
: http<ProviderImportResult>('/api/providers/import', { method: 'POST', body: JSON.stringify(input) })
}
export function saveAlias(input: AliasUpsertInput): Promise<AliasView> {
return isWails()
? bridge().SaveAlias(input)
: http<AliasView>('/api/aliases', { method: 'POST', body: JSON.stringify(input) })
}
export async function deleteAlias(alias: string): Promise<void> {
if (isWails()) {
await bridge().DeleteAlias(alias)
return
}
await http<{ ok: boolean }>('/api/aliases/delete', { method: 'POST', body: JSON.stringify({ alias }) })
}
export function bindAliasTarget(input: AliasTargetInput): Promise<AliasView> {
return isWails()
? bridge().BindTarget(input)
: http<AliasView>('/api/aliases/bind', { method: 'POST', body: JSON.stringify(input) })
}
export function setAliasTargetState(input: AliasTargetInput): Promise<AliasView> {
return isWails()
? bridge().SetTargetState(input)
: http<AliasView>('/api/aliases/state', { method: 'POST', body: JSON.stringify(input) })
}
export function unbindAliasTarget(input: AliasTargetInput): Promise<AliasView> {
return isWails()
? bridge().UnbindTarget(input)
: http<AliasView>('/api/aliases/unbind', { method: 'POST', body: JSON.stringify(input) })
}
export function getDesktopPrefs(): Promise<DesktopPrefsView> {
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
}
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsSaveResult> {
return isWails()
? bridge().SavePrefs(input)
: http<DesktopPrefsSaveResult>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
}
export function runDoctor(): Promise<DoctorRunResult> {
return isWails() ? bridge().DoctorRun() : http<DoctorRunResult>('/api/doctor', { method: 'POST' })
}
export function getProxyStatus(): Promise<ProxyStatusView> {
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
}
export function getProxySettings(): Promise<ProxySettingsView> {
return isWails() ? bridge().ProxySettings() : http<ProxySettingsView>('/api/proxy/settings')
}
export function saveProxySettings(input: ProxySettingsView): Promise<ProxySettingsSaveResult> {
return isWails()
? bridge().SaveProxySettings(input)
: http<ProxySettingsSaveResult>('/api/proxy/settings', { method: 'POST', body: JSON.stringify(input) })
}
export function listRequestTraces(): Promise<RequestTrace[]> {
return isWails() ? bridge().RequestTraces(100) : http<RequestTrace[]>('/api/proxy/traces')
}
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) })
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<!-- 渐变定义 -->
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E1E2E" />
<stop offset="100%" stop-color="#0F0F16" />
</linearGradient>
<linearGradient id="primary-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00F0FF" />
<stop offset="100%" stop-color="#7000FF" />
</linearGradient>
<!-- 发光滤镜 -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<!-- 文字专属轻度发光滤镜 -->
<filter id="text-glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- 背景底板 (圆角矩形适合Windows应用) -->
<rect x="16" y="16" width="224" height="224" rx="50" fill="url(#bg-gradient)" stroke="#2A2A3C" stroke-width="4"/>
<!-- ================= 主体图形 (整体中心上移至 Y=110) ================= -->
<!-- 左侧输入节点 (代表多个大模型上游) -->
<circle cx="60" cy="62" r="10" fill="#00F0FF" opacity="0.8"/>
<circle cx="50" cy="110" r="12" fill="#7000FF" opacity="0.8"/>
<circle cx="60" cy="158" r="10" fill="#00F0FF" opacity="0.8"/>
<!-- 连接线 (代表流式转发与故障切换) -->
<path d="M 70 62 Q 110 62 128 110" fill="none" stroke="#00F0FF" stroke-width="4" stroke-dasharray="4 4" opacity="0.6"/>
<path d="M 62 110 L 128 110" fill="none" stroke="#7000FF" stroke-width="6" opacity="0.8"/>
<path d="M 70 158 Q 110 158 128 110" fill="none" stroke="#00F0FF" stroke-width="4" stroke-dasharray="4 4" opacity="0.6"/>
<!-- 中心核心/切换器 (代表 OC Switch 本身) -->
<circle cx="128" cy="110" r="32" fill="url(#primary-gradient)" filter="url(#glow)"/>
<circle cx="128" cy="110" r="24" fill="#1E1E2E"/>
<!-- 中心 Switch 拨动形态 (代表控制台) -->
<rect x="112" y="100" width="32" height="20" rx="10" fill="#2A2A3C"/>
<circle cx="132" cy="110" r="6" fill="#00F0FF"/>
<!-- 右侧单输出 (代表单一稳定的聚合模型别名) -->
<path d="M 160 110 L 206 110" fill="none" stroke="url(#primary-gradient)" stroke-width="8" stroke-linecap="round" filter="url(#glow)"/>
<polygon points="196,100 212,110 196,120" fill="#7000FF" filter="url(#glow)"/>
<!-- ================= 底部文字排版 ================= -->
<!-- 使用系统无衬线粗体字,现代感强 -->
<text x="128" y="200" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="26" font-weight="900" text-anchor="middle" letter-spacing="2">
<!-- OC 部分青色高亮发光 -->
<tspan fill="#00F0FF" filter="url(#text-glow)">OC</tspan>
<!-- SWITCH 部分纯白对比 -->
<tspan fill="#FFFFFF"> SWITCH</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

51
frontend/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,51 @@
/// <reference types="vite/client" />
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare global {
interface Window {
go?: {
desktop?: {
App?: {
Meta: () => Promise<Record<string, string>>
OpenExternalURL: (url: string) => Promise<void>
Overview: () => Promise<import('./types').Overview>
ExportConfig: () => Promise<import('./types').ConfigExportView>
ImportConfig: (input: import('./types').ConfigImportInput) => Promise<import('./types').ConfigImportResult>
Providers: () => Promise<import('./types').ProviderView[]>
Aliases: () => Promise<import('./types').AliasView[]>
SaveProvider: (input: import('./types').ProviderUpsertInput) => Promise<import('./types').ProviderSaveResult>
SetProviderState: (input: import('./types').ProviderStateInput) => Promise<import('./types').ProviderView>
DeleteProvider: (id: string) => Promise<void>
ImportProviders: (input: import('./types').ProviderImportInput) => Promise<import('./types').ProviderImportResult>
SaveAlias: (input: import('./types').AliasUpsertInput) => Promise<import('./types').AliasView>
DeleteAlias: (alias: string) => Promise<void>
BindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
SetTargetState: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
UnbindTarget: (input: import('./types').AliasTargetInput) => Promise<import('./types').AliasView>
DoctorRun: () => Promise<import('./types').DoctorRunResult>
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
ProxySettings: () => Promise<import('./types').ProxySettingsView>
RequestTraces: (limit: number) => Promise<import('./types').RequestTrace[]>
StartProxy: () => Promise<import('./types').ProxyStatusView>
SaveProxySettings: (input: import('./types').ProxySettingsView) => Promise<import('./types').ProxySettingsSaveResult>
StopProxy: () => Promise<import('./types').ProxyStatusView>
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
PreviewSync: (input: import('./types').SyncInput) => Promise<import('./types').SyncPreview>
ApplySync: (input: import('./types').SyncInput) => Promise<import('./types').SyncResult>
}
}
}
}
}
export {}

View File

@ -0,0 +1,34 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { LanguagePreference } from '../types'
import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
export function normalizeSupportedLanguage(language?: string): 'en-US' | 'zh-CN' {
const value = language?.trim().toLowerCase()
if (value?.startsWith('zh')) {
return 'zh-CN'
}
return 'en-US'
}
export function resolveLanguagePreference(language: LanguagePreference, systemLanguage?: string): 'en-US' | 'zh-CN' {
if (language === 'en-US' || language === 'zh-CN') {
return language
}
return normalizeSupportedLanguage(systemLanguage)
}
void i18n.use(initReactI18next).init({
resources: {
'en-US': { translation: en },
'zh-CN': { translation: zhCN },
},
lng: 'en-US',
fallbackLng: 'en-US',
interpolation: {
escapeValue: false,
},
})
export default i18n

View File

@ -0,0 +1,336 @@
{
"app": {
"eyebrow": "ocswitch desktop",
"brand": "ocswitch desktop",
"title": "Native control panel",
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
"loadingConfig": "loading config",
"dev": "dev"
},
"tray": {
"appTitle": "ocswitch",
"appTooltip": "ocswitch desktop",
"proxyChecking": "Proxy: checking...",
"proxyStatusHint": "Current proxy status",
"proxyUnavailable": "Proxy: unavailable",
"proxyRunning": "Proxy: running (%s)",
"proxyStopped": "Proxy: stopped (%s)",
"openWindow": "Open window",
"openWindowHint": "Show desktop window",
"hideWindow": "Hide window",
"hideWindowHint": "Hide desktop window",
"startProxy": "Start proxy",
"startProxyHint": "Start local proxy",
"stopProxy": "Stop proxy",
"stopProxyHint": "Stop local proxy",
"quit": "Quit",
"quitHint": "Exit application"
},
"nav": {
"overview": { "title": "Overview", "description": "Status, actions, and environment" },
"providers": { "title": "Providers", "description": "Manage upstream endpoints" },
"aliases": { "title": "Aliases", "description": "Route models across targets" },
"log": { "title": "Log", "description": "Business routing, alias decisions, and failover chain" },
"network": { "title": "Network", "description": "Request metadata except LLM input and output content" },
"sync": { "title": "Sync", "description": "OpenCode sync and doctor" },
"settings": { "title": "Settings", "description": "Theme, language, desktop behavior" }
},
"actions": {
"refresh": "Refresh",
"runDoctor": "Run doctor",
"startProxy": "Start proxy",
"stopProxy": "Stop proxy",
"save": "Save",
"reset": "Reset",
"preview": "Preview",
"apply": "Apply sync",
"edit": "Edit",
"delete": "Delete",
"enable": "Enable",
"disable": "Disable",
"import": "Import providers",
"bind": "Bind target",
"unbind": "Unbind",
"newProvider": "New provider",
"newAlias": "New alias",
"close": "Close",
"cancel": "Cancel",
"confirm": "Confirm",
"exportConfig": "Export config",
"importConfig": "Import config"
},
"confirm": {
"deleteProviderTitle": "Delete provider",
"deleteAliasTitle": "Delete alias",
"unbindTargetTitle": "Unbind target"
},
"status": {
"proxyRunning": "Proxy running",
"proxyIdle": "Proxy idle",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No"
},
"messages": {
"refreshing": "Refreshing...",
"fresh": "Fresh",
"saving": "Saving...",
"saved": "Saved",
"running": "Running...",
"applying": "Applying...",
"previewing": "Previewing...",
"importing": "Importing providers...",
"loading": "Loading...",
"noData": "No data yet.",
"warningsSuffix": "Warnings: {{warnings}}",
"proxyStarted": "Proxy started",
"proxyStopped": "Proxy stopped",
"doctorOk": "Doctor OK",
"previewChanges": "Preview shows changes",
"previewNoChanges": "Preview shows no changes",
"syncApplied": "Sync applied",
"syncUpToDate": "Already up to date",
"confirmDeleteProvider": "Delete provider {{id}}? This cannot be undone.",
"confirmDeleteAlias": "Delete alias {{alias}}? This cannot be undone.",
"confirmUnbindTarget": "Unbind {{provider}}/{{model}} from {{alias}}?"
},
"overview": {
"title": "Overview",
"subtitle": "See the current desktop state and run common actions without leaving this screen.",
"actionsTitle": "Actions",
"statsTitle": "System snapshot",
"environmentTitle": "Environment",
"environmentSubtitle": "Only runtime and desktop app details stay here.",
"doctorTitle": "Doctor summary",
"debugTitle": "Debug details",
"debugSummary": "Show raw overview JSON",
"providers": "Providers",
"aliases": "Aliases",
"routableAliases": "Routable aliases",
"proxy": "Proxy",
"version": "Version",
"shell": "Shell",
"configPath": "Config path",
"startedAt": "Started at",
"lastError": "Last error",
"doctorReady": "Doctor has not run yet. Use it to inspect config and OpenCode wiring.",
"doctorIssues": "{{count}} issue(s) found",
"doctorHealthy": "No known issues"
},
"providers": {
"title": "Providers",
"subtitle": "Edit upstream providers with a fixed editor and a scrollable list.",
"listTitle": "Provider list",
"formCreateTitle": "Create provider",
"formEditTitle": "Edit provider {{id}}",
"importTitle": "Import from OpenCode",
"search": "Search providers",
"searchPlaceholder": "Filter by id, name, or URL",
"filter": "Status filter",
"filterAll": "All",
"filterEnabled": "Enabled",
"filterDisabled": "Disabled",
"listCount": "{{shown}} shown / {{total}} total",
"detailHint": "Select one row to inspect and edit it on the right.",
"selectTitle": "Select a provider",
"selectHint": "The left list is optimized for density. Pick one row to inspect or edit details here.",
"empty": "No providers configured yet.",
"emptyHint": "Create one manually or import providers from your OpenCode config.",
"noMatches": "No providers match the current filters.",
"noMatchesHint": "Try a broader search term or switch the status filter.",
"id": "Provider id",
"name": "Display name",
"baseUrl": "Base URL",
"apiKey": "API key",
"headers": "Headers",
"models": "Models",
"sourcePath": "Import path",
"saveDisabled": "Save as disabled",
"skipModels": "Skip /v1/models discovery",
"clearHeaders": "Clear saved headers before update",
"overwrite": "Overwrite existing providers",
"apiKeyMasked": "API key",
"apiKeyNotSet": "not set",
"cardBaseUrl": "URL",
"cardModels": "Models",
"cardApiKey": "API key",
"detailBasicsTitle": "Basics",
"detailTogglesTitle": "Behavior",
"headersNone": "none",
"modelsNone": "none",
"modelsCount": "{{count}} models",
"sourceLabel": "Source",
"placeholderId": "su8",
"placeholderName": "SU8",
"placeholderBaseUrl": "https://example.com/v1",
"placeholderApiKey": "Leave blank to keep existing key when editing",
"placeholderHeaders": "X-Token=abc\nX-Workspace=my-team",
"placeholderSourcePath": "Leave blank to use global OpenCode config",
"statusSaved": "Saved provider {{id}}",
"statusEditing": "Editing provider {{id}}. Leave API key blank to keep the current value.",
"statusEnabling": "Enabling provider {{id}}...",
"statusDisabling": "Disabling provider {{id}}...",
"statusEnabled": "Enabled provider {{id}}",
"statusDisabled": "Disabled provider {{id}}",
"statusDeleting": "Deleting provider {{id}}...",
"statusDeleted": "Deleted provider {{id}}",
"statusImportDone": "Import done: imported={{imported}}, skipped={{skipped}}"
},
"aliases": {
"title": "Aliases",
"subtitle": "Separate alias editing from target binding so routing stays readable.",
"listTitle": "Alias list",
"formCreateTitle": "Create alias",
"formEditTitle": "Edit alias {{alias}}",
"bindTitle": "Bind target",
"search": "Search aliases",
"searchPlaceholder": "Filter by alias, display name, provider, or model",
"listCount": "{{shown}} shown / {{total}} total",
"detailHint": "Select one row to inspect alias settings and targets on the right.",
"selectTitle": "Select an alias",
"selectHint": "The left list stays compact so you can scan more routes per screen. Pick one row to inspect or edit details here.",
"empty": "No aliases configured yet.",
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
"noMatches": "No aliases match the current filters.",
"noMatchesHint": "Try a different search term or create a new alias route.",
"alias": "Alias",
"displayName": "Display name",
"aliasForBinding": "Alias for binding",
"providerId": "Provider id",
"model": "Model",
"createDisabled": "Create or keep disabled",
"targetDisabled": "Add target disabled",
"placeholderAlias": "gpt-5.4",
"placeholderDisplayName": "GPT 5.4",
"placeholderAliasBinding": "Existing alias or new alias",
"placeholderProviderId": "su8",
"placeholderModel": "gpt-5.4",
"targets": "Targets",
"targetsCount": "{{count}} targets",
"routable": "{{available}}/{{total}} routable",
"cardRoute": "Route",
"cardTargets": "Targets",
"cardPrimary": "Primary",
"detailBasicsTitle": "Alias details",
"detailTargetsHint": "Manage routable provider/model targets for this alias.",
"statusSaved": "Saved alias {{alias}}",
"statusEditing": "Editing alias {{alias}}",
"statusDeleting": "Deleting alias {{alias}}...",
"statusDeleted": "Deleted alias {{alias}}",
"statusBinding": "Binding target...",
"statusBound": "Bound {{provider}}/{{model}} to {{alias}}",
"statusRemoving": "Removing {{provider}}/{{model}} from {{alias}}...",
"statusRemoved": "Removed {{provider}}/{{model}} from {{alias}}",
"statusEnabling": "Enabling {{provider}}/{{model}} on {{alias}}...",
"statusDisabling": "Disabling {{provider}}/{{model}} on {{alias}}...",
"statusEnabled": "Enabled {{provider}}/{{model}} on {{alias}}",
"statusDisabled": "Disabled {{provider}}/{{model}} on {{alias}}",
"noTargets": "No targets bound."
},
"sync": {
"title": "Sync",
"subtitle": "Preview and apply OpenCode sync without leaving the desktop shell.",
"syncTitle": "OpenCode sync",
"doctorTitle": "Doctor report",
"targetPath": "Target path",
"model": "model",
"smallModel": "small_model",
"placeholderTargetPath": "Use default OpenCode config",
"placeholderModel": "ocswitch/<alias>",
"outputTitle": "Last output",
"doctorHint": "Run doctor to inspect config and OpenCode wiring.",
"issuesTitle": "Issues",
"noIssues": "No issues reported.",
"debugTitle": "Raw doctor JSON",
"debugSummary": "Show doctor output"
},
"log": {
"title": "Request log",
"subtitle": "Inspect alias routing, final target, and failover chain for each request.",
"count": "{{count}} requests",
"empty": "No proxied requests yet.",
"emptyHint": "Start the proxy and send a request to populate the timeline.",
"success": "Success",
"failed": "Failed",
"failover": "Failover",
"detailTitle": "Business detail",
"detailSubtitle": "This panel focuses on alias-level routing semantics.",
"alias": "Alias",
"finalRoute": "Final route",
"status": "Status",
"totalTime": "Total time",
"startedAt": "Started at",
"firstByte": "First byte",
"stream": "Stream",
"chainTitle": "Attempt chain",
"attemptLabel": "Attempt {{attempt}}",
"provider": "Provider",
"model": "Model"
},
"network": {
"title": "Network detail",
"subtitle": "Inspect URL, timing, status, headers, and params without exposing model IO content.",
"count": "{{count}} requests",
"detailTitle": "Network inspector",
"detailSubtitle": "Each attempt shows technical metadata captured around the upstream call.",
"url": "URL",
"firstByte": "TTFB",
"totalTime": "Total time",
"statusCode": "Status code",
"requestHeaders": "Client request headers",
"requestParams": "Client request params",
"responseHeaders": "Upstream response headers",
"responseBody": "Upstream response body",
"attemptTitle": "Attempt {{attempt}} · {{provider}} / {{model}}"
},
"settings": {
"title": "Settings",
"subtitle": "Persist desktop behavior, theme preference, and language preference.",
"appearanceTitle": "Appearance",
"languageTitle": "Language",
"behaviorTitle": "Desktop behavior",
"timeoutTitle": "Proxy timeouts",
"configTitle": "Config file",
"importTitle": "Import mode",
"importModeText": "Import text",
"importModeFile": "Import file",
"importText": "Config text",
"importFile": "Config file",
"importPlaceholder": "Paste full ocswitch config JSON",
"fileEmpty": "No file selected yet.",
"exportDone": "Exported config {{path}}",
"importDone": "Imported config {{path}}",
"fileLoaded": "Loaded file {{name}}",
"importEmpty": "Provide config content first.",
"aboutTitle": "About",
"aboutSubtitle": "Read-only environment details for the current desktop shell.",
"theme": "Theme",
"language": "Language",
"languageLabel": "Preferred language",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"languageSystem": "System",
"languageEnglish": "English",
"languageChinese": "Simplified Chinese",
"resolvedTheme": "Resolved theme",
"resolvedLanguage": "Resolved language",
"launchAtLogin": "Launch at login",
"autoStartProxy": "Start proxy on app launch",
"connectTimeout": "Connect timeout (ms)",
"responseHeaderTimeout": "Response header timeout (ms)",
"firstByteTimeout": "First byte timeout (ms)",
"requestReadTimeout": "Client request read timeout (ms)",
"streamIdleTimeout": "Stream idle timeout (ms)",
"timeoutHint": "Saved values apply to the next proxy start.",
"timeoutRunningHint": "The proxy is currently running. Saved values apply only after you restart it.",
"saveTimeouts": "Save proxy timeouts",
"minimizeToTray": "Minimize to tray / close to background",
"notifications": "Native notifications"
},
"errors": {
"invalidHeaderFormat": "Invalid header \"{{line}}\". Use KEY=VALUE.",
"invalidHeaderName": "Invalid header \"{{line}}\". Header name must not be empty."
}
}

View File

@ -0,0 +1,336 @@
{
"app": {
"eyebrow": "ocswitch desktop",
"brand": "ocswitch desktop",
"title": "桌面控制台",
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
"loadingConfig": "正在读取配置",
"dev": "dev"
},
"tray": {
"appTitle": "ocswitch",
"appTooltip": "ocswitch desktop",
"proxyChecking": "代理:检查中...",
"proxyStatusHint": "当前代理状态",
"proxyUnavailable": "代理:不可用",
"proxyRunning": "代理:运行中(%s",
"proxyStopped": "代理:已停止(%s",
"openWindow": "打开主面板",
"openWindowHint": "显示桌面主窗口",
"hideWindow": "隐藏窗口",
"hideWindowHint": "隐藏桌面主窗口",
"startProxy": "启动代理",
"startProxyHint": "启动本地代理",
"stopProxy": "停止代理",
"stopProxyHint": "停止本地代理",
"quit": "退出",
"quitHint": "退出应用"
},
"nav": {
"overview": { "title": "概览", "description": "状态、操作与环境信息" },
"providers": { "title": "提供商", "description": "管理上游端点" },
"aliases": { "title": "别名路由", "description": "管理别名与目标绑定" },
"log": { "title": "日志", "description": "查看业务路由、alias 决策与 failover 链路" },
"network": { "title": "网络", "description": "查看除 LLM 输入输出内容外的全部请求元数据" },
"sync": { "title": "同步与体检", "description": "OpenCode 同步与 Doctor" },
"settings": { "title": "设置", "description": "主题、语言与桌面偏好" }
},
"actions": {
"refresh": "刷新",
"runDoctor": "运行 Doctor",
"startProxy": "启动代理",
"stopProxy": "停止代理",
"save": "保存",
"reset": "重置",
"preview": "预览",
"apply": "应用同步",
"edit": "编辑",
"delete": "删除",
"enable": "启用",
"disable": "禁用",
"import": "导入提供商",
"bind": "绑定目标",
"unbind": "解绑",
"newProvider": "新建提供商",
"newAlias": "新建别名",
"close": "关闭",
"cancel": "取消",
"confirm": "确认",
"exportConfig": "导出配置",
"importConfig": "导入配置"
},
"confirm": {
"deleteProviderTitle": "确认删除提供商",
"deleteAliasTitle": "确认删除别名",
"unbindTargetTitle": "确认解绑目标"
},
"status": {
"proxyRunning": "代理运行中",
"proxyIdle": "代理未运行",
"enabled": "已启用",
"disabled": "已禁用",
"yes": "是",
"no": "否"
},
"messages": {
"refreshing": "正在刷新...",
"fresh": "已刷新",
"saving": "正在保存...",
"saved": "已保存",
"running": "正在执行...",
"applying": "正在应用...",
"previewing": "正在预览...",
"importing": "正在导入提供商...",
"loading": "正在加载...",
"noData": "暂无数据。",
"warningsSuffix": "警告:{{warnings}}",
"proxyStarted": "代理已启动",
"proxyStopped": "代理已停止",
"doctorOk": "Doctor 通过",
"previewChanges": "预览显示有变更",
"previewNoChanges": "预览显示无变更",
"syncApplied": "同步已应用",
"syncUpToDate": "已经是最新状态",
"confirmDeleteProvider": "确认删除提供商 {{id}}?此操作不可撤销。",
"confirmDeleteAlias": "确认删除别名 {{alias}}?此操作不可撤销。",
"confirmUnbindTarget": "确认从 {{alias}} 解绑 {{provider}}/{{model}}"
},
"overview": {
"title": "概览",
"subtitle": "在一个页面里快速查看当前桌面状态,并完成常用操作。",
"actionsTitle": "快捷操作",
"statsTitle": "系统快照",
"environmentTitle": "环境信息",
"environmentSubtitle": "仅保留当前运行态和桌面应用相关信息。",
"doctorTitle": "Doctor 摘要",
"debugTitle": "调试详情",
"debugSummary": "查看原始 overview JSON",
"providers": "提供商",
"aliases": "别名",
"routableAliases": "可路由别名",
"proxy": "代理",
"version": "版本",
"shell": "Shell",
"configPath": "配置路径",
"startedAt": "启动时间",
"lastError": "最近错误",
"doctorReady": "尚未运行 Doctor可用来检查配置与 OpenCode 接线情况。",
"doctorIssues": "发现 {{count}} 个问题",
"doctorHealthy": "当前未发现问题"
},
"providers": {
"title": "提供商",
"subtitle": "左侧滚动列表,右侧固定编辑区,减少整页来回滚动。",
"listTitle": "提供商列表",
"formCreateTitle": "新建提供商",
"formEditTitle": "编辑提供商 {{id}}",
"importTitle": "从 OpenCode 导入",
"search": "搜索提供商",
"searchPlaceholder": "按 id、名称或 URL 过滤",
"filter": "状态筛选",
"filterAll": "全部",
"filterEnabled": "已启用",
"filterDisabled": "已禁用",
"listCount": "显示 {{shown}} / 共 {{total}}",
"detailHint": "左侧选中一行后,在右侧查看并编辑详情。",
"selectTitle": "选择一个提供商",
"selectHint": "左侧列表已压缩为高密度模式,适合首屏浏览更多条目;选中一行后在这里查看或编辑详情。",
"empty": "还没有配置任何提供商。",
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
"noMatches": "当前筛选条件下没有匹配的提供商。",
"noMatchesHint": "尝试放宽搜索词或切换状态筛选。",
"id": "提供商 id",
"name": "显示名称",
"baseUrl": "Base URL",
"apiKey": "API key",
"headers": "Headers",
"models": "Models",
"sourcePath": "导入路径",
"saveDisabled": "保存为禁用状态",
"skipModels": "跳过 /v1/models 发现",
"clearHeaders": "更新前清空已保存 headers",
"overwrite": "覆盖现有提供商",
"apiKeyMasked": "API key",
"apiKeyNotSet": "未设置",
"cardBaseUrl": "地址",
"cardModels": "模型",
"cardApiKey": "密钥",
"detailBasicsTitle": "基础信息",
"detailTogglesTitle": "行为选项",
"headersNone": "无",
"modelsNone": "无",
"modelsCount": "{{count}} 个模型",
"sourceLabel": "来源",
"placeholderId": "su8",
"placeholderName": "SU8",
"placeholderBaseUrl": "https://example.com/v1",
"placeholderApiKey": "编辑时留空表示保留现有 key",
"placeholderHeaders": "X-Token=abc\nX-Workspace=my-team",
"placeholderSourcePath": "留空则使用全局 OpenCode 配置",
"statusSaved": "已保存提供商 {{id}}",
"statusEditing": "正在编辑提供商 {{id}}API key 留空会保留当前值。",
"statusEnabling": "正在启用提供商 {{id}}...",
"statusDisabling": "正在禁用提供商 {{id}}...",
"statusEnabled": "已启用提供商 {{id}}",
"statusDisabled": "已禁用提供商 {{id}}",
"statusDeleting": "正在删除提供商 {{id}}...",
"statusDeleted": "已删除提供商 {{id}}",
"statusImportDone": "导入完成imported={{imported}}, skipped={{skipped}}"
},
"aliases": {
"title": "别名路由",
"subtitle": "把别名编辑和目标绑定拆开,路由关系更清晰。",
"listTitle": "别名列表",
"formCreateTitle": "新建别名",
"formEditTitle": "编辑别名 {{alias}}",
"bindTitle": "绑定目标",
"search": "搜索别名",
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
"listCount": "显示 {{shown}} / 共 {{total}}",
"detailHint": "左侧选中一行后,在右侧查看别名设置和目标详情。",
"selectTitle": "选择一个别名",
"selectHint": "左侧列表保持紧凑,方便一屏浏览更多路由;选中一行后在这里查看或编辑详情。",
"empty": "还没有配置任何别名。",
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
"noMatches": "当前筛选条件下没有匹配的别名。",
"noMatchesHint": "尝试修改搜索词,或直接新建一个新的别名路由。",
"alias": "别名",
"displayName": "显示名称",
"aliasForBinding": "用于绑定的别名",
"providerId": "提供商 id",
"model": "模型",
"createDisabled": "创建或保持为禁用",
"targetDisabled": "新增目标时设为禁用",
"placeholderAlias": "gpt-5.4",
"placeholderDisplayName": "GPT 5.4",
"placeholderAliasBinding": "已有别名或新别名",
"placeholderProviderId": "su8",
"placeholderModel": "gpt-5.4",
"targets": "目标",
"targetsCount": "{{count}} 个目标",
"routable": "可路由 {{available}}/{{total}}",
"cardRoute": "路由",
"cardTargets": "目标数",
"cardPrimary": "主目标",
"detailBasicsTitle": "别名信息",
"detailTargetsHint": "为当前别名维护可路由的 provider/model 目标。",
"statusSaved": "已保存别名 {{alias}}",
"statusEditing": "正在编辑别名 {{alias}}",
"statusDeleting": "正在删除别名 {{alias}}...",
"statusDeleted": "已删除别名 {{alias}}",
"statusBinding": "正在绑定目标...",
"statusBound": "已将 {{provider}}/{{model}} 绑定到 {{alias}}",
"statusRemoving": "正在从 {{alias}} 移除 {{provider}}/{{model}}...",
"statusRemoved": "已从 {{alias}} 移除 {{provider}}/{{model}}",
"statusEnabling": "正在启用 {{alias}} 上的 {{provider}}/{{model}}...",
"statusDisabling": "正在禁用 {{alias}} 上的 {{provider}}/{{model}}...",
"statusEnabled": "已启用 {{alias}} 上的 {{provider}}/{{model}}",
"statusDisabled": "已禁用 {{alias}} 上的 {{provider}}/{{model}}",
"noTargets": "还没有绑定目标。"
},
"sync": {
"title": "同步与体检",
"subtitle": "不离开桌面应用即可预览、应用 OpenCode 同步并运行 Doctor。",
"syncTitle": "OpenCode 同步",
"doctorTitle": "Doctor 报告",
"targetPath": "目标路径",
"model": "model",
"smallModel": "small_model",
"placeholderTargetPath": "使用默认 OpenCode 配置",
"placeholderModel": "ocswitch/<alias>",
"outputTitle": "最近输出",
"doctorHint": "运行 Doctor 以检查配置与 OpenCode 接线。",
"issuesTitle": "问题列表",
"noIssues": "未报告问题。",
"debugTitle": "原始 Doctor JSON",
"debugSummary": "查看 Doctor 输出"
},
"log": {
"title": "请求日志",
"subtitle": "按请求查看 alias 路由、最终目标与 failover 链路。",
"count": "共 {{count}} 条请求",
"empty": "还没有代理请求。",
"emptyHint": "先启动代理并发起一次请求,时间线才会出现内容。",
"success": "成功",
"failed": "失败",
"failover": "发生切换",
"detailTitle": "业务详情",
"detailSubtitle": "这里聚焦 alias 级别的业务路由语义。",
"alias": "Alias",
"finalRoute": "最终路由",
"status": "状态码",
"totalTime": "总耗时",
"startedAt": "开始时间",
"firstByte": "首字节时间",
"stream": "流式",
"chainTitle": "尝试链路",
"attemptLabel": "第 {{attempt}} 次尝试",
"provider": "提供商",
"model": "模型"
},
"network": {
"title": "网络详情",
"subtitle": "查看 URL、时延、状态码、请求头与参数同时避免暴露模型输入输出正文。",
"count": "共 {{count}} 条请求",
"detailTitle": "网络检查器",
"detailSubtitle": "每次尝试都会展示调用上游时采集到的技术元数据。",
"url": "URL",
"firstByte": "TTFB",
"totalTime": "总耗时",
"statusCode": "状态码",
"requestHeaders": "客户端请求头",
"requestParams": "客户端请求参数",
"responseHeaders": "上游响应头",
"responseBody": "上游响应体",
"attemptTitle": "第 {{attempt}} 次尝试 · {{provider}} / {{model}}"
},
"settings": {
"title": "设置",
"subtitle": "持久化桌面行为、主题偏好和语言偏好。",
"appearanceTitle": "外观",
"languageTitle": "语言",
"behaviorTitle": "桌面行为",
"timeoutTitle": "代理超时",
"configTitle": "配置文件",
"importTitle": "导入方式",
"importModeText": "文本导入",
"importModeFile": "文件导入",
"importText": "配置文本",
"importFile": "配置文件",
"importPlaceholder": "粘贴完整 ocswitch 配置 JSON",
"fileEmpty": "尚未选择文件。",
"exportDone": "已导出配置 {{path}}",
"importDone": "已导入配置 {{path}}",
"fileLoaded": "已读取文件 {{name}}",
"importEmpty": "请先提供配置内容。",
"aboutTitle": "关于",
"aboutSubtitle": "显示当前桌面壳环境信息,仅用于查看。",
"theme": "主题",
"language": "语言",
"languageLabel": "首选语言",
"themeSystem": "跟随系统",
"themeLight": "浅色",
"themeDark": "深色",
"languageSystem": "跟随系统",
"languageEnglish": "English",
"languageChinese": "简体中文",
"resolvedTheme": "实际主题",
"resolvedLanguage": "实际语言",
"launchAtLogin": "开机启动",
"autoStartProxy": "启动应用时自动开启代理",
"connectTimeout": "连接超时(毫秒)",
"responseHeaderTimeout": "响应头超时(毫秒)",
"firstByteTimeout": "首字节超时(毫秒)",
"requestReadTimeout": "客户端请求读取超时(毫秒)",
"streamIdleTimeout": "流空闲超时(毫秒)",
"timeoutHint": "保存后会在下一次启动代理时生效。",
"timeoutRunningHint": "代理当前正在运行,保存后的值要在重启代理后才会生效。",
"saveTimeouts": "保存代理超时",
"minimizeToTray": "最小化到托盘 / 关闭时转入后台",
"notifications": "系统通知"
},
"errors": {
"invalidHeaderFormat": "Header \"{{line}}\" 格式无效,请使用 KEY=VALUE。",
"invalidHeaderName": "Header \"{{line}}\" 无效,名称不能为空。"
}
}

11
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './i18n'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

1663
frontend/src/styles.css Normal file

File diff suppressed because it is too large Load Diff

225
frontend/src/types.ts Normal file
View File

@ -0,0 +1,225 @@
export type ThemePreference = 'system' | 'light' | 'dark'
export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
export type DesktopPrefsView = {
launchAtLogin: boolean
autoStartProxy: boolean
minimizeToTray: boolean
notifications: boolean
theme: ThemePreference
language: LanguagePreference
}
export type DesktopPrefsSaveResult = {
prefs: DesktopPrefsView
warnings?: string[]
}
export type ProxySettingsView = {
connectTimeoutMs: number
responseHeaderTimeoutMs: number
firstByteTimeoutMs: number
requestReadTimeoutMs: number
streamIdleTimeoutMs: number
}
export type ProxySettingsSaveResult = {
settings: ProxySettingsView
warnings?: string[]
}
export type ConfigExportView = {
configPath: string
content: string
}
export type ConfigImportInput = {
content: string
}
export type ConfigImportResult = {
configPath: string
warnings?: string[]
}
export type ProxyStatusView = {
running: boolean
bindAddress: string
startedAt?: string
lastError?: string
}
export type TraceAttempt = {
attempt: number
provider?: string
model?: string
url?: string
startedAt: string
durationMs: number
firstByteMs?: number
statusCode?: number
success: boolean
retryable: boolean
skipped: boolean
result?: string
error?: string
requestHeaders?: Record<string, string>
requestParams?: unknown
responseHeaders?: Record<string, string>
responseBody?: string
}
export type RequestTrace = {
id: number
startedAt: string
finishedAt?: string
durationMs: number
firstByteMs?: number
rawModel?: string
alias?: string
stream: boolean
success: boolean
statusCode?: number
error?: string
finalProvider?: string
finalModel?: string
finalUrl?: string
failover: boolean
attemptCount: number
requestHeaders?: Record<string, string>
requestParams?: unknown
attempts: TraceAttempt[]
}
export type Overview = {
configPath: string
providerCount: number
aliasCount: number
availableAliases: string[]
proxy: ProxyStatusView
desktop: DesktopPrefsView
}
export type ProviderView = {
id: string
name?: string
baseUrl: string
apiKeySet: boolean
apiKeyMasked?: string
headers?: Record<string, string>
models?: string[]
modelsSource?: string
disabled: boolean
}
export type ProviderSaveResult = {
provider: ProviderView
warnings?: string[]
}
export type ProviderUpsertInput = {
id: string
name?: string
baseUrl: string
apiKey?: string
headers?: Record<string, string>
disabled: boolean
skipModels: boolean
clearHeaders: boolean
}
export type ProviderStateInput = {
id: string
disabled: boolean
}
export type ProviderImportInput = {
sourcePath?: string
overwrite: boolean
}
export type ProviderImportResult = {
sourcePath: string
imported: number
skipped: number
warnings?: string[]
}
export type AliasTargetView = {
provider: string
model: string
enabled: boolean
}
export type AliasView = {
alias: string
displayName?: string
enabled: boolean
targetCount: number
availableTargetCount: number
targets: AliasTargetView[]
}
export type AliasUpsertInput = {
alias: string
displayName?: string
disabled: boolean
}
export type AliasTargetInput = {
alias: string
provider: string
model: string
disabled: boolean
}
export type DoctorIssue = {
message: string
}
export type DoctorReport = {
ok: boolean
issues: DoctorIssue[]
configPath: string
providerCount: number
aliasCount: number
proxyBindAddress: string
openCodeTargetPath: string
openCodeTargetFound: boolean
}
export type DoctorRunResult = {
report: DoctorReport
error?: string
}
export type SyncInput = {
target?: string
setModel?: string
setSmallModel?: string
dryRun?: boolean
}
export type SyncPreview = {
targetPath: string
aliasNames: string[]
setModel?: string
setSmallModel?: string
wouldChange: boolean
}
export type SyncResult = {
targetPath: string
aliasNames: string[]
changed: boolean
dryRun: boolean
setModel?: string
setSmallModel?: string
}
export type MetaView = {
version: string
shell: string
url?: string
}

21
frontend/tsconfig.json Normal file
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": "Bundler",
"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,
},
})

75
frontend/wailsjs/go/desktop/App.d.ts vendored Executable file
View File

@ -0,0 +1,75 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {app} from '../models';
import {context} from '../models';
import {desktop} from '../models';
export function Aliases():Promise<Array<app.AliasView>>;
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
export function BeforeClose(arg1:context.Context):Promise<boolean>;
export function BindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
export function Bindings():Promise<desktop.Bindings>;
export function DeleteAlias(arg1:string):Promise<void>;
export function DeleteProvider(arg1:string):Promise<void>;
export function DesktopPrefs():Promise<app.DesktopPrefsView>;
export function DoctorRun():Promise<app.DoctorRunResult>;
export function ExportConfig():Promise<app.ConfigExportView>;
export function ImportConfig(arg1:app.ConfigImportInput):Promise<app.ConfigImportResult>;
export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.ProviderImportResult>;
export function Meta():Promise<Record<string, string>>;
export function OpenExternalURL(arg1:string):Promise<void>;
export function Overview():Promise<app.Overview>;
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
export function Providers():Promise<Array<app.ProviderView>>;
export function ProxySettings():Promise<app.ProxySettingsView>;
export function ProxyStatus():Promise<app.ProxyStatusView>;
export function RequestTraces(arg1:number):Promise<Array<app.RequestTrace>>;
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsSaveResult>;
export function SaveProvider(arg1:app.ProviderUpsertInput):Promise<app.ProviderSaveResult>;
export function SaveProxySettings(arg1:app.ProxySettingsInput):Promise<app.ProxySettingsSaveResult>;
export function Service():Promise<app.Service>;
export function SetProviderState(arg1:app.ProviderStateInput):Promise<app.ProviderView>;
export function SetTargetState(arg1:app.AliasTargetInput):Promise<app.AliasView>;
export function SetVersion(arg1:string):Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function StartProxy():Promise<app.ProxyStatusView>;
export function Startup(arg1:context.Context):Promise<void>;
export function StopProxy():Promise<app.ProxyStatusView>;
export function SyncDesktopPreferences(arg1:context.Context):Promise<void>;
export function UnbindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;

View File

@ -0,0 +1,143 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Aliases() {
return window['go']['desktop']['App']['Aliases']();
}
export function ApplySync(arg1) {
return window['go']['desktop']['App']['ApplySync'](arg1);
}
export function BeforeClose(arg1) {
return window['go']['desktop']['App']['BeforeClose'](arg1);
}
export function BindTarget(arg1) {
return window['go']['desktop']['App']['BindTarget'](arg1);
}
export function Bindings() {
return window['go']['desktop']['App']['Bindings']();
}
export function DeleteAlias(arg1) {
return window['go']['desktop']['App']['DeleteAlias'](arg1);
}
export function DeleteProvider(arg1) {
return window['go']['desktop']['App']['DeleteProvider'](arg1);
}
export function DesktopPrefs() {
return window['go']['desktop']['App']['DesktopPrefs']();
}
export function DoctorRun() {
return window['go']['desktop']['App']['DoctorRun']();
}
export function ExportConfig() {
return window['go']['desktop']['App']['ExportConfig']();
}
export function ImportConfig(arg1) {
return window['go']['desktop']['App']['ImportConfig'](arg1);
}
export function ImportProviders(arg1) {
return window['go']['desktop']['App']['ImportProviders'](arg1);
}
export function Meta() {
return window['go']['desktop']['App']['Meta']();
}
export function OpenExternalURL(arg1) {
return window['go']['desktop']['App']['OpenExternalURL'](arg1);
}
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 ProxySettings() {
return window['go']['desktop']['App']['ProxySettings']();
}
export function ProxyStatus() {
return window['go']['desktop']['App']['ProxyStatus']();
}
export function RequestTraces(arg1) {
return window['go']['desktop']['App']['RequestTraces'](arg1);
}
export function SaveAlias(arg1) {
return window['go']['desktop']['App']['SaveAlias'](arg1);
}
export function SaveDesktopPrefs(arg1, arg2) {
return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2);
}
export function SavePrefs(arg1) {
return window['go']['desktop']['App']['SavePrefs'](arg1);
}
export function SaveProvider(arg1) {
return window['go']['desktop']['App']['SaveProvider'](arg1);
}
export function SaveProxySettings(arg1) {
return window['go']['desktop']['App']['SaveProxySettings'](arg1);
}
export function Service() {
return window['go']['desktop']['App']['Service']();
}
export function SetProviderState(arg1) {
return window['go']['desktop']['App']['SetProviderState'](arg1);
}
export function SetTargetState(arg1) {
return window['go']['desktop']['App']['SetTargetState'](arg1);
}
export function SetVersion(arg1) {
return window['go']['desktop']['App']['SetVersion'](arg1);
}
export function Shutdown(arg1) {
return window['go']['desktop']['App']['Shutdown'](arg1);
}
export function StartProxy() {
return window['go']['desktop']['App']['StartProxy']();
}
export function Startup(arg1) {
return window['go']['desktop']['App']['Startup'](arg1);
}
export function StopProxy() {
return window['go']['desktop']['App']['StopProxy']();
}
export function SyncDesktopPreferences(arg1) {
return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1);
}
export function UnbindTarget(arg1) {
return window['go']['desktop']['App']['UnbindTarget'](arg1);
}

764
frontend/wailsjs/go/models.ts Executable file
View File

@ -0,0 +1,764 @@
export namespace app {
export class AliasTargetInput {
alias: string;
provider: string;
model: string;
disabled: boolean;
static createFrom(source: any = {}) {
return new AliasTargetInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.alias = source["alias"];
this.provider = source["provider"];
this.model = source["model"];
this.disabled = source["disabled"];
}
}
export class AliasTargetView {
provider: string;
model: string;
enabled: boolean;
static createFrom(source: any = {}) {
return new AliasTargetView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.provider = source["provider"];
this.model = source["model"];
this.enabled = source["enabled"];
}
}
export class AliasUpsertInput {
alias: string;
displayName?: string;
disabled: boolean;
static createFrom(source: any = {}) {
return new AliasUpsertInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.alias = source["alias"];
this.displayName = source["displayName"];
this.disabled = source["disabled"];
}
}
export class AliasView {
alias: string;
displayName?: string;
enabled: boolean;
targetCount: number;
availableTargetCount: number;
targets: AliasTargetView[];
static createFrom(source: any = {}) {
return new AliasView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.alias = source["alias"];
this.displayName = source["displayName"];
this.enabled = source["enabled"];
this.targetCount = source["targetCount"];
this.availableTargetCount = source["availableTargetCount"];
this.targets = this.convertValues(source["targets"], AliasTargetView);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ConfigExportView {
configPath: string;
content: string;
static createFrom(source: any = {}) {
return new ConfigExportView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configPath = source["configPath"];
this.content = source["content"];
}
}
export class ConfigImportInput {
content: string;
static createFrom(source: any = {}) {
return new ConfigImportInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.content = source["content"];
}
}
export class ConfigImportResult {
configPath: string;
warnings?: string[];
static createFrom(source: any = {}) {
return new ConfigImportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configPath = source["configPath"];
this.warnings = source["warnings"];
}
}
export class DesktopPrefsInput {
launchAtLogin: boolean;
autoStartProxy: boolean;
minimizeToTray: boolean;
notifications: boolean;
theme: string;
language: string;
static createFrom(source: any = {}) {
return new DesktopPrefsInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.launchAtLogin = source["launchAtLogin"];
this.autoStartProxy = source["autoStartProxy"];
this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"];
this.theme = source["theme"];
this.language = source["language"];
}
}
export class DesktopPrefsView {
launchAtLogin: boolean;
autoStartProxy: boolean;
minimizeToTray: boolean;
notifications: boolean;
theme: string;
language: string;
static createFrom(source: any = {}) {
return new DesktopPrefsView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.launchAtLogin = source["launchAtLogin"];
this.autoStartProxy = source["autoStartProxy"];
this.minimizeToTray = source["minimizeToTray"];
this.notifications = source["notifications"];
this.theme = source["theme"];
this.language = source["language"];
}
}
export class DesktopPrefsSaveResult {
prefs: DesktopPrefsView;
warnings?: string[];
static createFrom(source: any = {}) {
return new DesktopPrefsSaveResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.prefs = this.convertValues(source["prefs"], DesktopPrefsView);
this.warnings = source["warnings"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class DoctorIssue {
message: string;
static createFrom(source: any = {}) {
return new DoctorIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.message = source["message"];
}
}
export class DoctorReport {
ok: boolean;
issues: DoctorIssue[];
configPath: string;
providerCount: number;
aliasCount: number;
proxyBindAddress: string;
openCodeTargetPath: string;
openCodeTargetFound: boolean;
static createFrom(source: any = {}) {
return new DoctorReport(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ok = source["ok"];
this.issues = this.convertValues(source["issues"], DoctorIssue);
this.configPath = source["configPath"];
this.providerCount = source["providerCount"];
this.aliasCount = source["aliasCount"];
this.proxyBindAddress = source["proxyBindAddress"];
this.openCodeTargetPath = source["openCodeTargetPath"];
this.openCodeTargetFound = source["openCodeTargetFound"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class DoctorRunResult {
report: DoctorReport;
error?: string;
static createFrom(source: any = {}) {
return new DoctorRunResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.report = this.convertValues(source["report"], DoctorReport);
this.error = source["error"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProxyStatusView {
running: boolean;
bindAddress: string;
startedAt?: string;
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 = source["startedAt"];
this.lastError = source["lastError"];
}
}
export class Overview {
configPath: string;
providerCount: number;
aliasCount: number;
availableAliases: string[];
proxy: ProxyStatusView;
desktop: DesktopPrefsView;
static createFrom(source: any = {}) {
return new Overview(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configPath = source["configPath"];
this.providerCount = source["providerCount"];
this.aliasCount = source["aliasCount"];
this.availableAliases = source["availableAliases"];
this.proxy = this.convertValues(source["proxy"], ProxyStatusView);
this.desktop = this.convertValues(source["desktop"], DesktopPrefsView);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProviderImportInput {
sourcePath?: string;
overwrite: boolean;
static createFrom(source: any = {}) {
return new ProviderImportInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sourcePath = source["sourcePath"];
this.overwrite = source["overwrite"];
}
}
export class ProviderImportResult {
sourcePath: string;
imported: number;
skipped: number;
warnings?: string[];
static createFrom(source: any = {}) {
return new ProviderImportResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sourcePath = source["sourcePath"];
this.imported = source["imported"];
this.skipped = source["skipped"];
this.warnings = source["warnings"];
}
}
export class ProviderView {
id: string;
name?: string;
baseUrl: string;
apiKeySet: boolean;
apiKeyMasked?: string;
headers?: Record<string, string>;
models?: string[];
modelsSource?: string;
disabled: boolean;
static createFrom(source: any = {}) {
return new ProviderView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.baseUrl = source["baseUrl"];
this.apiKeySet = source["apiKeySet"];
this.apiKeyMasked = source["apiKeyMasked"];
this.headers = source["headers"];
this.models = source["models"];
this.modelsSource = source["modelsSource"];
this.disabled = source["disabled"];
}
}
export class ProviderSaveResult {
provider: ProviderView;
warnings?: string[];
static createFrom(source: any = {}) {
return new ProviderSaveResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.provider = this.convertValues(source["provider"], ProviderView);
this.warnings = source["warnings"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProviderStateInput {
id: string;
disabled: boolean;
static createFrom(source: any = {}) {
return new ProviderStateInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.disabled = source["disabled"];
}
}
export class ProviderUpsertInput {
id: string;
name?: string;
baseUrl: string;
apiKey?: string;
headers?: Record<string, string>;
disabled: boolean;
skipModels: boolean;
clearHeaders: boolean;
static createFrom(source: any = {}) {
return new ProviderUpsertInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.headers = source["headers"];
this.disabled = source["disabled"];
this.skipModels = source["skipModels"];
this.clearHeaders = source["clearHeaders"];
}
}
export class ProxySettingsInput {
connectTimeoutMs: number;
responseHeaderTimeoutMs: number;
firstByteTimeoutMs: number;
requestReadTimeoutMs: number;
streamIdleTimeoutMs: number;
static createFrom(source: any = {}) {
return new ProxySettingsInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connectTimeoutMs = source["connectTimeoutMs"];
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
}
}
export class ProxySettingsView {
connectTimeoutMs: number;
responseHeaderTimeoutMs: number;
firstByteTimeoutMs: number;
requestReadTimeoutMs: number;
streamIdleTimeoutMs: number;
static createFrom(source: any = {}) {
return new ProxySettingsView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connectTimeoutMs = source["connectTimeoutMs"];
this.responseHeaderTimeoutMs = source["responseHeaderTimeoutMs"];
this.firstByteTimeoutMs = source["firstByteTimeoutMs"];
this.requestReadTimeoutMs = source["requestReadTimeoutMs"];
this.streamIdleTimeoutMs = source["streamIdleTimeoutMs"];
}
}
export class ProxySettingsSaveResult {
settings: ProxySettingsView;
warnings?: string[];
static createFrom(source: any = {}) {
return new ProxySettingsSaveResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.settings = this.convertValues(source["settings"], ProxySettingsView);
this.warnings = source["warnings"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class TraceAttempt {
attempt: number;
provider?: string;
model?: string;
url?: string;
startedAt: string;
durationMs: number;
firstByteMs?: number;
statusCode?: number;
success: boolean;
retryable: boolean;
skipped: boolean;
result?: string;
error?: string;
requestHeaders?: Record<string, string>;
requestParams?: any;
responseHeaders?: Record<string, string>;
responseBody?: string;
static createFrom(source: any = {}) {
return new TraceAttempt(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.attempt = source["attempt"];
this.provider = source["provider"];
this.model = source["model"];
this.url = source["url"];
this.startedAt = source["startedAt"];
this.durationMs = source["durationMs"];
this.firstByteMs = source["firstByteMs"];
this.statusCode = source["statusCode"];
this.success = source["success"];
this.retryable = source["retryable"];
this.skipped = source["skipped"];
this.result = source["result"];
this.error = source["error"];
this.requestHeaders = source["requestHeaders"];
this.requestParams = source["requestParams"];
this.responseHeaders = source["responseHeaders"];
this.responseBody = source["responseBody"];
}
}
export class RequestTrace {
id: number;
startedAt: string;
finishedAt?: string;
durationMs: number;
firstByteMs?: number;
rawModel?: string;
alias?: string;
stream: boolean;
success: boolean;
statusCode?: number;
error?: string;
finalProvider?: string;
finalModel?: string;
finalUrl?: string;
failover: boolean;
attemptCount: number;
requestHeaders?: Record<string, string>;
requestParams?: any;
attempts: TraceAttempt[];
static createFrom(source: any = {}) {
return new RequestTrace(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.startedAt = source["startedAt"];
this.finishedAt = source["finishedAt"];
this.durationMs = source["durationMs"];
this.firstByteMs = source["firstByteMs"];
this.rawModel = source["rawModel"];
this.alias = source["alias"];
this.stream = source["stream"];
this.success = source["success"];
this.statusCode = source["statusCode"];
this.error = source["error"];
this.finalProvider = source["finalProvider"];
this.finalModel = source["finalModel"];
this.finalUrl = source["finalUrl"];
this.failover = source["failover"];
this.attemptCount = source["attemptCount"];
this.requestHeaders = source["requestHeaders"];
this.requestParams = source["requestParams"];
this.attempts = this.convertValues(source["attempts"], TraceAttempt);
}
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 Service {
static createFrom(source: any = {}) {
return new Service(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class SyncInput {
target?: string;
setModel?: string;
setSmallModel?: string;
dryRun: boolean;
static createFrom(source: any = {}) {
return new SyncInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.target = source["target"];
this.setModel = source["setModel"];
this.setSmallModel = source["setSmallModel"];
this.dryRun = source["dryRun"];
}
}
export class SyncPreview {
targetPath: string;
aliasNames: string[];
setModel?: string;
setSmallModel?: string;
wouldChange: boolean;
static createFrom(source: any = {}) {
return new SyncPreview(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.targetPath = source["targetPath"];
this.aliasNames = source["aliasNames"];
this.setModel = source["setModel"];
this.setSmallModel = source["setSmallModel"];
this.wouldChange = source["wouldChange"];
}
}
export class SyncResult {
targetPath: string;
aliasNames: string[];
changed: boolean;
dryRun: boolean;
setModel?: string;
setSmallModel?: string;
static createFrom(source: any = {}) {
return new SyncResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.targetPath = source["targetPath"];
this.aliasNames = source["aliasNames"];
this.changed = source["changed"];
this.dryRun = source["dryRun"];
this.setModel = source["setModel"];
this.setSmallModel = source["setSmallModel"];
}
}
}
export namespace desktop {
export class Bindings {
static createFrom(source: any = {}) {
return new Bindings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
}

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

30
go.mod
View File

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

84
go.sum
View File

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

View File

@ -0,0 +1,90 @@
package app
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
func (s *Service) ExportConfig(ctx context.Context) (ConfigExportView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ConfigExportView{}, err
}
content, err := marshalConfigContent(cfg)
if err != nil {
return ConfigExportView{}, err
}
return ConfigExportView{ConfigPath: cfg.Path(), Content: content}, nil
}
func (s *Service) ImportConfig(ctx context.Context, in ConfigImportInput) (ConfigImportResult, error) {
_ = ctx
content := strings.TrimSpace(in.Content)
if content == "" {
return ConfigImportResult{}, fmt.Errorf("config content is required")
}
imported := config.Default()
if err := json.Unmarshal([]byte(content), imported); err != nil {
return ConfigImportResult{}, fmt.Errorf("parse config: %w", err)
}
if imported.Server.Host == "" {
imported.Server.Host = "127.0.0.1"
}
if imported.Server.Port == 0 {
imported.Server.Port = 9982
}
if imported.Server.APIKey == "" {
imported.Server.APIKey = config.DefaultLocalAPIKey
}
if errs := imported.Validate(); len(errs) > 0 {
return ConfigImportResult{}, errs[0]
}
cfg, err := s.loadConfig()
if err != nil {
return ConfigImportResult{}, err
}
cfg.Server = imported.Server
cfg.Desktop = imported.Desktop
cfg.Providers = append([]config.Provider(nil), imported.Providers...)
cfg.Aliases = append([]config.Alias(nil), imported.Aliases...)
if err := cfg.Save(); err != nil {
return ConfigImportResult{}, err
}
result := ConfigImportResult{ConfigPath: cfg.Path()}
if s.currentProxyStatus(proxyBindAddress(cfg)).Running {
result.Warnings = append(result.Warnings, "proxy is still running with the previous in-memory config; restart it to apply imported settings")
}
return result, nil
}
func marshalConfigContent(cfg *config.Config) (string, error) {
providers := append([]config.Provider(nil), cfg.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
aliases := append([]config.Alias(nil), cfg.Aliases...)
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
snapshot := struct {
Server config.Server `json:"server"`
Desktop config.Desktop `json:"desktop,omitempty"`
Providers []config.Provider `json:"providers"`
Aliases []config.Alias `json:"aliases"`
}{
Server: cfg.Server,
Desktop: cfg.Desktop,
Providers: providers,
Aliases: aliases,
}
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
return string(append(data, '\n')), nil
}

380
internal/app/manage.go Normal file
View File

@ -0,0 +1,380 @@
package app
import (
"context"
"fmt"
"reflect"
"slices"
"sort"
"strings"
"github.com/Apale7/opencode-provider-switch/internal/config"
"github.com/Apale7/opencode-provider-switch/internal/opencode"
)
func (s *Service) UpsertProvider(ctx context.Context, in ProviderUpsertInput) (ProviderSaveResult, error) {
_ = ctx
if strings.TrimSpace(in.ID) == "" {
return ProviderSaveResult{}, fmt.Errorf("provider id is required")
}
if err := config.ValidateProviderBaseURL(in.BaseURL); err != nil {
return ProviderSaveResult{}, fmt.Errorf("invalid baseUrl: %w", err)
}
cfg, err := s.loadConfig()
if err != nil {
return ProviderSaveResult{}, err
}
warnings := []string{}
provider := config.Provider{
ID: strings.TrimSpace(in.ID),
Name: strings.TrimSpace(in.Name),
BaseURL: config.NormalizeProviderBaseURL(in.BaseURL),
APIKey: in.APIKey,
Headers: normalizeProviderHeaders(in.Headers),
Disabled: in.Disabled,
}
var existing *config.Provider
if cur := cfg.FindProvider(provider.ID); cur != nil {
existing = cur
if provider.Name == "" {
provider.Name = cur.Name
}
if provider.APIKey == "" {
provider.APIKey = cur.APIKey
}
if len(provider.Headers) == 0 && !in.ClearHeaders && len(cur.Headers) > 0 {
provider.Headers = cloneHeaders(cur.Headers)
}
provider.Models = append([]string(nil), cur.Models...)
provider.ModelsSource = cur.ModelsSource
if !providerConnectionEqual(*cur, provider) {
provider.ModelsSource = ""
}
}
if !in.SkipModels {
models, err := opencode.FetchProviderModels(provider.BaseURL, provider.APIKey, provider.Headers)
if err != nil {
if existing != nil && !providerConnectionEqual(*existing, provider) {
provider.Models = append([]string(nil), existing.Models...)
provider.ModelsSource = ""
warnings = append(warnings, "provider connection changed and model discovery failed; keeping existing model catalog as untrusted")
}
warnings = append(warnings, fmt.Sprintf("could not discover provider models: %v", err))
} else if normalized := config.NormalizeProviderModels(models); len(normalized) > 0 {
provider.Models = normalized
provider.ModelsSource = "discovered"
} else if existing != nil && !providerConnectionEqual(*existing, provider) {
provider.Models = append([]string(nil), existing.Models...)
provider.ModelsSource = ""
warnings = append(warnings, "provider connection changed and model discovery returned no models; keeping existing model catalog as untrusted")
} else {
warnings = append(warnings, "provider model discovery returned no models; keeping existing model catalog")
}
} else if existing != nil && !providerConnectionEqual(*existing, provider) {
provider.Models = append([]string(nil), existing.Models...)
provider.ModelsSource = ""
warnings = append(warnings, "provider connection changed with skip models enabled; keeping existing model catalog as untrusted")
}
cfg.UpsertProvider(provider)
if err := cfg.Save(); err != nil {
return ProviderSaveResult{}, err
}
return ProviderSaveResult{Provider: providerView(provider), Warnings: warnings}, nil
}
func (s *Service) RemoveProvider(ctx context.Context, id string) error {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return err
}
if !cfg.RemoveProvider(strings.TrimSpace(id)) {
return fmt.Errorf("provider %q not found", id)
}
return cfg.Save()
}
func (s *Service) SetAliasTargetDisabled(ctx context.Context, in AliasTargetInput) (AliasView, error) {
_ = ctx
alias := strings.TrimSpace(in.Alias)
providerID := strings.TrimSpace(in.Provider)
model := strings.TrimSpace(in.Model)
if alias == "" || providerID == "" || model == "" {
return AliasView{}, fmt.Errorf("alias, provider and model are required")
}
cfg, err := s.loadConfig()
if err != nil {
return AliasView{}, err
}
current := cfg.FindAlias(alias)
if current == nil {
return AliasView{}, fmt.Errorf("alias %q not found", alias)
}
updated := *current
found := false
for i := range updated.Targets {
if updated.Targets[i].Provider == providerID && updated.Targets[i].Model == model {
updated.Targets[i].Enabled = !in.Disabled
found = true
break
}
}
if !found {
return AliasView{}, fmt.Errorf("target %s/%s not found on alias %s", providerID, model, alias)
}
cfg.UpsertAlias(updated)
if err := cfg.Save(); err != nil {
return AliasView{}, err
}
return aliasView(cfg, updated), nil
}
func (s *Service) SetProviderDisabled(ctx context.Context, in ProviderStateInput) (ProviderView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProviderView{}, err
}
existing := cfg.FindProvider(strings.TrimSpace(in.ID))
if existing == nil {
return ProviderView{}, fmt.Errorf("provider %q not found", in.ID)
}
updated := *existing
updated.Disabled = in.Disabled
cfg.UpsertProvider(updated)
if err := cfg.Save(); err != nil {
return ProviderView{}, err
}
return providerView(updated), nil
}
func (s *Service) ImportProviders(ctx context.Context, in ProviderImportInput) (ProviderImportResult, error) {
_ = ctx
sourcePath := strings.TrimSpace(in.SourcePath)
if sourcePath == "" {
p, existed := opencode.ResolveGlobalConfigPath()
if !existed {
return ProviderImportResult{}, fmt.Errorf("no OpenCode config found at %s; use sourcePath to specify", p)
}
sourcePath = p
}
raw, err := opencode.Load(sourcePath)
if err != nil {
return ProviderImportResult{}, err
}
imports := opencode.ImportCustomProviders(raw)
result := ProviderImportResult{SourcePath: sourcePath}
if len(imports) == 0 {
return result, nil
}
cfg, err := s.loadConfig()
if err != nil {
return ProviderImportResult{}, err
}
for _, ip := range imports {
if !in.Overwrite && cfg.FindProvider(ip.ID) != nil {
result.Skipped++
result.Warnings = append(result.Warnings, fmt.Sprintf("skip %q (already exists, enable overwrite to replace it)", ip.ID))
continue
}
baseURL := config.NormalizeProviderBaseURL(ip.BaseURL)
if err := config.ValidateProviderBaseURL(baseURL); err != nil {
result.Skipped++
result.Warnings = append(result.Warnings, fmt.Sprintf("skip %q (invalid baseURL %q: %v)", ip.ID, ip.BaseURL, err))
continue
}
merged := mergeImportedProvider(cfg.FindProvider(ip.ID), opencode.ImportableProvider{
ID: ip.ID,
Name: ip.Name,
BaseURL: baseURL,
APIKey: ip.APIKey,
Models: ip.Models,
})
cfg.UpsertProvider(merged)
result.Imported++
}
if result.Imported > 0 {
if err := cfg.Save(); err != nil {
return ProviderImportResult{}, err
}
}
return result, nil
}
func (s *Service) UpsertAlias(ctx context.Context, in AliasUpsertInput) (AliasView, error) {
_ = ctx
name := strings.TrimSpace(in.Alias)
if name == "" {
return AliasView{}, fmt.Errorf("alias name is required")
}
cfg, err := s.loadConfig()
if err != nil {
return AliasView{}, err
}
a := config.Alias{Alias: name, DisplayName: strings.TrimSpace(in.DisplayName), Enabled: !in.Disabled}
if existing := cfg.FindAlias(name); existing != nil {
if a.DisplayName == "" {
a.DisplayName = existing.DisplayName
}
a.Targets = existing.Targets
}
cfg.UpsertAlias(a)
if err := cfg.Save(); err != nil {
return AliasView{}, err
}
return aliasView(cfg, a), nil
}
func (s *Service) RemoveAlias(ctx context.Context, name string) error {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return err
}
if !cfg.RemoveAlias(strings.TrimSpace(name)) {
return fmt.Errorf("alias %q not found", name)
}
return cfg.Save()
}
func (s *Service) BindAliasTarget(ctx context.Context, in AliasTargetInput) (AliasView, error) {
_ = ctx
alias := strings.TrimSpace(in.Alias)
providerID := strings.TrimSpace(in.Provider)
model := strings.TrimSpace(in.Model)
if alias == "" || providerID == "" || model == "" {
return AliasView{}, fmt.Errorf("alias, provider and model are required")
}
cfg, err := s.loadConfig()
if err != nil {
return AliasView{}, err
}
p := cfg.FindProvider(providerID)
if p == nil {
return AliasView{}, fmt.Errorf("provider %q does not exist; add it first", providerID)
}
if err := validateProviderModelKnown(providerID, p.Models, p.ModelsSource, model); err != nil {
return AliasView{}, err
}
if cfg.FindAlias(alias) == nil {
cfg.UpsertAlias(config.Alias{Alias: alias, Enabled: true})
}
if err := cfg.AddTarget(alias, config.Target{Provider: providerID, Model: model, Enabled: !in.Disabled}); err != nil {
return AliasView{}, err
}
if err := cfg.Save(); err != nil {
return AliasView{}, err
}
current := cfg.FindAlias(alias)
if current == nil {
return AliasView{}, fmt.Errorf("alias %q not found", alias)
}
return aliasView(cfg, *current), nil
}
func (s *Service) UnbindAliasTarget(ctx context.Context, in AliasTargetInput) (AliasView, error) {
_ = ctx
alias := strings.TrimSpace(in.Alias)
providerID := strings.TrimSpace(in.Provider)
model := strings.TrimSpace(in.Model)
if alias == "" || providerID == "" || model == "" {
return AliasView{}, fmt.Errorf("alias, provider and model are required")
}
cfg, err := s.loadConfig()
if err != nil {
return AliasView{}, err
}
if err := cfg.RemoveTarget(alias, providerID, model); err != nil {
return AliasView{}, err
}
if err := cfg.Save(); err != nil {
return AliasView{}, err
}
current := cfg.FindAlias(alias)
if current == nil {
return AliasView{}, fmt.Errorf("alias %q not found", alias)
}
return aliasView(cfg, *current), nil
}
func providerConnectionEqual(a, b config.Provider) bool {
return config.NormalizeProviderBaseURL(a.BaseURL) == config.NormalizeProviderBaseURL(b.BaseURL) &&
a.APIKey == b.APIKey &&
reflect.DeepEqual(normalizeProviderHeaders(a.Headers), normalizeProviderHeaders(b.Headers))
}
func normalizeProviderHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
key := strings.ToLower(strings.TrimSpace(k))
if key == "" {
continue
}
out[key] = strings.TrimSpace(v)
}
if len(out) == 0 {
return nil
}
return out
}
func mergeImportedProvider(existing *config.Provider, ip opencode.ImportableProvider) config.Provider {
importedModels := config.NormalizeProviderModels(ip.Models)
merged := config.Provider{
ID: ip.ID,
Name: ip.Name,
BaseURL: config.NormalizeProviderBaseURL(ip.BaseURL),
APIKey: ip.APIKey,
Models: importedModels,
ModelsSource: "imported",
}
if len(importedModels) == 0 {
merged.ModelsSource = ""
}
if existing == nil {
return merged
}
merged.Headers = cloneHeaders(existing.Headers)
merged.Disabled = existing.Disabled
if merged.Name == "" {
merged.Name = existing.Name
}
if existing.ModelsSource == "discovered" {
prospective := merged
prospective.Headers = cloneHeaders(existing.Headers)
prospective.Disabled = existing.Disabled
if providerConnectionEqual(*existing, prospective) {
merged.Models = append([]string(nil), existing.Models...)
merged.ModelsSource = existing.ModelsSource
return merged
}
if len(importedModels) == 0 {
merged.Models = append([]string(nil), existing.Models...)
merged.ModelsSource = ""
return merged
}
}
if len(importedModels) == 0 {
merged.Models = nil
merged.ModelsSource = ""
}
return merged
}
func validateProviderModelKnown(providerID string, known []string, source string, model string) error {
if source != "discovered" || len(known) == 0 {
return nil
}
if slices.Contains(known, model) {
return nil
}
choices := make([]string, 0, len(known))
for _, item := range known {
choices = append(choices, providerID+"/"+item)
}
sort.Strings(choices)
return fmt.Errorf("model %q is not in provider %q discovered models; available: %s", model, providerID, strings.Join(choices, ", "))
}

547
internal/app/service.go Normal file
View File

@ -0,0 +1,547 @@
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
traces *proxy.TraceStore
mu sync.Mutex
proxyCancel context.CancelFunc
proxyDone chan struct{}
proxyReady chan error
proxyErr error
proxyStatus ProxyStatusView
}
func NewService(configPath string) *Service {
return &Service{configPath: strings.TrimSpace(configPath), traces: proxy.NewTraceStore(200)}
}
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 {
cfg, err := s.loadConfig()
if err != nil {
return err
}
if errs := cfg.Validate(); len(errs) > 0 {
return errs[0]
}
bindAddress := proxyBindAddress(cfg)
s.mu.Lock()
if s.proxyCancel == nil {
runCtx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
ready := make(chan error, 1)
s.proxyCancel = cancel
s.proxyDone = done
s.proxyReady = ready
s.proxyErr = nil
s.proxyStatus = ProxyStatusView{
Running: true,
BindAddress: bindAddress,
StartedAt: formatTimestamp(time.Now()),
}
go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
}
ready := s.proxyReady
done := s.proxyDone
s.mu.Unlock()
select {
case err := <-ready:
return err
case <-done:
return s.WaitProxy(context.Background())
case <-ctx.Done():
_ = s.StopProxy(context.Background())
return ctx.Err()
}
}
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) GetProxySettings(ctx context.Context) (ProxySettingsView, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxySettingsView{}, err
}
return proxySettingsView(cfg.Server), nil
}
func (s *Service) SaveProxySettings(ctx context.Context, in ProxySettingsInput) (ProxySettingsSaveResult, error) {
_ = ctx
cfg, err := s.loadConfig()
if err != nil {
return ProxySettingsSaveResult{}, err
}
cfg.Server.ConnectTimeoutMs = normalizePositiveInt(in.ConnectTimeoutMs, config.DefaultConnectTimeoutMs)
cfg.Server.ResponseHeaderTimeoutMs = normalizePositiveInt(in.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
cfg.Server.FirstByteTimeoutMs = normalizePositiveInt(in.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
cfg.Server.RequestReadTimeoutMs = normalizePositiveInt(in.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs)
cfg.Server.StreamIdleTimeoutMs = normalizePositiveInt(in.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
if err := cfg.Save(); err != nil {
return ProxySettingsSaveResult{}, err
}
warnings := []string{}
status := s.currentProxyStatus(proxyBindAddress(cfg))
if status.Running {
warnings = append(warnings, "saved proxy timeout settings; restart proxy to apply changes")
}
return ProxySettingsSaveResult{Settings: proxySettingsView(cfg.Server), Warnings: warnings}, nil
}
func (s *Service) ListRequestTraces(ctx context.Context, limit int) ([]RequestTrace, error) {
_ = ctx
raw := s.traces.List(limit)
out := make([]RequestTrace, 0, len(raw))
for _, trace := range raw {
out = append(out, requestTraceView(trace))
}
return out, 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,
AutoStartProxy: in.AutoStartProxy,
MinimizeToTray: in.MinimizeToTray,
Notifications: in.Notifications,
Theme: normalizeThemePreference(in.Theme),
Language: normalizeLanguagePreference(in.Language),
}
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.Running || status.BindAddress == "" {
status.BindAddress = bindAddress
}
return status
}
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, ready chan error, cfg *config.Config, bindAddress string) {
err := proxy.New(cfg, s.traces).ListenAndServeWithReady(runCtx, ready)
s.mu.Lock()
defer s.mu.Unlock()
if s.proxyDone == done {
s.proxyCancel = nil
s.proxyDone = nil
s.proxyReady = 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,
AutoStartProxy: prefs.AutoStartProxy,
MinimizeToTray: prefs.MinimizeToTray,
Notifications: prefs.Notifications,
Theme: normalizeThemePreference(prefs.Theme),
Language: normalizeLanguagePreference(prefs.Language),
}
}
func proxySettingsView(server config.Server) ProxySettingsView {
return ProxySettingsView{
ConnectTimeoutMs: normalizePositiveInt(server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
ResponseHeaderTimeoutMs: normalizePositiveInt(server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs),
FirstByteTimeoutMs: normalizePositiveInt(server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs),
RequestReadTimeoutMs: normalizePositiveInt(server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
StreamIdleTimeoutMs: normalizePositiveInt(server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs),
}
}
func normalizePositiveInt(value int, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
func normalizeThemePreference(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "light", "dark":
return trimmed
default:
return "system"
}
}
func normalizeLanguagePreference(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "en-US", "zh-CN":
return trimmed
default:
return "system"
}
}
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 formatTimestamp(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339Nano)
}
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,612 @@
package app
import (
"context"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
LaunchAtLogin: true,
AutoStartProxy: true,
MinimizeToTray: true,
Notifications: true,
Theme: "dark",
Language: "zh-CN",
})
if err != nil {
t.Fatalf("SaveDesktopPrefs() error = %v", err)
}
if !prefs.LaunchAtLogin || !prefs.AutoStartProxy || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
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.AutoStartProxy || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications || cfg.Desktop.Theme != "dark" || cfg.Desktop.Language != "zh-CN" {
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
}
}
func TestSaveDesktopPrefsNormalizesUnknownThemeAndLanguage(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
Theme: "night-mode",
Language: "fr-FR",
})
if err != nil {
t.Fatalf("SaveDesktopPrefs() error = %v", err)
}
if prefs.Theme != "system" || prefs.Language != "system" {
t.Fatalf("normalized prefs = %#v", prefs)
}
}
func TestSaveProxySettingsPersistsToConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
ConnectTimeoutMs: 12000,
ResponseHeaderTimeoutMs: 21000,
FirstByteTimeoutMs: 22000,
RequestReadTimeoutMs: 33000,
StreamIdleTimeoutMs: 70000,
})
if err != nil {
t.Fatalf("SaveProxySettings() error = %v", err)
}
if result.Settings.ConnectTimeoutMs != 12000 || result.Settings.ResponseHeaderTimeoutMs != 21000 || result.Settings.FirstByteTimeoutMs != 22000 || result.Settings.RequestReadTimeoutMs != 33000 || result.Settings.StreamIdleTimeoutMs != 70000 {
t.Fatalf("SaveProxySettings() = %#v", result.Settings)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if cfg.Server.ConnectTimeoutMs != 12000 || cfg.Server.ResponseHeaderTimeoutMs != 21000 || cfg.Server.FirstByteTimeoutMs != 22000 || cfg.Server.RequestReadTimeoutMs != 33000 || cfg.Server.StreamIdleTimeoutMs != 70000 {
t.Fatalf("persisted server settings = %#v", cfg.Server)
}
}
func TestSaveProxySettingsWarnsWhenProxyRunning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
port := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
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)
}
defer func() { _ = svc.StopProxy(context.Background()) }()
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{ConnectTimeoutMs: 12000})
if err != nil {
t.Fatalf("SaveProxySettings() error = %v", err)
}
if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "restart proxy") {
t.Fatalf("warnings = %#v", result.Warnings)
}
}
func TestSaveProxySettingsNormalizesNonPositiveValues(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
svc := NewService(path)
result, err := svc.SaveProxySettings(context.Background(), ProxySettingsInput{
ConnectTimeoutMs: 0,
ResponseHeaderTimeoutMs: -1,
FirstByteTimeoutMs: 0,
RequestReadTimeoutMs: -50,
StreamIdleTimeoutMs: 0,
})
if err != nil {
t.Fatalf("SaveProxySettings() error = %v", err)
}
if result.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
result.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
result.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
result.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
result.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
t.Fatalf("normalized settings = %#v", result.Settings)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if cfg.Server.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
cfg.Server.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
cfg.Server.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
cfg.Server.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
cfg.Server.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
t.Fatalf("persisted server settings = %#v", cfg.Server)
}
}
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 TestStartProxyReturnsBindErrorWithoutRunningStatus(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
port := freePort(t)
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
err = svc.StartProxy(context.Background())
if err == nil {
t.Fatal("StartProxy() error = nil, want bind failure")
}
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
t.Fatalf("StartProxy() error = %v, want bind failure", err)
}
status, statusErr := svc.GetProxyStatus(context.Background())
if statusErr != nil {
t.Fatalf("GetProxyStatus() error = %v", statusErr)
}
if status.Running {
t.Fatalf("status = %#v, want stopped", status)
}
if status.LastError == "" {
t.Fatalf("status = %#v, want last error", status)
}
}
func TestConcurrentStartProxyCallsShareStartupResult(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
port := freePort(t)
listener, err := net.Listen("tcp", "127.0.0.1:"+itoa(port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
errCh := make(chan error, 2)
start := make(chan struct{})
for range 2 {
go func() {
<-start
errCh <- svc.StartProxy(context.Background())
}()
}
close(start)
for range 2 {
err := <-errCh
if err == nil {
t.Fatal("StartProxy() error = nil, want bind failure")
}
if !strings.Contains(strings.ToLower(err.Error()), "bind") {
t.Fatalf("StartProxy() error = %v, want bind failure", err)
}
}
status, statusErr := svc.GetProxyStatus(context.Background())
if statusErr != nil {
t.Fatalf("GetProxyStatus() error = %v", statusErr)
}
if status.Running {
t.Fatalf("status = %#v, want stopped", status)
}
if status.LastError == "" {
t.Fatalf("status = %#v, want last error", status)
}
}
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
firstPort := freePort(t)
secondPort := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = firstPort
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)
}
if err := svc.StopProxy(context.Background()); err != nil {
t.Fatalf("StopProxy() error = %v", err)
}
cfg, err = config.Load(path)
if err != nil {
t.Fatalf("config.Load() reload error = %v", err)
}
cfg.Server.Port = secondPort
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() update error = %v", err)
}
status, err := svc.GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if status.Running {
t.Fatalf("status.Running = true, want false")
}
want := "127.0.0.1:" + itoa(secondPort)
if status.BindAddress != want {
t.Fatalf("status.BindAddress = %q, want %q", status.BindAddress, want)
}
}
func TestUpsertProviderReturnsWarningsAndKeepsCatalog(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{
ID: "relay",
BaseURL: "https://old.example.com/v1",
APIKey: "sk-old",
Models: []string{"gpt-4.1"},
ModelsSource: "discovered",
})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/models" {
t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models")
}
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
}))
defer upstream.Close()
svc := NewService(path)
result, err := svc.UpsertProvider(context.Background(), ProviderUpsertInput{
ID: "relay",
BaseURL: upstream.URL + "/v1",
APIKey: "sk-new",
})
if err != nil {
t.Fatalf("UpsertProvider() error = %v", err)
}
if result.Provider.BaseURL != upstream.URL+"/v1" {
t.Fatalf("saved baseUrl = %q, want %q", result.Provider.BaseURL, upstream.URL+"/v1")
}
if !containsWarning(result.Warnings, "model discovery failed") {
t.Fatalf("warnings %#v do not mention stale catalog preservation", result.Warnings)
}
if !containsWarning(result.Warnings, "could not discover provider models") {
t.Fatalf("warnings %#v do not mention discovery failure", result.Warnings)
}
reloaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
provider := reloaded.FindProvider("relay")
if provider == nil {
t.Fatal("provider relay not found after save")
}
if provider.ModelsSource != "" {
t.Fatalf("provider.ModelsSource = %q, want empty", provider.ModelsSource)
}
if len(provider.Models) != 1 || provider.Models[0] != "gpt-4.1" {
t.Fatalf("provider.Models = %#v, want existing catalog kept", provider.Models)
}
}
func TestImportProvidersReturnsWarnings(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "ocswitch.json")
sourcePath := filepath.Join(dir, "opencode.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{ID: "keep", BaseURL: "https://existing.example.com/v1"})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
if err := os.WriteFile(sourcePath, []byte(`{
"provider": {
"keep": {
"npm": "@ai-sdk/openai",
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
},
"broken": {
"npm": "@ai-sdk/openai",
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
},
"fresh": {
"npm": "@ai-sdk/openai",
"name": "Fresh",
"options": {"baseURL": "https://fresh.example.com/v1", "apiKey": "sk-fresh"},
"models": {"gpt-4.1": {}}
}
}
}`), 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
svc := NewService(path)
result, err := svc.ImportProviders(context.Background(), ProviderImportInput{SourcePath: sourcePath})
if err != nil {
t.Fatalf("ImportProviders() error = %v", err)
}
if result.Imported != 1 || result.Skipped != 2 {
t.Fatalf("ImportProviders() = %#v, want imported=1 skipped=2", result)
}
if !containsWarning(result.Warnings, `skip "keep"`) {
t.Fatalf("warnings %#v do not mention duplicate provider", result.Warnings)
}
if !containsWarning(result.Warnings, `skip "broken"`) {
t.Fatalf("warnings %#v do not mention invalid provider", result.Warnings)
}
reloaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if provider := reloaded.FindProvider("fresh"); provider == nil {
t.Fatal("provider fresh not imported")
}
}
func TestSetAliasTargetDisabledPersistsState(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{ID: "relay", BaseURL: "https://relay.example.com/v1"})
cfg.UpsertAlias(config.Alias{
Alias: "chat",
Enabled: true,
Targets: []config.Target{{Provider: "relay", Model: "gpt-4.1", Enabled: true}},
})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
disabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
Alias: "chat",
Provider: "relay",
Model: "gpt-4.1",
Disabled: true,
})
if err != nil {
t.Fatalf("SetAliasTargetDisabled(disable) error = %v", err)
}
if disabled.AvailableTargetCount != 0 || disabled.Targets[0].Enabled {
t.Fatalf("disabled alias view = %#v", disabled)
}
enabled, err := svc.SetAliasTargetDisabled(context.Background(), AliasTargetInput{
Alias: "chat",
Provider: "relay",
Model: "gpt-4.1",
Disabled: false,
})
if err != nil {
t.Fatalf("SetAliasTargetDisabled(enable) error = %v", err)
}
if enabled.AvailableTargetCount != 1 || !enabled.Targets[0].Enabled {
t.Fatalf("enabled alias view = %#v", enabled)
}
reloaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
alias := reloaded.FindAlias("chat")
if alias == nil {
t.Fatal("alias chat not found after update")
}
if len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
t.Fatalf("persisted alias targets = %#v", alias.Targets)
}
}
func TestUpsertAliasCanReEnableExistingAlias(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertAlias(config.Alias{Alias: "chat", DisplayName: "Chat", Enabled: false})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
svc := NewService(path)
alias, err := svc.UpsertAlias(context.Background(), AliasUpsertInput{
Alias: "chat",
DisplayName: "Chat enabled",
Disabled: false,
})
if err != nil {
t.Fatalf("UpsertAlias() error = %v", err)
}
if !alias.Enabled {
t.Fatalf("alias.Enabled = false, want true: %#v", alias)
}
if alias.DisplayName != "Chat enabled" {
t.Fatalf("alias.DisplayName = %q, want %q", alias.DisplayName, "Chat enabled")
}
reloaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
persisted := reloaded.FindAlias("chat")
if persisted == nil || !persisted.Enabled {
t.Fatalf("persisted alias = %#v, want enabled", persisted)
}
}
func containsWarning(warnings []string, want string) bool {
for _, warning := range warnings {
if strings.Contains(warning, want) {
return true
}
}
return false
}
func assertEventually(t *testing.T, fn func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not satisfied before timeout")
}
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func itoa(v int) string {
return strconv.Itoa(v)
}

286
internal/app/types.go Normal file
View File

@ -0,0 +1,286 @@
package app
import "github.com/Apale7/opencode-provider-switch/internal/proxy"
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 string `json:"startedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type ProxySettingsView struct {
ConnectTimeoutMs int `json:"connectTimeoutMs"`
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
}
type ProxySettingsInput struct {
ConnectTimeoutMs int `json:"connectTimeoutMs"`
ResponseHeaderTimeoutMs int `json:"responseHeaderTimeoutMs"`
FirstByteTimeoutMs int `json:"firstByteTimeoutMs"`
RequestReadTimeoutMs int `json:"requestReadTimeoutMs"`
StreamIdleTimeoutMs int `json:"streamIdleTimeoutMs"`
}
type ProxySettingsSaveResult struct {
Settings ProxySettingsView `json:"settings"`
Warnings []string `json:"warnings,omitempty"`
}
type RequestTrace struct {
ID uint64 `json:"id"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
FirstByteMs int64 `json:"firstByteMs,omitempty"`
RawModel string `json:"rawModel,omitempty"`
Alias string `json:"alias,omitempty"`
Stream bool `json:"stream"`
Success bool `json:"success"`
StatusCode int `json:"statusCode,omitempty"`
Error string `json:"error,omitempty"`
FinalProvider string `json:"finalProvider,omitempty"`
FinalModel string `json:"finalModel,omitempty"`
FinalURL string `json:"finalUrl,omitempty"`
Failover bool `json:"failover"`
AttemptCount int `json:"attemptCount"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestParams any `json:"requestParams,omitempty"`
Attempts []TraceAttempt `json:"attempts"`
}
type TraceAttempt struct {
Attempt int `json:"attempt"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
URL string `json:"url,omitempty"`
StartedAt string `json:"startedAt"`
DurationMs int64 `json:"durationMs"`
FirstByteMs int64 `json:"firstByteMs,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Success bool `json:"success"`
Retryable bool `json:"retryable"`
Skipped bool `json:"skipped"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestParams any `json:"requestParams,omitempty"`
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
ResponseBody string `json:"responseBody,omitempty"`
}
func requestTraceView(trace proxy.RequestTrace) RequestTrace {
attempts := make([]TraceAttempt, 0, len(trace.Attempts))
for _, attempt := range trace.Attempts {
attempts = append(attempts, traceAttemptView(attempt))
}
return RequestTrace{
ID: trace.ID,
StartedAt: formatTimestamp(trace.StartedAt),
FinishedAt: formatTimestamp(trace.FinishedAt),
DurationMs: trace.DurationMs,
FirstByteMs: trace.FirstByteMs,
RawModel: trace.RawModel,
Alias: trace.Alias,
Stream: trace.Stream,
Success: trace.Success,
StatusCode: trace.StatusCode,
Error: trace.Error,
FinalProvider: trace.FinalProvider,
FinalModel: trace.FinalModel,
FinalURL: trace.FinalURL,
Failover: trace.Failover,
AttemptCount: trace.AttemptCount,
RequestHeaders: trace.RequestHeaders,
RequestParams: trace.RequestParams,
Attempts: attempts,
}
}
func traceAttemptView(attempt proxy.TraceAttempt) TraceAttempt {
return TraceAttempt{
Attempt: attempt.Attempt,
Provider: attempt.Provider,
Model: attempt.Model,
URL: attempt.URL,
StartedAt: formatTimestamp(attempt.StartedAt),
DurationMs: attempt.DurationMs,
FirstByteMs: attempt.FirstByteMs,
StatusCode: attempt.StatusCode,
Success: attempt.Success,
Retryable: attempt.Retryable,
Skipped: attempt.Skipped,
Result: attempt.Result,
Error: attempt.Error,
RequestHeaders: attempt.RequestHeaders,
RequestParams: attempt.RequestParams,
ResponseHeaders: attempt.ResponseHeaders,
ResponseBody: attempt.ResponseBody,
}
}
type ProviderView struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
BaseURL string `json:"baseUrl"`
APIKeySet bool `json:"apiKeySet"`
APIKeyMasked string `json:"apiKeyMasked,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Models []string `json:"models,omitempty"`
ModelsSource string `json:"modelsSource,omitempty"`
Disabled bool `json:"disabled"`
}
type ProviderSaveResult struct {
Provider ProviderView `json:"provider"`
Warnings []string `json:"warnings,omitempty"`
}
type AliasTargetView struct {
Provider string `json:"provider"`
Model string `json:"model"`
Enabled bool `json:"enabled"`
}
type AliasView struct {
Alias string `json:"alias"`
DisplayName string `json:"displayName,omitempty"`
Enabled bool `json:"enabled"`
TargetCount int `json:"targetCount"`
AvailableTargetCount int `json:"availableTargetCount"`
Targets []AliasTargetView `json:"targets"`
}
type DoctorIssue struct {
Message string `json:"message"`
}
type DoctorReport struct {
OK bool `json:"ok"`
Issues []DoctorIssue `json:"issues"`
ConfigPath string `json:"configPath"`
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
ProxyBindAddress string `json:"proxyBindAddress"`
OpenCodeTargetPath string `json:"openCodeTargetPath"`
OpenCodeTargetFound bool `json:"openCodeTargetFound"`
}
type DoctorRunResult struct {
Report DoctorReport `json:"report"`
Error string `json:"error,omitempty"`
}
type SyncInput struct {
Target string `json:"target,omitempty"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
DryRun bool `json:"dryRun"`
}
type SyncPreview struct {
TargetPath string `json:"targetPath"`
AliasNames []string `json:"aliasNames"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
WouldChange bool `json:"wouldChange"`
}
type SyncResult struct {
TargetPath string `json:"targetPath"`
AliasNames []string `json:"aliasNames"`
Changed bool `json:"changed"`
DryRun bool `json:"dryRun"`
SetModel string `json:"setModel,omitempty"`
SetSmallModel string `json:"setSmallModel,omitempty"`
}
type DesktopPrefsView struct {
LaunchAtLogin bool `json:"launchAtLogin"`
AutoStartProxy bool `json:"autoStartProxy"`
MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"`
Theme string `json:"theme"`
Language string `json:"language"`
}
type DesktopPrefsSaveResult struct {
Prefs DesktopPrefsView `json:"prefs"`
Warnings []string `json:"warnings,omitempty"`
}
type DesktopPrefsInput struct {
LaunchAtLogin bool `json:"launchAtLogin"`
AutoStartProxy bool `json:"autoStartProxy"`
MinimizeToTray bool `json:"minimizeToTray"`
Notifications bool `json:"notifications"`
Theme string `json:"theme"`
Language string `json:"language"`
}
type ConfigExportView struct {
ConfigPath string `json:"configPath"`
Content string `json:"content"`
}
type ConfigImportInput struct {
Content string `json:"content"`
}
type ConfigImportResult struct {
ConfigPath string `json:"configPath"`
Warnings []string `json:"warnings,omitempty"`
}
type ProviderUpsertInput struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Disabled bool `json:"disabled"`
SkipModels bool `json:"skipModels"`
ClearHeaders bool `json:"clearHeaders"`
}
type ProviderImportInput struct {
SourcePath string `json:"sourcePath,omitempty"`
Overwrite bool `json:"overwrite"`
}
type ProviderImportResult struct {
SourcePath string `json:"sourcePath"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Warnings []string `json:"warnings,omitempty"`
}
type ProviderStateInput struct {
ID string `json:"id"`
Disabled bool `json:"disabled"`
}
type AliasUpsertInput struct {
Alias string `json:"alias"`
DisplayName string `json:"displayName,omitempty"`
Disabled bool `json:"disabled"`
}
type AliasTargetInput struct {
Alias string `json:"alias"`
Provider string `json:"provider"`
Model string `json:"model"`
Disabled bool `json:"disabled"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,14 +51,38 @@ type Provider struct {
// Server holds proxy listen settings. // Server holds proxy listen settings.
type Server struct { type Server struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
ConnectTimeoutMs int `json:"connect_timeout_ms,omitempty"`
ResponseHeaderTimeoutMs int `json:"response_header_timeout_ms,omitempty"`
FirstByteTimeoutMs int `json:"first_byte_timeout_ms,omitempty"`
RequestReadTimeoutMs int `json:"request_read_timeout_ms,omitempty"`
StreamIdleTimeoutMs int `json:"stream_idle_timeout_ms,omitempty"`
}
const (
DefaultConnectTimeoutMs = 10_000
DefaultResponseHeaderTimeoutMs = 15_000
DefaultFirstByteTimeoutMs = 15_000
DefaultRequestReadTimeoutMs = 30_000
DefaultStreamIdleTimeoutMs = 60_000
)
// Desktop holds desktop-shell user preferences.
type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
AutoStartProxy bool `json:"auto_start_proxy,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"`
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
} }
// Config is the on-disk ocswitch config. // Config is the on-disk ocswitch config.
type Config struct { type Config struct {
Server Server `json:"server"` Server Server `json:"server"`
Desktop Desktop `json:"desktop,omitempty"`
Providers []Provider `json:"providers"` Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"` Aliases []Alias `json:"aliases"`
@ -94,10 +118,16 @@ func ValidateProviderBaseURL(baseURL string) error {
func Default() *Config { func Default() *Config {
return &Config{ return &Config{
Server: Server{ Server: Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 9982, Port: 9982,
APIKey: DefaultLocalAPIKey, APIKey: DefaultLocalAPIKey,
ConnectTimeoutMs: DefaultConnectTimeoutMs,
ResponseHeaderTimeoutMs: DefaultResponseHeaderTimeoutMs,
FirstByteTimeoutMs: DefaultFirstByteTimeoutMs,
RequestReadTimeoutMs: DefaultRequestReadTimeoutMs,
StreamIdleTimeoutMs: DefaultStreamIdleTimeoutMs,
}, },
Desktop: Desktop{},
Providers: []Provider{}, Providers: []Provider{},
Aliases: []Alias{}, Aliases: []Alias{},
} }
@ -147,6 +177,7 @@ func Load(path string) (*Config, error) {
if c.Server.APIKey == "" { if c.Server.APIKey == "" {
c.Server.APIKey = DefaultLocalAPIKey c.Server.APIKey = DefaultLocalAPIKey
} }
normalizeServerTimeouts(&c.Server)
c.path = path c.path = path
return c, nil return c, nil
} }
@ -176,9 +207,10 @@ func (c *Config) Save() error {
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias }) sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
snap := struct { snap := struct {
Server Server `json:"server"` Server Server `json:"server"`
Desktop Desktop `json:"desktop,omitempty"`
Providers []Provider `json:"providers"` Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"` Aliases []Alias `json:"aliases"`
}{c.Server, providers, aliases} }{c.Server, c.Desktop, providers, aliases}
data, err := json.MarshalIndent(snap, "", " ") data, err := json.MarshalIndent(snap, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("marshal: %w", err) return fmt.Errorf("marshal: %w", err)
@ -436,8 +468,41 @@ func (c *Config) Validate() []error {
if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) { if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) {
errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host)) errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host))
} }
if c.Server.ConnectTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.connect_timeout_ms must be greater than 0"))
}
if c.Server.ResponseHeaderTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.response_header_timeout_ms must be greater than 0"))
}
if c.Server.FirstByteTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.first_byte_timeout_ms must be greater than 0"))
}
if c.Server.RequestReadTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.request_read_timeout_ms must be greater than 0"))
}
if c.Server.StreamIdleTimeoutMs <= 0 {
errs = append(errs, fmt.Errorf("server.stream_idle_timeout_ms must be greater than 0"))
}
return errs return errs
} }
func normalizeServerTimeouts(server *Server) {
if server == nil {
return
}
server.ConnectTimeoutMs = normalizeServerTimeoutMs(server.ConnectTimeoutMs, DefaultConnectTimeoutMs)
server.ResponseHeaderTimeoutMs = normalizeServerTimeoutMs(server.ResponseHeaderTimeoutMs, DefaultResponseHeaderTimeoutMs)
server.FirstByteTimeoutMs = normalizeServerTimeoutMs(server.FirstByteTimeoutMs, DefaultFirstByteTimeoutMs)
server.RequestReadTimeoutMs = normalizeServerTimeoutMs(server.RequestReadTimeoutMs, DefaultRequestReadTimeoutMs)
server.StreamIdleTimeoutMs = normalizeServerTimeoutMs(server.StreamIdleTimeoutMs, DefaultStreamIdleTimeoutMs)
}
func normalizeServerTimeoutMs(value int, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
func isLoopbackHost(host string) bool { func isLoopbackHost(host string) bool {
host = strings.TrimSpace(host) host = strings.TrimSpace(host)
if host == "" || strings.EqualFold(host, "localhost") { if host == "" || strings.EqualFold(host, "localhost") {

View File

@ -6,7 +6,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"syscall"
"testing" "testing"
"time" "time"
) )
@ -219,7 +218,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
t.Fatalf("OpenFile(lock): %v", err) t.Fatalf("OpenFile(lock): %v", err)
} }
defer lockFile.Close() defer lockFile.Close()
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { if err := lockTestFile(lockFile); err != nil {
t.Fatalf("Flock(lock): %v", err) t.Fatalf("Flock(lock): %v", err)
} }
@ -255,7 +254,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
case <-time.After(20 * time.Millisecond): case <-time.After(20 * time.Millisecond):
} }
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil { if err := unlockTestFile(lockFile); err != nil {
t.Fatalf("Flock(unlock): %v", err) t.Fatalf("Flock(unlock): %v", err)
} }

View File

@ -0,0 +1,23 @@
//go:build !windows
package config
import (
"fmt"
"os"
"syscall"
)
func lockTestFile(file *os.File) error {
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("flock lock: %w", err)
}
return nil
}
func unlockTestFile(file *os.File) error {
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil {
return fmt.Errorf("flock unlock: %w", err)
}
return nil
}

View File

@ -0,0 +1,26 @@
//go:build windows
package config
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
func lockTestFile(file *os.File) error {
var overlapped windows.Overlapped
if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
return fmt.Errorf("lock file ex: %w", err)
}
return nil
}
func unlockTestFile(file *os.File) error {
var overlapped windows.Overlapped
if err := windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped); err != nil {
return fmt.Errorf("unlock file ex: %w", err)
}
return nil
}

298
internal/desktop/app.go Normal file
View File

@ -0,0 +1,298 @@
package desktop
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
type trayAdapter interface {
Attach(context.Context)
Detach()
Sync(context.Context, app.DesktopPrefsView)
RefreshProxyStatus(context.Context)
BeforeClose(context.Context) (bool, error)
}
type notifierAdapter interface {
Attach(context.Context)
Detach()
Sync(context.Context, app.DesktopPrefsView)
Send(context.Context, string, string) error
}
type autoStartAdapter interface {
Attach(context.Context)
Detach()
Sync(context.Context, app.DesktopPrefsView) error
}
// App is the desktop-shell composition root. Native shell integrations are kept
// out of internal/app so CLI and GUI can share the same workflows.
type App struct {
service *app.Service
bindings *Bindings
tray trayAdapter
notify notifierAdapter
auto autoStartAdapter
ctx context.Context
version string
}
func New(configPath string) *App {
svc := app.NewService(configPath)
instance := &App{service: svc}
instance.bindings = NewBindings(svc)
instance.tray = NewTray(svc)
instance.notify = NewNotifier(svc)
instance.auto = NewAutoStart(svc)
return instance
}
func (a *App) SetVersion(version string) {
a.version = version
}
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.tray.Attach(ctx)
a.notify.Attach(ctx)
a.auto.Attach(ctx)
_ = a.SyncDesktopPreferences(ctx)
if prefs, err := a.bindings.GetDesktopPrefs(ctx); err == nil && prefs.AutoStartProxy {
_, _ = a.bindings.StartProxy(ctx)
a.watchAutoStartProxyFailure()
}
a.tray.RefreshProxyStatus(ctx)
}
func (a *App) watchAutoStartProxyFailure() {
go func() {
waitCtx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
err := a.service.WaitProxy(waitCtx)
if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return
}
callCtx := a.callContext()
a.tray.RefreshProxyStatus(callCtx)
_ = a.notify.Send(callCtx, "Proxy failed to start", err.Error())
}()
}
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
}
autoErr := a.auto.Sync(ctx, prefs)
a.tray.Sync(ctx, prefs)
a.notify.Sync(ctx, prefs)
return autoErr
}
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
previous, _ := a.bindings.GetDesktopPrefs(ctx)
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
if err != nil {
return app.DesktopPrefsSaveResult{}, err
}
warnings := []string{}
if err := a.auto.Sync(ctx, prefs); err != nil {
warnings = append(warnings, fmt.Sprintf("saved preferences but could not update launch-at-login integration: %v", err))
}
a.tray.Sync(ctx, prefs)
a.notify.Sync(ctx, prefs)
if prefs.Notifications && !previous.Notifications {
_ = a.notify.Send(ctx, "Notifications enabled", "ocswitch desktop will now show native alerts.")
}
return app.DesktopPrefsSaveResult{Prefs: prefs, Warnings: warnings}, nil
}
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
return a.SaveDesktopPrefs(a.callContext(), in)
}
func (a *App) Meta() map[string]string {
return map[string]string{
"version": a.version,
"shell": a.shellName(),
}
}
func (a *App) OpenExternalURL(rawURL string) error {
url := strings.TrimSpace(rawURL)
if url == "" {
return nil
}
return openExternalURL(a.callContext(), url)
}
func (a *App) Overview() (app.Overview, error) {
return a.bindings.GetOverview(a.callContext())
}
func (a *App) ExportConfig() (app.ConfigExportView, error) {
return a.bindings.ExportConfig(a.callContext())
}
func (a *App) ImportConfig(in app.ConfigImportInput) (app.ConfigImportResult, error) {
result, err := a.bindings.ImportConfig(a.callContext(), in)
if err != nil {
return app.ConfigImportResult{}, err
}
if syncErr := a.SyncDesktopPreferences(a.callContext()); syncErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("imported config but could not sync desktop integrations: %v", syncErr))
}
a.tray.RefreshProxyStatus(a.callContext())
return result, nil
}
func (a *App) Providers() ([]app.ProviderView, error) {
return a.bindings.ListProviders(a.callContext())
}
func (a *App) Aliases() ([]app.AliasView, error) {
return a.bindings.ListAliases(a.callContext())
}
func (a *App) SaveProvider(in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
return a.bindings.UpsertProvider(a.callContext(), in)
}
func (a *App) SetProviderState(in app.ProviderStateInput) (app.ProviderView, error) {
return a.bindings.SetProviderDisabled(a.callContext(), in)
}
func (a *App) DeleteProvider(id string) error {
return a.bindings.RemoveProvider(a.callContext(), id)
}
func (a *App) ImportProviders(in app.ProviderImportInput) (app.ProviderImportResult, error) {
return a.bindings.ImportProviders(a.callContext(), in)
}
func (a *App) SaveAlias(in app.AliasUpsertInput) (app.AliasView, error) {
return a.bindings.UpsertAlias(a.callContext(), in)
}
func (a *App) DeleteAlias(name string) error {
return a.bindings.RemoveAlias(a.callContext(), name)
}
func (a *App) BindTarget(in app.AliasTargetInput) (app.AliasView, error) {
return a.bindings.BindAliasTarget(a.callContext(), in)
}
func (a *App) SetTargetState(in app.AliasTargetInput) (app.AliasView, error) {
return a.bindings.SetAliasTargetDisabled(a.callContext(), in)
}
func (a *App) UnbindTarget(in app.AliasTargetInput) (app.AliasView, error) {
return a.bindings.UnbindAliasTarget(a.callContext(), in)
}
func (a *App) DoctorRun() (app.DoctorRunResult, error) {
report, err := a.bindings.RunDoctor(a.callContext())
return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil
}
func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
return a.bindings.GetProxyStatus(a.callContext())
}
func (a *App) RequestTraces(limit int) ([]app.RequestTrace, error) {
return a.bindings.ListRequestTraces(a.callContext(), limit)
}
func (a *App) ProxySettings() (app.ProxySettingsView, error) {
return a.bindings.GetProxySettings(a.callContext())
}
func (a *App) SaveProxySettings(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
return a.bindings.SaveProxySettings(a.callContext(), in)
}
func (a *App) StartProxy() (app.ProxyStatusView, error) {
status, err := a.bindings.StartProxy(a.callContext())
if err != nil {
return app.ProxyStatusView{}, err
}
a.tray.RefreshProxyStatus(a.callContext())
_ = a.notify.Send(a.callContext(), "Proxy started", status.BindAddress)
return status, nil
}
func (a *App) StopProxy() (app.ProxyStatusView, error) {
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
defer cancel()
status, err := a.bindings.StopProxy(ctx)
if err != nil {
return app.ProxyStatusView{}, err
}
a.tray.RefreshProxyStatus(a.callContext())
_ = a.notify.Send(a.callContext(), "Proxy stopped", status.BindAddress)
return status, nil
}
func (a *App) DesktopPrefs() (app.DesktopPrefsView, error) {
return a.bindings.GetDesktopPrefs(a.callContext())
}
func (a *App) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
return a.bindings.PreviewOpenCodeSync(a.callContext(), in)
}
func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) {
result, err := a.bindings.SyncOpenCode(a.callContext(), in)
if err != nil {
return app.SyncResult{}, err
}
if result.Changed && !result.DryRun {
_ = a.notify.Send(a.callContext(), "OpenCode sync applied", result.TargetPath)
}
return result, nil
}
func (a *App) callContext() context.Context {
if a.ctx != nil {
return a.ctx
}
return context.Background()
}
func (a *App) shellName() string {
if a.ctx != nil {
return "wails"
}
return "browser"
}
func (a *App) Service() *app.Service {
return a.service
}
func (a *App) Bindings() *Bindings {
return a.bindings
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -0,0 +1,151 @@
package desktop
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
// AutoStart manages real launch-at-login integration where the platform permits
// it. Linux uses XDG autostart; Windows uses Startup folder scripts.
type AutoStart struct {
service *app.Service
ctx context.Context
}
func NewAutoStart(service *app.Service) *AutoStart {
return &AutoStart{service: service}
}
func (a *AutoStart) Attach(ctx context.Context) {
a.ctx = ctx
}
func (a *AutoStart) Detach() {
a.ctx = nil
}
func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error {
_ = ctx
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
return nil
}
entryPath, err := a.entryPathForOS(runtime.GOOS)
if err != nil {
return err
}
if prefs.LaunchAtLogin {
return a.writeEntry(entryPath, runtime.GOOS)
}
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove autostart entry: %w", err)
}
return nil
}
func (a *AutoStart) entryPath() (string, error) {
return a.entryPathForOS(runtime.GOOS)
}
func (a *AutoStart) entryPathForOS(goos string) (string, error) {
switch goos {
case "linux":
return a.linuxEntryPath()
case "windows":
return a.windowsEntryPath()
default:
return "", fmt.Errorf("autostart unsupported on %s", goos)
}
}
func (a *AutoStart) linuxEntryPath() (string, error) {
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home for autostart: %w", err)
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil
}
func (a *AutoStart) windowsEntryPath() (string, error) {
base := strings.TrimSpace(os.Getenv("APPDATA"))
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home for autostart: %w", err)
}
base = filepath.Join(home, "AppData", "Roaming")
}
return filepath.Join(base, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "ocswitch-desktop.cmd"), nil
}
func (a *AutoStart) writeEntry(path string, goos string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir autostart dir: %w", err)
}
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve desktop executable: %w", err)
}
configPath := config.DefaultPath()
if a.service != nil {
configPath = a.service.ConfigPath()
}
var content string
switch goos {
case "linux":
content = linuxAutostartEntry(execPath, configPath)
case "windows":
content = windowsAutostartScript(execPath, configPath)
default:
return fmt.Errorf("autostart unsupported on %s", goos)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write autostart entry: %w", err)
}
return nil
}
func linuxAutostartEntry(execPath string, configPath string) string {
content := strings.Join([]string{
"[Desktop Entry]",
"Type=Application",
"Version=1.0",
"Name=ocswitch desktop",
"Comment=OpenCode provider switch desktop shell",
fmt.Sprintf("Exec=%s --config %s", shellQuote(execPath), shellQuote(configPath)),
"Terminal=false",
"X-GNOME-Autostart-enabled=true",
"Categories=Network;Development;",
"StartupNotify=false",
"",
}, "\n")
return content
}
func shellQuote(value string) string {
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
return `"` + replacer.Replace(value) + `"`
}
func windowsAutostartScript(execPath string, configPath string) string {
return strings.Join([]string{
"@echo off",
fmt.Sprintf("start \"\" %s --config %s", cmdQuote(execPath), cmdQuote(configPath)),
"",
}, "\r\n")
}
func cmdQuote(value string) string {
replacer := strings.NewReplacer(`"`, `""`)
return `"` + replacer.Replace(value) + `"`
}

View File

@ -0,0 +1,82 @@
package desktop
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
func TestAutoStartSyncLinuxWritesDesktopEntry(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("linux-only autostart behavior")
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
auto := NewAutoStart(nil)
if err := auto.Sync(context.Background(), app.DesktopPrefsView{LaunchAtLogin: true}); err != nil {
t.Fatalf("Sync() error = %v", err)
}
entry, err := auto.entryPath()
if err != nil {
t.Fatalf("entryPath() error = %v", err)
}
data, err := os.ReadFile(entry)
if err != nil {
t.Fatalf("os.ReadFile() error = %v", err)
}
text := string(data)
if !strings.Contains(text, "[Desktop Entry]") {
t.Fatalf("desktop entry missing header: %q", text)
}
if !strings.Contains(text, "Name=ocswitch desktop") {
t.Fatalf("desktop entry missing app name: %q", text)
}
if err := auto.Sync(context.Background(), app.DesktopPrefsView{}); err != nil {
t.Fatalf("Sync(remove) error = %v", err)
}
if _, err := os.Stat(entry); !os.IsNotExist(err) {
t.Fatalf("autostart entry still exists at %s", entry)
}
}
func TestShellQuoteEscapesDoubleQuotes(t *testing.T) {
t.Parallel()
quoted := shellQuote(filepath.Join(`/tmp/demo"path`, `bin`))
if !strings.HasPrefix(quoted, `"`) || !strings.HasSuffix(quoted, `"`) {
t.Fatalf("shellQuote() = %q", quoted)
}
if !strings.Contains(quoted, `\\"`) {
t.Fatalf("shellQuote() did not escape quotes: %q", quoted)
}
}
func TestEntryPathForWindowsUsesStartupFolder(t *testing.T) {
root := t.TempDir()
t.Setenv("APPDATA", root)
auto := NewAutoStart(nil)
path, err := auto.entryPathForOS("windows")
if err != nil {
t.Fatalf("entryPathForOS(windows) error = %v", err)
}
want := filepath.Join(root, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "ocswitch-desktop.cmd")
if path != want {
t.Fatalf("entryPathForOS(windows) = %q, want %q", path, want)
}
}
func TestWindowsAutostartScriptQuotesArgs(t *testing.T) {
t.Parallel()
script := windowsAutostartScript(`C:\Program Files\ocswitch\ocswitch-desktop.exe`, `C:\Users\demo\.config\ocswitch\config.json`)
if !strings.Contains(script, `start "" "C:\Program Files\ocswitch\ocswitch-desktop.exe" --config "C:\Users\demo\.config\ocswitch\config.json"`) {
t.Fatalf("windowsAutostartScript() missing command: %q", script)
}
}

View File

@ -0,0 +1,226 @@
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) ExportConfig(ctx context.Context) (app.ConfigExportView, error) {
return b.service.ExportConfig(ctx)
}
func (b *Bindings) ImportConfig(ctx context.Context, in app.ConfigImportInput) (app.ConfigImportResult, error) {
return b.service.ImportConfig(ctx, in)
}
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
return b.service.ListProviders(ctx)
}
func (b *Bindings) ListAliases(ctx context.Context) ([]app.AliasView, error) {
return b.service.ListAliases(ctx)
}
func (b *Bindings) UpsertProvider(ctx context.Context, in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
return b.service.UpsertProvider(ctx, in)
}
func (b *Bindings) SetProviderDisabled(ctx context.Context, in app.ProviderStateInput) (app.ProviderView, error) {
return b.service.SetProviderDisabled(ctx, in)
}
func (b *Bindings) RemoveProvider(ctx context.Context, id string) error {
return b.service.RemoveProvider(ctx, id)
}
func (b *Bindings) ImportProviders(ctx context.Context, in app.ProviderImportInput) (app.ProviderImportResult, error) {
return b.service.ImportProviders(ctx, in)
}
func (b *Bindings) UpsertAlias(ctx context.Context, in app.AliasUpsertInput) (app.AliasView, error) {
return b.service.UpsertAlias(ctx, in)
}
func (b *Bindings) RemoveAlias(ctx context.Context, name string) error {
return b.service.RemoveAlias(ctx, name)
}
func (b *Bindings) BindAliasTarget(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
return b.service.BindAliasTarget(ctx, in)
}
func (b *Bindings) SetAliasTargetDisabled(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
return b.service.SetAliasTargetDisabled(ctx, in)
}
func (b *Bindings) UnbindAliasTarget(ctx context.Context, in app.AliasTargetInput) (app.AliasView, error) {
return b.service.UnbindAliasTarget(ctx, in)
}
func (b *Bindings) RunDoctor(ctx context.Context) (app.DoctorReport, error) {
return b.service.RunDoctor(ctx)
}
func (b *Bindings) SyncOpenCode(ctx context.Context, in app.SyncInput) (app.SyncResult, error) {
return b.service.ApplyOpenCodeSync(ctx, in)
}
func (b *Bindings) PreviewOpenCodeSync(ctx context.Context, in app.SyncInput) (app.SyncPreview, error) {
return b.service.PreviewOpenCodeSync(ctx, in)
}
func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, error) {
return b.service.GetProxyStatus(ctx)
}
func (b *Bindings) ListRequestTraces(ctx context.Context, limit int) ([]app.RequestTrace, error) {
return b.service.ListRequestTraces(ctx, limit)
}
func (b *Bindings) GetProxySettings(ctx context.Context) (app.ProxySettingsView, error) {
return b.service.GetProxySettings(ctx)
}
func (b *Bindings) SaveProxySettings(ctx context.Context, in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
return b.service.SaveProxySettings(ctx, in)
}
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) ExportConfigNow() (app.ConfigExportView, error) {
return b.ExportConfig(context.Background())
}
func (b *Bindings) ImportConfigNow(in app.ConfigImportInput) (app.ConfigImportResult, error) {
return b.ImportConfig(context.Background(), in)
}
func (b *Bindings) Providers() ([]app.ProviderView, error) {
return b.ListProviders(context.Background())
}
func (b *Bindings) Aliases() ([]app.AliasView, error) {
return b.ListAliases(context.Background())
}
func (b *Bindings) SaveProvider(in app.ProviderUpsertInput) (app.ProviderSaveResult, error) {
return b.UpsertProvider(context.Background(), in)
}
func (b *Bindings) SetProviderState(in app.ProviderStateInput) (app.ProviderView, error) {
return b.SetProviderDisabled(context.Background(), in)
}
func (b *Bindings) DeleteProvider(id string) error {
return b.RemoveProvider(context.Background(), id)
}
func (b *Bindings) ImportProviderSet(in app.ProviderImportInput) (app.ProviderImportResult, error) {
return b.ImportProviders(context.Background(), in)
}
func (b *Bindings) SaveAlias(in app.AliasUpsertInput) (app.AliasView, error) {
return b.UpsertAlias(context.Background(), in)
}
func (b *Bindings) DeleteAlias(name string) error {
return b.RemoveAlias(context.Background(), name)
}
func (b *Bindings) BindTarget(in app.AliasTargetInput) (app.AliasView, error) {
return b.BindAliasTarget(context.Background(), in)
}
func (b *Bindings) SetTargetState(in app.AliasTargetInput) (app.AliasView, error) {
return b.SetAliasTargetDisabled(context.Background(), in)
}
func (b *Bindings) UnbindTarget(in app.AliasTargetInput) (app.AliasView, error) {
return b.UnbindAliasTarget(context.Background(), in)
}
func (b *Bindings) Doctor() (app.DoctorReport, error) {
return b.RunDoctor(context.Background())
}
func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
return b.GetProxyStatus(context.Background())
}
func (b *Bindings) RequestTraces(limit int) ([]app.RequestTrace, error) {
return b.ListRequestTraces(context.Background(), limit)
}
func (b *Bindings) ProxySettings() (app.ProxySettingsView, error) {
return b.GetProxySettings(context.Background())
}
func (b *Bindings) SaveProxyConfig(in app.ProxySettingsInput) (app.ProxySettingsSaveResult, error) {
return b.SaveProxySettings(context.Background(), in)
}
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)
}

480
internal/desktop/http.go Normal file
View File

@ -0,0 +1,480 @@
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/config/export", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.ExportConfig(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/config/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.ConfigImportInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := instance.ImportConfig(in)
writeResult(w, data, err)
})
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := b.ListProviders(r.Context())
writeResult(w, data, err)
case http.MethodPost:
var in appcore.ProviderUpsertInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.UpsertProvider(r.Context(), in)
writeResult(w, data, err)
default:
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
}
})
api.HandleFunc("/api/providers/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.ProviderImportInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.ImportProviders(r.Context(), in)
writeResult(w, data, err)
})
api.HandleFunc("/api/providers/state", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.ProviderStateInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.SetProviderDisabled(r.Context(), in)
writeResult(w, data, err)
})
api.HandleFunc("/api/providers/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var payload struct {
ID string `json:"id"`
}
if !decodeJSONBody(w, r, &payload) {
return
}
writeResult(w, map[string]bool{"ok": true}, b.RemoveProvider(r.Context(), payload.ID))
})
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := b.ListAliases(r.Context())
writeResult(w, data, err)
case http.MethodPost:
var in appcore.AliasUpsertInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.UpsertAlias(r.Context(), in)
writeResult(w, data, err)
default:
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
}
})
api.HandleFunc("/api/aliases/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var payload struct {
Alias string `json:"alias"`
}
if !decodeJSONBody(w, r, &payload) {
return
}
writeResult(w, map[string]bool{"ok": true}, b.RemoveAlias(r.Context(), payload.Alias))
})
api.HandleFunc("/api/aliases/bind", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.AliasTargetInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.BindAliasTarget(r.Context(), in)
writeResult(w, data, err)
})
api.HandleFunc("/api/aliases/state", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.AliasTargetInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.SetAliasTargetDisabled(r.Context(), in)
writeResult(w, data, err)
})
api.HandleFunc("/api/aliases/unbind", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
var in appcore.AliasTargetInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.UnbindAliasTarget(r.Context(), in)
writeResult(w, data, err)
})
api.HandleFunc("/api/desktop-prefs", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := b.GetDesktopPrefs(r.Context())
writeResult(w, data, err)
case http.MethodPost:
var in appcore.DesktopPrefsInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := instance.SaveDesktopPrefs(r.Context(), in)
writeResult(w, data, err)
default:
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
}
})
api.HandleFunc("/api/proxy/status", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.GetProxyStatus(r.Context())
writeResult(w, data, err)
})
api.HandleFunc("/api/proxy/settings", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := b.GetProxySettings(r.Context())
writeResult(w, data, err)
case http.MethodPost:
var in appcore.ProxySettingsInput
if !decodeJSONBody(w, r, &in) {
return
}
data, err := b.SaveProxySettings(r.Context(), in)
writeResult(w, data, err)
default:
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
}
})
api.HandleFunc("/api/proxy/traces", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
data, err := b.ListRequestTraces(r.Context(), 100)
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,672 @@
package desktop
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app"
"github.com/Apale7/opencode-provider-switch/internal/config"
)
func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "ocswitch.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{
ID: "demo",
Name: "Demo",
BaseURL: "https://example.com/v1",
APIKey: "sk-demo-12345678",
Models: []string{"gpt-4.1-mini"},
})
cfg.UpsertAlias(config.Alias{
Alias: "chat",
DisplayName: "Chat",
Enabled: true,
Targets: []config.Target{{
Provider: "demo",
Model: "gpt-4.1-mini",
Enabled: true,
}},
})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
instance := New(path)
h, err := newHandler(instance, "test", "http://127.0.0.1:9982")
if err != nil {
t.Fatalf("newHandler() error = %v", err)
}
t.Run("overview api", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/overview", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
var payload struct {
Data struct {
ProviderCount int `json:"providerCount"`
AliasCount int `json:"aliasCount"`
Aliases []string `json:"availableAliases"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.ProviderCount != 1 || payload.Data.AliasCount != 1 {
t.Fatalf("unexpected counts: %#v", payload.Data)
}
if len(payload.Data.Aliases) != 1 || payload.Data.Aliases[0] != "chat" {
t.Fatalf("unexpected aliases: %#v", payload.Data.Aliases)
}
})
t.Run("request traces api", func(t *testing.T) {
instance.Service().ListRequestTraces(context.Background(), 10)
req := httptest.NewRequest(http.MethodGet, "/api/proxy/traces", 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 []app.RequestTrace `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(payload.Data) != 0 {
t.Fatalf("traces = %#v, want empty list", payload.Data)
}
})
t.Run("proxy settings api", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/proxy/settings", 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 app.ProxySettingsView `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.ConnectTimeoutMs != config.DefaultConnectTimeoutMs || payload.Data.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs {
t.Fatalf("proxy settings = %#v", payload.Data)
}
})
t.Run("save proxy settings", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":12000,"responseHeaderTimeoutMs":21000,"firstByteTimeoutMs":22000,"requestReadTimeoutMs":33000,"streamIdleTimeoutMs":70000}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
loaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if loaded.Server.ConnectTimeoutMs != 12000 || loaded.Server.ResponseHeaderTimeoutMs != 21000 || loaded.Server.FirstByteTimeoutMs != 22000 || loaded.Server.RequestReadTimeoutMs != 33000 || loaded.Server.StreamIdleTimeoutMs != 70000 {
t.Fatalf("persisted server settings = %#v", loaded.Server)
}
})
t.Run("save proxy settings normalizes non-positive values", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/proxy/settings", strings.NewReader(`{"connectTimeoutMs":0,"responseHeaderTimeoutMs":-1,"firstByteTimeoutMs":0,"requestReadTimeoutMs":-50,"streamIdleTimeoutMs":0}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data app.ProxySettingsSaveResult `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.Settings.ConnectTimeoutMs != config.DefaultConnectTimeoutMs ||
payload.Data.Settings.ResponseHeaderTimeoutMs != config.DefaultResponseHeaderTimeoutMs ||
payload.Data.Settings.FirstByteTimeoutMs != config.DefaultFirstByteTimeoutMs ||
payload.Data.Settings.RequestReadTimeoutMs != config.DefaultRequestReadTimeoutMs ||
payload.Data.Settings.StreamIdleTimeoutMs != config.DefaultStreamIdleTimeoutMs {
t.Fatalf("proxy settings payload = %#v", payload.Data.Settings)
}
})
t.Run("save desktop prefs", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"autoStartProxy":true,"minimizeToTray":true,"notifications":true,"theme":"dark","language":"zh-CN"}`))
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.AutoStartProxy || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications || loaded.Desktop.Theme != "dark" || loaded.Desktop.Language != "zh-CN" {
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
}
})
t.Run("startup auto starts proxy when enabled", func(t *testing.T) {
port := freePort(t)
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
cfg.Desktop.AutoStartProxy = true
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
tray := &spyTray{}
notify := &spyNotifier{}
originalTray := instance.tray
originalNotify := instance.notify
defer func() {
instance.tray = originalTray
instance.notify = originalNotify
_ = instance.Service().StopProxy(context.Background())
}()
instance.tray = tray
instance.notify = notify
instance.Startup(context.Background())
status, err := instance.Service().GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if !status.Running {
t.Fatalf("status = %#v, want running", status)
}
if tray.refreshCalls != 1 {
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
}
if len(notify.sends) != 0 {
t.Fatalf("startup notifications = %#v, want none", notify.sends)
}
})
t.Run("startup auto start surfaces failure without success toast", func(t *testing.T) {
port := freePort(t)
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
cfg.Desktop.AutoStartProxy = true
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
tray := &spyTray{}
notify := &spyNotifier{}
originalTray := instance.tray
originalNotify := instance.notify
defer func() {
instance.tray = originalTray
instance.notify = originalNotify
_ = instance.Service().StopProxy(context.Background())
}()
instance.tray = tray
instance.notify = notify
instance.Startup(context.Background())
assertEventually(t, func() bool {
return len(notify.sends) == 1
})
status, err := instance.Service().GetProxyStatus(context.Background())
if err != nil {
t.Fatalf("GetProxyStatus() error = %v", err)
}
if status.Running {
t.Fatalf("status = %#v, want stopped after failure", status)
}
if status.LastError == "" {
t.Fatalf("status = %#v, want last error", status)
}
if len(notify.sends) != 1 {
t.Fatalf("notifications = %#v, want 1 failure notice", notify.sends)
}
if notify.sends[0].Title != "Proxy failed to start" {
t.Fatalf("notification = %#v, want failure title", notify.sends[0])
}
if !strings.Contains(strings.ToLower(notify.sends[0].Body), "bind") {
t.Fatalf("notification = %#v, want bind error", notify.sends[0])
}
if tray.refreshCalls < 2 {
t.Fatalf("tray refreshCalls = %d, want refresh after failure", tray.refreshCalls)
}
})
t.Run("proxy start api surfaces bind failure", func(t *testing.T) {
port := freePort(t)
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
defer listener.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = port
cfg.Server.APIKey = config.DefaultLocalAPIKey
cfg.Desktop.AutoStartProxy = false
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/proxy/start", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
if !strings.Contains(strings.ToLower(resp.Body.String()), "bind") {
t.Fatalf("body = %q, want bind error", resp.Body.String())
}
})
t.Run("save desktop prefs keeps success with integration warning", func(t *testing.T) {
originalAuto := instance.auto
instance.auto = failingAutoStart{message: "startup folder unavailable"}
defer func() {
instance.auto = originalAuto
}()
req := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":false,"notifications":false}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data struct {
Prefs struct {
LaunchAtLogin bool `json:"launchAtLogin"`
} `json:"prefs"`
Warnings []string `json:"warnings"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if !payload.Data.Prefs.LaunchAtLogin {
t.Fatalf("prefs payload = %#v", payload.Data.Prefs)
}
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "launch-at-login integration") {
t.Fatalf("warnings = %#v, want integration warning", payload.Data.Warnings)
}
loaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
if !loaded.Desktop.LaunchAtLogin {
t.Fatalf("persisted desktop prefs = %#v, want saved despite warning", loaded.Desktop)
}
})
t.Run("import config syncs desktop integrations and reports warning", func(t *testing.T) {
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)
}
originalAuto := instance.auto
originalTray := instance.tray
originalNotify := instance.notify
auto := failingAutoStart{message: "startup folder unavailable"}
tray := &spyTray{}
notify := &spyNotifier{}
instance.auto = auto
instance.tray = tray
instance.notify = notify
defer func() {
instance.auto = originalAuto
instance.tray = originalTray
instance.notify = originalNotify
}()
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{
"content":"{\"server\":{\"host\":\"127.0.0.1\",\"port\":9982,\"api_key\":\"ocswitch-local\"},\"desktop\":{\"launch_at_login\":true,\"minimize_to_tray\":true,\"notifications\":true,\"theme\":\"dark\",\"language\":\"zh-CN\"},\"providers\":[{\"id\":\"demo\",\"name\":\"Demo\",\"base_url\":\"https://example.com/v1\",\"api_key\":\"sk-demo-12345678\",\"models\":[\"gpt-4.1-mini\"]}],\"aliases\":[{\"alias\":\"chat\",\"display_name\":\"Chat\",\"enabled\":true,\"targets\":[{\"provider\":\"demo\",\"model\":\"gpt-4.1-mini\",\"enabled\":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, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data struct {
ConfigPath string `json:"configPath"`
Warnings []string `json:"warnings"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.ConfigPath == "" {
t.Fatalf("configPath is empty: %#v", payload.Data)
}
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "sync desktop integrations") {
t.Fatalf("warnings = %#v, want desktop sync warning", payload.Data.Warnings)
}
if tray.syncCalls != 1 {
t.Fatalf("tray syncCalls = %d, want 1", tray.syncCalls)
}
if tray.refreshCalls != 1 {
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
}
if !notify.lastPrefs.Notifications {
t.Fatalf("notify prefs = %#v, want notifications enabled", notify.lastPrefs)
}
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("provider save exposes warnings", func(t *testing.T) {
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":"bad upstream"}`))
}))
defer providerServer.Close()
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
cfg.UpsertProvider(config.Provider{
ID: "warnme",
BaseURL: "https://prior.example.com/v1",
APIKey: "sk-prior",
Models: []string{"gpt-4.1"},
ModelsSource: "discovered",
})
if err := cfg.Save(); err != nil {
t.Fatalf("cfg.Save() error = %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/providers", strings.NewReader(`{"id":"warnme","baseUrl":"`+providerServer.URL+`/v1","apiKey":"sk-new"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data struct {
Provider struct {
ID string `json:"id"`
} `json:"provider"`
Warnings []string `json:"warnings"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.Provider.ID != "warnme" {
t.Fatalf("saved provider = %#v", payload.Data.Provider)
}
if len(payload.Data.Warnings) == 0 {
t.Fatalf("warnings = %#v, want non-empty", payload.Data.Warnings)
}
})
t.Run("alias target state route toggles enabled flag", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/aliases/state", strings.NewReader(`{"alias":"chat","provider":"demo","model":"gpt-4.1-mini","disabled":true}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("disable status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data struct {
AvailableTargetCount int `json:"availableTargetCount"`
Targets []struct {
Enabled bool `json:"enabled"`
} `json:"targets"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.AvailableTargetCount != 0 || len(payload.Data.Targets) != 1 || payload.Data.Targets[0].Enabled {
t.Fatalf("disabled payload = %#v", payload.Data)
}
req = httptest.NewRequest(http.MethodPost, "/api/aliases/state", strings.NewReader(`{"alias":"chat","provider":"demo","model":"gpt-4.1-mini","disabled":false}`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("enable status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
loaded, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
alias := loaded.FindAlias("chat")
if alias == nil || len(alias.Targets) != 1 || !alias.Targets[0].Enabled {
t.Fatalf("persisted alias = %#v", alias)
}
})
t.Run("provider import exposes warnings", func(t *testing.T) {
sourcePath := filepath.Join(t.TempDir(), "opencode.json")
if err := os.WriteFile(sourcePath, []byte(`{
"provider": {
"demo": {
"npm": "@ai-sdk/openai",
"options": {"baseURL": "https://duplicate.example.com/v1", "apiKey": "sk-dup"}
},
"broken": {
"npm": "@ai-sdk/openai",
"options": {"baseURL": "https://broken.example.com", "apiKey": "sk-bad"}
}
}
}`), 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/providers/import", strings.NewReader(`{"sourcePath":"`+strings.ReplaceAll(sourcePath, "\\", "\\\\")+`"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
var payload struct {
Data struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Warnings []string `json:"warnings"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Data.Imported != 0 || payload.Data.Skipped != 2 {
t.Fatalf("import payload = %#v", payload.Data)
}
if len(payload.Data.Warnings) != 2 {
t.Fatalf("warnings = %#v, want 2 entries", payload.Data.Warnings)
}
})
t.Run("serves app shell", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
if !strings.Contains(resp.Body.String(), "ocswitch desktop") {
t.Fatalf("unexpected body = %q", resp.Body.String())
}
})
}
type failingAutoStart struct {
message string
}
func (f failingAutoStart) Attach(_ context.Context) {}
func (f failingAutoStart) Detach() {}
func (f failingAutoStart) Sync(_ context.Context, _ app.DesktopPrefsView) error {
return fmt.Errorf(f.message)
}
type spyTray struct {
syncCalls int
refreshCalls int
lastPrefs app.DesktopPrefsView
}
func (s *spyTray) Attach(_ context.Context) {}
func (s *spyTray) Detach() {}
func (s *spyTray) Sync(_ context.Context, prefs app.DesktopPrefsView) {
s.syncCalls++
s.lastPrefs = prefs
}
func (s *spyTray) RefreshProxyStatus(_ context.Context) {
s.refreshCalls++
}
func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
return false, nil
}
type spyNotifier struct {
syncCalls int
lastPrefs app.DesktopPrefsView
sends []notifyCall
}
func (s *spyNotifier) Attach(_ context.Context) {}
func (s *spyNotifier) Detach() {}
func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
s.syncCalls++
s.lastPrefs = prefs
}
func (s *spyNotifier) Send(_ context.Context, title string, body string) error {
s.sends = append(s.sends, notifyCall{Title: title, Body: body})
return nil
}
type notifyCall struct {
Title string
Body string
}
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
}

103
internal/desktop/notify.go Normal file
View File

@ -0,0 +1,103 @@
package desktop
import (
"context"
"fmt"
"sync"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
// Notifier bridges desktop preference state to native notifications.
type Notifier struct {
service *app.Service
mu sync.Mutex
ctx context.Context
prefs app.DesktopPrefsView
initialized bool
}
func NewNotifier(service *app.Service) *Notifier {
return &Notifier{service: service}
}
func (n *Notifier) Attach(ctx context.Context) {
n.mu.Lock()
n.ctx = ctx
n.mu.Unlock()
_ = n.ensureInitialized(ctx)
}
func (n *Notifier) Detach() {
n.mu.Lock()
n.ctx = nil
n.initialized = false
n.mu.Unlock()
}
func (n *Notifier) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
n.mu.Lock()
if ctx != nil {
n.ctx = ctx
}
n.prefs = prefs
n.mu.Unlock()
if prefs.Notifications {
_ = n.ensureInitialized(ctx)
}
}
func (n *Notifier) Send(ctx context.Context, title string, body string) error {
n.mu.Lock()
if !n.prefs.Notifications {
n.mu.Unlock()
return nil
}
if ctx == nil {
ctx = n.ctx
}
n.mu.Unlock()
if ctx == nil {
return nil
}
if err := n.ensureInitialized(ctx); err != nil {
return err
}
if !desktopNotificationsAvailable(ctx) {
return nil
}
return sendDesktopNotification(ctx, desktopNotification{
ID: fmt.Sprintf("ocswitch-%d", time.Now().UnixNano()),
Title: title,
Body: body,
})
}
func (n *Notifier) ensureInitialized(ctx context.Context) error {
if ctx == nil {
n.mu.Lock()
ctx = n.ctx
n.mu.Unlock()
}
if ctx == nil {
return nil
}
n.mu.Lock()
if n.initialized {
n.mu.Unlock()
return nil
}
n.mu.Unlock()
if err := initDesktopNotifications(ctx); err != nil {
return err
}
n.mu.Lock()
n.initialized = true
n.mu.Unlock()
return nil
}

View File

@ -0,0 +1,47 @@
//go:build !desktop_wails
package desktop
import "context"
type desktopNotification struct {
ID string
Title string
Body string
}
func hideWindow(ctx context.Context) error {
_ = ctx
return nil
}
func showWindow(ctx context.Context) error {
_ = ctx
return nil
}
func quitWindow(ctx context.Context) error {
_ = ctx
return nil
}
func openExternalURL(ctx context.Context, url string) error {
_ = ctx
return openBrowser(url)
}
func initDesktopNotifications(ctx context.Context) error {
_ = ctx
return nil
}
func desktopNotificationsAvailable(ctx context.Context) bool {
_ = ctx
return false
}
func sendDesktopNotification(ctx context.Context, notification desktopNotification) error {
_ = ctx
_ = notification
return nil
}

View File

@ -0,0 +1,57 @@
//go:build desktop_wails
package desktop
import (
"context"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type desktopNotification struct {
ID string
Title string
Body string
}
func hideWindow(ctx context.Context) error {
wruntime.Hide(ctx)
return nil
}
func showWindow(ctx context.Context) error {
wruntime.Show(ctx)
wruntime.WindowShow(ctx)
wruntime.WindowUnminimise(ctx)
wruntime.WindowSetAlwaysOnTop(ctx, true)
time.Sleep(80 * time.Millisecond)
wruntime.WindowSetAlwaysOnTop(ctx, false)
return nil
}
func quitWindow(ctx context.Context) error {
wruntime.Quit(ctx)
return nil
}
func openExternalURL(ctx context.Context, url string) error {
wruntime.BrowserOpenURL(ctx, url)
return nil
}
func initDesktopNotifications(ctx context.Context) error {
return wruntime.InitializeNotifications(ctx)
}
func desktopNotificationsAvailable(ctx context.Context) bool {
return wruntime.IsNotificationAvailable(ctx)
}
func sendDesktopNotification(ctx context.Context, notification desktopNotification) error {
return wruntime.SendNotification(ctx, wruntime.NotificationOptions{
ID: notification.ID,
Title: notification.Title,
Body: notification.Body,
})
}

45
internal/desktop/tray.go Normal file
View File

@ -0,0 +1,45 @@
//go:build !desktop_wails
package desktop
import (
"context"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
// Tray is a no-op shell adapter outside the Wails desktop build.
type Tray struct {
service *app.Service
ctx context.Context
prefs app.DesktopPrefsView
}
func NewTray(service *app.Service) *Tray {
return &Tray{service: service}
}
func (t *Tray) Attach(ctx context.Context) {
t.ctx = ctx
}
func (t *Tray) Detach() {
t.ctx = nil
}
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
_ = ctx
t.prefs = prefs
}
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
_ = ctx
}
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
_ = ctx
if !t.prefs.MinimizeToTray {
return false, nil
}
return true, hideWindow(ctx)
}

View File

@ -0,0 +1,36 @@
//go:build desktop_wails
package desktop
import (
"os"
"strings"
)
var systemTrayLanguage = detectSystemTrayLanguage
func trayLanguage(preference string) string {
if language := normalizeTrayLanguage(strings.TrimSpace(preference)); language != "" {
return language
}
if language := normalizeTrayLanguage(systemTrayLanguage()); language != "" {
return language
}
for _, value := range []string{os.Getenv("LC_ALL"), os.Getenv("LANG"), os.Getenv("LANGUAGE")} {
if language := normalizeTrayLanguage(value); language != "" {
return language
}
}
return "en-US"
}
func normalizeTrayLanguage(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
if strings.HasPrefix(lower, "zh") {
return "zh-CN"
}
if strings.HasPrefix(lower, "en") {
return "en-US"
}
return ""
}

View File

@ -0,0 +1,7 @@
//go:build desktop_wails && !windows
package desktop
func detectSystemTrayLanguage() string {
return ""
}

View File

@ -0,0 +1,47 @@
//go:build desktop_wails
package desktop
import "testing"
func TestTrayLanguageHonorsExplicitPreference(t *testing.T) {
original := systemTrayLanguage
systemTrayLanguage = func() string { return "en-US" }
t.Cleanup(func() { systemTrayLanguage = original })
t.Setenv("LC_ALL", "")
t.Setenv("LANG", "")
t.Setenv("LANGUAGE", "")
if got := trayLanguage("zh-CN"); got != "zh-CN" {
t.Fatalf("trayLanguage(zh-CN) = %q, want zh-CN", got)
}
}
func TestTrayLanguageUsesSystemLanguageBeforeEnv(t *testing.T) {
original := systemTrayLanguage
systemTrayLanguage = func() string { return "zh-CN" }
t.Cleanup(func() { systemTrayLanguage = original })
t.Setenv("LC_ALL", "en_US.UTF-8")
t.Setenv("LANG", "")
t.Setenv("LANGUAGE", "")
if got := trayLanguage("system"); got != "zh-CN" {
t.Fatalf("trayLanguage(system) = %q, want zh-CN", got)
}
}
func TestTrayLanguageFallsBackToEnv(t *testing.T) {
original := systemTrayLanguage
systemTrayLanguage = func() string { return "" }
t.Cleanup(func() { systemTrayLanguage = original })
t.Setenv("LC_ALL", "")
t.Setenv("LANG", "zh_CN.UTF-8")
t.Setenv("LANGUAGE", "")
if got := trayLanguage("system"); got != "zh-CN" {
t.Fatalf("trayLanguage(system) = %q, want zh-CN", got)
}
}

View File

@ -0,0 +1,20 @@
//go:build desktop_wails && windows
package desktop
import (
"syscall"
"unsafe"
)
func detectSystemTrayLanguage() string {
const localeNameMaxLength = 85
kernel32 := syscall.NewLazyDLL("kernel32.dll")
proc := kernel32.NewProc("GetUserDefaultLocaleName")
var buffer [localeNameMaxLength]uint16
result, _, _ := proc.Call(uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)))
if result == 0 {
return ""
}
return syscall.UTF16ToString(buffer[:])
}

View File

@ -0,0 +1,329 @@
//go:build desktop_wails
package desktop
import (
"context"
_ "embed"
"fmt"
"runtime"
"strings"
"sync"
"time"
"fyne.io/systray"
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
//go:embed assets/icon.ico
var trayIcon []byte
// Tray wires resident-mode controls into a native system tray.
type Tray struct {
service *app.Service
mu sync.Mutex
ctx context.Context
prefs app.DesktopPrefsView
language string
registered bool
ready bool
running bool
quitting bool
statusItem *systray.MenuItem
showItem *systray.MenuItem
hideItem *systray.MenuItem
startItem *systray.MenuItem
stopItem *systray.MenuItem
quitItem *systray.MenuItem
}
func NewTray(service *app.Service) *Tray {
return &Tray{service: service}
}
func (t *Tray) Attach(ctx context.Context) {
t.mu.Lock()
t.ctx = ctx
if t.registered {
t.mu.Unlock()
return
}
t.registered = true
t.mu.Unlock()
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(t.onReady, t.onExit)
}()
}
func (t *Tray) Detach() {
t.mu.Lock()
t.ctx = nil
registered := t.registered
t.mu.Unlock()
if registered {
systray.Quit()
}
}
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
t.mu.Lock()
if ctx != nil {
t.ctx = ctx
}
t.prefs = prefs
t.language = trayLanguage(prefs.Language)
ready := t.ready
statusItem := t.statusItem
showItem := t.showItem
hideItem := t.hideItem
startItem := t.startItem
stopItem := t.stopItem
quitItem := t.quitItem
labels := t.trayLabelsLocked()
t.mu.Unlock()
if ready && statusItem != nil && showItem != nil && hideItem != nil && startItem != nil && stopItem != nil && quitItem != nil {
systray.SetTitle(labels.appTitle)
systray.SetTooltip(labels.appTooltip)
showItem.SetTitle(labels.openWindow)
showItem.SetTooltip(labels.openWindowHint)
hideItem.SetTitle(labels.hideWindow)
hideItem.SetTooltip(labels.hideWindowHint)
startItem.SetTitle(labels.startProxy)
startItem.SetTooltip(labels.startProxyHint)
stopItem.SetTitle(labels.stopProxy)
stopItem.SetTooltip(labels.stopProxyHint)
quitItem.SetTitle(labels.quit)
quitItem.SetTooltip(labels.quitHint)
}
t.refresh()
}
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
t.mu.Lock()
if ctx != nil {
t.ctx = ctx
}
t.mu.Unlock()
t.refresh()
}
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
t.mu.Lock()
t.ctx = ctx
quitting := t.quitting
minimize := t.prefs.MinimizeToTray
t.mu.Unlock()
if quitting || !minimize {
return false, nil
}
return true, hideWindow(ctx)
}
func (t *Tray) onReady() {
labels := t.labels()
if len(trayIcon) > 0 {
systray.SetIcon(trayIcon)
}
systray.SetTitle(labels.appTitle)
systray.SetTooltip(labels.appTooltip)
systray.SetOnTapped(func() {
_ = t.withContext(showWindow)
})
t.mu.Lock()
t.ready = true
t.running = true
t.statusItem = systray.AddMenuItem(labels.proxyChecking, labels.proxyStatusHint)
t.statusItem.Disable()
systray.AddSeparator()
t.showItem = systray.AddMenuItem(labels.openWindow, labels.openWindowHint)
t.hideItem = systray.AddMenuItem(labels.hideWindow, labels.hideWindowHint)
systray.AddSeparator()
t.startItem = systray.AddMenuItem(labels.startProxy, labels.startProxyHint)
t.stopItem = systray.AddMenuItem(labels.stopProxy, labels.stopProxyHint)
systray.AddSeparator()
t.quitItem = systray.AddMenuItem(labels.quit, labels.quitHint)
t.mu.Unlock()
go t.loop()
t.refresh()
}
func (t *Tray) onExit() {
t.mu.Lock()
t.ready = false
t.running = false
t.registered = false
t.statusItem = nil
t.showItem = nil
t.hideItem = nil
t.startItem = nil
t.stopItem = nil
t.quitItem = nil
t.mu.Unlock()
}
func (t *Tray) loop() {
for {
t.mu.Lock()
showItem := t.showItem
hideItem := t.hideItem
startItem := t.startItem
stopItem := t.stopItem
quitItem := t.quitItem
running := t.running
t.mu.Unlock()
if !running || showItem == nil || hideItem == nil || startItem == nil || stopItem == nil || quitItem == nil {
return
}
select {
case <-showItem.ClickedCh:
_ = t.withContext(showWindow)
case <-hideItem.ClickedCh:
_ = t.withContext(hideWindow)
case <-startItem.ClickedCh:
t.startProxy()
case <-stopItem.ClickedCh:
t.stopProxy()
case <-quitItem.ClickedCh:
t.requestQuit()
return
}
}
}
func (t *Tray) startProxy() {
if t.service == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = t.service.StartProxy(ctx)
t.refresh()
}
func (t *Tray) stopProxy() {
if t.service == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = t.service.StopProxy(ctx)
t.refresh()
}
func (t *Tray) requestQuit() {
t.mu.Lock()
t.quitting = true
ctx := t.ctx
t.mu.Unlock()
if ctx == nil {
return
}
_ = quitWindow(ctx)
}
func (t *Tray) withContext(fn func(context.Context) error) error {
t.mu.Lock()
ctx := t.ctx
t.mu.Unlock()
if ctx == nil {
return nil
}
return fn(ctx)
}
func (t *Tray) refresh() {
t.mu.Lock()
ready := t.ready
statusItem := t.statusItem
startItem := t.startItem
stopItem := t.stopItem
labels := t.trayLabelsLocked()
t.mu.Unlock()
if !ready || statusItem == nil || startItem == nil || stopItem == nil || t.service == nil {
return
}
status, err := t.service.GetProxyStatus(context.Background())
if err != nil {
statusItem.SetTitle(labels.proxyUnavailable)
startItem.Enable()
stopItem.Disable()
return
}
if status.Running {
statusItem.SetTitle(fmt.Sprintf(labels.proxyRunning, status.BindAddress))
startItem.Disable()
stopItem.Enable()
return
}
statusItem.SetTitle(fmt.Sprintf(labels.proxyStopped, status.BindAddress))
startItem.Enable()
stopItem.Disable()
}
func (t *Tray) labels() trayLabels {
t.mu.Lock()
defer t.mu.Unlock()
return t.trayLabelsLocked()
}
func (t *Tray) trayLabelsLocked() trayLabels {
return trayLabels{
appTitle: trayLocaleString(t.language, "tray", "appTitle", "ocswitch"),
appTooltip: trayLocaleString(t.language, "tray", "appTooltip", "ocswitch desktop"),
proxyChecking: trayLocaleString(t.language, "tray", "proxyChecking", "Proxy: checking..."),
proxyStatusHint: trayLocaleString(t.language, "tray", "proxyStatusHint", "Current proxy status"),
proxyUnavailable: trayLocaleString(t.language, "tray", "proxyUnavailable", "Proxy: unavailable"),
proxyRunning: trayLocaleString(t.language, "tray", "proxyRunning", "Proxy: running (%s)"),
proxyStopped: trayLocaleString(t.language, "tray", "proxyStopped", "Proxy: stopped (%s)"),
openWindow: trayLocaleString(t.language, "tray", "openWindow", "Open window"),
openWindowHint: trayLocaleString(t.language, "tray", "openWindowHint", "Show desktop window"),
hideWindow: trayLocaleString(t.language, "tray", "hideWindow", "Hide window"),
hideWindowHint: trayLocaleString(t.language, "tray", "hideWindowHint", "Hide desktop window"),
startProxy: trayLocaleString(t.language, "tray", "startProxy", "Start proxy"),
startProxyHint: trayLocaleString(t.language, "tray", "startProxyHint", "Start local proxy"),
stopProxy: trayLocaleString(t.language, "tray", "stopProxy", "Stop proxy"),
stopProxyHint: trayLocaleString(t.language, "tray", "stopProxyHint", "Stop local proxy"),
quit: trayLocaleString(t.language, "tray", "quit", "Quit"),
quitHint: trayLocaleString(t.language, "tray", "quitHint", "Exit application"),
}
}
func trayLocaleString(language string, key string, field string, fallback string) string {
value, ok, err := frontendassets.LocaleValue(language, key, field)
if err == nil && ok && strings.TrimSpace(value) != "" {
return value
}
return fallback
}
type trayLabels struct {
appTitle string
appTooltip string
proxyChecking string
proxyStatusHint string
proxyUnavailable string
proxyRunning string
proxyStopped string
openWindow string
openWindowHint string
hideWindow string
hideWindowHint string
startProxy string
startProxyHint string
stopProxy string
stopProxyHint string
quit string
quitHint string
}

61
internal/desktop/wails.go Normal file
View File

@ -0,0 +1,61 @@
//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"
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
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: 1120,
MinHeight: 720,
AssetServer: &assetserver.Options{
Assets: mustFS(assets),
},
Bind: []any{instance},
OnStartup: func(ctx context.Context) {
instance.Startup(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
return instance.BeforeClose(ctx)
},
OnShutdown: func(ctx context.Context) {
instance.Shutdown(ctx)
},
Linux: &linux.Options{
ProgramName: "ocswitch-desktop",
},
Windows: &windows.Options{
DisableWindowIcon: false,
},
})
}
func mustFS(assets fs.FS) fs.FS {
return assets
}
func WailsProjectName() string {
return fmt.Sprintf("%s desktop", "ocswitch")
}

View File

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"syscall"
) )
type LockedFile struct { type LockedFile struct {
@ -61,7 +60,7 @@ func acquireLock(path string) (*LockedFile, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("open lock: %w", err) return nil, fmt.Errorf("open lock: %w", err)
} }
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { if err := lockFile(file); err != nil {
_ = file.Close() _ = file.Close()
return nil, fmt.Errorf("lock file: %w", err) return nil, fmt.Errorf("lock file: %w", err)
} }
@ -72,7 +71,7 @@ func (l *LockedFile) Close() error {
if l == nil || l.file == nil { if l == nil || l.file == nil {
return nil return nil
} }
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) unlockErr := unlockFile(l.file)
closeErr := l.file.Close() closeErr := l.file.Close()
if unlockErr != nil { if unlockErr != nil {
return fmt.Errorf("unlock file: %w", unlockErr) return fmt.Errorf("unlock file: %w", unlockErr)
@ -82,15 +81,3 @@ func (l *LockedFile) Close() error {
} }
return nil return nil
} }
func syncDir(dir string) error {
f, err := os.Open(dir)
if err != nil {
return fmt.Errorf("open dir: %w", err)
}
defer f.Close()
if err := f.Sync(); err != nil {
return fmt.Errorf("sync dir: %w", err)
}
return nil
}

View File

@ -0,0 +1,35 @@
//go:build !windows
package fileutil
import (
"fmt"
"os"
"syscall"
)
func lockFile(file *os.File) error {
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("flock lock: %w", err)
}
return nil
}
func unlockFile(file *os.File) error {
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil {
return fmt.Errorf("flock unlock: %w", err)
}
return nil
}
func syncDir(dir string) error {
f, err := os.Open(dir)
if err != nil {
return fmt.Errorf("open dir: %w", err)
}
defer f.Close()
if err := f.Sync(); err != nil {
return fmt.Errorf("sync dir: %w", err)
}
return nil
}

View File

@ -0,0 +1,31 @@
//go:build windows
package fileutil
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
func lockFile(file *os.File) error {
var overlapped windows.Overlapped
if err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &overlapped); err != nil {
return fmt.Errorf("lock file ex: %w", err)
}
return nil
}
func unlockFile(file *os.File) error {
var overlapped windows.Overlapped
if err := windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped); err != nil {
return fmt.Errorf("unlock file ex: %w", err)
}
return nil
}
func syncDir(dir string) error {
_ = dir
return nil
}

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"mime"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@ -17,10 +18,6 @@ import (
"github.com/Apale7/opencode-provider-switch/internal/config" "github.com/Apale7/opencode-provider-switch/internal/config"
) )
var firstByteTimeout = 15 * time.Second
var requestReadTimeout = 30 * time.Second
var streamIdleTimeout = 60 * time.Second
type openAIErrorEnvelope struct { type openAIErrorEnvelope struct {
Error openAIError `json:"error"` Error openAIError `json:"error"`
} }
@ -42,21 +39,31 @@ type Server struct {
cfg *config.Config cfg *config.Config
client *http.Client client *http.Client
logger *log.Logger logger *log.Logger
traces *TraceStore
} }
// New constructs a Server from cfg. // New constructs a Server from cfg.
func New(cfg *config.Config) *Server { func New(cfg *config.Config, stores ...*TraceStore) *Server {
var traces *TraceStore
if len(stores) > 0 {
traces = stores[0]
}
if traces == nil {
traces = NewTraceStore(defaultTraceLimit)
}
firstByteTimeout := timeoutDuration(cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
responseHeaderTimeout := timeoutDuration(cfg.Server.ResponseHeaderTimeoutMs, config.DefaultResponseHeaderTimeoutMs)
transport := &http.Transport{ transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 10 * time.Second, Timeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
}).DialContext, }).DialContext,
MaxIdleConns: 100, MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: firstByteTimeout, ResponseHeaderTimeout: minDuration(responseHeaderTimeout, firstByteTimeout),
DisableCompression: false, DisableCompression: false,
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
} }
@ -67,11 +74,18 @@ func New(cfg *config.Config) *Server {
Timeout: 0, Timeout: 0,
}, },
logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds), logger: log.New(log.Writer(), "[ocswitch] ", log.LstdFlags|log.Lmicroseconds),
traces: traces,
} }
} }
// ListenAndServe starts the HTTP listener until ctx is cancelled. // ListenAndServe starts the HTTP listener until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error { func (s *Server) ListenAndServe(ctx context.Context) error {
return s.ListenAndServeWithReady(ctx, nil)
}
// ListenAndServeWithReady starts the HTTP listener until ctx is cancelled and
// reports whether the listening socket was bound successfully.
func (s *Server) ListenAndServeWithReady(ctx context.Context, ready chan<- error) error {
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port) addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/v1/responses", s.handleResponses) mux.HandleFunc("/v1/responses", s.handleResponses)
@ -80,14 +94,21 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`)) _, _ = w.Write([]byte(`{"status":"ok"}`))
}) })
listener, err := net.Listen("tcp", addr)
if ready != nil {
ready <- err
}
if err != nil {
return err
}
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: requestReadTimeout, ReadTimeout: timeoutDuration(s.cfg.Server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }() go func() { errCh <- srv.Serve(listener) }()
select { select {
case <-ctx.Done(): case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -141,6 +162,7 @@ var reqCounter uint64
// handleResponses is the main alias→failover proxy entry. // handleResponses is the main alias→failover proxy entry.
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
reqID := atomic.AddUint64(&reqCounter, 1) reqID := atomic.AddUint64(&reqCounter, 1)
startedAt := time.Now()
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return return
@ -167,21 +189,50 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
} }
rawModel := aliasName rawModel := aliasName
aliasName = normalizeAliasName(aliasName) aliasName = normalizeAliasName(aliasName)
trace := RequestTrace{
ID: reqID,
StartedAt: startedAt,
RawModel: rawModel,
Alias: aliasName,
RequestHeaders: sanitizeHeaderMap(r.Header),
RequestParams: sanitizeJSONValue("", payload),
}
if stream, ok := payload["stream"].(bool); ok {
trace.Stream = stream
}
defer func() {
trace.FinishedAt = time.Now()
trace.DurationMs = trace.FinishedAt.Sub(trace.StartedAt).Milliseconds()
trace.AttemptCount = len(trace.Attempts)
trace.Failover = len(trace.Attempts) > 1
if trace.FirstByteMs == 0 {
for _, attempt := range trace.Attempts {
if attempt.FirstByteMs > 0 {
trace.FirstByteMs = attempt.FirstByteMs
break
}
}
}
s.traces.Add(trace)
}()
s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"]) s.logger.Printf("req=%d incoming model=%q alias=%q stream=%v", reqID, rawModel, aliasName, payload["stream"])
alias := s.cfg.FindAlias(aliasName) alias := s.cfg.FindAlias(aliasName)
if alias == nil { if alias == nil {
s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName) s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName)
trace.Error = fmt.Sprintf("alias %q not found", aliasName)
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName)) writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName))
return return
} }
if !alias.Enabled { if !alias.Enabled {
s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName) s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName)
trace.Error = fmt.Sprintf("alias %q is disabled", aliasName)
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName)) writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
return return
} }
targets := s.cfg.AvailableTargets(*alias) targets := s.cfg.AvailableTargets(*alias)
if len(targets) == 0 { if len(targets) == 0 {
s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName) s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName)
trace.Error = fmt.Sprintf("alias %q has no available targets", aliasName)
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName)) writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName))
return return
} }
@ -189,28 +240,62 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
failoverCount := 0 failoverCount := 0
var lastRetryable *upstreamFailure var lastRetryable *upstreamFailure
for attempt, t := range targets { for attempt, t := range targets {
attemptTrace := TraceAttempt{
Attempt: attempt + 1,
Provider: t.Provider,
Model: t.Model,
StartedAt: time.Now(),
Result: "pending",
}
p := s.cfg.FindProvider(t.Provider) p := s.cfg.FindProvider(t.Provider)
if p == nil || !p.IsEnabled() { if p == nil || !p.IsEnabled() {
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider) s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider)
attemptTrace.Skipped = true
attemptTrace.Result = "skipped"
attemptTrace.Error = fmt.Sprintf("provider %q unavailable", t.Provider)
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
trace.Attempts = append(trace.Attempts, attemptTrace)
failoverCount++ failoverCount++
continue continue
} }
s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount) s.logger.Printf("req=%d alias=%s attempt=%d provider=%s remote_model=%s failovers=%d", reqID, aliasName, attempt+1, p.ID, t.Model, failoverCount)
cloned := cloneMap(payload) cloned := cloneMap(payload)
cloned["model"] = t.Model cloned["model"] = t.Model
upstreamURL := strings.TrimRight(p.BaseURL, "/") + "/responses"
attemptTrace.URL = upstreamURL
attemptTrace.RequestParams = sanitizeJSONValue("", cloned)
newBody, err := json.Marshal(cloned) newBody, err := json.Marshal(cloned)
if err != nil { if err != nil {
s.logger.Printf("req=%d marshal error: %v", reqID, err) s.logger.Printf("req=%d marshal error: %v", reqID, err)
attemptTrace.Result = "internal_error"
attemptTrace.Error = "marshal error"
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
trace.Attempts = append(trace.Attempts, attemptTrace)
trace.Error = "marshal error"
writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error") writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error")
return return
} }
ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount) handled, success, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount, &attemptTrace, &trace)
if ok { attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
trace.Attempts = append(trace.Attempts, attemptTrace)
trace.FinalProvider = p.ID
trace.FinalModel = t.Model
trace.FinalURL = upstreamURL
trace.StatusCode = attemptTrace.StatusCode
if trace.FirstByteMs == 0 {
trace.FirstByteMs = attemptTrace.FirstByteMs
}
if handled {
trace.Success = success
if !success {
trace.Error = errorString(upstreamErr)
}
return return
} }
if !retryable { if !retryable {
s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr) s.logger.Printf("req=%d alias=%s attempt=%d final failure: %v", reqID, aliasName, attempt+1, upstreamErr)
trace.Error = errorString(upstreamErr)
return return
} }
if failure != nil { if failure != nil {
@ -221,6 +306,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
} }
if lastRetryable != nil { if lastRetryable != nil {
trace.StatusCode = lastRetryable.status
trace.Error = fmt.Sprintf("upstream %d", lastRetryable.status)
copyResponseHeaders(w.Header(), lastRetryable.header) copyResponseHeaders(w.Header(), lastRetryable.header)
w.WriteHeader(lastRetryable.status) w.WriteHeader(lastRetryable.status)
if len(lastRetryable.body) > 0 { if len(lastRetryable.body) > 0 {
@ -229,11 +316,13 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
return return
} }
trace.StatusCode = http.StatusBadGateway
trace.Error = fmt.Sprintf("all upstream targets failed for alias %q", aliasName)
writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName)) writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName))
} }
// tryOnce proxies one attempt. Returns (ok, retryable, err, failure). // tryOnce proxies one attempt. Returns (handled, success, retryable, err, failure).
// ok=true means successful response fully/partially written to client. // handled=true means a downstream response has already been started or completed.
// retryable=true means failure happened before any bytes flushed downstream. // retryable=true means failure happened before any bytes flushed downstream.
func (s *Server) tryOnce( func (s *Server) tryOnce(
ctx context.Context, ctx context.Context,
@ -245,14 +334,16 @@ func (s *Server) tryOnce(
aliasName string, aliasName string,
attempt int, attempt int,
failoverCount int, failoverCount int,
) (ok bool, retryable bool, err error, failure *upstreamFailure) { attemptTrace *TraceAttempt,
trace *RequestTrace,
) (handled bool, success bool, retryable bool, err error, failure *upstreamFailure) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses" upstreamURL := strings.TrimRight(provider.BaseURL, "/") + "/responses"
upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil { if err != nil {
return false, false, fmt.Errorf("build request: %w", err), nil return false, false, false, fmt.Errorf("build request: %w", err), nil
} }
copyForwardHeaders(upReq.Header, clientReq.Header) copyForwardHeaders(upReq.Header, clientReq.Header)
upReq.Header.Set("Content-Type", "application/json") upReq.Header.Set("Content-Type", "application/json")
@ -264,44 +355,98 @@ func (s *Server) tryOnce(
upReq.Header.Set(k, v) upReq.Header.Set(k, v)
} }
upReq.ContentLength = int64(len(body)) upReq.ContentLength = int64(len(body))
if attemptTrace != nil {
attemptTrace.RequestHeaders = sanitizeHeaderMap(upReq.Header)
}
startedAt := time.Now() startedAt := time.Now()
firstByteTimeout := timeoutDuration(s.cfg.Server.FirstByteTimeoutMs, config.DefaultFirstByteTimeoutMs)
resp, err := s.client.Do(upReq) resp, err := s.client.Do(upReq)
if err != nil { if err != nil {
return false, true, fmt.Errorf("upstream dial/transport: %w", err), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "transport_error"
attemptTrace.Error = fmt.Sprintf("upstream dial/transport: %v", err)
}
return false, false, true, fmt.Errorf("upstream dial/transport: %w", err), nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if attemptTrace != nil {
attemptTrace.StatusCode = resp.StatusCode
attemptTrace.ResponseHeaders = sanitizeHeaderMap(resp.Header)
}
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
failure = captureRetryableFailure(resp) failure = captureRetryableFailure(resp)
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure sanitizedBody := sanitizeResponseBody(resp.Header.Get("Content-Type"), failure.body)
if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "retryable_failure"
attemptTrace.Error = fmt.Sprintf("upstream %d: %s", resp.StatusCode, sanitizedBody)
attemptTrace.ResponseBody = sanitizedBody
}
return false, false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, sanitizedBody), failure
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode) s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
if attemptTrace != nil {
attemptTrace.Result = "final_failure"
}
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount) s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
copyResponseHeaders(w.Header(), resp.Header) copyResponseHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), nil if attemptTrace != nil {
attemptTrace.ResponseBody = sanitizeResponseBody(resp.Header.Get("Content-Type"), bodyBytes)
}
_, _ = w.Write(bodyBytes)
return true, false, false, fmt.Errorf("upstream %d", resp.StatusCode), nil
} }
remaining := firstByteTimeout - time.Since(startedAt) remaining := firstByteTimeout - time.Since(startedAt)
if remaining <= 0 { if remaining <= 0 {
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
} }
firstChunk, firstErr := readFirstChunk(resp.Body, remaining) firstChunk, firstErr := readFirstChunk(resp.Body, remaining)
if firstErr != nil { if firstErr != nil {
if errors.Is(firstErr, errFirstByteTimeout) { if errors.Is(firstErr, errFirstByteTimeout) {
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "first_byte_timeout"
attemptTrace.Error = fmt.Sprintf("upstream first byte timeout after %s", firstByteTimeout)
}
return false, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
} }
if errors.Is(firstErr, io.EOF) { if errors.Is(firstErr, io.EOF) {
if len(firstChunk) == 0 { if len(firstChunk) == 0 {
firstChunk = nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "empty_response"
attemptTrace.Error = "upstream closed before first byte"
}
return false, false, true, fmt.Errorf("upstream closed before first byte"), nil
} }
} else { } else {
return false, true, fmt.Errorf("upstream first read: %w", firstErr), nil if attemptTrace != nil {
attemptTrace.Retryable = true
attemptTrace.Result = "first_read_error"
attemptTrace.Error = fmt.Sprintf("upstream first read: %v", firstErr)
}
return false, false, true, fmt.Errorf("upstream first read: %w", firstErr), nil
} }
} }
if attemptTrace != nil {
attemptTrace.FirstByteMs = time.Since(startedAt).Milliseconds()
}
if trace != nil && trace.FirstByteMs == 0 && attemptTrace != nil {
trace.FirstByteMs = attemptTrace.FirstByteMs
}
isEventStream := false
streamIdleTimeout := timeoutDuration(s.cfg.Server.StreamIdleTimeoutMs, config.DefaultStreamIdleTimeoutMs)
if mediaType, _, parseErr := mime.ParseMediaType(resp.Header.Get("Content-Type")); parseErr == nil {
isEventStream = mediaType == "text/event-stream"
}
s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode) s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode)
s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount) s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount)
@ -310,7 +455,11 @@ func (s *Server) tryOnce(
flusher, _ := w.(http.Flusher) flusher, _ := w.(http.Flusher)
if len(firstChunk) > 0 { if len(firstChunk) > 0 {
if _, werr := w.Write(firstChunk); werr != nil { if _, werr := w.Write(firstChunk); werr != nil {
return true, false, werr, nil if attemptTrace != nil {
attemptTrace.Result = "downstream_write_error"
attemptTrace.Error = werr.Error()
}
return true, false, false, werr, nil
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@ -318,10 +467,22 @@ func (s *Server) tryOnce(
} }
buf := make([]byte, 16<<10) buf := make([]byte, 16<<10)
for { for {
n, rerr := readChunkWithTimeout(resp.Body, buf, streamIdleTimeout) var (
n int
rerr error
)
if isEventStream {
n, rerr = resp.Body.Read(buf)
} else {
n, rerr = readChunkWithTimeout(resp.Body, buf, streamIdleTimeout)
}
if n > 0 { if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil { if _, werr := w.Write(buf[:n]); werr != nil {
return true, false, werr, nil if attemptTrace != nil {
attemptTrace.Result = "downstream_write_error"
attemptTrace.Error = werr.Error()
}
return true, false, false, werr, nil
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
@ -329,13 +490,29 @@ func (s *Server) tryOnce(
} }
if rerr != nil { if rerr != nil {
if errors.Is(rerr, io.EOF) { if errors.Is(rerr, io.EOF) {
return true, false, nil, nil if attemptTrace != nil {
attemptTrace.Result = "success"
attemptTrace.Success = true
}
return true, true, false, nil, nil
} }
return true, false, rerr, nil s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream body read failed after response start: %v", aliasName, attempt, provider.ID, target.Model, rerr)
if attemptTrace != nil {
attemptTrace.Result = "stream_error"
attemptTrace.Error = rerr.Error()
}
return true, false, false, rerr, nil
} }
} }
} }
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
var errFirstByteTimeout = errors.New("first byte timeout") var errFirstByteTimeout = errors.New("first byte timeout")
var errStreamIdleTimeout = errors.New("stream idle timeout") var errStreamIdleTimeout = errors.New("stream idle timeout")
@ -433,6 +610,20 @@ func requestReadError(err error) (int, string) {
} }
} }
func timeoutDuration(value int, fallback int) time.Duration {
if value <= 0 {
value = fallback
}
return time.Duration(value) * time.Millisecond
}
func minDuration(a time.Duration, b time.Duration) time.Duration {
if a <= b {
return a
}
return b
}
func normalizeAliasName(model string) string { func normalizeAliasName(model string) string {
prefix := config.AppName + "/" prefix := config.AppName + "/"
if strings.HasPrefix(model, prefix) { if strings.HasPrefix(model, prefix) {

View File

@ -105,6 +105,187 @@ func TestHandleResponsesFailsOverOn429(t *testing.T) {
if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" { if got := rr.Header().Get("X-OCSWITCH-Provider"); got != "p2" {
t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got) t.Fatalf("X-OCSWITCH-Provider = %q, want p2", got)
} }
traces := srv.traces.List(10)
if len(traces) != 1 {
t.Fatalf("trace count = %d, want 1", len(traces))
}
if !traces[0].Failover || traces[0].FinalProvider != "p2" || traces[0].AttemptCount != 2 {
t.Fatalf("trace = %#v", traces[0])
}
if got := traces[0].RequestHeaders["Authorization"]; got == "Bearer "+config.DefaultLocalAPIKey || got == "" {
t.Fatalf("trace auth header = %q, want masked value", got)
}
}
func TestHandleResponsesFailsOverOnEmptySSE200(t *testing.T) {
t.Parallel()
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
}))
defer first.Close()
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: ok\n\n"))
}))
defer second.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{
{ID: "p1", BaseURL: first.URL + "/v1"},
{ID: "p2", BaseURL: second.URL + "/v1"},
},
Aliases: []config.Alias{{
Alias: "gpt-5.4",
Enabled: true,
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}, {Provider: "p2", Model: "up-2", Enabled: true}},
}},
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder()
srv.handleResponses(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
if body := rr.Body.String(); body != "data: ok\n\n" {
t.Fatalf("body = %q, want SSE payload from second upstream", body)
}
if got := rr.Header().Get("X-OCSWITCH-Attempt"); got != "2" {
t.Fatalf("X-OCSWITCH-Attempt = %q, want 2", got)
}
}
func TestHandleResponsesSSEBypassesIdleTimeout(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: first\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
time.Sleep(90 * time.Millisecond)
_, _ = w.Write([]byte("data: second\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}))
defer upstream.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: config.DefaultLocalAPIKey, StreamIdleTimeoutMs: 30},
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
Aliases: []config.Alias{{
Alias: "gpt-5.4",
Enabled: true,
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}},
}},
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder()
srv.handleResponses(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
if body := rr.Body.String(); body != "data: first\n\ndata: second\n\n" {
t.Fatalf("body = %q, want both SSE chunks", body)
}
}
func TestHandleResponsesMarksBrokenStreamAsFailure(t *testing.T) {
t.Parallel()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: first\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
hijacker, ok := w.(http.Hijacker)
if !ok {
t.Fatal("response writer does not support hijacking")
}
conn, _, err := hijacker.Hijack()
if err != nil {
t.Fatalf("Hijack() error = %v", err)
}
_ = conn.Close()
}))
defer upstream.Close()
srv := New(&config.Config{
Server: config.Server{APIKey: config.DefaultLocalAPIKey},
Providers: []config.Provider{{ID: "p1", BaseURL: upstream.URL + "/v1"}},
Aliases: []config.Alias{{
Alias: "gpt-5.4",
Enabled: true,
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}},
}},
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
req.Header.Set("Authorization", "Bearer "+config.DefaultLocalAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
rr := httptest.NewRecorder()
srv.handleResponses(rr, req)
traces := srv.traces.List(10)
if len(traces) != 1 {
t.Fatalf("trace count = %d, want 1", len(traces))
}
if traces[0].Success {
t.Fatalf("trace = %#v, want failed trace", traces[0])
}
if traces[0].Error == "" {
t.Fatalf("trace = %#v, want error", traces[0])
}
if len(traces[0].Attempts) != 1 {
t.Fatalf("attempts = %#v, want 1", traces[0].Attempts)
}
if traces[0].Attempts[0].Success {
t.Fatalf("attempt = %#v, want failed attempt", traces[0].Attempts[0])
}
if traces[0].Attempts[0].Result != "stream_error" {
t.Fatalf("attempt result = %q, want stream_error", traces[0].Attempts[0].Result)
}
}
func TestNewUsesConfiguredTimeouts(t *testing.T) {
t.Parallel()
srv := New(&config.Config{Server: config.Server{
ConnectTimeoutMs: 12000,
ResponseHeaderTimeoutMs: 21000,
FirstByteTimeoutMs: 22000,
RequestReadTimeoutMs: 33000,
StreamIdleTimeoutMs: 70000,
}})
transport, ok := srv.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T", srv.client.Transport)
}
if transport.ResponseHeaderTimeout != 21*time.Second {
t.Fatalf("ResponseHeaderTimeout = %s, want 21s", transport.ResponseHeaderTimeout)
}
} }
func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) { func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {

260
internal/proxy/traces.go Normal file
View File

@ -0,0 +1,260 @@
package proxy
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
)
const defaultTraceLimit = 200
type TraceStore struct {
mu sync.Mutex
limit int
traces []RequestTrace
}
type RequestTrace struct {
ID uint64 `json:"id"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
FirstByteMs int64 `json:"firstByteMs,omitempty"`
RawModel string `json:"rawModel,omitempty"`
Alias string `json:"alias,omitempty"`
Stream bool `json:"stream"`
Success bool `json:"success"`
StatusCode int `json:"statusCode,omitempty"`
Error string `json:"error,omitempty"`
FinalProvider string `json:"finalProvider,omitempty"`
FinalModel string `json:"finalModel,omitempty"`
FinalURL string `json:"finalUrl,omitempty"`
Failover bool `json:"failover"`
AttemptCount int `json:"attemptCount"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestParams any `json:"requestParams,omitempty"`
Attempts []TraceAttempt `json:"attempts"`
}
type TraceAttempt struct {
Attempt int `json:"attempt"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
URL string `json:"url,omitempty"`
StartedAt time.Time `json:"startedAt"`
DurationMs int64 `json:"durationMs"`
FirstByteMs int64 `json:"firstByteMs,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Success bool `json:"success"`
Retryable bool `json:"retryable"`
Skipped bool `json:"skipped"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
RequestHeaders map[string]string `json:"requestHeaders,omitempty"`
RequestParams any `json:"requestParams,omitempty"`
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
ResponseBody string `json:"responseBody,omitempty"`
}
func NewTraceStore(limit int) *TraceStore {
if limit <= 0 {
limit = defaultTraceLimit
}
return &TraceStore{limit: limit}
}
func (s *TraceStore) Add(trace RequestTrace) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
clone := cloneTrace(trace)
s.traces = append([]RequestTrace{clone}, s.traces...)
if len(s.traces) > s.limit {
s.traces = s.traces[:s.limit]
}
}
func (s *TraceStore) List(limit int) []RequestTrace {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if limit <= 0 || limit > len(s.traces) {
limit = len(s.traces)
}
out := make([]RequestTrace, 0, limit)
for _, trace := range s.traces[:limit] {
out = append(out, cloneTrace(trace))
}
return out
}
func cloneTrace(trace RequestTrace) RequestTrace {
trace.RequestHeaders = cloneStringMap(trace.RequestHeaders)
trace.RequestParams = cloneJSONValue(trace.RequestParams)
if len(trace.Attempts) == 0 {
trace.Attempts = []TraceAttempt{}
return trace
}
trace.Attempts = append([]TraceAttempt(nil), trace.Attempts...)
for index := range trace.Attempts {
trace.Attempts[index].RequestHeaders = cloneStringMap(trace.Attempts[index].RequestHeaders)
trace.Attempts[index].RequestParams = cloneJSONValue(trace.Attempts[index].RequestParams)
trace.Attempts[index].ResponseHeaders = cloneStringMap(trace.Attempts[index].ResponseHeaders)
}
return trace
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneJSONValue(value any) any {
switch typed := value.(type) {
case map[string]any:
out := make(map[string]any, len(typed))
for key, nested := range typed {
out[key] = cloneJSONValue(nested)
}
return out
case []any:
out := make([]any, len(typed))
for index, nested := range typed {
out[index] = cloneJSONValue(nested)
}
return out
default:
return typed
}
}
var sensitiveHeaderNames = map[string]bool{
"authorization": true,
"proxy-authorization": true,
"x-api-key": true,
"api-key": true,
"cookie": true,
"set-cookie": true,
}
var redactedPayloadKeys = map[string]bool{
"content": true,
"input": true,
"instructions": true,
"messages": true,
"output": true,
"output_text": true,
"prompt": true,
"response": true,
"text": true,
}
func sanitizeHeaderMap(header http.Header) map[string]string {
if len(header) == 0 {
return nil
}
keys := make([]string, 0, len(header))
for key := range header {
keys = append(keys, key)
}
sort.Strings(keys)
out := make(map[string]string, len(keys))
for _, key := range keys {
values := header.Values(key)
joined := strings.Join(values, ", ")
if sensitiveHeaderNames[strings.ToLower(key)] {
joined = maskSensitiveValue(joined)
}
out[key] = joined
}
return out
}
func sanitizeJSONValue(key string, value any) any {
if redactedPayloadKeys[strings.ToLower(strings.TrimSpace(key))] {
return redactedSummary(value)
}
switch typed := value.(type) {
case map[string]any:
out := make(map[string]any, len(typed))
keys := make([]string, 0, len(typed))
for nestedKey := range typed {
keys = append(keys, nestedKey)
}
sort.Strings(keys)
for _, nestedKey := range keys {
out[nestedKey] = sanitizeJSONValue(nestedKey, typed[nestedKey])
}
return out
case []any:
out := make([]any, len(typed))
for index, nested := range typed {
out[index] = sanitizeJSONValue("", nested)
}
return out
default:
return typed
}
}
func redactedSummary(value any) any {
switch typed := value.(type) {
case []any:
return fmt.Sprintf("<redacted %d item(s)>", len(typed))
case map[string]any:
return fmt.Sprintf("<redacted object with %d key(s)>", len(typed))
case string:
if typed == "" {
return "<redacted>"
}
return fmt.Sprintf("<redacted %d chars>", len(typed))
default:
return "<redacted>"
}
}
func sanitizeResponseBody(contentType string, body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return ""
}
if strings.Contains(strings.ToLower(contentType), "json") {
var payload any
if err := json.Unmarshal([]byte(trimmed), &payload); err == nil {
sanitized := sanitizeJSONValue("", payload)
encoded, err := json.Marshal(sanitized)
if err == nil {
return truncate(string(encoded), 200)
}
}
}
if redacted, ok := redactedSummary(trimmed).(string); ok {
return redacted
}
return "<redacted>"
}
func maskSensitiveValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "***"
}
if len(trimmed) <= 8 {
return "***"
}
return trimmed[:4] + "..." + trimmed[len(trimmed)-4:]
}

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

View File

@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "go",
"changelog-path": "CHANGELOG.md"
}
}
}

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