This commit is contained in:
2026-05-13 20:40:14 +08:00
parent bd13c842a8
commit c8a75ef2d5
17 changed files with 920 additions and 93 deletions

34
app.go
View File

@@ -5,12 +5,16 @@ import (
"sgg-ai-skill-manager/internal/domain"
"sgg-ai-skill-manager/internal/service"
"sgg-ai-skill-manager/internal/tray"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type App struct {
ctx context.Context
service *service.Service
initErr error
ctx context.Context
service *service.Service
initErr error
trayStop func()
}
func NewApp() *App {
@@ -23,6 +27,30 @@ func (a *App) startup(ctx context.Context) {
if a.service != nil {
a.service.StartAutoUpdate(ctx)
}
a.startTray(ctx)
}
func (a *App) shutdown(context.Context) {
if a.trayStop != nil {
a.trayStop()
a.trayStop = nil
}
}
func (a *App) startTray(ctx context.Context) {
stop, err := tray.Start(ctx, tray.Config{
Tooltip: appTitle,
IconPath: defaultTrayIconPath,
OnOpen: func() {
wailsruntime.WindowShow(ctx)
wailsruntime.WindowUnminimise(ctx)
},
})
if err != nil {
wailsruntime.LogErrorf(ctx, "failed to start tray icon: %v", err)
return
}
a.trayStop = stop
}
func (a *App) LoadConfig() (domain.Config, error) {

31
app_options.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"io/fs"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
const (
appTitle = "SGG AI 技能管理器"
defaultTrayIconPath = "build/windows/icon.ico"
)
func newAppOptions(app *App, assets fs.FS) *options.App {
return &options.App{
Title: appTitle,
Width: 1024,
Height: 768,
HideWindowOnClose: true,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
OnShutdown: app.shutdown,
Bind: []interface{}{
app,
},
}
}

23
app_options_test.go Normal file
View File

@@ -0,0 +1,23 @@
package main
import "testing"
func TestNewAppOptionsHidesWindowOnClose(t *testing.T) {
app := &App{}
options := newAppOptions(app, assets)
if !options.HideWindowOnClose {
t.Fatal("expected closing the window to hide it instead of quitting")
}
}
func TestNewAppOptionsStopsTrayOnShutdown(t *testing.T) {
app := &App{}
options := newAppOptions(app, assets)
if options.OnShutdown == nil {
t.Fatal("expected shutdown hook to clean up the tray icon")
}
}

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test:ui-language": "node scripts/check-chinese-ui.mjs",
"preview": "vite preview"
},
"dependencies": {

View File

@@ -0,0 +1,94 @@
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {resolve} from 'node:path';
const root = resolve(fileURLToPath(new URL('../..', import.meta.url)));
const appSource = readFileSync(resolve(root, 'frontend/src/App.tsx'), 'utf8');
const mainSource = readFileSync(resolve(root, 'main.go'), 'utf8');
const appOptionsSource = readFileSync(resolve(root, 'app_options.go'), 'utf8');
const backendEntrySource = `${mainSource}\n${appOptionsSource}`;
const forbiddenUiText = [
/<h1>Skill Manager<\/h1>/,
/>\s*Config\s*</,
/>\s*Remote\s*</,
/>\s*Local\s*</,
/>\s*Check Updates\s*</,
/<span>Gitea Base URL<\/span>/,
/<span>Organization<\/span>/,
/<span>Auth Type<\/span>/,
/>\s*Password\s*</,
/<span>Username<\/span>/,
/<span>Credential Key<\/span>/,
/<span>Auto update<\/span>/,
/<span>Check on startup<\/span>/,
/<span>Interval Minutes<\/span>/,
/>\s*Save\s*</,
/>\s*Test\s*</,
/placeholder="Search repositories"/,
/>\s*Refresh\s*</,
/<th>Description<\/th>/,
/<th>Branch<\/th>/,
/<th>Status<\/th>/,
/<th>Action<\/th>/,
/<th>Actions<\/th>/,
/>\s*Update\s*</,
/>\s*Download\s*</,
/title="Open folder"/,
/>No remote skills</,
/>No local skills</,
/return 'Remote Market';/,
/return 'Local Skills';/,
/return 'Configuration';/,
/Delete local skill/,
/title="Delete local"/,
/Remote list refreshed/,
/Config saved/,
/Connected as/,
/installed to/,
/uninstalled from/,
/>not installed</,
];
const requiredChineseText = [
'技能管理器',
'配置',
'远程市场',
'本地技能',
'检查更新',
'保存',
'测试连接',
'搜索仓库',
'刷新',
'下载',
'更新',
'操作',
'暂无远程技能',
'暂无本地技能',
'已下载',
'已安装',
'未安装',
];
const failures = [];
for (const pattern of forbiddenUiText) {
if (pattern.test(appSource)) {
failures.push(`App.tsx still matches English UI pattern: ${pattern}`);
}
}
for (const text of requiredChineseText) {
if (!appSource.includes(text)) {
failures.push(`App.tsx is missing Chinese UI text: ${text}`);
}
}
if (!backendEntrySource.includes('SGG AI 技能管理器')) {
failures.push('window title is not Chinese');
}
if (failures.length > 0) {
console.error(failures.join('\n'));
process.exit(1);
}

View File

@@ -131,6 +131,13 @@ button.danger:hover:not(:disabled) {
line-height: 28px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.notice {
margin-bottom: 12px;
padding: 10px 12px;

View File

@@ -11,6 +11,7 @@ import {
Trash2,
Unlink,
} from 'lucide-react';
import {BrowserOpenURL} from '../wailsjs/runtime/runtime';
import './App.css';
import {api} from './api';
import {Config, RemoteSkill, SkillState, defaultConfig, saveConfigRequest} from './types';
@@ -18,6 +19,29 @@ import {domain} from '../wailsjs/go/models';
type Tab = 'config' | 'remote' | 'local';
const statusLabels: Record<string, string> = {
remote: '远程',
downloaded: '已下载',
check_failed: '检查失败',
installed: '已安装',
not_installed: '未安装',
conflict: '冲突',
unknown: '未知',
local: '就绪',
};
const localizedMessages: Record<string, string> = {
connected: '已连接',
'gitea baseURL is required': '请填写 Gitea 服务地址',
'gitea org is required': '请填写组织',
'gitea username is required': '请填写用户名',
'authType must be password or token': '认证方式必须是密码或 Token',
'update interval must be positive': '检查间隔必须大于 0',
'local changes present': '存在本地改动',
};
const projectRepositoryURL = 'http://10.1.0.1:3000/sgg-sgg-tools/sgg-sgg-ai-skill-manage-windows';
function App() {
const [activeTab, setActiveTab] = useState<Tab>('config');
const [config, setConfig] = useState<Config>(defaultConfig());
@@ -57,7 +81,7 @@ function App() {
async function refreshRemote() {
await run('remote:refresh', async () => {
setRemoteSkills(await api.listRemoteSkills());
setMessage('Remote list refreshed');
setMessage('远程列表已刷新');
});
}
@@ -70,14 +94,14 @@ function App() {
await api.saveConfig(saveConfigRequest(config, password, token));
setPassword('');
setToken('');
setMessage('Config saved');
setMessage('配置已保存');
});
}
async function testConnection() {
await run('config:test', async () => {
const result = await api.testConnection(saveConfigRequest(config, password, token));
setMessage(result.ok ? `Connected as ${result.username}` : result.message);
setMessage(result.ok ? (result.username ? `已连接:${result.username}` : '已连接') : localizeMessage(result.message));
});
}
@@ -85,10 +109,10 @@ function App() {
await run(`remote:${skill.name}`, async () => {
if (skill.isDownloaded) {
await api.updateSkill(config.gitea.org, skill.name);
setMessage(`${skill.name} updated`);
setMessage(`${skill.name} 已更新`);
} else {
await api.downloadSkill(skill);
setMessage(`${skill.name} downloaded`);
setMessage(`${skill.name} 已下载`);
}
await refreshLocal();
await refreshRemote();
@@ -99,7 +123,7 @@ function App() {
await run(`local:update:${skill.repo}`, async () => {
await api.updateSkill(skill.org, skill.repo);
await refreshLocal();
setMessage(`${skill.repo} updated`);
setMessage(`${skill.repo} 已更新`);
});
}
@@ -107,7 +131,7 @@ function App() {
await run(`install:${targetID}:${skill.repo}`, async () => {
await api.installSkill(skill.org, skill.repo, targetID);
await refreshLocal();
setMessage(`${skill.repo} installed to ${targetLabel(targetID)}`);
setMessage(`${skill.repo} 已安装到 ${targetLabel(targetID)}`);
});
}
@@ -115,18 +139,18 @@ function App() {
await run(`uninstall:${targetID}:${skill.repo}`, async () => {
await api.uninstallSkill(skill.org, skill.repo, targetID);
await refreshLocal();
setMessage(`${skill.repo} uninstalled from ${targetLabel(targetID)}`);
setMessage(`${skill.repo} 已从 ${targetLabel(targetID)} 卸载`);
});
}
async function deleteSkill(skill: SkillState) {
if (!window.confirm(`Delete local skill ${skill.repo}?`)) {
if (!window.confirm(`确认删除本地技能 ${skill.repo}`)) {
return;
}
await run(`delete:${skill.repo}`, async () => {
await api.deleteSkill(skill.org, skill.repo);
await refreshLocal();
setMessage(`${skill.repo} deleted`);
setMessage(`${skill.repo} 已删除`);
});
}
@@ -155,7 +179,7 @@ function App() {
try {
await action();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setError(localizeMessage(err instanceof Error ? err.message : String(err)));
} finally {
setBusy('');
}
@@ -173,19 +197,19 @@ function App() {
<div className="brand">
<div className="brand-mark">S</div>
<div>
<h1>Skill Manager</h1>
<h1></h1>
<span>Gitea / Codex / Claude</span>
</div>
</div>
<nav className="tabs" aria-label="Primary">
<nav className="tabs" aria-label="主导航">
<button className={activeTab === 'config' ? 'active' : ''} onClick={() => setActiveTab('config')}>
<Settings size={17} /> Config
<Settings size={17} />
</button>
<button className={activeTab === 'remote' ? 'active' : ''} onClick={() => setActiveTab('remote')}>
<Download size={17} /> Remote
<Download size={17} />
</button>
<button className={activeTab === 'local' ? 'active' : ''} onClick={() => setActiveTab('local')}>
<FolderOpen size={17} /> Local
<FolderOpen size={17} />
</button>
</nav>
</aside>
@@ -196,9 +220,14 @@ function App() {
<h2>{pageTitle(activeTab)}</h2>
<span>{statusSummary(remoteSkills, localSkills)}</span>
</div>
<button className="ghost" onClick={() => run('auto:update', async () => { await api.runAutoUpdate(); await refreshLocal(); })} disabled={busy !== ''}>
<RefreshCw size={16} /> Check Updates
</button>
<div className="topbar-actions">
<button className="ghost" onClick={() => run('auto:update', async () => { await api.runAutoUpdate(); await refreshLocal(); })} disabled={busy !== ''}>
<RefreshCw size={16} />
</button>
<button className="ghost icon-only" onClick={() => BrowserOpenURL(projectRepositoryURL)} title="打开项目仓库" aria-label="打开项目仓库">
<GithubIcon size={17} />
</button>
</div>
</header>
{(message || error) && (
@@ -211,7 +240,7 @@ function App() {
<section className="panel">
<div className="form-grid">
<label>
<span>Gitea Base URL</span>
<span>Gitea </span>
<input
value={config.gitea.baseURL || ''}
placeholder="https://gitea.example.com"
@@ -219,20 +248,20 @@ function App() {
/>
</label>
<label>
<span>Organization</span>
<span></span>
<input
value={config.gitea.org || ''}
onChange={(event) => updateConfig((next) => { next.gitea.org = event.target.value; })}
/>
</label>
<div className="field">
<span>Auth Type</span>
<span></span>
<div className="segmented">
<button
className={config.gitea.authType !== 'token' ? 'active' : ''}
onClick={() => updateConfig((next) => { next.gitea.authType = 'password'; })}
>
Password
</button>
<button
className={config.gitea.authType === 'token' ? 'active' : ''}
@@ -243,7 +272,7 @@ function App() {
</div>
</div>
<label>
<span>Username</span>
<span></span>
<input
value={config.gitea.username || ''}
onChange={(event) => updateConfig((next) => { next.gitea.username = event.target.value; })}
@@ -256,24 +285,17 @@ function App() {
</label>
) : (
<label>
<span>Password</span>
<span></span>
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
)}
<label>
<span>Credential Key</span>
<input
value={config.gitea.credentialKey || ''}
onChange={(event) => updateConfig((next) => { next.gitea.credentialKey = event.target.value; })}
/>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(config.update.autoUpdate)}
onChange={(event) => updateConfig((next) => { next.update.autoUpdate = event.target.checked; })}
/>
<span>Auto update</span>
<span></span>
</label>
<label className="checkbox">
<input
@@ -281,10 +303,10 @@ function App() {
checked={Boolean(config.update.checkOnStartup)}
onChange={(event) => updateConfig((next) => { next.update.checkOnStartup = event.target.checked; })}
/>
<span>Check on startup</span>
<span></span>
</label>
<label>
<span>Interval Minutes</span>
<span></span>
<input
type="number"
min="1"
@@ -295,10 +317,10 @@ function App() {
</div>
<div className="toolbar">
<button onClick={saveConfig} disabled={busy !== ''}>
<Save size={16} /> Save
<Save size={16} />
</button>
<button className="secondary" onClick={testConnection} disabled={busy !== ''}>
<PlugZap size={16} /> Test
<PlugZap size={16} />
</button>
</div>
</section>
@@ -310,22 +332,22 @@ function App() {
<input
className="search"
value={search}
placeholder="Search repositories"
placeholder="搜索仓库"
onChange={(event) => setSearch(event.target.value)}
/>
<button onClick={refreshRemote} disabled={busy !== ''}>
<RefreshCw size={16} /> Refresh
<RefreshCw size={16} />
</button>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Branch</th>
<th>Status</th>
<th>Action</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@@ -337,12 +359,12 @@ function App() {
<td>{badge(skill.status || (skill.isDownloaded ? 'downloaded' : 'remote'))}</td>
<td>
<div className="row-actions">
<button onClick={() => downloadOrUpdate(skill)} disabled={busy !== ''} title={skill.isDownloaded ? 'Update' : 'Download'}>
<button onClick={() => downloadOrUpdate(skill)} disabled={busy !== ''} title={skill.isDownloaded ? '更新' : '下载'}>
{skill.isDownloaded ? <RefreshCw size={15} /> : <Download size={15} />}
{skill.isDownloaded ? 'Update' : 'Download'}
{skill.isDownloaded ? '更新' : '下载'}
</button>
{skill.isDownloaded && (
<button className="icon-only" onClick={() => openRemoteFolder(skill)} title="Open folder">
<button className="icon-only" onClick={() => openRemoteFolder(skill)} title="打开目录">
<FolderOpen size={15} />
</button>
)}
@@ -352,7 +374,7 @@ function App() {
))}
{filteredRemote.length === 0 && (
<tr>
<td colSpan={5} className="empty">No remote skills</td>
<td colSpan={5} className="empty"></td>
</tr>
)}
</tbody>
@@ -365,20 +387,20 @@ function App() {
<section className="panel">
<div className="toolbar">
<button onClick={refreshLocal} disabled={busy !== ''}>
<RefreshCw size={16} /> Refresh
<RefreshCw size={16} />
</button>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Commit</th>
<th>Status</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>Codex</th>
<th>Claude</th>
<th>Actions</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -387,39 +409,39 @@ function App() {
<td className="name-cell">{skill.repo}</td>
<td className="path-cell" title={skill.localPath}>{skill.localPath}</td>
<td>{shortCommit(skill.currentCommit)}</td>
<td>{skill.lastError ? <span className="badge error">{skill.lastError}</span> : <span className="badge local">ready</span>}</td>
<td>{skill.lastError ? <span className="badge error">{localizeMessage(skill.lastError)}</span> : <span className="badge local"></span>}</td>
<td>{targetBadge(skill, 'codex')}</td>
<td>{targetBadge(skill, 'claude')}</td>
<td>
<div className="row-actions">
<button onClick={() => updateLocal(skill)} disabled={busy !== ''} title="Update">
<RefreshCw size={15} /> Update
<button onClick={() => updateLocal(skill)} disabled={busy !== ''} title="更新">
<RefreshCw size={15} />
</button>
{targetInstalled(skill, 'codex') ? (
<button className="secondary" onClick={() => uninstall(skill, 'codex')} disabled={busy !== ''} title="Uninstall Codex">
<button className="secondary" onClick={() => uninstall(skill, 'codex')} disabled={busy !== ''} title="卸载 Codex">
<Unlink size={15} /> Codex
</button>
) : (
<button className="secondary" onClick={() => install(skill, 'codex')} disabled={busy !== ''} title="Install Codex">
<button className="secondary" onClick={() => install(skill, 'codex')} disabled={busy !== ''} title="安装 Codex">
<Link2 size={15} /> Codex
</button>
)}
{targetInstalled(skill, 'claude') ? (
<button className="secondary" onClick={() => uninstall(skill, 'claude')} disabled={busy !== ''} title="Uninstall Claude">
<button className="secondary" onClick={() => uninstall(skill, 'claude')} disabled={busy !== ''} title="卸载 Claude">
<Unlink size={15} /> Claude
</button>
) : (
<button className="secondary" onClick={() => install(skill, 'claude')} disabled={busy !== ''} title="Install Claude">
<button className="secondary" onClick={() => install(skill, 'claude')} disabled={busy !== ''} title="安装 Claude">
<Link2 size={15} /> Claude
</button>
)}
<button className="icon-only" onClick={() => openCode(skill)} disabled={busy !== ''} title="Open VS Code">
<button className="icon-only" onClick={() => openCode(skill)} disabled={busy !== ''} title="打开 VS Code">
<Code2 size={15} />
</button>
<button className="icon-only" onClick={() => openFolder(skill)} disabled={busy !== ''} title="Open folder">
<button className="icon-only" onClick={() => openFolder(skill)} disabled={busy !== ''} title="打开目录">
<FolderOpen size={15} />
</button>
<button className="danger icon-only" onClick={() => deleteSkill(skill)} disabled={busy !== ''} title="Delete local">
<button className="danger icon-only" onClick={() => deleteSkill(skill)} disabled={busy !== ''} title="删除本地技能">
<Trash2 size={15} />
</button>
</div>
@@ -428,7 +450,7 @@ function App() {
))}
{localSkills.length === 0 && (
<tr>
<td colSpan={7} className="empty">No local skills</td>
<td colSpan={7} className="empty"></td>
</tr>
)}
</tbody>
@@ -441,27 +463,35 @@ function App() {
);
}
function GithubIcon({size}: {size: number}) {
return (
<svg aria-hidden="true" focusable="false" width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.09 3.29 9.4 7.86 10.93.58.1.79-.25.79-.56v-2.15c-3.2.7-3.87-1.37-3.87-1.37-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.75 2.69 1.24 3.35.95.1-.74.4-1.24.73-1.53-2.55-.29-5.23-1.28-5.23-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.16 1.17.92-.25 1.9-.38 2.88-.38.98 0 1.96.13 2.88.38 2.2-1.48 3.16-1.17 3.16-1.17.62 1.58.23 2.75.11 3.04.73.8 1.18 1.83 1.18 3.08 0 4.41-2.69 5.38-5.25 5.67.41.36.78 1.06.78 2.13v3.18c0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
);
}
function pageTitle(tab: Tab) {
if (tab === 'remote') {
return 'Remote Market';
return '远程市场';
}
if (tab === 'local') {
return 'Local Skills';
return '本地技能';
}
return 'Configuration';
return '配置';
}
function statusSummary(remote: RemoteSkill[], local: SkillState[]) {
return `${remote.length} remote / ${local.length} local`;
return `${remote.length} 个远程 / ${local.length} 个本地`;
}
function badge(status: string) {
const label = status.replace(/_/g, ' ') || 'remote';
const label = statusLabels[status] || status.replace(/_/g, ' ') || statusLabels.remote;
return <span className={`badge ${status}`}>{label}</span>;
}
function targetBadge(skill: SkillState, targetID: 'codex' | 'claude') {
return targetInstalled(skill, targetID) ? <span className="badge installed">installed</span> : <span className="badge">not installed</span>;
return targetInstalled(skill, targetID) ? <span className="badge installed"></span> : <span className="badge"></span>;
}
function targetInstalled(skill: SkillState, targetID: 'codex' | 'claude') {
@@ -476,4 +506,9 @@ function shortCommit(commit: string) {
return commit ? commit.slice(0, 8) : '-';
}
function localizeMessage(message: string) {
const normalized = message.trim();
return localizedMessages[normalized] || normalized;
}
export default App;

25
frontend_config_test.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"os"
"strings"
"testing"
)
func TestFrontendDoesNotExposeCredentialKeyInput(t *testing.T) {
source, err := os.ReadFile("frontend/src/App.tsx")
if err != nil {
t.Fatal(err)
}
app := string(source)
forbidden := []string{
"<span>凭据键</span>",
"next.gitea.credentialKey = event.target.value",
}
for _, text := range forbidden {
if strings.Contains(app, text) {
t.Fatalf("App.tsx should not expose editable credential key UI, found %q", text)
}
}
}

View File

@@ -0,0 +1,27 @@
package main
import (
"os"
"strings"
"testing"
)
func TestFrontendHasRepositoryLinkButton(t *testing.T) {
source, err := os.ReadFile("frontend/src/App.tsx")
if err != nil {
t.Fatal(err)
}
app := string(source)
expectations := []string{
"BrowserOpenURL",
"Github",
"打开项目仓库",
"http://10.1.0.1:3000/sgg-sgg-tools/sgg-sgg-ai-skill-manage-windows",
}
for _, expected := range expectations {
if !strings.Contains(app, expected) {
t.Fatalf("App.tsx is missing %q", expected)
}
}
}

2
go.mod
View File

@@ -5,6 +5,7 @@ go 1.23.0
require (
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/sys v0.30.0
)
require (
@@ -35,7 +36,6 @@ require (
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
@@ -45,8 +46,8 @@ type apiUser struct {
func NewClient(baseURL string, auth Auth) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
auth: auth,
baseURL: strings.TrimRight(baseURL, "/"),
auth: auth,
httpClient: &http.Client{Timeout: 20 * time.Second},
}
}
@@ -80,7 +81,7 @@ func (c *Client) ListOrgSkills(ctx context.Context, org string) ([]domain.Remote
Name: repo.Name,
FullName: repo.FullName,
Description: repo.Description,
CloneURL: repo.CloneURL,
CloneURL: normalizeCloneURL(repo.CloneURL, c.baseURL),
SSHURL: repo.SSHURL,
DefaultBranch: repo.DefaultBranch,
UpdatedAt: repo.UpdatedAt,
@@ -175,3 +176,34 @@ func (c *Client) newRequest(ctx context.Context, method, endpoint string) (*http
req.Header.Set("Accept", "application/json")
return req, nil
}
func normalizeCloneURL(cloneURL, baseURL string) string {
clone, err := url.Parse(cloneURL)
if err != nil || clone.Scheme == "" || clone.Host == "" {
return cloneURL
}
if clone.Scheme != "http" && clone.Scheme != "https" {
return cloneURL
}
if !isLoopbackHost(clone.Hostname()) {
return cloneURL
}
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return cloneURL
}
if clone.Scheme == base.Scheme && strings.EqualFold(clone.Host, base.Host) {
return cloneURL
}
clone.Scheme = base.Scheme
clone.Host = base.Host
return clone.String()
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View File

@@ -44,6 +44,31 @@ func TestListOrgSkillsFiltersReposWithoutSkillMD(t *testing.T) {
}
}
func TestListOrgSkillsNormalizesLoopbackCloneURLToConfiguredBaseURL(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]apiRepo{
{Name: "good", FullName: "skills/good", CloneURL: "http://localhost:3000/skills/good.git", DefaultBranch: "main"},
})
})
mux.HandleFunc("/api/v1/repos/skills/good/contents/SKILL.md", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(mux)
defer server.Close()
client := NewClient(server.URL, Auth{Type: AuthPassword, Username: "alice", Secret: "secret"})
repos, err := client.ListOrgSkills(context.Background(), "skills")
if err != nil {
t.Fatalf("ListOrgSkills returned error: %v", err)
}
want := server.URL + "/skills/good.git"
if repos[0].CloneURL != want {
t.Fatalf("CloneURL = %q, want %q", repos[0].CloneURL, want)
}
}
func TestListOrgSkillsUsesTokenAuth(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/orgs/skills/repos", func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,42 @@
package tray
import (
"context"
"errors"
)
const (
wmLeftButtonUp = 0x0202
wmLeftButtonDoubleClick = 0x0203
)
type Config struct {
Tooltip string
IconPath string
OnOpen func()
}
type Controller struct {
config Config
}
func Start(ctx context.Context, config Config) (func(), error) {
if config.OnOpen == nil {
return nil, errors.New("tray open handler is required")
}
return start(ctx, newController(config))
}
func newController(config Config) *Controller {
return &Controller{config: config}
}
func (c *Controller) handleTrayEvent(event uintptr) bool {
if event&0xffff != wmLeftButtonDoubleClick {
return false
}
if c.config.OnOpen != nil {
c.config.OnOpen()
}
return true
}

View File

@@ -0,0 +1,57 @@
package tray
import "testing"
func TestControllerOpensWindowOnTrayDoubleClick(t *testing.T) {
opened := 0
controller := newController(Config{
OnOpen: func() {
opened++
},
})
handled := controller.handleTrayEvent(wmLeftButtonDoubleClick)
if !handled {
t.Fatal("expected tray double-click to be handled")
}
if opened != 1 {
t.Fatalf("expected open callback to run once, got %d", opened)
}
}
func TestControllerOpensWindowWhenTrayEventContainsIconID(t *testing.T) {
opened := 0
controller := newController(Config{
OnOpen: func() {
opened++
},
})
handled := controller.handleTrayEvent(wmLeftButtonDoubleClick | 1<<16)
if !handled {
t.Fatal("expected packed tray double-click event to be handled")
}
if opened != 1 {
t.Fatalf("expected open callback to run once, got %d", opened)
}
}
func TestControllerIgnoresTraySingleClick(t *testing.T) {
opened := 0
controller := newController(Config{
OnOpen: func() {
opened++
},
})
handled := controller.handleTrayEvent(wmLeftButtonUp)
if handled {
t.Fatal("expected single-click to be ignored")
}
if opened != 0 {
t.Fatalf("expected open callback not to run, got %d calls", opened)
}
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package tray
import "context"
func start(_ context.Context, _ *Controller) (func(), error) {
return func() {}, nil
}

View File

@@ -0,0 +1,405 @@
//go:build windows
package tray
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
wmClose = 0x0010
wmDestroy = 0x0002
wmApp = 0x8000
trayCallbackMessage = wmApp + 1
hwndMessage = ^uintptr(2)
nimAdd = 0x00000000
nimDelete = 0x00000002
nimSetVersion = 0x00000004
nifMessage = 0x00000001
nifIcon = 0x00000002
nifTip = 0x00000004
notifyIconVersion = 4
imageIcon = 1
lrLoadFromFile = 0x00000010
idiApplication = 32512
smCxSmallIcon = 49
smCySmallIcon = 50
)
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
user32 = windows.NewLazySystemDLL("user32.dll")
shell32 = windows.NewLazySystemDLL("shell32.dll")
procGetModuleHandle = kernel32.NewProc("GetModuleHandleW")
procRegisterClassEx = user32.NewProc("RegisterClassExW")
procCreateWindowEx = user32.NewProc("CreateWindowExW")
procDefWindowProc = user32.NewProc("DefWindowProcW")
procDestroyWindow = user32.NewProc("DestroyWindow")
procPostMessage = user32.NewProc("PostMessageW")
procGetMessage = user32.NewProc("GetMessageW")
procTranslateMsg = user32.NewProc("TranslateMessage")
procDispatchMsg = user32.NewProc("DispatchMessageW")
procPostQuitMessage = user32.NewProc("PostQuitMessage")
procLoadIcon = user32.NewProc("LoadIconW")
procLoadImage = user32.NewProc("LoadImageW")
procDestroyIcon = user32.NewProc("DestroyIcon")
procGetSystemMetric = user32.NewProc("GetSystemMetrics")
procShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW")
procExtractIconEx = shell32.NewProc("ExtractIconExW")
)
type notifyIconData struct {
cbSize uint32
hwnd uintptr
uid uint32
flags uint32
callbackMessage uint32
icon uintptr
tip [128]uint16
state uint32
stateMask uint32
info [256]uint16
version uint32
infoTitle [64]uint16
infoFlags uint32
guidItem windows.GUID
balloonIcon uintptr
}
type wndClassEx struct {
cbSize uint32
style uint32
wndProc uintptr
clsExtra int32
wndExtra int32
instance uintptr
icon uintptr
cursor uintptr
background uintptr
menuName *uint16
className *uint16
iconSmall uintptr
}
type point struct {
x int32
y int32
}
type msg struct {
hwnd uintptr
message uint32
wparam uintptr
lparam uintptr
time uint32
pt point
}
type windowsTray struct {
controller *Controller
wndProc uintptr
hwnd atomic.Uintptr
icon uintptr
destroyIcon bool
ready chan error
done chan struct{}
stopOnce sync.Once
cleanupOnce sync.Once
}
func start(ctx context.Context, controller *Controller) (func(), error) {
tray := &windowsTray{
controller: controller,
ready: make(chan error, 1),
done: make(chan struct{}),
}
go tray.run()
if err := <-tray.ready; err != nil {
return nil, err
}
stop := func() {
tray.stop()
}
go func() {
<-ctx.Done()
stop()
}()
return stop, nil
}
func (t *windowsTray) run() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := t.init(); err != nil {
hwnd := t.hwnd.Load()
t.cleanup()
destroyWindow(hwnd)
t.ready <- err
close(t.done)
return
}
t.ready <- nil
t.messageLoop()
t.cleanup()
close(t.done)
}
func (t *windowsTray) init() error {
t.wndProc = windows.NewCallback(t.windowProc)
instance, _, _ := procGetModuleHandle.Call(0)
className, err := windows.UTF16PtrFromString(fmt.Sprintf("SGGAISkillManagerTrayWindow-%d", os.Getpid()))
if err != nil {
return err
}
class := wndClassEx{
cbSize: uint32(unsafe.Sizeof(wndClassEx{})),
wndProc: t.wndProc,
instance: instance,
className: className,
}
if ret, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&class))); ret == 0 {
return syscallError("RegisterClassExW", callErr)
}
hwnd, _, callErr := procCreateWindowEx.Call(
0,
uintptr(unsafe.Pointer(className)),
0,
0,
0,
0,
0,
0,
hwndMessage,
0,
instance,
0,
)
if hwnd == 0 {
return syscallError("CreateWindowExW", callErr)
}
t.hwnd.Store(hwnd)
icon, destroyIcon, err := loadTrayIcon(t.controller.config.IconPath)
if err != nil {
return err
}
t.icon = icon
t.destroyIcon = destroyIcon
nid := t.newNotifyIconData()
if !shellNotifyIcon(nimAdd, &nid) {
return fmt.Errorf("Shell_NotifyIconW add failed")
}
nid.version = notifyIconVersion
if !shellNotifyIcon(nimSetVersion, &nid) {
return fmt.Errorf("Shell_NotifyIconW set version failed")
}
return nil
}
func (t *windowsTray) messageLoop() {
var message msg
for {
ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0)
if int32(ret) <= 0 {
return
}
procTranslateMsg.Call(uintptr(unsafe.Pointer(&message)))
procDispatchMsg.Call(uintptr(unsafe.Pointer(&message)))
}
}
func (t *windowsTray) stop() {
t.stopOnce.Do(func() {
if hwnd := t.hwnd.Load(); hwnd != 0 {
procPostMessage.Call(hwnd, wmClose, 0, 0)
<-t.done
}
})
}
func (t *windowsTray) windowProc(hwnd uintptr, message uint32, wparam uintptr, lparam uintptr) uintptr {
switch message {
case trayCallbackMessage:
if t.controller.handleTrayEvent(lparam) {
return 0
}
case wmClose:
t.cleanup()
procDestroyWindow.Call(hwnd)
return 0
case wmDestroy:
procPostQuitMessage.Call(0)
return 0
}
ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wparam, lparam)
return ret
}
func (t *windowsTray) cleanup() {
t.cleanupOnce.Do(func() {
if hwnd := t.hwnd.Load(); hwnd != 0 {
nid := t.newNotifyIconData()
shellNotifyIcon(nimDelete, &nid)
t.hwnd.Store(0)
}
if t.icon != 0 && t.destroyIcon {
procDestroyIcon.Call(t.icon)
}
})
}
func destroyWindow(hwnd uintptr) {
if hwnd != 0 {
procDestroyWindow.Call(hwnd)
}
}
func (t *windowsTray) newNotifyIconData() notifyIconData {
nid := notifyIconData{
cbSize: uint32(unsafe.Sizeof(notifyIconData{})),
hwnd: t.hwnd.Load(),
uid: 1,
flags: nifMessage | nifIcon | nifTip,
callbackMessage: trayCallbackMessage,
icon: t.icon,
}
copyUTF16(nid.tip[:], t.controller.config.Tooltip)
return nid
}
func loadTrayIcon(iconPath string) (uintptr, bool, error) {
if icon, ok := loadIconFromFile(iconPath); ok {
return icon, true, nil
}
if icon, ok := extractExecutableIcon(); ok {
return icon, true, nil
}
if icon, _, _ := procLoadIcon.Call(0, idiApplication); icon != 0 {
return icon, false, nil
}
return 0, false, fmt.Errorf("load tray icon failed")
}
func loadIconFromFile(iconPath string) (uintptr, bool) {
if iconPath == "" {
return 0, false
}
absolutePath, err := filepath.Abs(iconPath)
if err != nil {
return 0, false
}
if _, err := os.Stat(absolutePath); err != nil {
return 0, false
}
path, err := windows.UTF16PtrFromString(absolutePath)
if err != nil {
return 0, false
}
width := systemMetric(smCxSmallIcon, 16)
height := systemMetric(smCySmallIcon, 16)
icon, _, _ := procLoadImage.Call(
0,
uintptr(unsafe.Pointer(path)),
imageIcon,
uintptr(width),
uintptr(height),
lrLoadFromFile,
)
return icon, icon != 0
}
func extractExecutableIcon() (uintptr, bool) {
exePath, err := os.Executable()
if err != nil {
return 0, false
}
exe, err := windows.UTF16PtrFromString(exePath)
if err != nil {
return 0, false
}
var largeIcon uintptr
var smallIcon uintptr
count, _, _ := procExtractIconEx.Call(
uintptr(unsafe.Pointer(exe)),
0,
uintptr(unsafe.Pointer(&largeIcon)),
uintptr(unsafe.Pointer(&smallIcon)),
1,
)
if count == 0 {
return 0, false
}
if smallIcon != 0 {
if largeIcon != 0 {
procDestroyIcon.Call(largeIcon)
}
return smallIcon, true
}
return largeIcon, largeIcon != 0
}
func systemMetric(index int32, fallback int) int {
value, _, _ := procGetSystemMetric.Call(uintptr(index))
if value == 0 {
return fallback
}
return int(value)
}
func shellNotifyIcon(command uintptr, data *notifyIconData) bool {
ret, _, _ := procShellNotifyIcon.Call(command, uintptr(unsafe.Pointer(data)))
return ret != 0
}
func copyUTF16(destination []uint16, value string) {
encoded, err := windows.UTF16FromString(value)
if err != nil {
return
}
if len(encoded) > len(destination) {
encoded = encoded[:len(destination)]
encoded[len(encoded)-1] = 0
}
copy(destination, encoded)
}
func syscallError(operation string, err error) error {
if errno, ok := err.(syscall.Errno); ok && errno == 0 {
return fmt.Errorf("%s failed", operation)
}
return fmt.Errorf("%s failed: %w", operation, err)
}

16
main.go
View File

@@ -4,8 +4,6 @@ import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
@@ -16,19 +14,7 @@ func main() {
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
Title: "sgg-ai-skill-manager",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
err := wails.Run(newAppOptions(app, assets))
if err != nil {
println("Error:", err.Error())