feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

View File

@@ -48,7 +48,8 @@ import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
const WINDOWS_APP_USER_MODEL_ID = 'app.zhinian.assistant';
const PRODUCT_NAME = '智念助手';
const isE2EMode = process.env.CLAWX_E2E === '1';
const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim();
@@ -56,6 +57,8 @@ if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
app.setName(PRODUCT_NAME);
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
//
@@ -77,7 +80,7 @@ app.disableHardwareAcceleration();
// on X11 it supplements the StartupWMClass matching.
// Must be called before app.whenReady() / before any window is created.
if (process.platform === 'linux') {
app.setDesktopName('clawx.desktop');
app.setDesktopName('zhinian-assistant.desktop');
}
// Prevent multiple instances of the app from running simultaneously.
@@ -139,21 +142,29 @@ function getIconsDir(): string {
return join(__dirname, '../../resources/icons');
}
function getAppIconPath(): string {
const iconsDir = getIconsDir();
return process.platform === 'win32'
? join(iconsDir, 'icon.ico')
: join(iconsDir, 'icon.png');
}
/**
* Get the app icon for the current platform
*/
function getAppIcon(): Electron.NativeImage | undefined {
if (process.platform === 'darwin') return undefined; // macOS uses the app bundle icon
const iconsDir = getIconsDir();
const iconPath =
process.platform === 'win32'
? join(iconsDir, 'icon.ico')
: join(iconsDir, 'icon.png');
const icon = nativeImage.createFromPath(iconPath);
const icon = nativeImage.createFromPath(getAppIconPath());
return icon.isEmpty() ? undefined : icon;
}
function applyRuntimeAppIcon(): void {
if (process.platform !== 'darwin' || !app.dock) return;
const icon = nativeImage.createFromPath(join(getIconsDir(), 'icon.png'));
if (!icon.isEmpty()) {
app.dock.setIcon(icon);
}
}
/**
* Create the main application window
*/
@@ -162,6 +173,12 @@ function createWindow(): BrowserWindow {
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
const e2eRendererQuery = isE2EMode
? {
e2e: '1',
...(shouldSkipSetupForE2E ? { e2eSkipSetup: '1' } : {}),
}
: undefined;
const win = new BrowserWindow({
width: 1280,
@@ -180,6 +197,7 @@ function createWindow(): BrowserWindow {
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
frame: isMac || !useCustomTitleBar,
show: false,
title: PRODUCT_NAME,
});
// Handle external links — only allow safe protocols to prevent arbitrary
@@ -201,18 +219,18 @@ function createWindow(): BrowserWindow {
// Load the app
if (process.env.VITE_DEV_SERVER_URL) {
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
if (shouldSkipSetupForE2E) {
rendererUrl.searchParams.set('e2eSkipSetup', '1');
if (e2eRendererQuery) {
Object.entries(e2eRendererQuery).forEach(([key, value]) => {
rendererUrl.searchParams.set(key, value);
});
}
win.loadURL(rendererUrl.toString());
if (!isE2EMode) {
if (!isE2EMode && process.env.YINIAN_OPEN_DEVTOOLS === '1') {
win.webContents.openDevTools();
}
} else {
win.loadFile(join(__dirname, '../../dist/index.html'), {
query: shouldSkipSetupForE2E
? { e2eSkipSetup: '1' }
: undefined,
query: e2eRendererQuery,
});
}
@@ -281,7 +299,7 @@ function createMainWindow(): BrowserWindow {
async function initialize(): Promise<void> {
// Initialize logger first
logger.init();
logger.info('=== ClawX Application Starting ===');
logger.info('=== 智念助手 Application Starting ===');
logger.debug(
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
);
@@ -469,9 +487,12 @@ async function initialize(): Promise<void> {
hostEventBus.emit('channel:whatsapp-error', error);
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
// YINIAN: do not start Gateway before a user has logged in and a workspace
// context has been loaded. Legacy ClawX auto-start can be restored for
// debugging with CLAWX_LEGACY_AUTOSTART=1.
const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (!isE2EMode && gatewayAutoStart) {
const legacyAutoStart = process.env.CLAWX_LEGACY_AUTOSTART === '1';
if (!isE2EMode && gatewayAutoStart && legacyAutoStart) {
try {
await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...');
@@ -483,6 +504,8 @@ async function initialize(): Promise<void> {
}
} else if (isE2EMode) {
logger.info('Gateway auto-start skipped in E2E mode');
} else if (!legacyAutoStart) {
logger.info('Gateway auto-start deferred until YINIAN login');
} else {
logger.info('Gateway auto-start disabled in settings');
}
@@ -543,7 +566,7 @@ if (gotTheLock) {
// When a second instance is launched, focus the existing window instead.
app.on('second-instance', () => {
logger.info('Second ClawX instance detected; redirecting to the existing window');
logger.info('Second 智念助手 instance detected; redirecting to the existing window');
const focusRequest = requestSecondInstanceFocus(
mainWindowFocusState,
@@ -560,6 +583,7 @@ if (gotTheLock) {
// Application lifecycle
app.whenReady().then(() => {
applyRuntimeAppIcon();
void initialize().catch((error) => {
logger.error('Application initialization failed:', error);
});

View File

@@ -63,6 +63,7 @@ import {
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import { appUpdater } from './updater';
import { registerHostApiProxyHandlers } from './ipc/host-api-proxy';
import { registerYinianHandlers } from './ipc/yinian';
import {
isLaunchAtStartupKey,
isProxyKey,
@@ -85,6 +86,9 @@ export function registerIpcHandlers(
// Host API proxy handlers
registerHostApiProxyHandlers();
// YINIAN hotel control-plane handlers
registerYinianHandlers();
// Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow);

View File

@@ -0,0 +1,60 @@
import { ipcMain } from 'electron';
import { getYinianControlPlane } from '../../yinian/control-plane';
import { getYinianStorage } from '../../yinian/storage';
import type { YinianLoginWithPasswordInput, YinianLoginWithSmsInput, YinianSavedCredentials } from '../../../shared/yinian';
export function registerYinianHandlers(): void {
ipcMain.handle('yinian:server:status', async () => getYinianControlPlane().getServerStatus());
ipcMain.handle('yinian:auth:createImageCaptcha', async (_, randomStr?: string) => {
return getYinianControlPlane().createImageCaptcha(randomStr);
});
ipcMain.handle('yinian:auth:restoreSession', async () => getYinianControlPlane().restoreSession());
ipcMain.handle('yinian:auth:getSessionState', async () => getYinianControlPlane().getSessionState());
ipcMain.handle('yinian:auth:loginWithSms', async (_, input: YinianLoginWithSmsInput) => {
return getYinianControlPlane().loginWithSms(input);
});
ipcMain.handle('yinian:auth:loginWithPassword', async (_, input: YinianLoginWithPasswordInput) => {
return getYinianControlPlane().loginWithPassword(input);
});
ipcMain.handle('yinian:auth:logout', async () => getYinianControlPlane().logout());
ipcMain.handle('yinian:auth:getSavedCredentials', async () => {
return getYinianStorage().getSavedCredentials();
});
ipcMain.handle('yinian:auth:saveCredentials', async (_, input: Pick<YinianSavedCredentials, 'account' | 'password' | 'rememberPassword'>) => {
const account = typeof input.account === 'string' ? input.account.trim() : '';
if (!account) {
await getYinianStorage().clearSavedCredentials();
return undefined;
}
const credentials: YinianSavedCredentials = {
account,
password: input.rememberPassword ? input.password ?? '' : undefined,
rememberPassword: Boolean(input.rememberPassword),
updatedAt: Date.now(),
};
await getYinianStorage().setSavedCredentials(credentials);
return credentials;
});
ipcMain.handle('yinian:auth:clearSavedCredentials', async () => {
await getYinianStorage().clearSavedCredentials();
});
ipcMain.handle('yinian:config:get', async () => getYinianControlPlane().getConfigSnapshot());
ipcMain.handle('yinian:hotel:switch', async (_, hotelId: string) => getYinianControlPlane().switchHotel(hotelId));
ipcMain.handle('yinian:skills:sync', async () => getYinianControlPlane().syncSkills());
ipcMain.handle('yinian:skills:listLocal', async () => getYinianControlPlane().listLocalSkills());
ipcMain.handle('yinian:skills:getRegistry', async (_, hotelId?: string) => getYinianControlPlane().getSkillRegistry(hotelId));
}

View File

@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
import { logger } from '../utils/logger';
import { getSetting } from '../utils/store';
const LINUX_AUTOSTART_FILE = join('.config', 'autostart', 'clawx.desktop');
const LINUX_AUTOSTART_FILE = join('.config', 'autostart', 'zhinian-assistant.desktop');
function quoteDesktopArg(value: string): string {
if (!value) return '""';
@@ -30,8 +30,8 @@ function getLinuxDesktopEntry(): string {
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=ClawX',
'Comment=ClawX - AI Assistant',
'Name=智念助手',
'Comment=B 端 AI Agent 桌面助手',
`Exec=${getLinuxExecCommand()}`,
'Terminal=false',
'Categories=Utility;',

View File

@@ -57,7 +57,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
tray = new Tray(icon);
// Set tooltip
tray.setToolTip('ClawX - AI Assistant');
tray.setToolTip('智念助手 - B 端 AI Agent');
const showWindow = () => {
if (mainWindow.isDestroyed()) return;
@@ -68,7 +68,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Create context menu
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show ClawX',
label: '显示智念助手',
click: showWindow,
},
{
@@ -91,7 +91,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
label: 'Quick Actions',
submenu: [
{
label: 'Open Chat',
label: '打开对话',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
@@ -99,7 +99,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
},
},
{
label: 'Open Settings',
label: '打开设置',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
@@ -112,7 +112,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
type: 'separator',
},
{
label: 'Check for Updates...',
label: '检查更新...',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.webContents.send('update:check');
@@ -122,7 +122,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
type: 'separator',
},
{
label: 'Quit ClawX',
label: '退出智念助手',
click: () => {
app.quit();
},
@@ -157,7 +157,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
*/
export function updateTrayStatus(status: string): void {
if (tray) {
tray.setToolTip(`ClawX - ${status}`);
tray.setToolTip(`智念助手 - ${status}`);
}
}