Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
- name: Sync version from tag
run: node scripts/set-version.mjs ${{ github.ref_name }}

# Bundle the clone-export bot engine to resources/bot-runtime/bot.mjs so
# electron-builder ships it; the "导出好友" feature copies it into products.
- name: Build clone-bot runtime
run: pnpm run build:bot

- name: Build renderer + main
run: pnpm --filter @weq/desktop exec electron-vite build

Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ release/

# extend
.claude/
.agents/
.agents/
# 导出 bot 的预打包引擎(由 pnpm build:bot 生成)
resources/bot-runtime/
52 changes: 52 additions & 0 deletions apps/desktop/src/main/bot_webui_window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* 打开导出 bot 的 WebUI 控制台窗口(本机 http 页面)。
*
* 用导出时生成的密钥「免手输」直接登录:先 loadURL 打开控制台,页面首帧加载完成后注入
* sessionStorage 密钥并 reload —— 控制台前端在 sessionStorage 有密钥时会自动进主界面。
*
* 窗口隔离:无 preload(加载的是 bot 自己的页面,不该看到应用的 tRPC bridge),
* 链接一律走系统浏览器。镜像 report_window.ts 的隔离范式。
*/
import { BrowserWindow } from 'electron';

/** 探活:GET <url> 判断 bot 的 WebUI 是否在跑(3s 超时)。 */
export async function probeBotWebUi(url: string): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, { method: 'GET', signal: controller.signal });
return res.ok || res.status === 401; // 200 页面 或 401(服务在跑,只是缺鉴权)都算可达
} catch {
return false;
} finally {
clearTimeout(timer);
}
}

/** 打开控制台窗口并用密钥自动登录。url 为基址(如 http://127.0.0.1:8090)。 */
export async function openBotWebUiWindow(url: string, key: string, botName?: string): Promise<void> {
const win = new BrowserWindow({
width: 1040,
height: 800,
minWidth: 520,
minHeight: 420,
title: botName ? `${botName} · 控制台` : '克隆体 · 控制台',
autoHideMenuBar: true,
backgroundColor: '#0f1216',
webPreferences: {
sandbox: true,
contextIsolation: true,
// 无 preload:控制台是 bot 自己的页面,应用的特权 bridge 不能进它的视野。
},
});
// 控制台里的外链一律走系统浏览器,不在本窗口内导航/开子窗。
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));

// 首帧加载完成后注入密钥并 reload(once:只在首次注入,reload 后的加载不再重复)。
win.webContents.once('did-finish-load', () => {
const js = `try { sessionStorage.setItem('weq-bot-key', ${JSON.stringify(key)}); location.reload(); } catch (e) {}`;
void win.webContents.executeJavaScript(js).catch(() => undefined);
});

await win.loadURL(url);
}
23 changes: 16 additions & 7 deletions apps/desktop/src/main/context/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,13 +694,19 @@ export function initAppContext(): AppContext {
// Start it now if enabled; live toggling is handled by `applyMcp`.
const mcp = userConfig.getSettings().mcp;
if (mcp.enabled && mcp.token) {
startMcpServer({ port: mcp.port, token: mcp.token }).catch((error) => {
logger.error('failed to start mcp server on account open', {
event: 'mcp-start-failed',
port: mcp.port,
...logErrorContext(error),
startMcpServer({ port: mcp.port, token: mcp.token })
.then((boundPort) => {
// Port fallback may have moved us off a squatted port; persist the
// real one so the UI / client config stay in sync.
if (boundPort !== mcp.port) userConfig.setSettings({ mcp: { port: boundPort } });
})
.catch((error) => {
logger.error('failed to start mcp server on account open', {
event: 'mcp-start-failed',
port: mcp.port,
...logErrorContext(error),
});
});
});
}
// No health check at open — it now runs lazily, only if a real query
// later fails in a way that looks like corruption (see the openAccount
Expand Down Expand Up @@ -918,7 +924,10 @@ export function initAppContext(): AppContext {
port: config.port,
});
if (config.enabled && config.token) {
await startMcpServer({ port: config.port, token: config.token });
const boundPort = await startMcpServer({ port: config.port, token: config.token });
// Port fallback may have moved us off a squatted port; persist the real
// one so getMcpStatus / the client config snippet report what's live.
if (boundPort !== config.port) userConfig.setSettings({ mcp: { port: boundPort } });
} else {
await stopMcpServer();
}
Expand Down
151 changes: 148 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeImage, protocol, shell } from 'electron';
import { app, BrowserWindow, ipcMain, Menu, nativeImage, protocol, shell, Tray } from 'electron';
import fs from 'node:fs';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createRequire } from 'node:module';
Expand All @@ -25,7 +25,13 @@ import { stopMcpServer } from './mcp/server';
import { disposeExternalMcp } from './mcp/external';
import { registerChannelIpc } from './channel';
import { registerQzoneIpc } from './qzone';
import { getLogDir, getLogger, logErrorContext, type MediaElement } from '@weq/service';
import {
getLogDir,
getLogger,
logErrorContext,
type MediaElement,
type WindowCloseBehavior,
} from '@weq/service';
import { systemAuthService } from './system_auth';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -54,6 +60,81 @@ const WINDOW_LAYOUTS = {

const logger = getLogger().child({ scope: 'desktop-main' });

/** The primary app window. Tracked so the tray / single-instance / close flows
* can reach it without threading it through every call. */
let mainWindow: BrowserWindow | null = null;
/** System-tray icon; null until built (or if creation failed). */
let tray: Tray | null = null;
/** Set true right before a *real* quit so the `close` handler stops intercepting
* and lets the window actually close (托盘退出 / 确认框「完全退出」/ before-quit). */
let isQuitting = false;

/** Bring the main window back to the foreground (from tray / second instance). */
function revealWindow(win: BrowserWindow): void {
if (win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}

/**
* Resolve the current 关闭行为 from persisted settings. Falls back to `'quit'`
* when bootstrap is unavailable (native-load failure → only an error window is
* showing; hiding it to tray would be pointless).
*/
function resolveCloseBehavior(): WindowCloseBehavior {
try {
const boot = getAppContext().bootstrap;
if (!boot) return 'quit';
return boot.userConfig.getSettings().windowCloseBehavior ?? 'ask';
} catch {
return 'quit';
}
}

/**
* Build the system tray once. The icon is the brand logo, downscaled to a
* tray-appropriate size (16px on Windows; 18px template image on macOS so it
* tracks the menu-bar's light/dark). Menu: 显示主窗口 / 退出 WeQ. Left-click and
* double-click both re-reveal the window.
*/
function buildTray(win: BrowserWindow): void {
if (tray) return;
const iconPath = resolveResource('brand', 'logo.png');
let image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
if (!image.isEmpty()) {
image = image.resize(
process.platform === 'darwin' ? { width: 18, height: 18 } : { width: 16, height: 16 },
);
if (process.platform === 'darwin') image.setTemplateImage(true);
}
try {
tray = new Tray(image);
tray.setToolTip('WeQ');
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => revealWindow(win) },
{ type: 'separator' },
{
label: '退出 WeQ',
click: () => {
isQuitting = true;
app.quit();
},
},
]),
);
tray.on('click', () => revealWindow(win));
tray.on('double-click', () => revealWindow(win));
logger.info('system tray created', { event: 'tray-create' });
} catch (e) {
logger.warn('failed to create system tray', {
event: 'tray-create-failed',
error: String(e),
});
}
}

function registerWindowLayoutIpc(): void {
ipcMain.handle('window:set-layout', (event, layout: keyof typeof WINDOW_LAYOUTS) => {
const win = BrowserWindow.fromWebContents(event.sender);
Expand Down Expand Up @@ -82,6 +163,17 @@ function registerWindowLayoutIpc(): void {
ipcMain.on('window-close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close();
});

// Reply from the renderer's 关闭确认框 (only sent when behavior === 'ask').
// 'tray' → hide to tray; 'quit' → real quit; 'cancel' → do nothing.
ipcMain.on('window:respond-close', (_event, action: 'tray' | 'quit' | 'cancel') => {
if (action === 'tray') {
mainWindow?.hide();
} else if (action === 'quit') {
isQuitting = true;
app.quit();
}
});
}

/**
Expand Down Expand Up @@ -289,6 +381,27 @@ function createWindow(): BrowserWindow {
// (e.g. compositor/driver quirks). Guarantee visibility once content loads.
win.webContents.on('did-finish-load', reveal);

// Intercept the close button so it doesn't hard-quit. Behavior is
// user-configurable (设置 → 全局设置 → 关闭按钮):
// 'quit' → let it close (window-all-closed then quits);
// 'tray' → hide to the system tray, keep the process alive;
// 'ask' → ask the renderer to show the 关闭确认框 (default, first time).
win.on('close', (e) => {
if (isQuitting || win !== mainWindow) return;
const behavior = resolveCloseBehavior();
if (behavior === 'quit') {
isQuitting = true;
return;
}
e.preventDefault();
if (behavior === 'tray' && tray) {
win.hide();
return;
}
// 'ask' — or 'tray' but the tray failed to build → fall back to asking.
win.webContents.send('window:confirm-close', { canMinimizeToTray: Boolean(tray) });
});

win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
Expand All @@ -299,10 +412,24 @@ function createWindow(): BrowserWindow {
} else {
void win.loadFile(join(__dirname, '../renderer/index.html'));
}
mainWindow = win;
return win;
}

// Single-instance guard: with the app now living in the tray, a second launch
// should just re-reveal the existing window rather than spawn another
// background process. The non-primary instance quits immediately.
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) revealWindow(mainWindow);
});
}

void app.whenReady().then(() => {
if (!hasSingleInstanceLock) return;
electronApp.setAppUserModelId('app.weq.desktop');

// Order matters: AppContext (loads native + platform) before IPC handler.
Expand All @@ -326,6 +453,7 @@ void app.whenReady().then(() => {
const win = createWindow();
logger.info('main window created', { event: 'create-window' });
createIPCHandler({ router: appRouter, windows: [win] });
buildTray(win);

// Silent background update check (packaged builds only). Result is cached and
// pushed to the renderer via the `update.onEvent` subscription → settings red
Expand All @@ -343,7 +471,24 @@ void app.whenReady().then(() => {
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
// With the tray keeping the process alive, closing the window normally hides
// it (never reaching here). We only get here on a real quit path — which
// already sets `isQuitting` — so honour it. macOS keeps the app resident.
if (process.platform !== 'darwin' && isQuitting) app.quit();
});

// A real quit is underway (tray「退出」/ 确认框「完全退出」/ Cmd+Q / OS shutdown):
// flip the flag so the `close` handler stops intercepting, and drop the tray.
app.on('before-quit', () => {
isQuitting = true;
if (tray) {
try {
tray.destroy();
} catch {
/* ignore */
}
tray = null;
}
});

// Best-effort: stop the account-bound MCP server on quit even if the account
Expand Down
Loading
Loading