mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
desktop: make backend port deterministic (fixed + pre-launch cleanup) and reorder footer menu
This commit is contained in:
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -128,13 +128,11 @@ jobs:
|
|||||||
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Publish to the GitHub Release on tag pushes; otherwise build only.
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
||||||
PUBLISH=always
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
else
|
# `--publish never` still emits the latest*.yml files we parse for sha512.
|
||||||
PUBLISH=never
|
npx electron-builder ${{ matrix.eb_flags }} --publish never
|
||||||
fi
|
|
||||||
npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH"
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -201,10 +199,11 @@ jobs:
|
|||||||
id: stage
|
id: stage
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
# Flatten installers from every per-platform artifact dir; only the
|
# Flatten installers + their .blockmap (used by electron-updater for
|
||||||
# user-facing installers go to R2 (updater .yml/.blockmap stay on the
|
# differential downloads) from every per-platform artifact dir. The
|
||||||
# GitHub Release, which electron-updater reads directly).
|
# .yml feed is generated dynamically by the /update Function from D1,
|
||||||
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \;
|
# 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
|
echo "Staged files:"; ls -la dist
|
||||||
# When the whole matrix failed there's nothing to publish; flag it so
|
# 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.
|
# 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." ;;
|
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
|
||||||
esac
|
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: <name>
|
||||||
|
# sha512: <b64>
|
||||||
|
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
|
for f in dist/*; do
|
||||||
base="$(basename "$f")"
|
base="$(basename "$f")"
|
||||||
size="$(stat -c%s "$f")"
|
size="$(stat -c%s "$f")"
|
||||||
@@ -263,12 +293,15 @@ jobs:
|
|||||||
*) echo "Skipping unrecognized artifact: $base"; continue ;;
|
*) echo "Skipping unrecognized artifact: $base"; continue ;;
|
||||||
esac
|
esac
|
||||||
key="v${VER}/${base}"
|
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
|
# Stable only: clear the previous latest for THIS platform first, so
|
||||||
# a partial backfill never wipes other platforms' latest flag.
|
# a partial backfill never wipes other platforms' latest flag.
|
||||||
if [ "$is_latest" = "1" ]; then
|
if [ "$is_latest" = "1" ]; then
|
||||||
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
|
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
|
||||||
fi
|
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
|
done
|
||||||
echo "==> D1 statements:"; cat "$sql_file"
|
echo "==> D1 statements:"; cat "$sql_file"
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cowagent-desktop",
|
"name": "cowagent-desktop",
|
||||||
"version": "1.0.0",
|
"version": "2.1.3",
|
||||||
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
||||||
"main": "dist/main/index.js",
|
"main": "dist/main/index.js",
|
||||||
"author": "CowAgent",
|
"author": "CowAgent",
|
||||||
@@ -95,9 +95,8 @@
|
|||||||
"createStartMenuShortcut": true
|
"createStartMenuShortcut": true
|
||||||
},
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "generic",
|
||||||
"owner": "zhayujie",
|
"url": "https://cowagent.ai/update/"
|
||||||
"repo": "chatgpt-on-wechat"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ function createWindow() {
|
|||||||
mainWindow.loadFile(rendererHtml)
|
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.once('ready-to-show', () => {
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
})
|
})
|
||||||
@@ -160,14 +170,20 @@ async function startBackend() {
|
|||||||
pythonBackend = new PythonBackend(backendPath)
|
pythonBackend = new PythonBackend(backendPath)
|
||||||
|
|
||||||
pythonBackend.on('ready', (port: number) => {
|
pythonBackend.on('ready', (port: number) => {
|
||||||
|
console.log(`[backend] ready on port ${port}`)
|
||||||
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
||||||
})
|
})
|
||||||
|
|
||||||
pythonBackend.on('error', (error: string) => {
|
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 })
|
mainWindow?.webContents.send('backend-status', { status: 'error', error })
|
||||||
})
|
})
|
||||||
|
|
||||||
pythonBackend.on('log', (line: string) => {
|
pythonBackend.on('log', (line: string) => {
|
||||||
|
console.log(`[backend] ${line}`)
|
||||||
mainWindow?.webContents.send('backend-log', line)
|
mainWindow?.webContents.send('backend-log', line)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,6 +230,9 @@ function setupIPC() {
|
|||||||
ipcMain.handle('window-close', () => mainWindow?.close())
|
ipcMain.handle('window-close', () => mainWindow?.close())
|
||||||
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
|
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)
|
// Auto-update controls (renderer-driven: check, then opt-in download/install)
|
||||||
ipcMain.handle('update-check', () => checkForUpdates())
|
ipcMain.handle('update-check', () => checkForUpdates())
|
||||||
ipcMain.handle('update-download', () => startDownload())
|
ipcMain.handle('update-download', () => startDownload())
|
||||||
@@ -279,10 +298,14 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
await startBackend()
|
await startBackend()
|
||||||
|
|
||||||
// Wire auto-update and do a first silent check a few seconds after launch so
|
// Wire auto-update: a first silent check a few seconds after launch (so it
|
||||||
// it doesn't compete with backend startup for resources.
|
// 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)
|
initUpdater(() => mainWindow)
|
||||||
setTimeout(() => checkForUpdates(), 5000)
|
setTimeout(() => checkForUpdates(), 5000)
|
||||||
|
const UPDATE_POLL_MS = 4 * 60 * 60 * 1000
|
||||||
|
setInterval(() => checkForUpdates(), UPDATE_POLL_MS)
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeListener('menu-action', handler)
|
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.
|
// Auto-update: trigger checks/download/install and subscribe to status.
|
||||||
checkForUpdate: () => ipcRenderer.invoke('update-check'),
|
checkForUpdate: () => ipcRenderer.invoke('update-check'),
|
||||||
downloadUpdate: () => ipcRenderer.invoke('update-download'),
|
downloadUpdate: () => ipcRenderer.invoke('update-download'),
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ import net from 'net'
|
|||||||
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
||||||
|
|
||||||
// Preferred port for the desktop backend. Deliberately not 9899 (the web
|
// Fixed port for the desktop backend. Deliberately not 9899 (the web console's
|
||||||
// console's default) so a source-run `python app.py` never collides with the
|
// default) so a source-run `python app.py` never collides with the packaged
|
||||||
// packaged app. It's only a *preference*: if it's taken we bind a free port
|
// app. This is a SINGLE SOURCE OF TRUTH shared with the renderer (see
|
||||||
// chosen by the OS, so we never hard-depend on any single port being free.
|
// useBackend.ts BACKEND_PORT): the backend is always told to bind exactly here
|
||||||
const PREFERRED_PORT = 9876
|
// 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 {
|
export class PythonBackend extends EventEmitter {
|
||||||
private process: ChildProcess | null = null
|
private process: ChildProcess | null = null
|
||||||
private backendPath: string
|
private backendPath: string
|
||||||
private port: number = PREFERRED_PORT
|
private port: number = DESKTOP_BACKEND_PORT
|
||||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||||
|
|
||||||
constructor(backendPath: string) {
|
constructor(backendPath: string) {
|
||||||
@@ -95,21 +98,16 @@ export class PythonBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a usable port without hard-depending on any specific one:
|
* Resolve the port to bind. The whole point is determinism: the renderer must
|
||||||
* 1. A user-pinned web_port is honored as-is (their explicit choice).
|
* be able to reach the backend WITHOUT guessing, so we use exactly one fixed
|
||||||
* 2. Otherwise try PREFERRED_PORT; if free, use it.
|
* port (DESKTOP_BACKEND_PORT) unless the user explicitly pinned a web_port.
|
||||||
* 3. If taken, let the OS hand us a guaranteed-free ephemeral port.
|
* We never auto-roll to a random port — instead start() proactively frees the
|
||||||
* This never throws on a busy port — it just moves on to a free one.
|
* 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<number> {
|
private resolvePort(dataDir: string): number {
|
||||||
const pinned = this.readConfiguredPort(dataDir)
|
const pinned = this.readConfiguredPort(dataDir)
|
||||||
if (pinned !== null) {
|
return pinned !== null ? pinned : DESKTOP_BACKEND_PORT
|
||||||
return pinned
|
|
||||||
}
|
|
||||||
if (await this.isPortFree(PREFERRED_PORT)) {
|
|
||||||
return PREFERRED_PORT
|
|
||||||
}
|
|
||||||
return this.findFreePort()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if we can bind 127.0.0.1:port right now (i.e. it's free). */
|
/** 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<number> {
|
* Make sure our fixed port is usable before launch by killing whatever is
|
||||||
return new Promise((resolve, reject) => {
|
* holding it (almost always a stale backend from a previous run that didn't
|
||||||
const srv = net.createServer()
|
* shut down cleanly). We only ever target a process actually listening on
|
||||||
srv.once('error', reject)
|
* 127.0.0.1:<port>, so we won't touch unrelated apps. Best-effort: if we
|
||||||
srv.listen(0, '127.0.0.1', () => {
|
* can't free it we still try to bind and let the backend surface EADDRINUSE.
|
||||||
const addr = srv.address()
|
*/
|
||||||
const port = typeof addr === 'object' && addr ? addr.port : PREFERRED_PORT
|
private async freePort(port: number): Promise<void> {
|
||||||
srv.close(() => resolve(port))
|
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:<port>, via lsof (POSIX) / netstat (Windows). */
|
||||||
|
private findListenerPids(port: number): Promise<number[]> {
|
||||||
|
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<number>()
|
||||||
|
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.
|
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
||||||
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
||||||
|
|
||||||
// Always launch our OWN backend (re-entrancy is guarded above by the
|
// Always launch our OWN backend (re-entrancy is guarded above by the status
|
||||||
// status check, so we never double-spawn for this instance). We don't reuse
|
// 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
|
// whatever happens to be on the port: that's how the app previously attached
|
||||||
// attached to a source-run web console and read the wrong config. Pick a
|
// to a source-run web console and read the wrong config. The port is fixed
|
||||||
// usable port instead — honoring a user-pinned web_port, else PREFERRED_PORT
|
// (or the user's pinned web_port) — never random — so the renderer always
|
||||||
// when free, else an OS-assigned free port. This never depends on a probe.
|
// knows it. We then proactively free that port (kill stale listeners)
|
||||||
try {
|
// before spawning, so a leftover process from a previous run can't block us.
|
||||||
this.port = await this.resolvePort(dataDir)
|
this.port = this.resolvePort(dataDir)
|
||||||
} catch {
|
await this.freePort(this.port)
|
||||||
this.port = PREFERRED_PORT
|
|
||||||
}
|
|
||||||
|
|
||||||
let command: string
|
let command: string
|
||||||
let args: string[]
|
let args: string[]
|
||||||
@@ -229,10 +286,15 @@ export class PythonBackend extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.process.on('exit', (code) => {
|
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.status = 'stopped'
|
||||||
this.emit('log', `Python process exited with code ${code}`)
|
this.emit('log', `Python process exited with code ${code}`)
|
||||||
if (code !== 0 && code !== null) {
|
if (!wasReady && code !== 0 && code !== null) {
|
||||||
this.emit('error', `Python process exited with code ${code}`)
|
this.status = 'error'
|
||||||
|
this.emit('error', `Backend exited during startup (code ${code})`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -272,7 +334,9 @@ export class PythonBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retry = () => {
|
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()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function checkForUpdates(): void {
|
||||||
if (!app.isPackaged) return
|
if (!app.isPackaged) {
|
||||||
|
send({ state: 'not-available' })
|
||||||
|
return
|
||||||
|
}
|
||||||
autoUpdater.checkForUpdates().catch((err) => {
|
autoUpdater.checkForUpdates().catch((err) => {
|
||||||
send({ state: 'error', message: err?.message || String(err) })
|
send({ state: 'error', message: err?.message || String(err) })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 {
|
interface BackendState {
|
||||||
status: 'connecting' | 'ready' | 'error'
|
status: 'connecting' | 'ready' | 'error'
|
||||||
port: number
|
port: number
|
||||||
@@ -9,7 +16,7 @@ interface BackendState {
|
|||||||
export function useBackend() {
|
export function useBackend() {
|
||||||
const [state, setState] = useState<BackendState>({
|
const [state, setState] = useState<BackendState>({
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
port: 9876,
|
port: BACKEND_PORT,
|
||||||
})
|
})
|
||||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ export function useBackend() {
|
|||||||
const readyRef = useRef(false)
|
const readyRef = useRef(false)
|
||||||
// Holds the latest resolved port so the visibility handler (registered once)
|
// Holds the latest resolved port so the visibility handler (registered once)
|
||||||
// always probes the correct port without re-running the effect.
|
// always probes the correct port without re-running the effect.
|
||||||
const portRef = useRef(9876)
|
const portRef = useRef(BACKEND_PORT)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -74,12 +81,21 @@ export function useBackend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
api.getBackendPort().then((port) => {
|
// Always start polling, even if getBackendPort rejects or the ready event
|
||||||
const p = port || 9876
|
// was already emitted before we subscribed: polling /config is the
|
||||||
portRef.current = p
|
// self-sufficient path to "ready" and must never depend on the IPC round
|
||||||
setState((prev) => ({ ...prev, port: p }))
|
// trip succeeding (otherwise the app can hang forever on "connecting").
|
||||||
startPolling(p)
|
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) => {
|
offStatus = api.onBackendStatus((data) => {
|
||||||
if (data.status === 'ready' && data.port) {
|
if (data.status === 'ready' && data.port) {
|
||||||
@@ -98,7 +114,7 @@ export function useBackend() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
startPolling(9876)
|
startPolling(BACKEND_PORT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the window comes back to the foreground, re-probe immediately so a
|
// When the window comes back to the foreground, re-probe immediately so a
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ function getSystemTheme(): ResolvedTheme {
|
|||||||
function readStored(): ThemePref {
|
function readStored(): ThemePref {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
||||||
// Default to dark to match the app's flagship look
|
// First run: follow the OS appearance rather than forcing a fixed theme.
|
||||||
return 'dark'
|
return 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(resolved: ResolvedTheme) {
|
function applyTheme(resolved: ResolvedTheme) {
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
update_restart: '重启以更新',
|
update_restart: '重启以更新',
|
||||||
update_later: '稍后',
|
update_later: '稍后',
|
||||||
update_latest: '已是最新版本',
|
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
|
||||||
onboarding_welcome_title: '欢迎使用 CowAgent',
|
onboarding_welcome_title: '欢迎使用 CowAgent',
|
||||||
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
||||||
@@ -435,6 +444,15 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
update_restart: 'Restart to update',
|
update_restart: 'Restart to update',
|
||||||
update_later: 'Later',
|
update_later: 'Later',
|
||||||
update_latest: 'You are up to date',
|
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
|
||||||
onboarding_welcome_title: 'Welcome to CowAgent',
|
onboarding_welcome_title: 'Welcome to CowAgent',
|
||||||
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -13,13 +13,39 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
MoreHorizontal,
|
||||||
|
Languages,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { t, getLang, setLang, Lang } from '../i18n'
|
import { t, getLang, setLang, Lang } from '../i18n'
|
||||||
import { useUIStore } from '../store/uiStore'
|
import { useUIStore } from '../store/uiStore'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
|
||||||
import UpdateBanner from '../components/UpdateBanner'
|
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 {
|
interface NavItem {
|
||||||
path: string
|
path: string
|
||||||
labelKey: string
|
labelKey: string
|
||||||
@@ -49,12 +75,75 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
const collapsed = navCollapsed
|
const collapsed = navCollapsed
|
||||||
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
|
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<HTMLDivElement>(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 toggleLanguage = () => {
|
||||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||||
setLang(next)
|
setLang(next)
|
||||||
onLangChange()
|
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 (
|
return (
|
||||||
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
||||||
{/* Top: full-width drag strip; bottom border continues the header divider
|
{/* Top: full-width drag strip; bottom border continues the header divider
|
||||||
@@ -93,26 +182,56 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
{!collapsed && <UpdateBanner />}
|
{!collapsed && <UpdateBanner />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions: a single "more" entry (with version + update dot) that
|
||||||
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
|
opens an upward popover, plus the always-visible collapse toggle. */}
|
||||||
<FooterBtn
|
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
|
||||||
collapsed={collapsed}
|
{menuOpen && (
|
||||||
onClick={() => navigate('/logs')}
|
<FooterMenu
|
||||||
title={t('menu_logs')}
|
theme={theme}
|
||||||
active={location.pathname === '/logs'}
|
checking={checking}
|
||||||
>
|
pendingUpdate={pendingUpdate}
|
||||||
<ScrollText size={17} />
|
upToDate={checkedManually && updateStatusState === 'not-available'}
|
||||||
</FooterBtn>
|
onLogs={() => {
|
||||||
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
|
setMenuOpen(false)
|
||||||
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
navigate('/logs')
|
||||||
</FooterBtn>
|
}}
|
||||||
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
|
onTheme={toggleTheme}
|
||||||
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
|
onLanguage={toggleLanguage}
|
||||||
</FooterBtn>
|
onCheckUpdate={checkUpdate}
|
||||||
<div className={collapsed ? '' : 'flex-1'} />
|
onOpenLink={(url) => {
|
||||||
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
setMenuOpen(false)
|
||||||
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
openExternal(url)
|
||||||
</FooterBtn>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
|
||||||
|
{/* Single clickable entry: version label (left) + the three dots
|
||||||
|
(right) form one button; the whole block opens the popover. The
|
||||||
|
version is the packaged app version, also what auto-update
|
||||||
|
compares against. Collapsed: dots only, version hidden. */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
title={t('menu_more')}
|
||||||
|
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
|
||||||
|
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
|
||||||
|
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
|
||||||
|
>
|
||||||
|
{!collapsed && version && (
|
||||||
|
<span className="text-[12px] truncate">{`v${version}`}</span>
|
||||||
|
)}
|
||||||
|
<MoreHorizontal size={17} className="flex-shrink-0" />
|
||||||
|
{pendingUpdate && (
|
||||||
|
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && <div className="flex-1" />}
|
||||||
|
|
||||||
|
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
||||||
|
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||||
|
</FooterBtn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -139,4 +258,78 @@ const FooterBtn: React.FC<{
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="absolute bottom-full left-2 right-2 mb-2 z-50 rounded-lg border border-default bg-elevated shadow-lg py-1">
|
||||||
|
{/* External destinations first (skill hub, docs, website) */}
|
||||||
|
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
|
||||||
|
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
|
||||||
|
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
|
||||||
|
|
||||||
|
<div className="my-1 border-t border-subtle" />
|
||||||
|
|
||||||
|
{/* App actions below: update, theme, language, logs */}
|
||||||
|
<MenuItem
|
||||||
|
icon={checking ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||||
|
label={updateLabel}
|
||||||
|
onClick={onCheckUpdate}
|
||||||
|
dot={pendingUpdate}
|
||||||
|
disabled={checking || upToDate}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
|
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
|
||||||
|
onClick={onTheme}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<Languages size={16} />}
|
||||||
|
label={t('menu_language')}
|
||||||
|
trailing={getLang() === 'zh' ? 'EN' : '中'}
|
||||||
|
onClick={onLanguage}
|
||||||
|
/>
|
||||||
|
<MenuItem icon={<ScrollText size={16} />} label={t('menu_logs')} onClick={onLogs} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuItem: React.FC<{
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
trailing?: string
|
||||||
|
dot?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}> = ({ icon, label, trailing, dot, disabled, onClick }) => (
|
||||||
|
<button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 h-9 text-[13px] text-content-secondary hover:bg-surface-2 hover:text-content cursor-pointer transition-colors disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-content-secondary"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 text-content-tertiary relative">
|
||||||
|
{icon}
|
||||||
|
{dot && <span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-danger" />}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-left truncate">{label}</span>
|
||||||
|
{trailing && <span className="text-[11px] font-medium text-content-tertiary">{trailing}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
export default NavRail
|
export default NavRail
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ElectronAPI {
|
|||||||
windowIsMaximized: () => Promise<boolean>
|
windowIsMaximized: () => Promise<boolean>
|
||||||
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
|
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
|
||||||
onMenuAction?: (callback: (action: string) => void) => () => void
|
onMenuAction?: (callback: (action: string) => void) => () => void
|
||||||
|
// Current app version string (e.g. "0.0.5").
|
||||||
|
getAppVersion?: () => Promise<string>
|
||||||
// Auto-update
|
// Auto-update
|
||||||
checkForUpdate?: () => Promise<void>
|
checkForUpdate?: () => Promise<void>
|
||||||
downloadUpdate?: () => Promise<void>
|
downloadUpdate?: () => Promise<void>
|
||||||
|
|||||||
Reference in New Issue
Block a user