opencode-provider-switch/.trellis/tasks/04-18-desktop-shell-gui/prd.md
2026-04-18 17:02:45 +08:00

20 KiB

OCSWITCH Desktop Shell Design

Summary

ocswitch is currently a Go CLI and local proxy. It already has the core routing, config persistence, and OpenCode sync logic needed for daily use, but it does not yet provide a desktop-native control surface.

This task defines a desktop-shell architecture for ocswitch with these explicit product requirements:

  • launch at login
  • native tray/menu integration
  • native notifications
  • a GUI control panel for configuration and runtime status

The key constraint is equally explicit:

  • do not rewrite existing Go core logic into desktop-only code

Instead, this task introduces a shared application service layer so the CLI and desktop GUI can reuse the same workflows.

Background And Why This Is A Separate Task

The completed task 04-17-forwarding-log-viewer-gui correctly designed a same-process local web log viewer and explicitly excluded Electron or native desktop GUI.

That task remains valid for its original goal.

This task exists because product direction has changed. The GUI now needs real desktop capabilities:

  • launch at login
  • tray/menu presence
  • notifications
  • background-style operation

Those requirements justify a desktop shell and should not be retrofitted into the completed local-web-viewer task.

Product Goal

Provide a desktop-native control surface for ocswitch that can:

  1. manage providers, aliases, and sync operations through a GUI
  2. control and observe local proxy runtime state
  3. run comfortably as a background desktop utility
  4. expose tray/menu and notification workflows expected from a resident local tool
  5. preserve the existing Go codebase as the primary implementation of business rules

Primary Requirements

Desktop capabilities

  • Launch at login must be supported.
  • Native tray/menu integration must be supported.
  • Native notifications must be supported.
  • The application should be able to behave like a local resident utility, not just a browser page.

Reuse requirements

  • internal/config must continue to own config structures, loading, saving, and validation.
  • internal/proxy must continue to own proxy runtime and failover behavior.
  • internal/opencode must continue to own OpenCode config sync behavior.
  • A new internal/app layer must own shared workflows used by both CLI and GUI.
  • The desktop shell must not directly reimplement provider/alias/sync rules.

GUI scope

The first GUI iteration should focus on a control-panel workflow rather than a full analytics product.

Minimum surface:

  • overview/status page
  • provider management page
  • alias management page
  • doctor and OpenCode sync page

Non-Goals

  1. No rewrite of config/proxy/opencode logic into desktop-specific modules.
  2. No replacement of the existing CLI.
  3. No hosted service or remote control plane.
  4. No multi-user or authenticated remote administration model.
  5. No forced migration away from existing config file formats.
  6. No requirement that the initial desktop shell also ships the full request log viewer.

Technology Recommendation

  • Desktop shell: Wails
  • Frontend: React + TypeScript
  • Styling: Tailwind CSS
  • Forms and validation: react-hook-form + zod

Why Wails

ocswitch already centers on Go. The desktop shell should wrap that Go core rather than forcing a new primary runtime.

Wails is recommended because:

  • it matches the existing Go-heavy codebase
  • it avoids introducing Rust as another core runtime concern
  • it supports the desktop shell product shape better than a browser-only UI
  • it allows Go services to remain the real source of business behavior

Why React over Vue

The GUI is primarily a configuration-heavy control panel.

React is recommended because:

  • the form-heavy workflow aligns well with react-hook-form + zod
  • long-term ecosystem support for control-panel style applications is strong
  • it is a pragmatic default when the frontend is not the product center but must remain maintainable as scope grows

Vue is not rejected as incapable; it is simply not the preferred choice for this project's chosen form stack and expected long-term expansion.

Current Project State

Existing reusable layers

internal/config

Already provides:

  • Config, Provider, Alias, Target, Server models
  • config load/save
  • default path resolution
  • provider and alias mutation helpers
  • structural validation

internal/proxy

Already provides:

  • local HTTP server
  • /v1/responses
  • /v1/models
  • deterministic alias failover behavior
  • downstream streaming pass-through

internal/opencode

Already provides:

  • OpenCode config load/save
  • provider sync patching
  • global target path resolution

internal/cli

Currently provides command entry points, but it also contains business orchestration that should move into a shared application layer.

Examples:

  • provider add/update orchestration
  • alias bind/unbind orchestration
  • doctor orchestration
  • OpenCode sync orchestration
  • serve command bootstrap

Proposed Architecture

Layer model

Recommended layers:

  1. internal/config
  2. internal/proxy
  3. internal/opencode
  4. internal/app
  5. internal/cli
  6. internal/desktop
  7. web/

Responsibility boundaries

internal/config

Owns:

  • persisted config structures
  • file path resolution
  • load/save with locking and atomic write behavior
  • config-level validation helpers

Must not own:

  • desktop shell behavior
  • UI-specific DTOs
  • tray/menu integration

internal/proxy

Owns:

  • proxy server construction
  • request routing
  • upstream retry/failover semantics
  • HTTP runtime behavior

Must not own:

  • desktop lifecycle decisions
  • UI-specific runtime state projection

internal/opencode

Owns:

  • OpenCode config parsing and writing
  • provider.ocswitch sync logic
  • target resolution for the OpenCode config file

Must not own:

  • GUI orchestration
  • desktop notifications

internal/app

Owns:

  • reusable business workflows
  • shared DTOs for CLI and GUI
  • orchestration across config, proxy, and opencode
  • runtime supervision of the embedded proxy process in desktop mode

Must not own:

  • Wails-specific bindings
  • frontend rendering
  • tray/menu implementation

internal/cli

Owns:

  • cobra command tree
  • flag parsing
  • terminal printing

Must delegate business workflows to internal/app.

internal/desktop

Owns:

  • Wails application bootstrap
  • window lifecycle
  • tray/menu wiring
  • native notifications
  • launch-at-login integration
  • binding frontend calls to internal/app

Must not own:

  • provider/alias mutation rules
  • OpenCode sync rules
  • direct config mutation logic beyond reading desktop preference values via services

web/

Owns:

  • React pages and components
  • visual state and form UX
  • calling Wails-bound methods

Must not own:

  • config file persistence rules
  • routing/failover behavior
cmd/
  ocswitch/
    main.go
  ocswitch-desktop/
    main.go

internal/
  config/
  proxy/
  opencode/
  app/
    service.go
    types.go
    config_service.go
    provider_service.go
    alias_service.go
    sync_service.go
    runtime_service.go
  cli/
  desktop/
    app.go
    tray.go
    menu.go
    notify.go
    autostart.go
    bindings.go

web/
  package.json
  src/
    pages/
    components/
    lib/

The exact filenames can vary, but the boundary itself should remain stable.

internal/app Design

Purpose

internal/app is the shared application service layer.

It exists to solve a current structural problem in the codebase: many workflows already exist, but they are embedded inside CLI command handlers. The desktop GUI should not duplicate those workflows, and the CLI should not remain the only caller.

internal/app should therefore become the only place where cross-package use cases are orchestrated.

Design principles

  1. Organize by user workflows, not by storage structs.
  2. Return stable DTOs instead of exposing raw persistence objects directly to GUI code.
  3. Keep validation and persistence in the existing packages where appropriate.
  4. Keep the initial design small and explicit rather than overly abstract.

Top-level service shape

Recommended root type:

type Service struct {
    configPath string

    mu          sync.Mutex
    proxyCancel context.CancelFunc
    proxyDone   chan struct{}
    proxyStatus ProxyStatusView
}

func NewService(configPath string) *Service

The root service may either expose all methods directly or hold narrower sub-services internally. The important point is that callers should consume one coherent application boundary.

Shared DTOs

Recommended DTOs are intentionally UI-safe and transport-safe.

type Overview struct {
    ConfigPath        string            `json:"configPath"`
    ProviderCount     int               `json:"providerCount"`
    AliasCount        int               `json:"aliasCount"`
    AvailableAliases  []string          `json:"availableAliases"`
    Proxy             ProxyStatusView   `json:"proxy"`
    Desktop           DesktopPrefsView  `json:"desktop"`
}

type ProxyStatusView struct {
    Running     bool      `json:"running"`
    BindAddress string    `json:"bindAddress"`
    StartedAt   time.Time `json:"startedAt,omitempty"`
    LastError   string    `json:"lastError,omitempty"`
}

type ProviderView struct {
    ID           string            `json:"id"`
    Name         string            `json:"name,omitempty"`
    BaseURL      string            `json:"baseUrl"`
    APIKeySet    bool              `json:"apiKeySet"`
    Headers      map[string]string `json:"headers,omitempty"`
    Models       []string          `json:"models,omitempty"`
    ModelsSource string            `json:"modelsSource,omitempty"`
    Disabled     bool              `json:"disabled"`
}

type AliasTargetView struct {
    Provider string `json:"provider"`
    Model    string `json:"model"`
    Enabled  bool   `json:"enabled"`
}

type AliasView struct {
    Alias         string            `json:"alias"`
    DisplayName   string            `json:"displayName,omitempty"`
    Enabled       bool              `json:"enabled"`
    Targets       []AliasTargetView `json:"targets"`
    Available     bool              `json:"available"`
}

type DoctorIssue struct {
    Message string `json:"message"`
}

type DoctorReport struct {
    OK                  bool          `json:"ok"`
    Issues              []DoctorIssue `json:"issues"`
    ConfigPath          string        `json:"configPath"`
    ProviderCount       int           `json:"providerCount"`
    AliasCount          int           `json:"aliasCount"`
    ProxyBindAddress    string        `json:"proxyBindAddress"`
    OpenCodeTargetPath  string        `json:"openCodeTargetPath"`
    OpenCodeTargetFound bool          `json:"openCodeTargetFound"`
}

type SyncPreview struct {
    TargetPath      string   `json:"targetPath"`
    AliasNames      []string `json:"aliasNames"`
    SetModel        string   `json:"setModel,omitempty"`
    SetSmallModel   string   `json:"setSmallModel,omitempty"`
    WouldChange     bool     `json:"wouldChange"`
}

type SyncResult struct {
    TargetPath    string   `json:"targetPath"`
    AliasNames    []string `json:"aliasNames"`
    Changed       bool     `json:"changed"`
    DryRun        bool     `json:"dryRun"`
    SetModel      string   `json:"setModel,omitempty"`
    SetSmallModel string   `json:"setSmallModel,omitempty"`
}

type DesktopPrefsView struct {
    LaunchAtLogin bool `json:"launchAtLogin"`
    MinimizeToTray bool `json:"minimizeToTray"`
    Notifications bool `json:"notifications"`
}

Input types

Recommended command inputs:

type SaveProviderInput 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"`
    ClearHeaders bool              `json:"clearHeaders"`
    Disabled     bool              `json:"disabled"`
    SkipModels   bool              `json:"skipModels"`
}

type SaveAliasInput struct {
    Alias       string `json:"alias"`
    DisplayName string `json:"displayName,omitempty"`
    Disabled    bool   `json:"disabled"`
}

type BindAliasTargetInput struct {
    Alias    string `json:"alias"`
    Provider string `json:"provider,omitempty"`
    Model    string `json:"model"`
    Disabled bool   `json:"disabled"`
}

type UnbindAliasTargetInput struct {
    Alias    string `json:"alias"`
    Provider string `json:"provider,omitempty"`
    Model    string `json:"model"`
}

type SyncInput struct {
    Target        string `json:"target,omitempty"`
    SetModel      string `json:"setModel,omitempty"`
    SetSmallModel string `json:"setSmallModel,omitempty"`
    DryRun        bool   `json:"dryRun"`
}

type DesktopPrefsInput struct {
    LaunchAtLogin bool `json:"launchAtLogin"`
    MinimizeToTray bool `json:"minimizeToTray"`
    Notifications bool `json:"notifications"`
}

Required service methods

At minimum, internal/app should expose these use cases:

func (s *Service) GetOverview(ctx context.Context) (Overview, error)

func (s *Service) ListProviders(ctx context.Context) ([]ProviderView, error)
func (s *Service) SaveProvider(ctx context.Context, in SaveProviderInput) (ProviderView, error)
func (s *Service) EnableProvider(ctx context.Context, id string) error
func (s *Service) DisableProvider(ctx context.Context, id string) error
func (s *Service) RemoveProvider(ctx context.Context, id string) error
func (s *Service) ImportProvidersFromOpenCode(ctx context.Context, from string, overwrite bool) ([]ProviderView, error)

func (s *Service) ListAliases(ctx context.Context) ([]AliasView, error)
func (s *Service) SaveAlias(ctx context.Context, in SaveAliasInput) (AliasView, error)
func (s *Service) RemoveAlias(ctx context.Context, alias string) error
func (s *Service) BindAliasTarget(ctx context.Context, in BindAliasTargetInput) error
func (s *Service) UnbindAliasTarget(ctx context.Context, in UnbindAliasTargetInput) error

func (s *Service) RunDoctor(ctx context.Context) (DoctorReport, error)
func (s *Service) PreviewOpenCodeSync(ctx context.Context, in SyncInput) (SyncPreview, error)
func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResult, error)

func (s *Service) StartProxy(ctx context.Context) error
func (s *Service) StopProxy(ctx context.Context) error
func (s *Service) RestartProxy(ctx context.Context) error
func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error)

func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error)
func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (DesktopPrefsView, error)

Method behavior notes

SaveProvider

Must preserve current provider semantics already implemented in CLI orchestration:

  • update-or-insert behavior
  • optional model discovery
  • preservation of fields not explicitly changed
  • stale discovered catalog downgrade to untrusted metadata when needed

The GUI must not reimplement any of this itself.

BindAliasTarget

Must preserve current CLI semantics:

  • allow combined provider/model parsing when provider is omitted
  • validate provider existence
  • validate known model when model catalog is trusted
  • auto-create alias when current workflow requires it

RunDoctor

Should centralize the same checks currently wired in the CLI command:

  • config validation
  • OpenCode target resolution
  • provider.ocswitch preview validation

Runtime methods

Must make desktop mode safe:

  • no duplicate proxy start
  • clean shutdown
  • observable runtime state
  • clear last-error reporting for the GUI and tray status

Runtime supervision model

Desktop mode requires the proxy to be managed as an internal long-running component.

Recommended runtime model:

desktop shell starts
  -> app.Service constructed
  -> user or preference requests proxy start
  -> app.Service loads config and validates it
  -> app.Service creates proxy.Server
  -> app.Service starts ListenAndServe under managed context
  -> proxy status is updated for GUI/tray visibility

Important implementation requirements:

  • repeated start requests must be idempotent or return a clear error
  • stop must call graceful shutdown through context cancellation
  • runtime state must survive UI refreshes within the same process

Desktop Shell Design

app.go

  • bootstrap Wails
  • construct internal/app.Service
  • wire lifecycle hooks

bindings.go

  • expose thin Wails-callable methods
  • forward all real work to internal/app

tray.go

  • tray icon
  • quick actions such as show window, start proxy, stop proxy, sync, quit
  • tray status updates based on app.Service runtime state

menu.go

  • native app menu entries if needed per platform

notify.go

  • success and error notifications for:
    • proxy start/stop
    • config save failures
    • sync results

autostart.go

  • platform-specific launch-at-login registration
  • read desired state from desktop preferences exposed through internal/app

Configuration Extensions

Desktop behavior may require a small extension to the persisted config.

Recommended addition:

type Desktop struct {
    LaunchAtLogin  bool `json:"launch_at_login,omitempty"`
    MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
    Notifications  bool `json:"notifications,omitempty"`
}

Then extend config.Config with:

Desktop Desktop `json:"desktop,omitempty"`

This should remain a lightweight preference store, not a place to encode platform-specific shell details.

CLI Migration Plan

The CLI should remain as a first-class interface.

Recommended migration pattern:

  1. move workflow orchestration from internal/cli/*.go into internal/app
  2. keep cobra commands as thin argument adapters
  3. update CLI output formatting without changing business semantics

This task is successful only if CLI and GUI both use the same business paths.

GUI Surface Recommendation

Initial pages

  1. Overview
  2. Providers
  3. Aliases
  4. Doctor and OpenCode Sync
  5. Settings

Overview page should show

  • current config path
  • proxy runtime status
  • bind address
  • provider count
  • alias count
  • last doctor result summary if available

Provider page should support

  • add provider
  • edit provider
  • enable/disable provider
  • remove provider
  • show discovered model metadata summary

Alias page should support

  • add/edit alias
  • remove alias
  • bind/unbind provider targets
  • show failover order clearly

Doctor and Sync page should support

  • run doctor
  • preview sync target
  • apply sync
  • show resolved OpenCode target path

Settings page should support

  • launch at login
  • minimize to tray
  • notifications preference
  • optional auto-start proxy preference in a later iteration

Acceptance Criteria

This design task is complete when the PRD clearly defines:

  1. the desktop product shape and why it is separate from the earlier local-web-viewer task
  2. the recommended technology stack
  3. the layer boundaries between config, proxy, opencode, app, cli, desktop, and web
  4. the required internal/app service methods and DTO shapes
  5. the runtime supervision model for the embedded proxy
  6. the desktop-only responsibilities for Wails shell code
  7. the first GUI page set and minimum feature scope

After this design is accepted, the next implementation task should focus on skeleton wiring only:

  1. create internal/app with one or two end-to-end migrated workflows
  2. create Wails shell bootstrap
  3. wire a minimal React frontend
  4. expose overview plus provider list through the new shared layer

That first implementation task should avoid trying to deliver every page and every shell integration at once.