Compare commits
1 Commits
eb46fa3bd3
...
cc3359f773
| Author | SHA1 | Date | |
|---|---|---|---|
| cc3359f773 |
138
.github/workflows/release-assets.yml
vendored
138
.github/workflows/release-assets.yml
vendored
@ -1,138 +0,0 @@
|
|||||||
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
22
.github/workflows/release-please.yml
vendored
@ -1,22 +0,0 @@
|
|||||||
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
15
.github/workflows/release-precheck.md
vendored
@ -1,15 +0,0 @@
|
|||||||
# 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.
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,5 @@
|
|||||||
/dist/
|
/dist/
|
||||||
*.tmp
|
*.tmp
|
||||||
output
|
output
|
||||||
build/
|
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.4.6"
|
"@opencode-ai/plugin": "1.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
".": "0.0.0"
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"}
|
|
||||||
{"file": ".opencode/commands/trellis/check.md", "reason": "Code quality check spec"}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{"file": ".opencode/commands/trellis/check.md", "reason": "Code quality check spec"}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"}
|
|
||||||
@ -1,563 +0,0 @@
|
|||||||
# 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 布局重构,避免同时改数据层与视觉层
|
|
||||||
|
|
||||||
这是当前仓库里风险最低、可持续迭代的推进顺序。
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
# 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
|
|
||||||
64
README.md
64
README.md
@ -54,70 +54,6 @@ 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
|
||||||
|
|||||||
66
README_EN.md
66
README_EN.md
@ -21,72 +21,6 @@ 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
|
||||||
|
|||||||
@ -2,65 +2,12 @@ package frontend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed dist/*
|
//go:embed dist/*
|
||||||
var assets embed.FS
|
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) {
|
func DistFS() (fs.FS, error) {
|
||||||
return fs.Sub(assets, "dist")
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ocswitch desktop</title>
|
<title>ocswitch desktop</title>
|
||||||
|
<script>
|
||||||
|
window.__OCSWITCH_WAILS__ = typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined'
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
@ -8,10 +8,8 @@
|
|||||||
"name": "ocswitch-desktop-frontend",
|
"name": "ocswitch-desktop-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.0.2",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1"
|
||||||
"react-i18next": "^15.5.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
@ -52,7 +50,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -256,15 +253,6 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.29.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
|
|
||||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
||||||
@ -1227,7 +1215,6 @@
|
|||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@ -1297,7 +1284,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@ -1467,47 +1453,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"void-elements": "3.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/i18next": {
|
|
||||||
"version": "25.10.10",
|
|
||||||
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.10.10.tgz",
|
|
||||||
"integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://www.locize.com/i18next"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://www.locize.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.29.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5 || ^6"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"typescript": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -1608,7 +1553,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -1650,7 +1594,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -1671,32 +1614,6 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
|
||||||
"version": "15.7.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.7.4.tgz",
|
|
||||||
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.27.6",
|
|
||||||
"html-parse-stringify": "^3.0.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"i18next": ">= 23.4.0",
|
|
||||||
"react": ">= 16.8.0",
|
|
||||||
"typescript": "^5"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-native": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@ -1802,9 +1719,8 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -1850,7 +1766,6 @@
|
|||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@ -1920,15 +1835,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/void-elements": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -9,9 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.0.2",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-i18next": "^15.5.3",
|
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
30ca7bcbca69c2b717e8a2e8cbadd5c5
|
2f0b51396a8b6c44ea214228c533bdbe
|
||||||
2826
frontend/src/App.tsx
2826
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
AliasTargetInput,
|
|
||||||
AliasUpsertInput,
|
|
||||||
ConfigExportView,
|
|
||||||
ConfigImportInput,
|
|
||||||
ConfigImportResult,
|
|
||||||
DesktopPrefsSaveResult,
|
|
||||||
AliasView,
|
AliasView,
|
||||||
DesktopPrefsView,
|
DesktopPrefsView,
|
||||||
DoctorRunResult,
|
DoctorRunResult,
|
||||||
MetaView,
|
MetaView,
|
||||||
Overview,
|
Overview,
|
||||||
ProviderImportInput,
|
|
||||||
ProviderImportResult,
|
|
||||||
ProviderSaveResult,
|
|
||||||
ProviderStateInput,
|
|
||||||
ProviderUpsertInput,
|
|
||||||
ProviderView,
|
ProviderView,
|
||||||
ProxySettingsSaveResult,
|
|
||||||
ProxySettingsView,
|
|
||||||
ProxyStatusView,
|
ProxyStatusView,
|
||||||
RequestTrace,
|
|
||||||
SyncInput,
|
SyncInput,
|
||||||
SyncPreview,
|
SyncPreview,
|
||||||
SyncResult,
|
SyncResult,
|
||||||
@ -31,7 +17,7 @@ type ApiEnvelope<T> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWails(): boolean {
|
function isWails(): boolean {
|
||||||
return typeof window.go?.desktop?.App !== 'undefined'
|
return Boolean(window.__OCSWITCH_WAILS__ && window.go?.main?.App)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@ -47,7 +33,7 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bridge() {
|
function bridge() {
|
||||||
const app = window.go?.desktop?.App
|
const app = window.go?.main?.App
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new Error('Wails bridge unavailable')
|
throw new Error('Wails bridge unavailable')
|
||||||
}
|
}
|
||||||
@ -62,32 +48,10 @@ export async function getMeta(): Promise<MetaView> {
|
|||||||
return http<MetaView>('/api/meta')
|
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> {
|
export function getOverview(): Promise<Overview> {
|
||||||
return isWails() ? bridge().Overview() : http<Overview>('/api/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[]> {
|
export function listProviders(): Promise<ProviderView[]> {
|
||||||
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
|
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
|
||||||
}
|
}
|
||||||
@ -96,72 +60,14 @@ export function listAliases(): Promise<AliasView[]> {
|
|||||||
return isWails() ? bridge().Aliases() : http<AliasView[]>('/api/aliases')
|
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> {
|
export function getDesktopPrefs(): Promise<DesktopPrefsView> {
|
||||||
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
|
return isWails() ? bridge().DesktopPrefs() : http<DesktopPrefsView>('/api/desktop-prefs')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsSaveResult> {
|
export function saveDesktopPrefs(input: DesktopPrefsView): Promise<DesktopPrefsView> {
|
||||||
return isWails()
|
return isWails()
|
||||||
? bridge().SavePrefs(input)
|
? bridge().SavePrefs(input)
|
||||||
: http<DesktopPrefsSaveResult>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
|
: http<DesktopPrefsView>('/api/desktop-prefs', { method: 'POST', body: JSON.stringify(input) })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runDoctor(): Promise<DoctorRunResult> {
|
export function runDoctor(): Promise<DoctorRunResult> {
|
||||||
@ -172,20 +78,6 @@ export function getProxyStatus(): Promise<ProxyStatusView> {
|
|||||||
return isWails() ? bridge().ProxyStatus() : http<ProxyStatusView>('/api/proxy/status')
|
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> {
|
export function startProxy(): Promise<ProxyStatusView> {
|
||||||
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
|
return isWails() ? bridge().StartProxy() : http<ProxyStatusView>('/api/proxy/start', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB |
@ -1,61 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
30
frontend/src/env.d.ts
vendored
30
frontend/src/env.d.ts
vendored
@ -1,45 +1,21 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare module '*.png' {
|
|
||||||
const src: string
|
|
||||||
export default src
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.svg' {
|
|
||||||
const src: string
|
|
||||||
export default src
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
__OCSWITCH_WAILS__?: boolean
|
||||||
go?: {
|
go?: {
|
||||||
desktop?: {
|
main?: {
|
||||||
App?: {
|
App?: {
|
||||||
Meta: () => Promise<Record<string, string>>
|
Meta: () => Promise<Record<string, string>>
|
||||||
OpenExternalURL: (url: string) => Promise<void>
|
|
||||||
Overview: () => Promise<import('./types').Overview>
|
Overview: () => Promise<import('./types').Overview>
|
||||||
ExportConfig: () => Promise<import('./types').ConfigExportView>
|
|
||||||
ImportConfig: (input: import('./types').ConfigImportInput) => Promise<import('./types').ConfigImportResult>
|
|
||||||
Providers: () => Promise<import('./types').ProviderView[]>
|
Providers: () => Promise<import('./types').ProviderView[]>
|
||||||
Aliases: () => Promise<import('./types').AliasView[]>
|
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>
|
DoctorRun: () => Promise<import('./types').DoctorRunResult>
|
||||||
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
ProxyStatus: () => Promise<import('./types').ProxyStatusView>
|
||||||
ProxySettings: () => Promise<import('./types').ProxySettingsView>
|
|
||||||
RequestTraces: (limit: number) => Promise<import('./types').RequestTrace[]>
|
|
||||||
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
StartProxy: () => Promise<import('./types').ProxyStatusView>
|
||||||
SaveProxySettings: (input: import('./types').ProxySettingsView) => Promise<import('./types').ProxySettingsSaveResult>
|
|
||||||
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
StopProxy: () => Promise<import('./types').ProxyStatusView>
|
||||||
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
DesktopPrefs: () => Promise<import('./types').DesktopPrefsView>
|
||||||
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsSaveResult>
|
SavePrefs: (input: import('./types').DesktopPrefsView) => Promise<import('./types').DesktopPrefsView>
|
||||||
PreviewSync: (input: import('./types').SyncInput) => Promise<import('./types').SyncPreview>
|
PreviewSync: (input: import('./types').SyncInput) => Promise<import('./types').SyncPreview>
|
||||||
ApplySync: (input: import('./types').SyncInput) => Promise<import('./types').SyncResult>
|
ApplySync: (input: import('./types').SyncInput) => Promise<import('./types').SyncResult>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,336 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,336 +0,0 @@
|
|||||||
{
|
|
||||||
"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}}\" 无效,名称不能为空。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './i18n'
|
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,46 +1,7 @@
|
|||||||
export type ThemePreference = 'system' | 'light' | 'dark'
|
|
||||||
|
|
||||||
export type LanguagePreference = 'system' | 'en-US' | 'zh-CN'
|
|
||||||
|
|
||||||
export type DesktopPrefsView = {
|
export type DesktopPrefsView = {
|
||||||
launchAtLogin: boolean
|
launchAtLogin: boolean
|
||||||
autoStartProxy: boolean
|
|
||||||
minimizeToTray: boolean
|
minimizeToTray: boolean
|
||||||
notifications: 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 = {
|
export type ProxyStatusView = {
|
||||||
@ -50,48 +11,6 @@ export type ProxyStatusView = {
|
|||||||
lastError?: 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 = {
|
export type Overview = {
|
||||||
configPath: string
|
configPath: string
|
||||||
providerCount: number
|
providerCount: number
|
||||||
@ -113,39 +32,6 @@ export type ProviderView = {
|
|||||||
disabled: boolean
|
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 = {
|
export type AliasTargetView = {
|
||||||
provider: string
|
provider: string
|
||||||
model: string
|
model: string
|
||||||
@ -161,19 +47,6 @@ export type AliasView = {
|
|||||||
targets: AliasTargetView[]
|
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 = {
|
export type DoctorIssue = {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
38
frontend/wailsjs/go/desktop/App.d.ts
vendored
38
frontend/wailsjs/go/desktop/App.d.ts
vendored
@ -1,65 +1,39 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {app} from '../models';
|
import {app} from '../models';
|
||||||
import {context} from '../models';
|
|
||||||
import {desktop} from '../models';
|
import {desktop} from '../models';
|
||||||
|
import {context} from '../models';
|
||||||
|
|
||||||
export function Aliases():Promise<Array<app.AliasView>>;
|
export function Aliases():Promise<Array<app.AliasView>>;
|
||||||
|
|
||||||
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
|
export function ApplySync(arg1:app.SyncInput):Promise<app.SyncResult>;
|
||||||
|
|
||||||
|
export function AutoStart():Promise<desktop.AutoStart>;
|
||||||
|
|
||||||
export function BeforeClose(arg1:context.Context):Promise<boolean>;
|
export function BeforeClose(arg1:context.Context):Promise<boolean>;
|
||||||
|
|
||||||
export function BindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
|
||||||
|
|
||||||
export function Bindings():Promise<desktop.Bindings>;
|
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 DesktopPrefs():Promise<app.DesktopPrefsView>;
|
||||||
|
|
||||||
export function DoctorRun():Promise<app.DoctorRunResult>;
|
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 Meta():Promise<Record<string, string>>;
|
||||||
|
|
||||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
|
||||||
|
|
||||||
export function Overview():Promise<app.Overview>;
|
export function Overview():Promise<app.Overview>;
|
||||||
|
|
||||||
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
|
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;
|
||||||
|
|
||||||
export function Providers():Promise<Array<app.ProviderView>>;
|
export function Providers():Promise<Array<app.ProviderView>>;
|
||||||
|
|
||||||
export function ProxySettings():Promise<app.ProxySettingsView>;
|
|
||||||
|
|
||||||
export function ProxyStatus():Promise<app.ProxyStatusView>;
|
export function ProxyStatus():Promise<app.ProxyStatusView>;
|
||||||
|
|
||||||
export function RequestTraces(arg1:number):Promise<Array<app.RequestTrace>>;
|
export function SaveDesktopPrefs(arg1:context.Context,arg2:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
|
||||||
|
|
||||||
export function SaveAlias(arg1:app.AliasUpsertInput):Promise<app.AliasView>;
|
export function SavePrefs(arg1:app.DesktopPrefsInput):Promise<app.DesktopPrefsView>;
|
||||||
|
|
||||||
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 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 SetVersion(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function Shutdown(arg1:context.Context):Promise<void>;
|
export function Shutdown(arg1:context.Context):Promise<void>;
|
||||||
@ -72,4 +46,4 @@ export function StopProxy():Promise<app.ProxyStatusView>;
|
|||||||
|
|
||||||
export function SyncDesktopPreferences(arg1:context.Context):Promise<void>;
|
export function SyncDesktopPreferences(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
export function UnbindTarget(arg1:app.AliasTargetInput):Promise<app.AliasView>;
|
export function Tray():Promise<desktop.Tray>;
|
||||||
|
|||||||
@ -10,26 +10,18 @@ export function ApplySync(arg1) {
|
|||||||
return window['go']['desktop']['App']['ApplySync'](arg1);
|
return window['go']['desktop']['App']['ApplySync'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AutoStart() {
|
||||||
|
return window['go']['desktop']['App']['AutoStart']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BeforeClose(arg1) {
|
export function BeforeClose(arg1) {
|
||||||
return window['go']['desktop']['App']['BeforeClose'](arg1);
|
return window['go']['desktop']['App']['BeforeClose'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BindTarget(arg1) {
|
|
||||||
return window['go']['desktop']['App']['BindTarget'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Bindings() {
|
export function Bindings() {
|
||||||
return window['go']['desktop']['App']['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() {
|
export function DesktopPrefs() {
|
||||||
return window['go']['desktop']['App']['DesktopPrefs']();
|
return window['go']['desktop']['App']['DesktopPrefs']();
|
||||||
}
|
}
|
||||||
@ -38,26 +30,10 @@ export function DoctorRun() {
|
|||||||
return window['go']['desktop']['App']['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() {
|
export function Meta() {
|
||||||
return window['go']['desktop']['App']['Meta']();
|
return window['go']['desktop']['App']['Meta']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenExternalURL(arg1) {
|
|
||||||
return window['go']['desktop']['App']['OpenExternalURL'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Overview() {
|
export function Overview() {
|
||||||
return window['go']['desktop']['App']['Overview']();
|
return window['go']['desktop']['App']['Overview']();
|
||||||
}
|
}
|
||||||
@ -70,22 +46,10 @@ export function Providers() {
|
|||||||
return window['go']['desktop']['App']['Providers']();
|
return window['go']['desktop']['App']['Providers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProxySettings() {
|
|
||||||
return window['go']['desktop']['App']['ProxySettings']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProxyStatus() {
|
export function ProxyStatus() {
|
||||||
return window['go']['desktop']['App']['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) {
|
export function SaveDesktopPrefs(arg1, arg2) {
|
||||||
return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2);
|
return window['go']['desktop']['App']['SaveDesktopPrefs'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@ -94,26 +58,10 @@ export function SavePrefs(arg1) {
|
|||||||
return window['go']['desktop']['App']['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() {
|
export function Service() {
|
||||||
return window['go']['desktop']['App']['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) {
|
export function SetVersion(arg1) {
|
||||||
return window['go']['desktop']['App']['SetVersion'](arg1);
|
return window['go']['desktop']['App']['SetVersion'](arg1);
|
||||||
}
|
}
|
||||||
@ -138,6 +86,6 @@ export function SyncDesktopPreferences(arg1) {
|
|||||||
return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1);
|
return window['go']['desktop']['App']['SyncDesktopPreferences'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnbindTarget(arg1) {
|
export function Tray() {
|
||||||
return window['go']['desktop']['App']['UnbindTarget'](arg1);
|
return window['go']['desktop']['App']['Tray']();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,5 @@
|
|||||||
export namespace app {
|
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 {
|
export class AliasTargetView {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
@ -34,22 +16,6 @@ export namespace app {
|
|||||||
this.enabled = source["enabled"];
|
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 {
|
export class AliasView {
|
||||||
alias: string;
|
alias: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@ -90,53 +56,10 @@ export namespace app {
|
|||||||
return 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 {
|
export class DesktopPrefsInput {
|
||||||
launchAtLogin: boolean;
|
launchAtLogin: boolean;
|
||||||
autoStartProxy: boolean;
|
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
theme: string;
|
|
||||||
language: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new DesktopPrefsInput(source);
|
return new DesktopPrefsInput(source);
|
||||||
@ -145,20 +68,14 @@ export namespace app {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.launchAtLogin = source["launchAtLogin"];
|
this.launchAtLogin = source["launchAtLogin"];
|
||||||
this.autoStartProxy = source["autoStartProxy"];
|
|
||||||
this.minimizeToTray = source["minimizeToTray"];
|
this.minimizeToTray = source["minimizeToTray"];
|
||||||
this.notifications = source["notifications"];
|
this.notifications = source["notifications"];
|
||||||
this.theme = source["theme"];
|
|
||||||
this.language = source["language"];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class DesktopPrefsView {
|
export class DesktopPrefsView {
|
||||||
launchAtLogin: boolean;
|
launchAtLogin: boolean;
|
||||||
autoStartProxy: boolean;
|
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
theme: string;
|
|
||||||
language: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new DesktopPrefsView(source);
|
return new DesktopPrefsView(source);
|
||||||
@ -167,46 +84,10 @@ export namespace app {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.launchAtLogin = source["launchAtLogin"];
|
this.launchAtLogin = source["launchAtLogin"];
|
||||||
this.autoStartProxy = source["autoStartProxy"];
|
|
||||||
this.minimizeToTray = source["minimizeToTray"];
|
this.minimizeToTray = source["minimizeToTray"];
|
||||||
this.notifications = source["notifications"];
|
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 {
|
export class DoctorIssue {
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
@ -298,7 +179,8 @@ export namespace app {
|
|||||||
export class ProxyStatusView {
|
export class ProxyStatusView {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
bindAddress: string;
|
bindAddress: string;
|
||||||
startedAt?: string;
|
// Go type: time
|
||||||
|
startedAt?: any;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@ -309,9 +191,27 @@ export namespace app {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.running = source["running"];
|
this.running = source["running"];
|
||||||
this.bindAddress = source["bindAddress"];
|
this.bindAddress = source["bindAddress"];
|
||||||
this.startedAt = source["startedAt"];
|
this.startedAt = this.convertValues(source["startedAt"], null);
|
||||||
this.lastError = source["lastError"];
|
this.lastError = source["lastError"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export class Overview {
|
export class Overview {
|
||||||
configPath: string;
|
configPath: string;
|
||||||
@ -353,38 +253,6 @@ export namespace app {
|
|||||||
return 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 {
|
export class ProviderView {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -413,263 +281,7 @@ export namespace app {
|
|||||||
this.disabled = source["disabled"];
|
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 {
|
export class Service {
|
||||||
|
|
||||||
|
|
||||||
@ -747,6 +359,18 @@ export namespace app {
|
|||||||
|
|
||||||
export namespace desktop {
|
export namespace desktop {
|
||||||
|
|
||||||
|
export class AutoStart {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoStart(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Bindings {
|
export class Bindings {
|
||||||
|
|
||||||
|
|
||||||
@ -759,6 +383,18 @@ export namespace desktop {
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class Tray {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Tray(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
go.mod
3
go.mod
@ -3,11 +3,9 @@ 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
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
golang.org/x/sys v0.30.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -39,5 +37,6 @@ require (
|
|||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -1,5 +1,3 @@
|
|||||||
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 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
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, ", "))
|
|
||||||
}
|
|
||||||
@ -15,18 +15,16 @@ import (
|
|||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
configPath string
|
configPath string
|
||||||
traces *proxy.TraceStore
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
proxyCancel context.CancelFunc
|
proxyCancel context.CancelFunc
|
||||||
proxyDone chan struct{}
|
proxyDone chan struct{}
|
||||||
proxyReady chan error
|
|
||||||
proxyErr error
|
proxyErr error
|
||||||
proxyStatus ProxyStatusView
|
proxyStatus ProxyStatusView
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(configPath string) *Service {
|
func NewService(configPath string) *Service {
|
||||||
return &Service{configPath: strings.TrimSpace(configPath), traces: proxy.NewTraceStore(200)}
|
return &Service{configPath: strings.TrimSpace(configPath)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ConfigPath() string {
|
func (s *Service) ConfigPath() string {
|
||||||
@ -157,6 +155,7 @@ func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) StartProxy(ctx context.Context) error {
|
func (s *Service) StartProxy(ctx context.Context) error {
|
||||||
|
_ = ctx
|
||||||
cfg, err := s.loadConfig()
|
cfg, err := s.loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -166,36 +165,29 @@ func (s *Service) StartProxy(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindAddress := proxyBindAddress(cfg)
|
bindAddress := proxyBindAddress(cfg)
|
||||||
|
started := false
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.proxyCancel == nil {
|
if s.proxyCancel == nil {
|
||||||
runCtx, cancel := context.WithCancel(context.Background())
|
runCtx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
ready := make(chan error, 1)
|
|
||||||
s.proxyCancel = cancel
|
s.proxyCancel = cancel
|
||||||
s.proxyDone = done
|
s.proxyDone = done
|
||||||
s.proxyReady = ready
|
|
||||||
s.proxyErr = nil
|
s.proxyErr = nil
|
||||||
s.proxyStatus = ProxyStatusView{
|
s.proxyStatus = ProxyStatusView{
|
||||||
Running: true,
|
Running: true,
|
||||||
BindAddress: bindAddress,
|
BindAddress: bindAddress,
|
||||||
StartedAt: formatTimestamp(time.Now()),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
go s.runProxy(runCtx, cancel, done, ready, cfg, bindAddress)
|
started = true
|
||||||
|
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
|
||||||
}
|
}
|
||||||
ready := s.proxyReady
|
|
||||||
done := s.proxyDone
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
select {
|
if !started {
|
||||||
case err := <-ready:
|
return nil
|
||||||
return err
|
|
||||||
case <-done:
|
|
||||||
return s.WaitProxy(context.Background())
|
|
||||||
case <-ctx.Done():
|
|
||||||
_ = s.StopProxy(context.Background())
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) StopProxy(ctx context.Context) error {
|
func (s *Service) StopProxy(ctx context.Context) error {
|
||||||
@ -242,47 +234,6 @@ func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
|
|||||||
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
|
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) {
|
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
cfg, err := s.loadConfig()
|
cfg, err := s.loadConfig()
|
||||||
@ -300,11 +251,8 @@ func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (D
|
|||||||
}
|
}
|
||||||
cfg.Desktop = config.Desktop{
|
cfg.Desktop = config.Desktop{
|
||||||
LaunchAtLogin: in.LaunchAtLogin,
|
LaunchAtLogin: in.LaunchAtLogin,
|
||||||
AutoStartProxy: in.AutoStartProxy,
|
|
||||||
MinimizeToTray: in.MinimizeToTray,
|
MinimizeToTray: in.MinimizeToTray,
|
||||||
Notifications: in.Notifications,
|
Notifications: in.Notifications,
|
||||||
Theme: normalizeThemePreference(in.Theme),
|
|
||||||
Language: normalizeLanguagePreference(in.Language),
|
|
||||||
}
|
}
|
||||||
if err := cfg.Save(); err != nil {
|
if err := cfg.Save(); err != nil {
|
||||||
return DesktopPrefsView{}, err
|
return DesktopPrefsView{}, err
|
||||||
@ -366,20 +314,19 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
status := s.proxyStatus
|
status := s.proxyStatus
|
||||||
if !status.Running || status.BindAddress == "" {
|
if status.BindAddress == "" {
|
||||||
status.BindAddress = bindAddress
|
status.BindAddress = bindAddress
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, ready chan error, cfg *config.Config, bindAddress string) {
|
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) {
|
||||||
err := proxy.New(cfg, s.traces).ListenAndServeWithReady(runCtx, ready)
|
err := proxy.New(cfg).ListenAndServe(runCtx)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if s.proxyDone == done {
|
if s.proxyDone == done {
|
||||||
s.proxyCancel = nil
|
s.proxyCancel = nil
|
||||||
s.proxyDone = nil
|
s.proxyDone = nil
|
||||||
s.proxyReady = nil
|
|
||||||
}
|
}
|
||||||
s.proxyErr = err
|
s.proxyErr = err
|
||||||
status := s.proxyStatus
|
status := s.proxyStatus
|
||||||
@ -438,48 +385,8 @@ func doctorIssues(errs []error) []DoctorIssue {
|
|||||||
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
||||||
return DesktopPrefsView{
|
return DesktopPrefsView{
|
||||||
LaunchAtLogin: prefs.LaunchAtLogin,
|
LaunchAtLogin: prefs.LaunchAtLogin,
|
||||||
AutoStartProxy: prefs.AutoStartProxy,
|
|
||||||
MinimizeToTray: prefs.MinimizeToTray,
|
MinimizeToTray: prefs.MinimizeToTray,
|
||||||
Notifications: prefs.Notifications,
|
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,13 +436,6 @@ func cloneHeaders(in map[string]string) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatTimestamp(value time.Time) string {
|
|
||||||
if value.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return value.Format(time.RFC3339Nano)
|
|
||||||
}
|
|
||||||
|
|
||||||
func maskKey(k string) string {
|
func maskKey(k string) string {
|
||||||
if k == "" {
|
if k == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -4,11 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -23,16 +20,13 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
|||||||
|
|
||||||
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
prefs, err := svc.SaveDesktopPrefs(context.Background(), DesktopPrefsInput{
|
||||||
LaunchAtLogin: true,
|
LaunchAtLogin: true,
|
||||||
AutoStartProxy: true,
|
|
||||||
MinimizeToTray: true,
|
MinimizeToTray: true,
|
||||||
Notifications: true,
|
Notifications: true,
|
||||||
Theme: "dark",
|
|
||||||
Language: "zh-CN",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
t.Fatalf("SaveDesktopPrefs() error = %v", err)
|
||||||
}
|
}
|
||||||
if !prefs.LaunchAtLogin || !prefs.AutoStartProxy || !prefs.MinimizeToTray || !prefs.Notifications || prefs.Theme != "dark" || prefs.Language != "zh-CN" {
|
if !prefs.LaunchAtLogin || !prefs.MinimizeToTray || !prefs.Notifications {
|
||||||
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
t.Fatalf("SaveDesktopPrefs() = %#v", prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,125 +34,11 @@ func TestSaveDesktopPrefsPersistsToConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config.Load() error = %v", err)
|
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" {
|
if !cfg.Desktop.LaunchAtLogin || !cfg.Desktop.MinimizeToTray || !cfg.Desktop.Notifications {
|
||||||
t.Fatalf("persisted desktop prefs = %#v", cfg.Desktop)
|
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) {
|
func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@ -213,378 +93,6 @@ func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func assertEventually(t *testing.T, fn func() bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "github.com/Apale7/opencode-provider-switch/internal/proxy"
|
import "time"
|
||||||
|
|
||||||
type Overview struct {
|
type Overview struct {
|
||||||
ConfigPath string `json:"configPath"`
|
ConfigPath string `json:"configPath"`
|
||||||
@ -12,123 +12,10 @@ type Overview struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProxyStatusView struct {
|
type ProxyStatusView struct {
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
BindAddress string `json:"bindAddress"`
|
BindAddress string `json:"bindAddress"`
|
||||||
StartedAt string `json:"startedAt,omitempty"`
|
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||||
LastError string `json:"lastError,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 {
|
type ProviderView struct {
|
||||||
@ -143,11 +30,6 @@ type ProviderView struct {
|
|||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderSaveResult struct {
|
|
||||||
Provider ProviderView `json:"provider"`
|
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AliasTargetView struct {
|
type AliasTargetView struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@ -208,79 +90,13 @@ type SyncResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DesktopPrefsView struct {
|
type DesktopPrefsView struct {
|
||||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||||
AutoStartProxy bool `json:"autoStartProxy"`
|
MinimizeToTray bool `json:"minimizeToTray"`
|
||||||
MinimizeToTray bool `json:"minimizeToTray"`
|
Notifications bool `json:"notifications"`
|
||||||
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 {
|
type DesktopPrefsInput struct {
|
||||||
LaunchAtLogin bool `json:"launchAtLogin"`
|
LaunchAtLogin bool `json:"launchAtLogin"`
|
||||||
AutoStartProxy bool `json:"autoStartProxy"`
|
MinimizeToTray bool `json:"minimizeToTray"`
|
||||||
MinimizeToTray bool `json:"minimizeToTray"`
|
Notifications bool `json:"notifications"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,32 +51,16 @@ 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.
|
// Desktop holds desktop-shell user preferences.
|
||||||
type Desktop struct {
|
type Desktop struct {
|
||||||
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
|
||||||
AutoStartProxy bool `json:"auto_start_proxy,omitempty"`
|
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
||||||
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
|
Notifications bool `json:"notifications,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.
|
||||||
@ -118,14 +102,9 @@ 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{},
|
Desktop: Desktop{},
|
||||||
Providers: []Provider{},
|
Providers: []Provider{},
|
||||||
@ -177,7 +156,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -468,41 +446,8 @@ 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") {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -218,7 +219,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 := lockTestFile(lockFile); err != nil {
|
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil {
|
||||||
t.Fatalf("Flock(lock): %v", err)
|
t.Fatalf("Flock(lock): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,7 +255,7 @@ func TestSaveLinearizesConcurrentWriters(t *testing.T) {
|
|||||||
case <-time.After(20 * time.Millisecond):
|
case <-time.After(20 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := unlockTestFile(lockFile); err != nil {
|
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil {
|
||||||
t.Fatalf("Flock(unlock): %v", err)
|
t.Fatalf("Flock(unlock): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -2,43 +2,19 @@ package desktop
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
"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
|
// 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.
|
// out of internal/app so CLI and GUI can share the same workflows.
|
||||||
type App struct {
|
type App struct {
|
||||||
service *app.Service
|
service *app.Service
|
||||||
bindings *Bindings
|
bindings *Bindings
|
||||||
tray trayAdapter
|
tray *Tray
|
||||||
notify notifierAdapter
|
notify *Notifier
|
||||||
auto autoStartAdapter
|
auto *AutoStart
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
version string
|
version string
|
||||||
@ -64,25 +40,6 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
a.notify.Attach(ctx)
|
a.notify.Attach(ctx)
|
||||||
a.auto.Attach(ctx)
|
a.auto.Attach(ctx)
|
||||||
_ = a.SyncDesktopPreferences(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 {
|
func (a *App) BeforeClose(ctx context.Context) bool {
|
||||||
@ -106,31 +63,26 @@ func (a *App) SyncDesktopPreferences(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
autoErr := a.auto.Sync(ctx, prefs)
|
if err := a.auto.Sync(ctx, prefs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
a.tray.Sync(ctx, prefs)
|
a.tray.Sync(ctx, prefs)
|
||||||
a.notify.Sync(ctx, prefs)
|
return nil
|
||||||
return autoErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
|
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||||
previous, _ := a.bindings.GetDesktopPrefs(ctx)
|
|
||||||
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
|
prefs, err := a.bindings.SaveDesktopPrefs(ctx, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.DesktopPrefsSaveResult{}, err
|
return app.DesktopPrefsView{}, err
|
||||||
}
|
}
|
||||||
warnings := []string{}
|
|
||||||
if err := a.auto.Sync(ctx, prefs); err != nil {
|
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))
|
return app.DesktopPrefsView{}, err
|
||||||
}
|
}
|
||||||
a.tray.Sync(ctx, prefs)
|
a.tray.Sync(ctx, prefs)
|
||||||
a.notify.Sync(ctx, prefs)
|
return prefs, nil
|
||||||
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) {
|
func (a *App) SavePrefs(in app.DesktopPrefsInput) (app.DesktopPrefsView, error) {
|
||||||
return a.SaveDesktopPrefs(a.callContext(), in)
|
return a.SaveDesktopPrefs(a.callContext(), in)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,34 +93,10 @@ func (a *App) Meta() map[string]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func (a *App) Overview() (app.Overview, error) {
|
||||||
return a.bindings.GetOverview(a.callContext())
|
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) {
|
func (a *App) Providers() ([]app.ProviderView, error) {
|
||||||
return a.bindings.ListProviders(a.callContext())
|
return a.bindings.ListProviders(a.callContext())
|
||||||
}
|
}
|
||||||
@ -177,42 +105,6 @@ func (a *App) Aliases() ([]app.AliasView, error) {
|
|||||||
return a.bindings.ListAliases(a.callContext())
|
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) {
|
func (a *App) DoctorRun() (app.DoctorRunResult, error) {
|
||||||
report, err := a.bindings.RunDoctor(a.callContext())
|
report, err := a.bindings.RunDoctor(a.callContext())
|
||||||
return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil
|
return app.DoctorRunResult{Report: report, Error: errorString(err)}, nil
|
||||||
@ -222,38 +114,14 @@ func (a *App) ProxyStatus() (app.ProxyStatusView, error) {
|
|||||||
return a.bindings.GetProxyStatus(a.callContext())
|
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) {
|
func (a *App) StartProxy() (app.ProxyStatusView, error) {
|
||||||
status, err := a.bindings.StartProxy(a.callContext())
|
return 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) {
|
func (a *App) StopProxy() (app.ProxyStatusView, error) {
|
||||||
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(a.callContext(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
status, err := a.bindings.StopProxy(ctx)
|
return 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) {
|
func (a *App) DesktopPrefs() (app.DesktopPrefsView, error) {
|
||||||
@ -265,14 +133,7 @@ func (a *App) PreviewSync(in app.SyncInput) (app.SyncPreview, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) {
|
func (a *App) ApplySync(in app.SyncInput) (app.SyncResult, error) {
|
||||||
result, err := a.bindings.SyncOpenCode(a.callContext(), in)
|
return 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 {
|
func (a *App) callContext() context.Context {
|
||||||
@ -296,3 +157,11 @@ func (a *App) Service() *app.Service {
|
|||||||
func (a *App) Bindings() *Bindings {
|
func (a *App) Bindings() *Bindings {
|
||||||
return a.bindings
|
return a.bindings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) Tray() *Tray {
|
||||||
|
return a.tray
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AutoStart() *AutoStart {
|
||||||
|
return a.auto
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 203 KiB |
@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AutoStart manages real launch-at-login integration where the platform permits
|
// AutoStart manages real launch-at-login integration where the platform permits
|
||||||
// it. Linux uses XDG autostart; Windows uses Startup folder scripts.
|
// it. This implementation currently targets Linux XDG autostart.
|
||||||
type AutoStart struct {
|
type AutoStart struct {
|
||||||
service *app.Service
|
service *app.Service
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@ -33,15 +33,15 @@ func (a *AutoStart) Detach() {
|
|||||||
|
|
||||||
func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error {
|
func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
|
if runtime.GOOS != "linux" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
entryPath, err := a.entryPathForOS(runtime.GOOS)
|
entryPath, err := a.entryPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if prefs.LaunchAtLogin {
|
if prefs.LaunchAtLogin {
|
||||||
return a.writeEntry(entryPath, runtime.GOOS)
|
return a.writeLinuxEntry(entryPath)
|
||||||
}
|
}
|
||||||
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("remove autostart entry: %w", err)
|
return fmt.Errorf("remove autostart entry: %w", err)
|
||||||
@ -50,21 +50,6 @@ func (a *AutoStart) Sync(ctx context.Context, prefs app.DesktopPrefsView) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AutoStart) entryPath() (string, error) {
|
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"))
|
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
|
||||||
if base == "" {
|
if base == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@ -76,19 +61,7 @@ func (a *AutoStart) linuxEntryPath() (string, error) {
|
|||||||
return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil
|
return filepath.Join(base, "autostart", "ocswitch-desktop.desktop"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AutoStart) windowsEntryPath() (string, error) {
|
func (a *AutoStart) writeLinuxEntry(path 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 {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return fmt.Errorf("mkdir autostart dir: %w", err)
|
return fmt.Errorf("mkdir autostart dir: %w", err)
|
||||||
}
|
}
|
||||||
@ -100,22 +73,6 @@ func (a *AutoStart) writeEntry(path string, goos string) error {
|
|||||||
if a.service != nil {
|
if a.service != nil {
|
||||||
configPath = a.service.ConfigPath()
|
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{
|
content := strings.Join([]string{
|
||||||
"[Desktop Entry]",
|
"[Desktop Entry]",
|
||||||
"Type=Application",
|
"Type=Application",
|
||||||
@ -129,23 +86,13 @@ func linuxAutostartEntry(execPath string, configPath string) string {
|
|||||||
"StartupNotify=false",
|
"StartupNotify=false",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
return content
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write autostart entry: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func shellQuote(value string) string {
|
func shellQuote(value string) string {
|
||||||
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
|
replacer := strings.NewReplacer("\\", "\\\\", `"`, `\\"`)
|
||||||
return `"` + replacer.Replace(value) + `"`
|
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) + `"`
|
|
||||||
}
|
|
||||||
|
|||||||
@ -57,26 +57,3 @@ func TestShellQuoteEscapesDoubleQuotes(t *testing.T) {
|
|||||||
t.Fatalf("shellQuote() did not escape quotes: %q", 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -21,14 +21,6 @@ func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) {
|
|||||||
return b.service.GetOverview(ctx)
|
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) {
|
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
|
||||||
return b.service.ListProviders(ctx)
|
return b.service.ListProviders(ctx)
|
||||||
}
|
}
|
||||||
@ -37,42 +29,6 @@ func (b *Bindings) ListAliases(ctx context.Context) ([]app.AliasView, error) {
|
|||||||
return b.service.ListAliases(ctx)
|
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) {
|
func (b *Bindings) RunDoctor(ctx context.Context) (app.DoctorReport, error) {
|
||||||
return b.service.RunDoctor(ctx)
|
return b.service.RunDoctor(ctx)
|
||||||
}
|
}
|
||||||
@ -89,18 +45,6 @@ func (b *Bindings) GetProxyStatus(ctx context.Context) (app.ProxyStatusView, err
|
|||||||
return b.service.GetProxyStatus(ctx)
|
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) {
|
func (b *Bindings) StartProxy(ctx context.Context) (app.ProxyStatusView, error) {
|
||||||
if err := b.service.StartProxy(ctx); err != nil {
|
if err := b.service.StartProxy(ctx); err != nil {
|
||||||
return app.ProxyStatusView{}, err
|
return app.ProxyStatusView{}, err
|
||||||
@ -119,14 +63,6 @@ func (b *Bindings) Overview() (app.Overview, error) {
|
|||||||
return b.GetOverview(context.Background())
|
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) {
|
func (b *Bindings) Providers() ([]app.ProviderView, error) {
|
||||||
return b.ListProviders(context.Background())
|
return b.ListProviders(context.Background())
|
||||||
}
|
}
|
||||||
@ -135,42 +71,6 @@ func (b *Bindings) Aliases() ([]app.AliasView, error) {
|
|||||||
return b.ListAliases(context.Background())
|
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) {
|
func (b *Bindings) Doctor() (app.DoctorReport, error) {
|
||||||
return b.RunDoctor(context.Background())
|
return b.RunDoctor(context.Background())
|
||||||
}
|
}
|
||||||
@ -179,18 +79,6 @@ func (b *Bindings) ProxyStatus() (app.ProxyStatusView, error) {
|
|||||||
return b.GetProxyStatus(context.Background())
|
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) {
|
func (b *Bindings) StartProxyNow() (app.ProxyStatusView, error) {
|
||||||
return b.StartProxy(context.Background())
|
return b.StartProxy(context.Background())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,152 +123,21 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
|||||||
writeResult(w, data, err)
|
writeResult(w, data, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
api.HandleFunc("/api/config/export", func(w http.ResponseWriter, r *http.Request) {
|
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
writeMethodNotAllowed(w, http.MethodGet)
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := b.ExportConfig(r.Context())
|
data, err := b.ListProviders(r.Context())
|
||||||
writeResult(w, data, err)
|
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) {
|
api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
if r.Method != http.MethodGet {
|
||||||
case http.MethodGet:
|
writeMethodNotAllowed(w, 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
|
return
|
||||||
}
|
}
|
||||||
var payload struct {
|
data, err := b.ListAliases(r.Context())
|
||||||
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)
|
writeResult(w, data, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -282,7 +151,7 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
|||||||
if !decodeJSONBody(w, r, &in) {
|
if !decodeJSONBody(w, r, &in) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := instance.SaveDesktopPrefs(r.Context(), in)
|
data, err := b.SaveDesktopPrefs(r.Context(), in)
|
||||||
writeResult(w, data, err)
|
writeResult(w, data, err)
|
||||||
default:
|
default:
|
||||||
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
writeMethodNotAllowed(w, http.MethodGet, http.MethodPost)
|
||||||
@ -298,32 +167,6 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
|||||||
writeResult(w, data, err)
|
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) {
|
api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
|||||||
@ -1,19 +1,13 @@
|
|||||||
package desktop
|
package desktop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/config"
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,8 +40,7 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
t.Fatalf("cfg.Save() error = %v", err)
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := New(path)
|
h, err := newHandler(New(path), "test", "http://127.0.0.1:9982")
|
||||||
h, err := newHandler(instance, "test", "http://127.0.0.1:9982")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newHandler() error = %v", err)
|
t.Fatalf("newHandler() error = %v", err)
|
||||||
}
|
}
|
||||||
@ -78,89 +71,8 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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) {
|
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 := httptest.NewRequest(http.MethodPost, "/api/desktop-prefs", strings.NewReader(`{"launchAtLogin":true,"minimizeToTray":true,"notifications":true}`))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
h.ServeHTTP(resp, req)
|
h.ServeHTTP(resp, req)
|
||||||
@ -168,270 +80,6 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d", 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)
|
loaded, err := config.Load(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config.Load() error = %v", err)
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
@ -441,140 +89,6 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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) {
|
t.Run("serves app shell", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
@ -588,85 +102,3 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,21 +2,14 @@ package desktop
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notifier bridges desktop preference state to native notifications.
|
// Notifier is the future native notification adapter.
|
||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
service *app.Service
|
service *app.Service
|
||||||
|
ctx context.Context
|
||||||
mu sync.Mutex
|
|
||||||
ctx context.Context
|
|
||||||
prefs app.DesktopPrefsView
|
|
||||||
initialized bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotifier(service *app.Service) *Notifier {
|
func NewNotifier(service *app.Service) *Notifier {
|
||||||
@ -24,80 +17,9 @@ func NewNotifier(service *app.Service) *Notifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) Attach(ctx context.Context) {
|
func (n *Notifier) Attach(ctx context.Context) {
|
||||||
n.mu.Lock()
|
|
||||||
n.ctx = ctx
|
n.ctx = ctx
|
||||||
n.mu.Unlock()
|
|
||||||
_ = n.ensureInitialized(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) Detach() {
|
func (n *Notifier) Detach() {
|
||||||
n.mu.Lock()
|
|
||||||
n.ctx = nil
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,44 +4,7 @@ package desktop
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
type desktopNotification struct {
|
|
||||||
ID string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
}
|
|
||||||
|
|
||||||
func hideWindow(ctx context.Context) error {
|
func hideWindow(ctx context.Context) error {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,54 +4,11 @@ package desktop
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type desktopNotification struct {
|
|
||||||
ID string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
}
|
|
||||||
|
|
||||||
func hideWindow(ctx context.Context) error {
|
func hideWindow(ctx context.Context) error {
|
||||||
wruntime.Hide(ctx)
|
wruntime.Hide(ctx)
|
||||||
return nil
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
//go:build !desktop_wails
|
|
||||||
|
|
||||||
package desktop
|
package desktop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -8,7 +6,9 @@ import (
|
|||||||
"github.com/Apale7/opencode-provider-switch/internal/app"
|
"github.com/Apale7/opencode-provider-switch/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tray is a no-op shell adapter outside the Wails desktop build.
|
// Tray holds desktop resident-mode preferences. Wails v2 does not expose a
|
||||||
|
// stable public tray API in the current pinned version, so this type currently
|
||||||
|
// manages close-to-background behavior without depending on private APIs.
|
||||||
type Tray struct {
|
type Tray struct {
|
||||||
service *app.Service
|
service *app.Service
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@ -32,10 +32,6 @@ func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
|
|||||||
t.prefs = prefs
|
t.prefs = prefs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
|
|
||||||
_ = ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
if !t.prefs.MinimizeToTray {
|
if !t.prefs.MinimizeToTray {
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
//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 ""
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
//go:build desktop_wails && !windows
|
|
||||||
|
|
||||||
package desktop
|
|
||||||
|
|
||||||
func detectSystemTrayLanguage() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
//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[:])
|
|
||||||
}
|
|
||||||
@ -1,329 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func RunWails(configPath string, version string) error {
|
func RunWails(configPath string, version string) error {
|
||||||
@ -25,11 +24,12 @@ func RunWails(configPath string, version string) error {
|
|||||||
instance.SetVersion(version)
|
instance.SetVersion(version)
|
||||||
|
|
||||||
return wails.Run(&options.App{
|
return wails.Run(&options.App{
|
||||||
Title: "ocswitch desktop",
|
Title: "ocswitch desktop",
|
||||||
Width: 1280,
|
Width: 1280,
|
||||||
Height: 880,
|
Height: 880,
|
||||||
MinWidth: 1120,
|
MinWidth: 980,
|
||||||
MinHeight: 720,
|
MinHeight: 720,
|
||||||
|
HideWindowOnClose: true,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: mustFS(assets),
|
Assets: mustFS(assets),
|
||||||
},
|
},
|
||||||
@ -46,9 +46,6 @@ func RunWails(configPath string, version string) error {
|
|||||||
Linux: &linux.Options{
|
Linux: &linux.Options{
|
||||||
ProgramName: "ocswitch-desktop",
|
ProgramName: "ocswitch-desktop",
|
||||||
},
|
},
|
||||||
Windows: &windows.Options{
|
|
||||||
DisableWindowIcon: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LockedFile struct {
|
type LockedFile struct {
|
||||||
@ -60,7 +61,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 := lockFile(file); err != nil {
|
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
return nil, fmt.Errorf("lock file: %w", err)
|
return nil, fmt.Errorf("lock file: %w", err)
|
||||||
}
|
}
|
||||||
@ -71,7 +72,7 @@ func (l *LockedFile) Close() error {
|
|||||||
if l == nil || l.file == nil {
|
if l == nil || l.file == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
unlockErr := unlockFile(l.file)
|
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
||||||
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)
|
||||||
@ -81,3 +82,15 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@ -18,6 +17,10 @@ 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"`
|
||||||
}
|
}
|
||||||
@ -39,31 +42,21 @@ 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, stores ...*TraceStore) *Server {
|
func New(cfg *config.Config) *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: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
Timeout: 10 * time.Second,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
}).DialContext,
|
}).DialContext,
|
||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: timeoutDuration(cfg.Server.ConnectTimeoutMs, config.DefaultConnectTimeoutMs),
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
ResponseHeaderTimeout: minDuration(responseHeaderTimeout, firstByteTimeout),
|
ResponseHeaderTimeout: firstByteTimeout,
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
}
|
}
|
||||||
@ -74,18 +67,11 @@ func New(cfg *config.Config, stores ...*TraceStore) *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)
|
||||||
@ -94,21 +80,14 @@ func (s *Server) ListenAndServeWithReady(ctx context.Context, ready chan<- 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: timeoutDuration(s.cfg.Server.RequestReadTimeoutMs, config.DefaultRequestReadTimeoutMs),
|
ReadTimeout: requestReadTimeout,
|
||||||
}
|
}
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() { errCh <- srv.Serve(listener) }()
|
go func() { errCh <- srv.ListenAndServe() }()
|
||||||
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)
|
||||||
@ -162,7 +141,6 @@ 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
|
||||||
@ -189,50 +167,21 @@ 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
|
||||||
}
|
}
|
||||||
@ -240,62 +189,28 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
handled, success, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount, &attemptTrace, &trace)
|
ok, retryable, upstreamErr, failure := s.tryOnce(r.Context(), w, r, p, t, newBody, aliasName, attempt+1, failoverCount)
|
||||||
attemptTrace.DurationMs = time.Since(attemptTrace.StartedAt).Milliseconds()
|
if ok {
|
||||||
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 {
|
||||||
@ -306,8 +221,6 @@ 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 {
|
||||||
@ -316,13 +229,11 @@ 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 (handled, success, retryable, err, failure).
|
// tryOnce proxies one attempt. Returns (ok, retryable, err, failure).
|
||||||
// handled=true means a downstream response has already been started or completed.
|
// ok=true means successful response fully/partially written to client.
|
||||||
// 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,
|
||||||
@ -334,16 +245,14 @@ func (s *Server) tryOnce(
|
|||||||
aliasName string,
|
aliasName string,
|
||||||
attempt int,
|
attempt int,
|
||||||
failoverCount int,
|
failoverCount int,
|
||||||
attemptTrace *TraceAttempt,
|
) (ok bool, retryable bool, err error, failure *upstreamFailure) {
|
||||||
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, false, fmt.Errorf("build request: %w", err), nil
|
return 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")
|
||||||
@ -355,98 +264,44 @@ 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 {
|
||||||
if attemptTrace != nil {
|
return false, true, fmt.Errorf("upstream dial/transport: %w", err), 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)
|
||||||
sanitizedBody := sanitizeResponseBody(resp.Header.Get("Content-Type"), failure.body)
|
return false, true, fmt.Errorf("upstream %d: %s", resp.StatusCode, truncate(string(failure.body), 200)), failure
|
||||||
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)
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
_, _ = io.Copy(w, resp.Body)
|
||||||
if attemptTrace != nil {
|
return true, false, fmt.Errorf("upstream %d", resp.StatusCode), 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, false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), nil
|
return 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) {
|
||||||
if attemptTrace != nil {
|
return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout), 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 {
|
||||||
if attemptTrace != nil {
|
firstChunk = 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 {
|
||||||
if attemptTrace != nil {
|
return false, true, fmt.Errorf("upstream first read: %w", firstErr), 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)
|
||||||
@ -455,11 +310,7 @@ 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 {
|
||||||
if attemptTrace != nil {
|
return true, false, werr, 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()
|
||||||
@ -467,22 +318,10 @@ func (s *Server) tryOnce(
|
|||||||
}
|
}
|
||||||
buf := make([]byte, 16<<10)
|
buf := make([]byte, 16<<10)
|
||||||
for {
|
for {
|
||||||
var (
|
n, rerr := readChunkWithTimeout(resp.Body, buf, streamIdleTimeout)
|
||||||
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 {
|
||||||
if attemptTrace != nil {
|
return true, false, werr, 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()
|
||||||
@ -490,29 +329,13 @@ func (s *Server) tryOnce(
|
|||||||
}
|
}
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
if errors.Is(rerr, io.EOF) {
|
if errors.Is(rerr, io.EOF) {
|
||||||
if attemptTrace != nil {
|
return true, false, nil, nil
|
||||||
attemptTrace.Result = "success"
|
|
||||||
attemptTrace.Success = true
|
|
||||||
}
|
|
||||||
return true, true, false, nil, 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)
|
return true, false, rerr, nil
|
||||||
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")
|
||||||
|
|
||||||
@ -610,20 +433,6 @@ 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) {
|
||||||
|
|||||||
@ -105,187 +105,6 @@ 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) {
|
||||||
|
|||||||
@ -1,260 +0,0 @@
|
|||||||
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:]
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
||||||
"packages": {
|
|
||||||
".": {
|
|
||||||
"release-type": "go",
|
|
||||||
"changelog-path": "CHANGELOG.md"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user