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:
parent
b70f49cbef
commit
8fc72e87e4
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/GitHub_Invertocat_Black_Clearspace.png
Normal file
BIN
frontend/src/assets/GitHub_Invertocat_Black_Clearspace.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
61
frontend/src/assets/icon.svg
Normal file
61
frontend/src/assets/icon.svg
Normal 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
11
frontend/src/env.d.ts
vendored
@ -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>
|
||||
|
||||
@ -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}}...",
|
||||
|
||||
@ -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}}...",
|
||||
|
||||
@ -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;
|
||||
|
||||
2
frontend/wailsjs/go/desktop/App.d.ts
vendored
2
frontend/wailsjs/go/desktop/App.d.ts
vendored
@ -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>;
|
||||
|
||||
@ -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
10
go.mod
@ -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
28
go.sum
@ -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=
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
BIN
internal/desktop/assets/icon.ico
Normal file
BIN
internal/desktop/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
36
internal/desktop/tray_language.go
Normal file
36
internal/desktop/tray_language.go
Normal 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 ""
|
||||
}
|
||||
7
internal/desktop/tray_language_other.go
Normal file
7
internal/desktop/tray_language_other.go
Normal file
@ -0,0 +1,7 @@
|
||||
//go:build desktop_wails && !windows
|
||||
|
||||
package desktop
|
||||
|
||||
func detectSystemTrayLanguage() string {
|
||||
return ""
|
||||
}
|
||||
47
internal/desktop/tray_language_test.go
Normal file
47
internal/desktop/tray_language_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
internal/desktop/tray_language_windows.go
Normal file
20
internal/desktop/tray_language_windows.go
Normal 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[:])
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user