Bring the Wails desktop shell to feature parity with the CLI for provider and alias management while keeping the browser fallback working. Wire tray, notifications, autostart, and GUI warnings so desktop flows behave predictably.
448 lines
11 KiB
Go
448 lines
11 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
|
"github.com/Apale7/opencode-provider-switch/internal/opencode"
|
|
"github.com/Apale7/opencode-provider-switch/internal/proxy"
|
|
)
|
|
|
|
type Service struct {
|
|
configPath string
|
|
|
|
mu sync.Mutex
|
|
proxyCancel context.CancelFunc
|
|
proxyDone chan struct{}
|
|
proxyErr error
|
|
proxyStatus ProxyStatusView
|
|
}
|
|
|
|
func NewService(configPath string) *Service {
|
|
return &Service{configPath: strings.TrimSpace(configPath)}
|
|
}
|
|
|
|
func (s *Service) ConfigPath() string {
|
|
if s.configPath != "" {
|
|
return s.configPath
|
|
}
|
|
return config.DefaultPath()
|
|
}
|
|
|
|
func (s *Service) GetOverview(ctx context.Context) (Overview, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return Overview{}, err
|
|
}
|
|
aliases := cfg.AvailableAliasNames()
|
|
sort.Strings(aliases)
|
|
status := s.currentProxyStatus(proxyBindAddress(cfg))
|
|
return Overview{
|
|
ConfigPath: cfg.Path(),
|
|
ProviderCount: len(cfg.Providers),
|
|
AliasCount: len(cfg.Aliases),
|
|
AvailableAliases: aliases,
|
|
Proxy: status,
|
|
Desktop: desktopPrefsView(cfg.Desktop),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ListProviders(ctx context.Context) ([]ProviderView, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
providers := append([]config.Provider(nil), cfg.Providers...)
|
|
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
|
views := make([]ProviderView, 0, len(providers))
|
|
for _, provider := range providers {
|
|
views = append(views, providerView(provider))
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
func (s *Service) ListAliases(ctx context.Context) ([]AliasView, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
aliases := append([]config.Alias(nil), cfg.Aliases...)
|
|
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
|
views := make([]AliasView, 0, len(aliases))
|
|
for _, alias := range aliases {
|
|
views = append(views, aliasView(cfg, alias))
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
func (s *Service) RunDoctor(ctx context.Context) (DoctorReport, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return DoctorReport{}, err
|
|
}
|
|
issues := cfg.Validate()
|
|
path, existed := opencode.ResolveGlobalConfigPath()
|
|
raw, err := opencode.Load(path)
|
|
if err != nil {
|
|
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
|
|
} else {
|
|
aliasNames := cfg.AvailableAliasNames()
|
|
baseURL := proxyBaseURL(cfg)
|
|
opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
|
if err := opencode.ValidateOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
|
issues = append(issues, fmt.Errorf("opencode provider.ocswitch invalid: %w", err))
|
|
}
|
|
}
|
|
report := DoctorReport{
|
|
OK: len(issues) == 0,
|
|
Issues: doctorIssues(issues),
|
|
ConfigPath: cfg.Path(),
|
|
ProviderCount: len(cfg.Providers),
|
|
AliasCount: len(cfg.Aliases),
|
|
ProxyBindAddress: proxyBindAddress(cfg),
|
|
OpenCodeTargetPath: path,
|
|
OpenCodeTargetFound: existed,
|
|
}
|
|
if report.OK {
|
|
return report, nil
|
|
}
|
|
return report, fmt.Errorf("%d config issue(s)", len(issues))
|
|
}
|
|
|
|
func (s *Service) PreviewOpenCodeSync(ctx context.Context, in SyncInput) (SyncPreview, error) {
|
|
prepared, err := s.prepareSync(ctx, in)
|
|
if err != nil {
|
|
return SyncPreview{}, err
|
|
}
|
|
return SyncPreview{
|
|
TargetPath: prepared.targetPath,
|
|
AliasNames: append([]string(nil), prepared.aliasNames...),
|
|
SetModel: in.SetModel,
|
|
SetSmallModel: in.SetSmallModel,
|
|
WouldChange: prepared.changed,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ApplyOpenCodeSync(ctx context.Context, in SyncInput) (SyncResult, error) {
|
|
prepared, err := s.prepareSync(ctx, in)
|
|
if err != nil {
|
|
return SyncResult{}, err
|
|
}
|
|
result := SyncResult{
|
|
TargetPath: prepared.targetPath,
|
|
AliasNames: append([]string(nil), prepared.aliasNames...),
|
|
Changed: prepared.changed,
|
|
DryRun: in.DryRun,
|
|
SetModel: in.SetModel,
|
|
SetSmallModel: in.SetSmallModel,
|
|
}
|
|
if !prepared.changed || in.DryRun {
|
|
return result, nil
|
|
}
|
|
if err := opencode.Save(prepared.targetPath, prepared.raw); err != nil {
|
|
return SyncResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) StartProxy(ctx context.Context) error {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if errs := cfg.Validate(); len(errs) > 0 {
|
|
return errs[0]
|
|
}
|
|
|
|
bindAddress := proxyBindAddress(cfg)
|
|
started := false
|
|
|
|
s.mu.Lock()
|
|
if s.proxyCancel == nil {
|
|
runCtx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
s.proxyCancel = cancel
|
|
s.proxyDone = done
|
|
s.proxyErr = nil
|
|
s.proxyStatus = ProxyStatusView{
|
|
Running: true,
|
|
BindAddress: bindAddress,
|
|
StartedAt: time.Now(),
|
|
}
|
|
started = true
|
|
go s.runProxy(runCtx, cancel, done, cfg, bindAddress)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if !started {
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) StopProxy(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
cancel := s.proxyCancel
|
|
done := s.proxyDone
|
|
s.mu.Unlock()
|
|
if cancel == nil || done == nil {
|
|
return nil
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (s *Service) WaitProxy(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
done := s.proxyDone
|
|
proxyErr := s.proxyErr
|
|
s.mu.Unlock()
|
|
if done == nil {
|
|
return proxyErr
|
|
}
|
|
select {
|
|
case <-done:
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.proxyErr
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (s *Service) GetProxyStatus(ctx context.Context) (ProxyStatusView, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return ProxyStatusView{}, err
|
|
}
|
|
return s.currentProxyStatus(proxyBindAddress(cfg)), nil
|
|
}
|
|
|
|
func (s *Service) GetDesktopPrefs(ctx context.Context) (DesktopPrefsView, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return DesktopPrefsView{}, err
|
|
}
|
|
return desktopPrefsView(cfg.Desktop), nil
|
|
}
|
|
|
|
func (s *Service) SaveDesktopPrefs(ctx context.Context, in DesktopPrefsInput) (DesktopPrefsView, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return DesktopPrefsView{}, err
|
|
}
|
|
cfg.Desktop = config.Desktop{
|
|
LaunchAtLogin: in.LaunchAtLogin,
|
|
MinimizeToTray: in.MinimizeToTray,
|
|
Notifications: in.Notifications,
|
|
}
|
|
if err := cfg.Save(); err != nil {
|
|
return DesktopPrefsView{}, err
|
|
}
|
|
return desktopPrefsView(cfg.Desktop), nil
|
|
}
|
|
|
|
type preparedSync struct {
|
|
targetPath string
|
|
aliasNames []string
|
|
raw opencode.Raw
|
|
changed bool
|
|
}
|
|
|
|
func (s *Service) prepareSync(ctx context.Context, in SyncInput) (preparedSync, error) {
|
|
_ = ctx
|
|
cfg, err := s.loadConfig()
|
|
if err != nil {
|
|
return preparedSync{}, err
|
|
}
|
|
if errs := cfg.Validate(); len(errs) > 0 {
|
|
return preparedSync{}, errs[0]
|
|
}
|
|
targetPath := strings.TrimSpace(in.Target)
|
|
if targetPath == "" {
|
|
resolved, _ := opencode.ResolveGlobalConfigPath()
|
|
targetPath = resolved
|
|
}
|
|
raw, err := opencode.Load(targetPath)
|
|
if err != nil {
|
|
return preparedSync{}, err
|
|
}
|
|
aliasNames := cfg.AvailableAliasNames()
|
|
sort.Strings(aliasNames)
|
|
if err := validateSyncedModelSelection(in.SetModel, aliasNames, "--set-model"); err != nil {
|
|
return preparedSync{}, err
|
|
}
|
|
if err := validateSyncedModelSelection(in.SetSmallModel, aliasNames, "--set-small-model"); err != nil {
|
|
return preparedSync{}, err
|
|
}
|
|
baseURL := proxyBaseURL(cfg)
|
|
changed := opencode.EnsureOcswitchProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
|
if in.SetModel != "" && raw["model"] != in.SetModel {
|
|
raw["model"] = in.SetModel
|
|
changed = true
|
|
}
|
|
if in.SetSmallModel != "" && raw["small_model"] != in.SetSmallModel {
|
|
raw["small_model"] = in.SetSmallModel
|
|
changed = true
|
|
}
|
|
return preparedSync{targetPath: targetPath, aliasNames: aliasNames, raw: raw, changed: changed}, nil
|
|
}
|
|
|
|
func (s *Service) loadConfig() (*config.Config, error) {
|
|
return config.Load(s.configPath)
|
|
}
|
|
|
|
func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
status := s.proxyStatus
|
|
if status.BindAddress == "" {
|
|
status.BindAddress = bindAddress
|
|
}
|
|
return status
|
|
}
|
|
|
|
func (s *Service) runProxy(runCtx context.Context, cancel context.CancelFunc, done chan struct{}, cfg *config.Config, bindAddress string) {
|
|
err := proxy.New(cfg).ListenAndServe(runCtx)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.proxyDone == done {
|
|
s.proxyCancel = nil
|
|
s.proxyDone = nil
|
|
}
|
|
s.proxyErr = err
|
|
status := s.proxyStatus
|
|
status.Running = false
|
|
status.BindAddress = bindAddress
|
|
if err != nil {
|
|
status.LastError = err.Error()
|
|
} else {
|
|
status.LastError = ""
|
|
}
|
|
s.proxyStatus = status
|
|
close(done)
|
|
}
|
|
|
|
func providerView(provider config.Provider) ProviderView {
|
|
return ProviderView{
|
|
ID: provider.ID,
|
|
Name: provider.Name,
|
|
BaseURL: provider.BaseURL,
|
|
APIKeySet: provider.APIKey != "",
|
|
APIKeyMasked: maskKey(provider.APIKey),
|
|
Headers: cloneHeaders(provider.Headers),
|
|
Models: append([]string(nil), provider.Models...),
|
|
ModelsSource: provider.ModelsSource,
|
|
Disabled: provider.Disabled,
|
|
}
|
|
}
|
|
|
|
func aliasView(cfg *config.Config, alias config.Alias) AliasView {
|
|
targets := make([]AliasTargetView, 0, len(alias.Targets))
|
|
for _, target := range alias.Targets {
|
|
targets = append(targets, AliasTargetView{
|
|
Provider: target.Provider,
|
|
Model: target.Model,
|
|
Enabled: target.Enabled,
|
|
})
|
|
}
|
|
return AliasView{
|
|
Alias: alias.Alias,
|
|
DisplayName: alias.DisplayName,
|
|
Enabled: alias.Enabled,
|
|
TargetCount: len(alias.Targets),
|
|
AvailableTargetCount: len(cfg.AvailableTargets(alias)),
|
|
Targets: targets,
|
|
}
|
|
}
|
|
|
|
func doctorIssues(errs []error) []DoctorIssue {
|
|
issues := make([]DoctorIssue, 0, len(errs))
|
|
for _, err := range errs {
|
|
issues = append(issues, DoctorIssue{Message: err.Error()})
|
|
}
|
|
return issues
|
|
}
|
|
|
|
func desktopPrefsView(prefs config.Desktop) DesktopPrefsView {
|
|
return DesktopPrefsView{
|
|
LaunchAtLogin: prefs.LaunchAtLogin,
|
|
MinimizeToTray: prefs.MinimizeToTray,
|
|
Notifications: prefs.Notifications,
|
|
}
|
|
}
|
|
|
|
func validateSyncedModelSelection(value string, aliases []string, flagName string) error {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
const prefix = "ocswitch/"
|
|
if !strings.HasPrefix(value, prefix) {
|
|
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
|
|
}
|
|
alias := strings.TrimPrefix(value, prefix)
|
|
if alias == "" {
|
|
return fmt.Errorf("%s must use the ocswitch/<alias> form", flagName)
|
|
}
|
|
for _, name := range aliases {
|
|
if name == alias {
|
|
return nil
|
|
}
|
|
}
|
|
if len(aliases) == 0 {
|
|
return fmt.Errorf("%s requires at least one routable alias; run ocswitch alias list or doctor first", flagName)
|
|
}
|
|
choices := make([]string, 0, len(aliases))
|
|
for _, name := range aliases {
|
|
choices = append(choices, prefix+name)
|
|
}
|
|
return fmt.Errorf("%s %q is not a routable alias; available: %s", flagName, value, strings.Join(choices, ", "))
|
|
}
|
|
|
|
func proxyBindAddress(cfg *config.Config) string {
|
|
return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
|
}
|
|
|
|
func proxyBaseURL(cfg *config.Config) string {
|
|
return fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
|
}
|
|
|
|
func cloneHeaders(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func maskKey(k string) string {
|
|
if k == "" {
|
|
return ""
|
|
}
|
|
if len(k) <= 8 {
|
|
return "***"
|
|
}
|
|
return k[:4] + "…" + k[len(k)-4:]
|
|
}
|