fix(desktop): finish Windows shell polish

Stabilize the tray icon and locale handling on Windows while aligning provider and alias management panels with the refreshed desktop branding.
This commit is contained in:
apale7 2026-04-20 02:36:54 +08:00
parent b70f49cbef
commit 8fc72e87e4
24 changed files with 1249 additions and 318 deletions

View File

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

View File

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

View File

@ -20,6 +20,7 @@ import {
saveDesktopPrefs,
saveProxySettings,
saveProvider,
openExternalURL,
setAliasTargetState,
setProviderState,
startProxy,
@ -27,6 +28,7 @@ import {
unbindAliasTarget,
} from './api'
import i18n, { resolveLanguagePreference } from './i18n'
import githubMark from './assets/GitHub_Invertocat_Black_Clearspace.png'
import type {
AliasTargetInput,
AliasView,
@ -85,6 +87,7 @@ type ConfirmIntent =
| { kind: 'unbind-target'; alias: string; provider: string; model: string }
const tabs: TabKey[] = ['overview', 'providers', 'aliases', 'log', 'network', 'sync', 'settings']
const GITHUB_REPOSITORY_URL = 'https://github.com/Apale7/opencode-provider-switch'
const emptyPrefs: DesktopPrefsView = {
launchAtLogin: false,
@ -1105,6 +1108,13 @@ export default function App() {
: t('confirm.unbindTargetTitle')
: ''
function openRepository() {
if (!GITHUB_REPOSITORY_URL) {
return
}
void openExternalURL(GITHUB_REPOSITORY_URL)
}
const confirmMessage = confirmIntent
? confirmIntent.kind === 'delete-provider'
? t('messages.confirmDeleteProvider', { id: confirmIntent.id })
@ -1131,8 +1141,19 @@ export default function App() {
<aside className="sidebar">
<div className="sidebar-brand">
<div className="sidebar-brand-line" />
<p className="eyebrow">{t('app.eyebrow')}</p>
<h1>{t('app.brand')}</h1>
<div className="sidebar-brand-header">
<button
type="button"
className="brand-github"
onClick={openRepository}
disabled={!GITHUB_REPOSITORY_URL}
aria-label="Open project repository"
title={GITHUB_REPOSITORY_URL || 'Set GITHUB_REPOSITORY_URL in App.tsx'}
>
<img src={githubMark} alt="" />
</button>
<h1>{t('app.brand')}</h1>
</div>
<div className="brand-meta">
<span className="badge">v{meta.version || t('app.dev')}</span>
</div>
@ -1282,51 +1303,58 @@ export default function App() {
: t('providers.subtitle')}
</p>
</div>
<div className="toolbar toolbar-end">
<span className="subtle">
<div className="list-header-actions">
<span className="subtle list-status-text">
{providerStatus ||
t('providers.listCount', { shown: filteredProviders.length, total: providers.length })}
</span>
{providerDetailOpen ? (
<button type="button" onClick={closeProviderDetail}>
{t('actions.close')}
<div className="toolbar list-toolbar-actions">
{providerDetailOpen ? (
<button type="button" onClick={closeProviderDetail}>
{t('actions.close')}
</button>
) : null}
<button type="button" className="primary" onClick={openProviderCreateModal}>
{t('actions.newProvider')}
</button>
) : null}
<button type="button" className="primary" onClick={openProviderCreateModal}>
{t('actions.newProvider')}
</button>
<button type="button" onClick={openProviderImportModal}>
{t('actions.import')}
</button>
<button type="button" onClick={openProviderImportModal}>
{t('actions.import')}
</button>
</div>
</div>
</div>
<div className="list-toolbar">
<label>
<span>{t('providers.search')}</span>
<input
type="text"
value={providerQuery}
onChange={(event) => setProviderQuery(event.target.value)}
placeholder={t('providers.searchPlaceholder')}
/>
</label>
<label>
<span>{t('providers.filter')}</span>
<select
value={providerFilter}
onChange={(event) => setProviderFilter(event.target.value as FilterState)}
>
<option value="all">{t('providers.filterAll')}</option>
<option value="enabled">{t('providers.filterEnabled')}</option>
<option value="disabled">{t('providers.filterDisabled')}</option>
</select>
</label>
<div className="filter-group filter-group-search">
<span className="filter-group-label">{t('providers.search')}</span>
<label className="search-field">
<input
type="text"
value={providerQuery}
onChange={(event) => setProviderQuery(event.target.value)}
placeholder={t('providers.searchPlaceholder')}
/>
</label>
</div>
<div className="filter-group filter-group-select">
<span className="filter-group-label">{t('providers.filter')}</span>
<label>
<select
value={providerFilter}
onChange={(event) => setProviderFilter(event.target.value as FilterState)}
>
<option value="all">{t('providers.filterAll')}</option>
<option value="enabled">{t('providers.filterEnabled')}</option>
<option value="disabled">{t('providers.filterDisabled')}</option>
</select>
</label>
</div>
</div>
<div className="scroll-list compact-list">
{providers.length === 0 ? (
<article className="empty-card">
<div className="empty-illustration" aria-hidden="true"></div>
<span className="empty-kicker">{t('providers.title')}</span>
<h4>{t('providers.empty')}</h4>
<p className="subtle">{t('providers.emptyHint')}</p>
@ -1350,22 +1378,36 @@ export default function App() {
<button
key={provider.id}
type="button"
className={`compact-row ${providerDetailOpen && selectedProviderId === provider.id ? 'active' : ''}`}
className={`resource-card ${providerDetailOpen && selectedProviderId === provider.id ? 'active' : ''}`}
onClick={() => onEditProvider(provider)}
>
<div className="compact-row-main">
<div className="compact-row-titleblock">
<strong>{provider.name || provider.id}</strong>
<code>{provider.id}</code>
<div className="resource-card-top">
<div className="resource-card-heading">
<div className="resource-card-titlewrap">
<strong className="resource-card-title">{provider.name || provider.id}</strong>
<code className="resource-card-code">{provider.id}</code>
</div>
<p className="resource-card-subtitle">{provider.baseUrl}</p>
</div>
<div className="resource-card-side">
<span className={`badge status-badge ${provider.disabled ? 'idle' : 'live'}`}>
{provider.disabled ? t('status.disabled') : t('status.enabled')}
</span>
</div>
<span className={`badge ${provider.disabled ? 'idle' : 'live'}`}>
{provider.disabled ? t('status.disabled') : t('status.enabled')}
</span>
</div>
<div className="compact-row-summary compact-row-summary-grid">
<span>{provider.baseUrl}</span>
<span>{t('providers.modelsCount', { count: provider.models?.length || 0 })}</span>
<span>{provider.apiKeyMasked || t('providers.apiKeyNotSet')}</span>
<div className="resource-card-meta">
<div className="resource-meta-item resource-meta-route">
<span className="resource-meta-label">{t('providers.cardBaseUrl')}</span>
<span className="resource-meta-value">{provider.baseUrl}</span>
</div>
<div className="resource-meta-item">
<span className="resource-meta-label">{t('providers.cardModels')}</span>
<span className="resource-meta-value">{t('providers.modelsCount', { count: provider.models?.length || 0 })}</span>
</div>
<div className="resource-meta-item">
<span className="resource-meta-label">{t('providers.cardApiKey')}</span>
<span className="resource-meta-value">{provider.apiKeyMasked || t('providers.apiKeyNotSet')}</span>
</div>
</div>
</button>
))}
@ -1390,39 +1432,44 @@ export default function App() {
: t('aliases.subtitle')}
</p>
</div>
<div className="toolbar toolbar-end">
<span className="subtle">
<div className="list-header-actions">
<span className="subtle list-status-text">
{aliasStatus || t('aliases.listCount', { shown: filteredAliases.length, total: aliases.length })}
</span>
{aliasDetailOpen ? (
<button type="button" onClick={closeAliasDetail}>
{t('actions.close')}
<div className="toolbar list-toolbar-actions">
{aliasDetailOpen ? (
<button type="button" onClick={closeAliasDetail}>
{t('actions.close')}
</button>
) : null}
<button type="button" className="primary" onClick={openAliasCreateModal}>
{t('actions.newAlias')}
</button>
) : null}
<button type="button" className="primary" onClick={openAliasCreateModal}>
{t('actions.newAlias')}
</button>
<button type="button" onClick={() => openAliasTargetModal()}>
{t('actions.bind')}
</button>
<button type="button" onClick={() => openAliasTargetModal()}>
{t('actions.bind')}
</button>
</div>
</div>
</div>
<div className="list-toolbar list-toolbar-single">
<label>
<span>{t('aliases.search')}</span>
<input
type="text"
value={aliasQuery}
onChange={(event) => setAliasQuery(event.target.value)}
placeholder={t('aliases.searchPlaceholder')}
/>
</label>
<div className="filter-group filter-group-search">
<span className="filter-group-label">{t('aliases.search')}</span>
<label className="search-field">
<input
type="text"
value={aliasQuery}
onChange={(event) => setAliasQuery(event.target.value)}
placeholder={t('aliases.searchPlaceholder')}
/>
</label>
</div>
</div>
<div className="scroll-list compact-list">
{aliases.length === 0 ? (
<article className="empty-card">
<div className="empty-illustration" aria-hidden="true"></div>
<span className="empty-kicker">{t('aliases.title')}</span>
<h4>{t('aliases.empty')}</h4>
<p className="subtle">{t('aliases.emptyHint')}</p>
@ -1446,22 +1493,42 @@ export default function App() {
<button
key={alias.alias}
type="button"
className={`compact-row ${aliasDetailOpen && selectedAliasId === alias.alias ? 'active' : ''}`}
className={`resource-card ${aliasDetailOpen && selectedAliasId === alias.alias ? 'active' : ''}`}
onClick={() => onEditAlias(alias)}
>
<div className="compact-row-main">
<div className="compact-row-titleblock">
<strong>{alias.displayName || alias.alias}</strong>
<code>{alias.alias}</code>
<div className="resource-card-top">
<div className="resource-card-heading">
<div className="resource-card-titlewrap">
<strong className="resource-card-title">{alias.displayName || alias.alias}</strong>
<code className="resource-card-code">{alias.alias}</code>
</div>
<p className="resource-card-subtitle">
{alias.targets[0]
? `${alias.targets[0].provider}/${alias.targets[0].model}`
: t('aliases.noTargets')}
</p>
</div>
<div className="resource-card-side">
<span className={`badge status-badge ${alias.enabled ? 'live' : 'idle'}`}>
{alias.enabled ? t('status.enabled') : t('status.disabled')}
</span>
</div>
<span className={`badge ${alias.enabled ? 'live' : 'idle'}`}>
{alias.enabled ? t('status.enabled') : t('status.disabled')}
</span>
</div>
<div className="compact-row-summary compact-row-summary-grid">
<span>{t('aliases.routable', { available: alias.availableTargetCount, total: alias.targetCount })}</span>
<span>{t('aliases.targetsCount', { count: alias.targets.length })}</span>
<span>{alias.targets[0] ? `${alias.targets[0].provider}/${alias.targets[0].model}` : t('aliases.noTargets')}</span>
<div className="resource-card-meta">
<div className="resource-meta-item resource-meta-route">
<span className="resource-meta-label">{t('aliases.cardRoute')}</span>
<span className="resource-meta-value">{t('aliases.routable', { available: alias.availableTargetCount, total: alias.targetCount })}</span>
</div>
<div className="resource-meta-item">
<span className="resource-meta-label">{t('aliases.cardTargets')}</span>
<span className="resource-meta-value">{t('aliases.targetsCount', { count: alias.targets.length })}</span>
</div>
<div className="resource-meta-item">
<span className="resource-meta-label">{t('aliases.cardPrimary')}</span>
<span className="resource-meta-value">
{alias.targets[0] ? `${alias.targets[0].provider}/${alias.targets[0].model}` : t('aliases.noTargets')}
</span>
</div>
</div>
</button>
))}
@ -2047,86 +2114,114 @@ export default function App() {
</div>
<form className="stack-blocks" onSubmit={(event) => void onSaveProvider(event)}>
<div className="inline-meta compact-inline-meta">
<div>
<span className="meta-label">{t('providers.baseUrl')}</span>
<strong>{selectedProvider?.baseUrl || providerForm.baseUrl || '-'}</strong>
<section className="detail-hero detail-hero-grid">
<div className="detail-hero-card detail-hero-primary">
<span className="meta-label">{t('providers.name')}</span>
<strong>{providerForm.name || providerForm.id || t('providers.formCreateTitle')}</strong>
<p className="subtle detail-hero-subtle">{selectedProvider?.baseUrl || providerForm.baseUrl || '-'}</p>
</div>
<div>
<div className="detail-hero-card">
<span className="meta-label">{t('status.status')}</span>
<strong>{providerForm.disabled ? t('status.disabled') : t('status.enabled')}</strong>
</div>
<div className="detail-hero-card">
<span className="meta-label">{t('providers.models')}</span>
<strong>{selectedProvider?.models?.join(', ') || t('providers.modelsNone')}</strong>
<strong>{t('providers.modelsCount', { count: selectedProvider?.models?.length || 0 })}</strong>
</div>
</div>
<label>
<span>{t('providers.id')}</span>
<input
type="text"
value={providerForm.id}
onChange={(event) => setProviderForm((current) => ({ ...current, id: event.target.value }))}
placeholder={t('providers.placeholderId')}
/>
</label>
<label>
<span>{t('providers.name')}</span>
<input
type="text"
value={providerForm.name}
onChange={(event) => setProviderForm((current) => ({ ...current, name: event.target.value }))}
placeholder={t('providers.placeholderName')}
/>
</label>
<label>
<span>{t('providers.baseUrl')}</span>
<input
type="text"
value={providerForm.baseUrl}
onChange={(event) => setProviderForm((current) => ({ ...current, baseUrl: event.target.value }))}
placeholder={t('providers.placeholderBaseUrl')}
/>
</label>
<label>
<span>{t('providers.apiKey')}</span>
<input
type="text"
value={providerForm.apiKey}
onChange={(event) => setProviderForm((current) => ({ ...current, apiKey: event.target.value }))}
placeholder={t('providers.placeholderApiKey')}
/>
</label>
<label>
<span>{t('providers.headers')}</span>
<textarea
value={providerForm.headersText}
onChange={(event) => setProviderForm((current) => ({ ...current, headersText: event.target.value }))}
placeholder={t('providers.placeholderHeaders')}
rows={4}
/>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={providerForm.disabled}
onChange={(event) => setProviderForm((current) => ({ ...current, disabled: event.target.checked }))}
/>
<span>{t('providers.saveDisabled')}</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={providerForm.skipModels}
onChange={(event) => setProviderForm((current) => ({ ...current, skipModels: event.target.checked }))}
/>
<span>{t('providers.skipModels')}</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={providerForm.clearHeaders}
onChange={(event) => setProviderForm((current) => ({ ...current, clearHeaders: event.target.checked }))}
/>
<span>{t('providers.clearHeaders')}</span>
</label>
<div className="toolbar">
</section>
<section className="detail-section">
<div className="detail-section-header">
<div>
<h4>{t('providers.detailBasicsTitle')}</h4>
<p className="subtle">{t('providers.detailHint')}</p>
</div>
</div>
<div className="detail-form-grid">
<label>
<span>{t('providers.id')}</span>
<input
type="text"
value={providerForm.id}
onChange={(event) => setProviderForm((current) => ({ ...current, id: event.target.value }))}
placeholder={t('providers.placeholderId')}
/>
</label>
<label>
<span>{t('providers.name')}</span>
<input
type="text"
value={providerForm.name}
onChange={(event) => setProviderForm((current) => ({ ...current, name: event.target.value }))}
placeholder={t('providers.placeholderName')}
/>
</label>
<label className="detail-form-span">
<span>{t('providers.baseUrl')}</span>
<input
type="text"
value={providerForm.baseUrl}
onChange={(event) => setProviderForm((current) => ({ ...current, baseUrl: event.target.value }))}
placeholder={t('providers.placeholderBaseUrl')}
/>
</label>
<label className="detail-form-span">
<span>{t('providers.apiKey')}</span>
<input
type="text"
value={providerForm.apiKey}
onChange={(event) => setProviderForm((current) => ({ ...current, apiKey: event.target.value }))}
placeholder={t('providers.placeholderApiKey')}
/>
</label>
<label className="detail-form-span">
<span>{t('providers.headers')}</span>
<textarea
value={providerForm.headersText}
onChange={(event) => setProviderForm((current) => ({ ...current, headersText: event.target.value }))}
placeholder={t('providers.placeholderHeaders')}
rows={4}
/>
</label>
</div>
</section>
<section className="detail-section">
<div className="detail-section-header">
<div>
<h4>{t('providers.detailTogglesTitle')}</h4>
<p className="subtle">{selectedProvider?.models?.join(', ') || t('providers.modelsNone')}</p>
</div>
</div>
<div className="toggle-grid">
<label className="checkbox-row checkbox-card">
<input
type="checkbox"
checked={providerForm.disabled}
onChange={(event) => setProviderForm((current) => ({ ...current, disabled: event.target.checked }))}
/>
<span>{t('providers.saveDisabled')}</span>
</label>
<label className="checkbox-row checkbox-card">
<input
type="checkbox"
checked={providerForm.skipModels}
onChange={(event) => setProviderForm((current) => ({ ...current, skipModels: event.target.checked }))}
/>
<span>{t('providers.skipModels')}</span>
</label>
<label className="checkbox-row checkbox-card detail-form-span">
<input
type="checkbox"
checked={providerForm.clearHeaders}
onChange={(event) => setProviderForm((current) => ({ ...current, clearHeaders: event.target.checked }))}
/>
<span>{t('providers.clearHeaders')}</span>
</label>
</div>
</section>
<div className="toolbar detail-actions">
<button type="submit" className="primary">
{t('actions.save')}
</button>
@ -2139,9 +2234,9 @@ export default function App() {
}
resetProviderForm()
}}
>
{t('actions.reset')}
</button>
>
{t('actions.reset')}
</button>
</div>
</form>
</div>
@ -2255,34 +2350,63 @@ export default function App() {
</div>
<div className="stack-blocks">
<form className="stack" onSubmit={(event) => void onSaveAlias(event)}>
<label>
<span>{t('aliases.alias')}</span>
<input
type="text"
value={aliasForm.alias}
onChange={(event) => setAliasForm((current) => ({ ...current, alias: event.target.value }))}
placeholder={t('aliases.placeholderAlias')}
/>
</label>
<label>
<span>{t('aliases.displayName')}</span>
<input
type="text"
value={aliasForm.displayName}
onChange={(event) => setAliasForm((current) => ({ ...current, displayName: event.target.value }))}
placeholder={t('aliases.placeholderDisplayName')}
/>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={aliasForm.disabled}
onChange={(event) => setAliasForm((current) => ({ ...current, disabled: event.target.checked }))}
/>
<span>{t('aliases.createDisabled')}</span>
</label>
<div className="toolbar">
<section className="detail-hero detail-hero-grid">
<div className="detail-hero-card detail-hero-primary">
<span className="meta-label">{t('aliases.alias')}</span>
<strong>{aliasForm.displayName || aliasForm.alias || t('aliases.formCreateTitle')}</strong>
<p className="subtle detail-hero-subtle">{aliasForm.alias || '-'}</p>
</div>
<div className="detail-hero-card">
<span className="meta-label">{t('status.status')}</span>
<strong>{aliasForm.disabled ? t('status.disabled') : t('status.enabled')}</strong>
</div>
<div className="detail-hero-card">
<span className="meta-label">{t('aliases.targets')}</span>
<strong>{t('aliases.targetsCount', { count: selectedAlias?.targets.length || 0 })}</strong>
</div>
</section>
<form className="stack-blocks" onSubmit={(event) => void onSaveAlias(event)}>
<section className="detail-section">
<div className="detail-section-header">
<div>
<h4>{t('aliases.detailBasicsTitle')}</h4>
<p className="subtle">{t('aliases.detailHint')}</p>
</div>
</div>
<div className="detail-form-grid">
<label>
<span>{t('aliases.alias')}</span>
<input
type="text"
value={aliasForm.alias}
onChange={(event) => setAliasForm((current) => ({ ...current, alias: event.target.value }))}
placeholder={t('aliases.placeholderAlias')}
/>
</label>
<label>
<span>{t('aliases.displayName')}</span>
<input
type="text"
value={aliasForm.displayName}
onChange={(event) => setAliasForm((current) => ({ ...current, displayName: event.target.value }))}
placeholder={t('aliases.placeholderDisplayName')}
/>
</label>
</div>
<div className="toggle-grid">
<label className="checkbox-row checkbox-card detail-form-span">
<input
type="checkbox"
checked={aliasForm.disabled}
onChange={(event) => setAliasForm((current) => ({ ...current, disabled: event.target.checked }))}
/>
<span>{t('aliases.createDisabled')}</span>
</label>
</div>
</section>
<div className="toolbar detail-actions">
<button type="submit" className="primary">
{t('actions.save')}
</button>
@ -2301,20 +2425,32 @@ export default function App() {
</div>
</form>
<section className="subpanel">
<div className="subpanel-header">
<h4>{t('aliases.targets')}</h4>
<section className="detail-section detail-targets-section">
<div className="detail-section-header">
<div>
<h4>{t('aliases.targets')}</h4>
<p className="subtle">{t('aliases.detailTargetsHint')}</p>
</div>
{selectedAlias ? <span className="subtle">{t('aliases.targetsCount', { count: selectedAlias.targets.length })}</span> : null}
</div>
<div className="target-list target-list-compact">
{!selectedAlias || selectedAlias.targets.length === 0 ? <p className="subtle">{t('aliases.noTargets')}</p> : null}
{!selectedAlias || selectedAlias.targets.length === 0 ? (
<article className="empty-card compact-empty detail-empty-card">
<div className="empty-illustration" aria-hidden="true"></div>
<h4>{t('aliases.noTargets')}</h4>
<p className="subtle">{t('aliases.emptyHint')}</p>
</article>
) : null}
{selectedAlias?.targets.map((target) => (
<div className="target-card" key={`${selectedAlias.alias}-${target.provider}-${target.model}`}>
<div className="target-card-main">
<code>
{target.provider}/{target.model}
</code>
<span className={`badge ${target.enabled ? 'live' : 'idle'}`}>
<div className="target-card-copy">
<code>
{target.provider}/{target.model}
</code>
<span className="subtle target-card-subtitle">{selectedAlias.alias}</span>
</div>
<span className={`badge status-badge ${target.enabled ? 'live' : 'idle'}`}>
{target.enabled ? t('status.enabled') : t('status.disabled')}
</span>
</div>

View File

@ -62,6 +62,18 @@ export async function getMeta(): Promise<MetaView> {
return http<MetaView>('/api/meta')
}
export async function openExternalURL(url: string): Promise<void> {
if (isWails()) {
await bridge().OpenExternalURL(url)
return
}
const target = url.trim()
if (!target) {
return
}
window.open(target, '_blank', 'noopener,noreferrer')
}
export function getOverview(): Promise<Overview> {
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.0 KiB

11
frontend/src/env.d.ts vendored
View File

@ -1,11 +1,22 @@
/// <reference types="vite/client" />
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare global {
interface Window {
go?: {
desktop?: {
App?: {
Meta: () => Promise<Record<string, string>>
OpenExternalURL: (url: string) => Promise<void>
Overview: () => Promise<import('./types').Overview>
ExportConfig: () => Promise<import('./types').ConfigExportView>
ImportConfig: (input: import('./types').ConfigImportInput) => Promise<import('./types').ConfigImportResult>

View File

@ -7,6 +7,25 @@
"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" },
@ -133,6 +152,11 @@
"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",
@ -185,6 +209,11 @@
"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}}...",

View File

@ -7,6 +7,25 @@
"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": "管理上游端点" },
@ -133,6 +152,11 @@
"overwrite": "覆盖现有提供商",
"apiKeyMasked": "API key",
"apiKeyNotSet": "未设置",
"cardBaseUrl": "地址",
"cardModels": "模型",
"cardApiKey": "密钥",
"detailBasicsTitle": "基础信息",
"detailTogglesTitle": "行为选项",
"headersNone": "无",
"modelsNone": "无",
"modelsCount": "{{count}} 个模型",
@ -185,6 +209,11 @@
"targets": "目标",
"targetsCount": "{{count}} 个目标",
"routable": "可路由 {{available}}/{{total}}",
"cardRoute": "路由",
"cardTargets": "目标数",
"cardPrimary": "主目标",
"detailBasicsTitle": "别名信息",
"detailTargetsHint": "为当前别名维护可路由的 provider/model 目标。",
"statusSaved": "已保存别名 {{alias}}",
"statusEditing": "正在编辑别名 {{alias}}",
"statusDeleting": "正在删除别名 {{alias}}...",

View File

@ -1,5 +1,6 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei UI', Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, sans-serif;
--bg: #f4f7fc;
--bg-elevated: rgba(255, 255, 255, 0.72);
--bg-grid: rgba(10, 34, 66, 0.05);
@ -158,11 +159,11 @@ button.primary {
border-color: transparent;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #ffffff;
box-shadow: 0 18px 34px rgba(0, 102, 255, 0.22);
box-shadow: 0 8px 18px rgba(0, 102, 255, 0.16);
}
html[data-theme='dark'] button.primary {
box-shadow: 0 20px 38px rgba(30, 112, 255, 0.28);
box-shadow: 0 10px 24px rgba(30, 112, 255, 0.18);
}
button:disabled {
@ -241,6 +242,7 @@ h4 {
display: grid;
grid-template-columns: 224px minmax(0, 1fr);
min-height: 100vh;
min-width: 1120px;
}
.app-shell.shell-locked,
@ -279,8 +281,8 @@ html[data-theme='dark'] .sidebar {
.sidebar-brand {
position: relative;
display: grid;
gap: 0.55rem;
padding: 1rem;
gap: 0.65rem;
padding: 1.1rem;
overflow: hidden;
}
@ -302,6 +304,36 @@ html[data-theme='dark'] .sidebar {
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
}
.sidebar-brand-header {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.brand-github {
position: relative;
z-index: 1;
flex: 0 0 auto;
min-height: 0;
width: 36px;
height: 36px;
padding: 0.35rem;
border-radius: 12px;
background: var(--panel-strong);
}
.brand-github img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-github:disabled {
opacity: 0.55;
}
.brand-meta {
display: flex;
flex-wrap: wrap;
@ -326,7 +358,10 @@ dt {
}
.sidebar-brand h1 {
font-size: 1.1rem;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.2;
text-transform: lowercase;
}
.subtle {
@ -348,10 +383,10 @@ dt {
position: relative;
display: flex;
align-items: center;
min-height: 42px;
min-height: 40px;
justify-items: start;
text-align: left;
padding: 0.7rem 0.85rem 0.7rem 1rem;
padding: 0.62rem 0.8rem 0.62rem 0.95rem;
border-radius: 14px;
border-color: transparent;
background: transparent;
@ -397,6 +432,19 @@ html[data-theme='dark'] .nav-item.active .nav-title {
width: 100%;
}
.badge,
button,
input,
textarea,
select,
.subtle,
.nav-title,
.meta-label,
dt,
dd {
font-family: inherit;
}
.panel-header,
.toolbar,
.item-heading,
@ -410,7 +458,12 @@ html[data-theme='dark'] .nav-item.active .nav-title {
.panel-header,
.subpanel-header {
margin-bottom: 1.1rem;
margin-bottom: 0.95rem;
}
.panel-header h3,
.subpanel-header h4 {
line-height: 1.25;
}
.stack,
@ -434,14 +487,14 @@ html[data-theme='dark'] .nav-item.active .nav-title {
.workspace {
display: flex;
flex-direction: column;
gap: 1.15rem;
gap: 1rem;
min-width: 0;
padding: 1.5rem;
padding: 1.35rem;
}
.tab-layout {
display: grid;
gap: 1rem;
gap: 0.9rem;
align-content: start;
}
@ -465,7 +518,7 @@ html[data-theme='dark'] .nav-item.active .nav-title {
}
.panel {
padding: 1.3rem;
padding: 1.15rem;
min-width: 0;
background: var(--panel);
overflow: hidden;
@ -493,9 +546,9 @@ html[data-theme='dark'] .nav-item.active .nav-title {
.trace-row {
display: grid;
gap: 0.7rem;
gap: 0.6rem;
width: 100%;
padding: 1rem;
padding: 0.9rem;
border-radius: 18px;
text-align: left;
background: var(--panel-muted);
@ -590,7 +643,7 @@ html[data-theme='dark'] .nav-item.active .nav-title {
.stat-card {
position: relative;
overflow: hidden;
padding: 1rem;
padding: 0.92rem;
}
.stat-card::after {
@ -616,13 +669,13 @@ html[data-theme='dark'] .nav-item.active .nav-title {
align-items: center;
justify-content: center;
width: fit-content;
min-height: 32px;
padding: 0.36rem 0.7rem;
min-height: 28px;
padding: 0.22rem 0.58rem;
border-radius: 999px;
border: 1px solid transparent;
background: var(--accent-soft);
color: var(--accent-strong);
font-size: 0.78rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
}
@ -638,13 +691,13 @@ html[data-theme='dark'] .badge {
}
.badge.live {
background: var(--success-soft);
background: color-mix(in srgb, var(--success-soft) 94%, var(--panel));
color: var(--success);
}
.badge.idle {
background: rgba(148, 163, 184, 0.14);
color: var(--text-muted);
background: color-mix(in srgb, var(--panel-muted) 82%, var(--panel));
color: color-mix(in srgb, var(--text-muted) 92%, var(--text));
}
button.danger {
@ -699,11 +752,13 @@ html[data-theme='dark'] button.ghost-danger:hover {
.list-column {
display: grid;
gap: 1rem;
gap: 1.25rem;
}
.list-toolbar {
padding: 0.1rem 0;
padding: 0;
gap: 0.9rem;
align-items: stretch;
}
.list-toolbar > label,
@ -720,69 +775,194 @@ html[data-theme='dark'] button.ghost-danger:hover {
justify-content: stretch;
}
.list-header-actions {
display: grid;
justify-items: end;
gap: 0.65rem;
}
.list-toolbar-actions {
justify-content: flex-end;
gap: 0.7rem;
}
.list-status-text {
max-width: 28rem;
text-align: right;
}
.search-field {
gap: 0.4rem;
}
.filter-group {
display: grid;
gap: 0.45rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--border);
border-radius: 18px;
background: color-mix(in srgb, var(--panel-soft) 86%, var(--panel));
}
.filter-group-search {
flex: 1 1 0;
}
.filter-group-select {
flex: 0 0 min(240px, 100%);
}
.filter-group-label {
color: var(--text-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.filter-group label {
gap: 0;
}
.filter-group input,
.filter-group select {
min-height: 42px;
}
.scroll-list {
display: grid;
gap: 0.9rem;
gap: 0.8rem;
max-height: calc(100vh - 18rem);
padding-right: 0.25rem;
overflow: auto;
}
.compact-list {
gap: 0.7rem;
gap: 0.8rem;
}
.compact-row {
.resource-card {
display: grid;
gap: 0.75rem;
gap: 0.88rem;
width: 100%;
padding: 0.9rem 1rem;
padding: 1rem 1.05rem;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel-muted);
border-radius: 20px;
background: var(--panel);
text-align: left;
box-shadow: none;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.compact-row:hover,
.compact-row.active {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
.resource-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.compact-row-main,
.compact-row-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
.resource-card.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent-softer) 68%, var(--panel));
box-shadow: var(--shadow-sm);
}
.compact-row-titleblock {
.resource-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.resource-card-heading {
display: grid;
gap: 0.15rem;
gap: 0.38rem;
min-width: 0;
flex: 1;
}
.resource-card-titlewrap {
display: grid;
gap: 0.16rem;
min-width: 0;
}
.compact-row-titleblock code,
.compact-row-summary span {
color: var(--text-muted);
.resource-card-title {
color: var(--text);
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
}
.compact-row-titleblock strong,
.compact-row-titleblock code,
.compact-row-summary span {
.resource-card-code {
color: var(--text-muted);
font-size: 0.82rem;
}
.resource-card-subtitle {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.5;
}
.resource-card-title,
.resource-card-code,
.resource-card-subtitle,
.resource-meta-value {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.compact-row-summary-grid {
.resource-card-side {
display: flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
}
.status-badge {
min-height: 26px;
padding: 0.18rem 0.58rem;
}
.resource-card-meta {
display: grid;
grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(110px, auto));
align-items: center;
font-size: 0.88rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.7rem;
}
.resource-meta-item {
display: grid;
gap: 0.26rem;
min-width: 0;
padding: 0.72rem 0.82rem;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--panel-muted);
}
.resource-meta-route {
grid-column: span 1;
}
.resource-meta-label {
color: var(--text-muted);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.resource-meta-value {
color: var(--text);
font-size: 0.9rem;
font-weight: 600;
line-height: 1.45;
}
.detail-panel {
@ -805,7 +985,7 @@ html[data-theme='dark'] button.ghost-danger:hover {
width: min(100%, 720px);
height: calc(100vh - 2rem);
overflow: auto;
padding: 1.3rem;
padding: 1.2rem;
border: 1px solid var(--border-strong);
border-radius: 28px;
background: var(--panel);
@ -817,9 +997,91 @@ html[data-theme='dark'] button.ghost-danger:hover {
top: 0;
z-index: 1;
padding-bottom: 1rem;
background: linear-gradient(180deg, var(--panel) 78%, transparent);
}
.detail-hero {
display: grid;
gap: 0.85rem;
}
.detail-hero-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detail-hero-card,
.detail-section {
border: 1px solid var(--border);
border-radius: 20px;
background: var(--panel-soft);
}
.detail-hero-card {
display: grid;
gap: 0.4rem;
padding: 0.95rem 1rem;
min-width: 0;
}
.detail-hero-card strong {
font-size: 1rem;
line-height: 1.3;
}
.detail-hero-primary {
background: linear-gradient(135deg, color-mix(in srgb, var(--accent-softer) 72%, var(--panel)) 0%, var(--panel-soft) 100%);
}
.detail-hero-subtle {
margin: 0;
}
.detail-section {
display: grid;
gap: 1rem;
padding: 1rem;
}
.detail-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.8rem;
}
.detail-section-header h4 {
margin: 0;
font-size: 1rem;
}
.detail-section-header p {
margin-top: 0.2rem;
}
.detail-form-grid,
.toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.detail-form-span {
grid-column: 1 / -1;
}
.detail-actions {
padding-top: 0.2rem;
}
.checkbox-card {
min-height: 58px;
background: var(--panel);
}
.detail-targets-section {
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-soft) 92%, var(--panel)) 0%, var(--panel) 100%);
}
.compact-inline-meta {
align-items: flex-start;
}
@ -843,8 +1105,8 @@ html[data-theme='dark'] button.ghost-danger:hover {
.item-card {
display: grid;
gap: 1rem;
padding: 1.05rem;
gap: 0.85rem;
padding: 0.96rem;
transition:
border-color 160ms ease,
background 160ms ease,
@ -917,6 +1179,18 @@ html[data-theme='dark'] button.ghost-danger:hover {
justify-content: flex-end;
}
html[data-theme='dark'] .resource-card {
background: color-mix(in srgb, var(--panel) 94%, #09111f);
}
html[data-theme='dark'] .resource-card.active {
background: color-mix(in srgb, var(--accent-soft) 34%, var(--panel));
}
html[data-theme='dark'] .resource-meta-item {
background: color-mix(in srgb, var(--panel-muted) 86%, #09111f);
}
.item-grid > div,
.info-grid > div {
display: grid;
@ -941,6 +1215,9 @@ dd {
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 0.95rem;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--panel);
transition:
border-color 160ms ease,
background 160ms ease,
@ -961,6 +1238,23 @@ dd {
min-width: 0;
}
.target-card-copy {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.target-card-copy code,
.target-card-subtitle {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.target-card-subtitle {
margin: 0;
}
.target-list-compact {
gap: 0.7rem;
}
@ -1106,7 +1400,7 @@ input[type='file']::file-selector-button {
width: min(100%, 680px);
max-height: calc(100vh - 3rem);
overflow: auto;
padding: 1.15rem;
padding: 1.05rem;
border: 1px solid var(--border-strong);
border-radius: 24px;
background: var(--panel);
@ -1133,8 +1427,8 @@ input[type='file']::file-selector-button {
.empty-card {
display: grid;
gap: 0.8rem;
padding: 1.2rem;
gap: 0.7rem;
padding: 1.05rem;
border: 1px dashed var(--border-strong);
border-radius: 22px;
background: linear-gradient(135deg, var(--panel-soft), var(--panel-muted));
@ -1142,14 +1436,30 @@ input[type='file']::file-selector-button {
align-content: center;
}
.empty-illustration {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: color-mix(in srgb, var(--accent-soft) 88%, var(--panel));
color: var(--accent-strong);
font-size: 1.2rem;
font-weight: 800;
}
.detail-empty-card {
min-height: 0;
background: linear-gradient(135deg, color-mix(in srgb, var(--accent-softer) 62%, var(--panel)) 0%, var(--panel-soft) 100%);
}
.compact-empty {
min-height: 180px;
}
.empty-kicker {
font-size: 0.76rem;
letter-spacing: 0.12em;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
@ -1208,9 +1518,23 @@ dd {
flex-shrink: 0;
}
html[data-theme='dark'] .filter-group,
html[data-theme='dark'] .detail-section,
html[data-theme='dark'] .detail-hero-card,
html[data-theme='dark'] .target-card,
html[data-theme='dark'] .checkbox-card {
background: color-mix(in srgb, var(--panel-soft) 88%, #09111f);
}
html[data-theme='dark'] .detail-hero-primary,
html[data-theme='dark'] .detail-empty-card {
background: linear-gradient(135deg, color-mix(in srgb, var(--accent-soft) 40%, var(--panel)) 0%, color-mix(in srgb, var(--panel-soft) 92%, #09111f) 100%);
}
@media (max-width: 1240px) {
.app-shell {
grid-template-columns: 1fr;
min-width: 0;
}
.sidebar {
@ -1221,7 +1545,7 @@ dd {
}
.nav-list {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(160px, 1fr));
}
.overview-layout,
@ -1263,13 +1587,14 @@ dd {
.panel-header,
.toolbar,
.card-actions,
.detail-section-header,
.subpanel-header,
.list-toolbar,
.target-card,
.target-card-main,
.item-heading,
.item-summary,
.compact-row-main,
.resource-card-top,
.inline-meta {
flex-direction: column;
align-items: stretch;
@ -1287,14 +1612,31 @@ dd {
.settings-layout,
.stats-grid,
.card-grid-list,
.detail-hero-grid,
.info-grid,
.item-grid,
.action-grid,
.split-view,
.compact-row-summary-grid {
.detail-form-grid,
.toggle-grid,
.resource-card-meta {
grid-template-columns: 1fr;
}
.list-header-actions {
justify-items: stretch;
}
.list-status-text {
max-width: none;
text-align: left;
}
.list-toolbar-actions,
.resource-card-side {
justify-content: flex-start;
}
.scroll-list {
max-height: none;
overflow: visible;

View File

@ -30,6 +30,8 @@ export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.Provid
export function Meta():Promise<Record<string, string>>;
export function OpenExternalURL(arg1:string):Promise<void>;
export function Overview():Promise<app.Overview>;
export function PreviewSync(arg1:app.SyncInput):Promise<app.SyncPreview>;

View File

@ -54,6 +54,10 @@ export function Meta() {
return window['go']['desktop']['App']['Meta']();
}
export function OpenExternalURL(arg1) {
return window['go']['desktop']['App']['OpenExternalURL'](arg1);
}
export function Overview() {
return window['go']['desktop']['App']['Overview']();
}

10
go.mod
View File

@ -3,7 +3,7 @@ module github.com/Apale7/opencode-provider-switch
go 1.22.2
require (
github.com/getlantern/systray v1.2.2
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58
github.com/spf13/cobra v1.8.1
github.com/tidwall/jsonc v0.3.2
github.com/wailsapp/wails/v2 v2.12.0
@ -13,14 +13,7 @@ require (
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
@ -34,7 +27,6 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect

28
go.sum
View File

@ -1,29 +1,14 @@
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI=
fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -48,8 +33,6 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -58,8 +41,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -72,13 +53,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
@ -101,7 +79,6 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -114,7 +91,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Apale7/opencode-provider-switch/internal/app"
@ -140,6 +141,14 @@ 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) {
return a.bindings.GetOverview(a.callContext())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -25,6 +25,11 @@ func quitWindow(ctx context.Context) error {
return nil
}
func openExternalURL(ctx context.Context, url string) error {
_ = ctx
return openBrowser(url)
}
func initDesktopNotifications(ctx context.Context) error {
_ = ctx
return nil

View File

@ -4,6 +4,7 @@ package desktop
import (
"context"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
@ -21,6 +22,11 @@ func hideWindow(ctx context.Context) error {
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
}
@ -29,6 +35,11 @@ func quitWindow(ctx context.Context) error {
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)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,14 +4,21 @@ 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"
"github.com/getlantern/systray"
)
//go:embed assets/icon.ico
var trayIcon []byte
// Tray wires resident-mode controls into a native system tray.
type Tray struct {
service *app.Service
@ -19,8 +26,10 @@ type Tray struct {
mu sync.Mutex
ctx context.Context
prefs app.DesktopPrefsView
language string
registered bool
ready bool
running bool
quitting bool
statusItem *systray.MenuItem
@ -44,7 +53,12 @@ func (t *Tray) Attach(ctx context.Context) {
}
t.registered = true
t.mu.Unlock()
systray.Register(t.onReady, nil)
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(t.onReady, t.onExit)
}()
}
func (t *Tray) Detach() {
@ -63,7 +77,30 @@ func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
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()
}
@ -89,27 +126,49 @@ func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
}
func (t *Tray) onReady() {
systray.SetTitle("ocswitch")
systray.SetTooltip("ocswitch desktop")
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.statusItem = systray.AddMenuItem("Proxy: checking...", "Current proxy status")
t.running = true
t.statusItem = systray.AddMenuItem(labels.proxyChecking, labels.proxyStatusHint)
t.statusItem.Disable()
systray.AddSeparator()
t.showItem = systray.AddMenuItem("Open window", "Show desktop window")
t.hideItem = systray.AddMenuItem("Hide window", "Hide desktop window")
t.showItem = systray.AddMenuItem(labels.openWindow, labels.openWindowHint)
t.hideItem = systray.AddMenuItem(labels.hideWindow, labels.hideWindowHint)
systray.AddSeparator()
t.startItem = systray.AddMenuItem("Start proxy", "Start local proxy")
t.stopItem = systray.AddMenuItem("Stop proxy", "Stop local proxy")
t.startItem = systray.AddMenuItem(labels.startProxy, labels.startProxyHint)
t.stopItem = systray.AddMenuItem(labels.stopProxy, labels.stopProxyHint)
systray.AddSeparator()
t.quitItem = systray.AddMenuItem("Quit", "Exit application")
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()
@ -118,7 +177,11 @@ func (t *Tray) loop() {
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:
@ -183,6 +246,7 @@ func (t *Tray) refresh() {
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
@ -190,20 +254,76 @@ func (t *Tray) refresh() {
status, err := t.service.GetProxyStatus(context.Background())
if err != nil {
statusItem.SetTitle("Proxy: unavailable")
statusItem.SetTitle(labels.proxyUnavailable)
startItem.Enable()
stopItem.Disable()
return
}
if status.Running {
statusItem.SetTitle(fmt.Sprintf("Proxy: running (%s)", status.BindAddress))
statusItem.SetTitle(fmt.Sprintf(labels.proxyRunning, status.BindAddress))
startItem.Disable()
stopItem.Enable()
return
}
statusItem.SetTitle(fmt.Sprintf("Proxy: stopped (%s)", status.BindAddress))
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
}

View File

@ -12,6 +12,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/linux"
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
func RunWails(configPath string, version string) error {
@ -27,7 +28,7 @@ func RunWails(configPath string, version string) error {
Title: "ocswitch desktop",
Width: 1280,
Height: 880,
MinWidth: 980,
MinWidth: 1120,
MinHeight: 720,
AssetServer: &assetserver.Options{
Assets: mustFS(assets),
@ -45,6 +46,9 @@ func RunWails(configPath string, version string) error {
Linux: &linux.Options{
ProgramName: "ocswitch-desktop",
},
Windows: &windows.Options{
DisableWindowIcon: false,
},
})
}