diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b9c927f..e4d515a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,13 +128,11 @@ jobs: unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD fi - # Publish to the GitHub Release on tag pushes; otherwise build only. - if [ "${{ github.event_name }}" = "push" ]; then - PUBLISH=always - else - PUBLISH=never - fi - npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH" + # Never let electron-builder publish: our publish target is a generic + # (read-only) feed served from R2/D1, which it can't upload to. We mirror + # installers to R2 and register them in D1 ourselves (publish-r2 job). + # `--publish never` still emits the latest*.yml files we parse for sha512. + npx electron-builder ${{ matrix.eb_flags }} --publish never - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -201,10 +199,11 @@ jobs: id: stage run: | mkdir -p dist - # Flatten installers from every per-platform artifact dir; only the - # user-facing installers go to R2 (updater .yml/.blockmap stay on the - # GitHub Release, which electron-updater reads directly). - find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \; + # Flatten installers + their .blockmap (used by electron-updater for + # differential downloads) from every per-platform artifact dir. The + # .yml feed is generated dynamically by the /update Function from D1, + # so the yml files themselves don't need to go to R2. + find artifacts -type f \( -name '*.dmg' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \; echo "Staged files:"; ls -la dist # When the whole matrix failed there's nothing to publish; flag it so # the R2/D1 steps skip instead of writing an empty/partial release. @@ -253,6 +252,37 @@ jobs: *) is_latest=1; echo "==> $VER is a stable release; marking latest." ;; esac + # The updater yml files (in the per-platform artifacts) carry the + # sha512 electron-updater validates against; read it from there so the + # /update feed serves the exact same hash. Build a name->sha512 map. + python3 - "$VER" <<'PY' > sha_map.txt + import os, re, sys, glob + # Parse electron-builder's latest*.yml file lists: pairs of + # - url: + # sha512: + out = {} + for yml in glob.glob('artifacts/**/*.yml', recursive=True): + try: + text = open(yml, encoding='utf-8').read() + except Exception: + continue + cur = None + for line in text.splitlines(): + m = re.match(r'\s*-?\s*url:\s*(\S+)', line) + if m: + cur = m.group(1).strip() + continue + m = re.match(r'\s*sha512:\s*(\S+)', line) + if m and cur: + out[os.path.basename(cur)] = m.group(1).strip() + cur = None + for name, sha in out.items(): + print(f"{name}\t{sha}") + PY + echo "==> sha512 map:"; cat sha_map.txt || true + + sha_for() { awk -F'\t' -v n="$1" '$1==n{print $2; exit}' sha_map.txt; } + for f in dist/*; do base="$(basename "$f")" size="$(stat -c%s "$f")" @@ -263,12 +293,15 @@ jobs: *) echo "Skipping unrecognized artifact: $base"; continue ;; esac key="v${VER}/${base}" + sha="$(sha_for "$base")" + # SQL-escape single quotes defensively (base64 has none, but be safe). + sha="${sha//\'/\'\'}" # Stable only: clear the previous latest for THIS platform first, so # a partial backfill never wipes other platforms' latest flag. if [ "$is_latest" = "1" ]; then echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file" fi - echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, ${is_latest});" >> "$sql_file" + echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, sha512, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, '${sha}', ${is_latest});" >> "$sql_file" done echo "==> D1 statements:"; cat "$sql_file" npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file" diff --git a/desktop/package.json b/desktop/package.json index 5b1eaff2..849ddcc8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "cowagent-desktop", - "version": "1.0.0", + "version": "2.1.3", "description": "CowAgent Desktop Client - AI Agent on your desktop", "main": "dist/main/index.js", "author": "CowAgent", @@ -95,9 +95,8 @@ "createStartMenuShortcut": true }, "publish": { - "provider": "github", - "owner": "zhayujie", - "repo": "chatgpt-on-wechat" + "provider": "generic", + "url": "https://cowagent.ai/update/" } } } diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 36816f5e..d635c2cf 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -125,6 +125,16 @@ function createWindow() { mainWindow.loadFile(rendererHtml) } + // Surface renderer-side console output and load failures to the main-process + // stdout. Without this, "stuck on initializing" hangs are invisible from the + // terminal because all renderer logs stay in the (closed) devtools. + mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { + console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`) + }) + mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => { + console.error(`[renderer] did-fail-load ${code} ${desc} ${url}`) + }) + mainWindow.once('ready-to-show', () => { mainWindow?.show() }) @@ -160,14 +170,20 @@ async function startBackend() { pythonBackend = new PythonBackend(backendPath) pythonBackend.on('ready', (port: number) => { + console.log(`[backend] ready on port ${port}`) mainWindow?.webContents.send('backend-status', { status: 'ready', port }) }) pythonBackend.on('error', (error: string) => { + // Mirror to the main-process stdout too: otherwise backend startup errors + // are only visible in the renderer devtools, making `npm run dev` hangs + // impossible to diagnose from the terminal. + console.error(`[backend] error: ${error}`) mainWindow?.webContents.send('backend-status', { status: 'error', error }) }) pythonBackend.on('log', (line: string) => { + console.log(`[backend] ${line}`) mainWindow?.webContents.send('backend-log', line) }) @@ -214,6 +230,9 @@ function setupIPC() { ipcMain.handle('window-close', () => mainWindow?.close()) ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false) + // Current app version, shown in the NavRail footer. + ipcMain.handle('get-app-version', () => app.getVersion()) + // Auto-update controls (renderer-driven: check, then opt-in download/install) ipcMain.handle('update-check', () => checkForUpdates()) ipcMain.handle('update-download', () => startDownload()) @@ -279,10 +298,14 @@ app.whenReady().then(async () => { } await startBackend() - // Wire auto-update and do a first silent check a few seconds after launch so - // it doesn't compete with backend startup for resources. + // Wire auto-update: a first silent check a few seconds after launch (so it + // doesn't compete with backend startup), then poll every 4 hours so a + // long-running window still surfaces new releases. autoDownload is off, so a + // found update only lights the badge + opens the panel for the user to opt in. initUpdater(() => mainWindow) setTimeout(() => checkForUpdates(), 5000) + const UPDATE_POLL_MS = 4 * 60 * 60 * 1000 + setInterval(() => checkForUpdates(), UPDATE_POLL_MS) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index 3e7de874..c647cc9b 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -39,6 +39,9 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeListener('menu-action', handler) }, + // Current app version (e.g. "0.0.5"), shown in the NavRail footer. + getAppVersion: () => ipcRenderer.invoke('get-app-version'), + // Auto-update: trigger checks/download/install and subscribe to status. checkForUpdate: () => ipcRenderer.invoke('update-check'), downloadUpdate: () => ipcRenderer.invoke('update-download'), diff --git a/desktop/src/main/python-manager.ts b/desktop/src/main/python-manager.ts index a7487a03..9857548e 100644 --- a/desktop/src/main/python-manager.ts +++ b/desktop/src/main/python-manager.ts @@ -11,16 +11,19 @@ import net from 'net' // the read-only app bundle. Source/dev runs keep using the repo CWD instead. const COW_DATA_DIR = path.join(os.homedir(), '.cow') -// Preferred port for the desktop backend. Deliberately not 9899 (the web -// console's default) so a source-run `python app.py` never collides with the -// packaged app. It's only a *preference*: if it's taken we bind a free port -// chosen by the OS, so we never hard-depend on any single port being free. -const PREFERRED_PORT = 9876 +// Fixed port for the desktop backend. Deliberately not 9899 (the web console's +// default) so a source-run `python app.py` never collides with the packaged +// app. This is a SINGLE SOURCE OF TRUTH shared with the renderer (see +// useBackend.ts BACKEND_PORT): the backend is always told to bind exactly here +// via COW_WEB_PORT, and the renderer always talks to exactly here. We do NOT +// fall back to an OS-random port, because the renderer could never guess it — +// instead we proactively free this port before launch (see freePort()). +export const DESKTOP_BACKEND_PORT = 9876 export class PythonBackend extends EventEmitter { private process: ChildProcess | null = null private backendPath: string - private port: number = PREFERRED_PORT + private port: number = DESKTOP_BACKEND_PORT private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped' constructor(backendPath: string) { @@ -95,21 +98,16 @@ export class PythonBackend extends EventEmitter { } /** - * Resolve a usable port without hard-depending on any specific one: - * 1. A user-pinned web_port is honored as-is (their explicit choice). - * 2. Otherwise try PREFERRED_PORT; if free, use it. - * 3. If taken, let the OS hand us a guaranteed-free ephemeral port. - * This never throws on a busy port — it just moves on to a free one. + * Resolve the port to bind. The whole point is determinism: the renderer must + * be able to reach the backend WITHOUT guessing, so we use exactly one fixed + * port (DESKTOP_BACKEND_PORT) unless the user explicitly pinned a web_port. + * We never auto-roll to a random port — instead start() proactively frees the + * fixed port. The returned value is the single source of truth handed to both + * the backend (COW_WEB_PORT) and the renderer (getBackendPort IPC). */ - private async resolvePort(dataDir: string): Promise { + private resolvePort(dataDir: string): number { const pinned = this.readConfiguredPort(dataDir) - if (pinned !== null) { - return pinned - } - if (await this.isPortFree(PREFERRED_PORT)) { - return PREFERRED_PORT - } - return this.findFreePort() + return pinned !== null ? pinned : DESKTOP_BACKEND_PORT } /** True if we can bind 127.0.0.1:port right now (i.e. it's free). */ @@ -125,16 +123,77 @@ export class PythonBackend extends EventEmitter { }) } - /** Ask the OS for a free ephemeral port (listen on 0, read the assigned one). */ - private findFreePort(): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer() - srv.once('error', reject) - srv.listen(0, '127.0.0.1', () => { - const addr = srv.address() - const port = typeof addr === 'object' && addr ? addr.port : PREFERRED_PORT - srv.close(() => resolve(port)) - }) + /** + * Make sure our fixed port is usable before launch by killing whatever is + * holding it (almost always a stale backend from a previous run that didn't + * shut down cleanly). We only ever target a process actually listening on + * 127.0.0.1:, so we won't touch unrelated apps. Best-effort: if we + * can't free it we still try to bind and let the backend surface EADDRINUSE. + */ + private async freePort(port: number): Promise { + if (await this.isPortFree(port)) { + return + } + this.emit('log', `Port ${port} is busy — clearing stale process before launch`) + const pids = await this.findListenerPids(port) + for (const pid of pids) { + // Never signal ourselves (Electron could, in theory, be the listener). + if (pid === process.pid) continue + try { + process.kill(pid, 'SIGTERM') + } catch { + // already gone / no permission — ignore + } + } + // Give the OS a beat to release the socket, then force-kill leftovers. + await new Promise((r) => setTimeout(r, 600)) + if (!(await this.isPortFree(port))) { + for (const pid of await this.findListenerPids(port)) { + if (pid === process.pid) continue + try { + process.kill(pid, 'SIGKILL') + } catch { + // ignore + } + } + await new Promise((r) => setTimeout(r, 400)) + } + } + + /** PIDs listening on 127.0.0.1:, via lsof (POSIX) / netstat (Windows). */ + private findListenerPids(port: number): Promise { + return new Promise((resolve) => { + const isWin = process.platform === 'win32' + const cmd = isWin ? 'netstat' : 'lsof' + const args = isWin + ? ['-ano', '-p', 'tcp'] + : ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'] + let out = '' + try { + const child = spawn(cmd, args) + child.stdout?.on('data', (d: Buffer) => (out += d.toString())) + child.on('error', () => resolve([])) + child.on('close', () => { + const pids = new Set() + if (isWin) { + // Match lines like: TCP 127.0.0.1:9876 ... LISTENING 12345 + for (const line of out.split('\n')) { + if (!/LISTENING/i.test(line)) continue + if (!new RegExp(`[:.]${port}\\b`).test(line)) continue + const pid = Number(line.trim().split(/\s+/).pop()) + if (Number.isInteger(pid) && pid > 0) pids.add(pid) + } + } else { + for (const tok of out.split(/\s+/)) { + const pid = Number(tok) + if (Number.isInteger(pid) && pid > 0) pids.add(pid) + } + } + resolve([...pids]) + }) + } catch { + resolve([]) + } }) } @@ -151,17 +210,15 @@ export class PythonBackend extends EventEmitter { // Packaged app stores writable data in ~/.cow; dev keeps it in the repo. const dataDir = bundled ? COW_DATA_DIR : this.backendPath - // Always launch our OWN backend (re-entrancy is guarded above by the - // status check, so we never double-spawn for this instance). We don't reuse - // whatever happens to be on the port: that's how the app previously - // attached to a source-run web console and read the wrong config. Pick a - // usable port instead — honoring a user-pinned web_port, else PREFERRED_PORT - // when free, else an OS-assigned free port. This never depends on a probe. - try { - this.port = await this.resolvePort(dataDir) - } catch { - this.port = PREFERRED_PORT - } + // Always launch our OWN backend (re-entrancy is guarded above by the status + // check, so we never double-spawn for this instance). We don't reuse + // whatever happens to be on the port: that's how the app previously attached + // to a source-run web console and read the wrong config. The port is fixed + // (or the user's pinned web_port) — never random — so the renderer always + // knows it. We then proactively free that port (kill stale listeners) + // before spawning, so a leftover process from a previous run can't block us. + this.port = this.resolvePort(dataDir) + await this.freePort(this.port) let command: string let args: string[] @@ -229,10 +286,15 @@ export class PythonBackend extends EventEmitter { }) this.process.on('exit', (code) => { + // If the backend dies before it ever became ready, surface an error now + // instead of letting waitForReady spin for the full timeout. A clean exit + // (code 0/null, e.g. our own stop()) just marks stopped. + const wasReady = this.status === 'ready' this.status = 'stopped' this.emit('log', `Python process exited with code ${code}`) - if (code !== 0 && code !== null) { - this.emit('error', `Python process exited with code ${code}`) + if (!wasReady && code !== 0 && code !== null) { + this.status = 'error' + this.emit('error', `Backend exited during startup (code ${code})`) } }) @@ -272,7 +334,9 @@ export class PythonBackend extends EventEmitter { } const retry = () => { - if (this.status === 'stopped' || this.status === 'ready') { + // Backend already settled: ready (done), or stopped/errored by the exit + // handler (don't keep polling a dead process — the error was emitted). + if (this.status === 'ready' || this.status === 'stopped' || this.status === 'error') { resolve() return } diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index aec52736..469c5ab4 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -51,9 +51,14 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void { ) } -// Silent check shortly after launch; safe to call when not packaged (no-op). +// Silent check shortly after launch. When not packaged there's no update feed, +// but a manual click should still get visible feedback instead of looking dead: +// reply "not-available" so the menu can show "up to date". export function checkForUpdates(): void { - if (!app.isPackaged) return + if (!app.isPackaged) { + send({ state: 'not-available' }) + return + } autoUpdater.checkForUpdates().catch((err) => { send({ state: 'error', message: err?.message || String(err) }) }) diff --git a/desktop/src/renderer/src/hooks/useBackend.ts b/desktop/src/renderer/src/hooks/useBackend.ts index 2a62e69f..a6e495fe 100644 --- a/desktop/src/renderer/src/hooks/useBackend.ts +++ b/desktop/src/renderer/src/hooks/useBackend.ts @@ -1,5 +1,12 @@ import { useState, useEffect, useCallback, useRef } from 'react' +// Fixed default port — MUST match DESKTOP_BACKEND_PORT in main/python-manager.ts. +// The backend is launched on exactly this port (the main process frees it first +// and passes it via COW_WEB_PORT), so probing it works even before the +// getBackendPort IPC resolves. Keeping both sides on one constant means the +// renderer can never end up talking to the wrong port. +const BACKEND_PORT = 9876 + interface BackendState { status: 'connecting' | 'ready' | 'error' port: number @@ -9,7 +16,7 @@ interface BackendState { export function useBackend() { const [state, setState] = useState({ status: 'connecting', - port: 9876, + port: BACKEND_PORT, }) const pollingRef = useRef | null>(null) @@ -31,7 +38,7 @@ export function useBackend() { const readyRef = useRef(false) // Holds the latest resolved port so the visibility handler (registered once) // always probes the correct port without re-running the effect. - const portRef = useRef(9876) + const portRef = useRef(BACKEND_PORT) useEffect(() => { let cancelled = false @@ -74,12 +81,21 @@ export function useBackend() { } if (api) { - api.getBackendPort().then((port) => { - const p = port || 9876 - portRef.current = p - setState((prev) => ({ ...prev, port: p })) - startPolling(p) - }) + // Always start polling, even if getBackendPort rejects or the ready event + // was already emitted before we subscribed: polling /config is the + // self-sufficient path to "ready" and must never depend on the IPC round + // trip succeeding (otherwise the app can hang forever on "connecting"). + api + .getBackendPort() + .then((port) => { + const p = port || BACKEND_PORT + portRef.current = p + setState((prev) => ({ ...prev, port: p })) + startPolling(p) + }) + .catch(() => { + startPolling(BACKEND_PORT) + }) offStatus = api.onBackendStatus((data) => { if (data.status === 'ready' && data.port) { @@ -98,7 +114,7 @@ export function useBackend() { } }) } else { - startPolling(9876) + startPolling(BACKEND_PORT) } // When the window comes back to the foreground, re-probe immediately so a diff --git a/desktop/src/renderer/src/hooks/useTheme.ts b/desktop/src/renderer/src/hooks/useTheme.ts index 47f19270..0f496b15 100644 --- a/desktop/src/renderer/src/hooks/useTheme.ts +++ b/desktop/src/renderer/src/hooks/useTheme.ts @@ -12,8 +12,8 @@ function getSystemTheme(): ResolvedTheme { function readStored(): ThemePref { const saved = localStorage.getItem(STORAGE_KEY) if (saved === 'dark' || saved === 'light' || saved === 'system') return saved - // Default to dark to match the app's flagship look - return 'dark' + // First run: follow the OS appearance rather than forcing a fixed theme. + return 'system' } function applyTheme(resolved: ResolvedTheme) { diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index 5e7f391d..c751959a 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -75,6 +75,15 @@ const translations: Record> = { update_restart: '重启以更新', update_later: '稍后', update_latest: '已是最新版本', + update_check: '检查更新', + update_checking: '正在检查…', + menu_more: '更多', + menu_theme_light: '浅色模式', + menu_theme_dark: '深色模式', + menu_language: '语言', + menu_website: '官网', + menu_docs: '文档中心', + menu_skill_hub: '技能广场', // onboarding onboarding_welcome_title: '欢迎使用 CowAgent', onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。', @@ -435,6 +444,15 @@ const translations: Record> = { update_restart: 'Restart to update', update_later: 'Later', update_latest: 'You are up to date', + update_check: 'Check for updates', + update_checking: 'Checking…', + menu_more: 'More', + menu_theme_light: 'Light mode', + menu_theme_dark: 'Dark mode', + menu_language: 'Language', + menu_website: 'Website', + menu_docs: 'Documentation', + menu_skill_hub: 'Skill Hub', // onboarding onboarding_welcome_title: 'Welcome to CowAgent', onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.', diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index fe7d9f53..60c3a1f0 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState, useRef, useEffect } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { MessageSquare, @@ -13,13 +13,39 @@ import { Sun, Moon, ScrollText, + MoreHorizontal, + Languages, + Download, + Loader2, + Globe, + FileText, + Store, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { t, getLang, setLang, Lang } from '../i18n' import { useUIStore } from '../store/uiStore' import { useTheme } from '../hooks/useTheme' +import { useUpdateStore, hasPendingUpdate } from '../store/updateStore' import UpdateBanner from '../components/UpdateBanner' +// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep +// in sync with desktop/package.json "version"; the packaged app overrides this +// with the real value via IPC, so it only matters outside a packaged build. +const FALLBACK_VERSION = '2.1.3' + +// External links opened in the user's default browser. The window-open handler +// in the main process routes window.open() through shell.openExternal. +// English is the default (no suffix); Chinese gets a /zh suffix. Skill hub is +// language-agnostic. +const SKILL_HUB_URL = 'https://skills.cowagent.ai/' + +const websiteUrl = () => (getLang() === 'zh' ? 'https://cowagent.ai/zh' : 'https://cowagent.ai') +const docsUrl = () => (getLang() === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai') + +const openExternal = (url: string) => { + window.open(url, '_blank', 'noopener,noreferrer') +} + interface NavItem { path: string labelKey: string @@ -49,12 +75,75 @@ const NavRail: React.FC = ({ onLangChange }) => { const collapsed = navCollapsed const width = collapsed ? 'w-[56px]' : 'w-[208px]' + const updateState = useUpdateStore() + const pendingUpdate = hasPendingUpdate(updateState) + const checking = updateState.status?.state === 'checking' + + const [menuOpen, setMenuOpen] = useState(false) + // Local fallback so a version always shows even if the main-process IPC is + // unavailable (e.g. dev/web preview). The real value comes from + // app.getVersion() (packaged package.json), never from a remote service. + const [version, setVersion] = useState(FALLBACK_VERSION) + const menuRef = useRef(null) + + useEffect(() => { + window.electronAPI + ?.getAppVersion?.() + .then((v) => v && setVersion(v)) + .catch(() => {}) + }, []) + + // Close the popover on any outside click / Escape. + useEffect(() => { + if (!menuOpen) return + const onDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [menuOpen]) + const toggleLanguage = () => { const next: Lang = getLang() === 'zh' ? 'en' : 'zh' setLang(next) onLangChange() } + // Track a user-initiated check so we can show "up to date" feedback in the + // menu when the result comes back as not-available (the auto poll stays + // silent). Cleared shortly after, and whenever the menu closes. + const [checkedManually, setCheckedManually] = useState(false) + const updateStatusState = updateState.status?.state + + useEffect(() => { + if (!checkedManually) return + if (updateStatusState === 'not-available') { + const id = setTimeout(() => setCheckedManually(false), 4000) + return () => clearTimeout(id) + } + // A pending update opens its own panel; no need for the inline hint. + if (updateStatusState === 'available' || updateStatusState === 'downloaded') { + setCheckedManually(false) + } + return + }, [checkedManually, updateStatusState]) + + useEffect(() => { + if (!menuOpen) setCheckedManually(false) + }, [menuOpen]) + + const checkUpdate = () => { + setCheckedManually(true) + window.electronAPI?.checkForUpdate?.() + } + return ( @@ -139,4 +258,78 @@ const FooterBtn: React.FC<{ ) +// Upward popover holding the secondary actions previously crammed into the +// footer (theme, language, logs, update check). Keeps the footer to a single +// entry so new items can be added here without cluttering the rail. +const FooterMenu: React.FC<{ + theme: string + checking: boolean + pendingUpdate: boolean + upToDate: boolean + onLogs: () => void + onTheme: () => void + onLanguage: () => void + onCheckUpdate: () => void + onOpenLink: (url: string) => void +}> = ({ theme, checking, pendingUpdate, upToDate, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => { + const updateLabel = checking + ? t('update_checking') + : upToDate + ? t('update_latest') + : t('update_check') + return ( +
+ {/* External destinations first (skill hub, docs, website) */} + } label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} /> + } label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} /> + } label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} /> + +
+ + {/* App actions below: update, theme, language, logs */} + : } + label={updateLabel} + onClick={onCheckUpdate} + dot={pendingUpdate} + disabled={checking || upToDate} + /> + : } + label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')} + onClick={onTheme} + /> + } + label={t('menu_language')} + trailing={getLang() === 'zh' ? 'EN' : '中'} + onClick={onLanguage} + /> + } label={t('menu_logs')} onClick={onLogs} /> +
+ ) +} + +const MenuItem: React.FC<{ + icon: React.ReactNode + label: string + trailing?: string + dot?: boolean + disabled?: boolean + onClick: () => void +}> = ({ icon, label, trailing, dot, disabled, onClick }) => ( + +) + export default NavRail diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 337fed59..755049e1 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -17,6 +17,8 @@ export interface ElectronAPI { windowIsMaximized: () => Promise onMaximizeChange: (callback: (maximized: boolean) => void) => () => void onMenuAction?: (callback: (action: string) => void) => () => void + // Current app version string (e.g. "0.0.5"). + getAppVersion?: () => Promise // Auto-update checkForUpdate?: () => Promise downloadUpdate?: () => Promise