init
This commit is contained in:
34
app.go
34
app.go
@@ -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
31
app_options.go
Normal 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
23
app_options_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test:ui-language": "node scripts/check-chinese-ui.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
94
frontend/scripts/check-chinese-ui.mjs
Normal file
94
frontend/scripts/check-chinese-ui.mjs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
25
frontend_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
frontend_github_link_test.go
Normal file
27
frontend_github_link_test.go
Normal 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
2
go.mod
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
42
internal/tray/controller.go
Normal file
42
internal/tray/controller.go
Normal 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
|
||||
}
|
||||
57
internal/tray/controller_test.go
Normal file
57
internal/tray/controller_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
9
internal/tray/tray_other.go
Normal file
9
internal/tray/tray_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package tray
|
||||
|
||||
import "context"
|
||||
|
||||
func start(_ context.Context, _ *Controller) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
405
internal/tray/tray_windows.go
Normal file
405
internal/tray/tray_windows.go
Normal 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
16
main.go
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user