diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index a6ff022..892e106 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -72,6 +72,7 @@ import { } from './session-registry' import { upsertDesktopAccount } from './transport/api-client' import { STANDARD_USER_AGENT } from './user-agent' +import { openWorkbenchWindow } from './workbench-window' interface DetectedAccount { platformUid: string @@ -4559,7 +4560,11 @@ export async function openPublishAccountConsole(account: PublishAccountIdentity) throw new Error(`desktop_account_session_expired:${account.platform}`) } } - const window = createBoundWindow(`${definition.label} 创作台`, consoleURL, handle.session) + const window = await openWorkbenchWindow({ + title: `${definition.label} 创作台`, + targetURL: consoleURL, + session: handle.session, + }) window.show() window.focus() } diff --git a/apps/desktop-client/src/main/tray.ts b/apps/desktop-client/src/main/tray.ts index 2476134..95e3ae9 100644 --- a/apps/desktop-client/src/main/tray.ts +++ b/apps/desktop-client/src/main/tray.ts @@ -91,9 +91,9 @@ export function initTray(onOpen: () => void): ElectronTray { updateTrayIssueIndicator(0) tray.setContextMenu( Menu.buildFromTemplate([ - { label: 'Open', click: () => onOpen() }, + { label: '打开省心推', click: () => onOpen() }, { type: 'separator' }, - { label: 'Quit', click: () => app.quit() }, + { label: '退出省心推', click: () => app.quit() }, ]), ) tray.on('click', () => onOpen()) diff --git a/apps/desktop-client/src/main/workbench-window.ts b/apps/desktop-client/src/main/workbench-window.ts new file mode 100644 index 0000000..954fe1b --- /dev/null +++ b/apps/desktop-client/src/main/workbench-window.ts @@ -0,0 +1,807 @@ +import { join } from 'node:path' + +import type { Session, WebContents } from 'electron/main' +import { BrowserWindow, WebContentsView, ipcMain, nativeTheme } from 'electron/main' + +import { STANDARD_USER_AGENT } from './user-agent' + +interface WorkbenchWindowOptions { + title: string + targetURL: string + session: Session +} + +interface WorkbenchNavigationState { + canGoBack: boolean + canGoForward: boolean + loading: boolean + url: string + title: string + host: string + secure: boolean +} + +interface WorkbenchWindowRecord { + shellContentsId: number + window: BrowserWindow + view: WebContentsView + title: string + initialURL: string + activeTargetURL: string + pageTitle: string + loading: boolean +} + +const TOOLBAR_HEIGHT = 46 +const workbenchWindows = new Map() +let handlersRegistered = false + +function preloadPath(): string { + return join(__dirname, '../preload/bridge.cjs') +} + +function titleBarStyle(): 'hidden' | 'hiddenInset' { + return process.platform === 'darwin' ? 'hiddenInset' : 'hidden' +} + +function titleBarOverlay(): { color: string; symbolColor: string; height: number } | undefined { + if (process.platform === 'darwin') { + return undefined + } + + return { + color: nativeTheme.shouldUseDarkColors ? '#15191a' : '#fbfcfd', + symbolColor: nativeTheme.shouldUseDarkColors ? '#f8fafc' : '#111827', + height: TOOLBAR_HEIGHT, + } +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function hostFromURL(rawURL: string): string { + try { + return new URL(rawURL).host + } catch { + return '' + } +} + +function isRemoteHTTPURL(rawURL: string): boolean { + return /^https?:\/\//i.test(rawURL) +} + +function isLocalWorkbenchPage(rawURL: string): boolean { + return rawURL.startsWith('data:text/html') +} + +function workbenchShellHTML(title: string, targetURL: string): string { + const initialHost = hostFromURL(targetURL) + const platformClass = `platform-${process.platform}` + + return ` + + + + + ${escapeHtml(title)} + + + +
+ +
+ ${escapeHtml(title)} + + + ${escapeHtml(initialHost)} + +
+ + +
+ + +` +} + +function workbenchShellDataURL(title: string, targetURL: string): string { + return `data:text/html;charset=UTF-8,${encodeURIComponent(workbenchShellHTML(title, targetURL))}` +} + +function workbenchErrorDataURL(title: string, targetURL: string, message: string): string { + const host = hostFromURL(targetURL) + const html = ` + + + + + ${escapeHtml(title)} - 加载失败 + + + +
+

${escapeHtml(title)}加载失败

+

远程工作台暂时无法打开。可以使用窗口左上方的刷新按钮重试。

+ ${escapeHtml(host || targetURL)} + +${escapeHtml(message)} +
+ +` + + return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}` +} + +function stateForRecord(record: WorkbenchWindowRecord): WorkbenchNavigationState { + const contents = record.view.webContents + const currentURL = contents.isDestroyed() ? '' : contents.getURL() + const displayURL = isLocalWorkbenchPage(currentURL) ? record.activeTargetURL : currentURL + return { + canGoBack: !contents.isDestroyed() && contents.canGoBack(), + canGoForward: !contents.isDestroyed() && contents.canGoForward(), + loading: record.loading || (!contents.isDestroyed() && contents.isLoading()), + url: displayURL, + title: record.pageTitle || record.title, + host: hostFromURL(displayURL), + secure: displayURL.startsWith('https://'), + } +} + +function sendNavigationState(record: WorkbenchWindowRecord): void { + if (record.window.isDestroyed()) { + return + } + + record.window.webContents.send('desktop:workbench-navigation-state', stateForRecord(record)) +} + +function getRecordForSender(sender: WebContents): WorkbenchWindowRecord | null { + const record = workbenchWindows.get(sender.id) + if (!record || record.window.isDestroyed() || record.view.webContents.isDestroyed()) { + return null + } + return record +} + +function defaultNavigationState(): WorkbenchNavigationState { + return { + canGoBack: false, + canGoForward: false, + loading: false, + url: '', + title: '', + host: '', + secure: false, + } +} + +function registerWorkbenchNavigationHandlers(): void { + if (handlersRegistered) { + return + } + handlersRegistered = true + + ipcMain.handle('desktop:workbench-navigation:get-state', (event) => { + const record = getRecordForSender(event.sender) + return record ? stateForRecord(record) : defaultNavigationState() + }) + + ipcMain.handle('desktop:workbench-navigation:go-back', (event) => { + const record = getRecordForSender(event.sender) + if (record?.view.webContents.canGoBack()) { + record.view.webContents.goBack() + } + return null + }) + + ipcMain.handle('desktop:workbench-navigation:go-forward', (event) => { + const record = getRecordForSender(event.sender) + if (record?.view.webContents.canGoForward()) { + record.view.webContents.goForward() + } + return null + }) + + ipcMain.handle('desktop:workbench-navigation:reload', (event) => { + const record = getRecordForSender(event.sender) + if (!record) { + return null + } + + const currentURL = record.view.webContents.getURL() + if (isLocalWorkbenchPage(currentURL)) { + void navigateWorkbenchRemote(record, record.activeTargetURL || record.initialURL) + } else { + record.view.webContents.reload() + } + return null + }) +} + +function layoutWorkbenchView(record: WorkbenchWindowRecord): void { + if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) { + return + } + + const [width, height] = record.window.getContentSize() + record.view.setBounds({ + x: 0, + y: TOOLBAR_HEIGHT, + width: Math.max(0, width), + height: Math.max(0, height - TOOLBAR_HEIGHT), + }) +} + +function attachWorkbenchViewEvents(record: WorkbenchWindowRecord): void { + const { webContents } = record.view + + webContents.on('did-start-loading', () => { + record.loading = true + sendNavigationState(record) + }) + + webContents.on('did-stop-loading', () => { + record.loading = false + sendNavigationState(record) + }) + + webContents.on('did-start-navigation', (_event, url, isInPlace, isMainFrame) => { + if (isMainFrame && isRemoteHTTPURL(url)) { + record.activeTargetURL = url + record.loading = !isInPlace + sendNavigationState(record) + } + }) + + webContents.on('did-navigate', (_event, url) => { + if (isRemoteHTTPURL(url)) { + record.activeTargetURL = url + } + sendNavigationState(record) + }) + + webContents.on('did-navigate-in-page', (_event, url) => { + if (isRemoteHTTPURL(url)) { + record.activeTargetURL = url + } + sendNavigationState(record) + }) + + webContents.on('page-title-updated', (_event, pageTitle) => { + record.pageTitle = pageTitle || record.title + sendNavigationState(record) + }) + + webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === -3 || errorCode === -20) { + return + } + + const targetURL = validatedURL || record.activeTargetURL || record.initialURL + console.warn('[desktop-workbench] workbench page load failed', { + title: record.title, + targetURL, + errorCode, + errorDescription, + }) + void showWorkbenchError(record, targetURL, `${errorCode}: ${errorDescription}`) + }, + ) + + webContents.setWindowOpenHandler(({ url }) => { + if (isRemoteHTTPURL(url)) { + setImmediate(() => { + void navigateWorkbenchRemote(record, url) + }) + } + return { action: 'deny' } + }) +} + +async function showWorkbenchError( + record: WorkbenchWindowRecord, + targetURL: string, + message: string, +): Promise { + if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) { + return + } + + record.loading = false + record.activeTargetURL = targetURL + record.pageTitle = `${record.title}加载失败` + await record.view.webContents.loadURL(workbenchErrorDataURL(record.title, targetURL, message)) + sendNavigationState(record) +} + +async function navigateWorkbenchRemote( + record: WorkbenchWindowRecord, + targetURL: string, +): Promise { + if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) { + return + } + + record.activeTargetURL = targetURL + record.loading = true + sendNavigationState(record) + + try { + await record.view.webContents.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|\((-3|-20)\) loading/i.test(message)) { + return + } + + await showWorkbenchError(record, targetURL, message) + } +} + +export async function openWorkbenchWindow(options: WorkbenchWindowOptions): Promise { + registerWorkbenchNavigationHandlers() + + const window = new BrowserWindow({ + show: false, + width: 1320, + height: 900, + minWidth: 1100, + minHeight: 760, + title: options.title, + autoHideMenuBar: true, + titleBarStyle: titleBarStyle(), + titleBarOverlay: titleBarOverlay(), + trafficLightPosition: process.platform === 'darwin' ? { x: 14, y: 16 } : undefined, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#15191a' : '#fbfcfd', + webPreferences: { + preload: preloadPath(), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }) + const view = new WebContentsView({ + webPreferences: { + session: options.session, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }) + + view.webContents.setUserAgent(STANDARD_USER_AGENT) + window.contentView.addChildView(view) + + const record: WorkbenchWindowRecord = { + shellContentsId: window.webContents.id, + window, + view, + title: options.title, + initialURL: options.targetURL, + activeTargetURL: options.targetURL, + pageTitle: options.title, + loading: false, + } + + workbenchWindows.set(record.shellContentsId, record) + attachWorkbenchViewEvents(record) + + window.on('resize', () => { + layoutWorkbenchView(record) + }) + window.on('closed', () => { + workbenchWindows.delete(record.shellContentsId) + if (!record.view.webContents.isDestroyed()) { + record.view.webContents.close() + } + }) + + await window.webContents.loadURL(workbenchShellDataURL(options.title, options.targetURL)) + layoutWorkbenchView(record) + sendNavigationState(record) + void navigateWorkbenchRemote(record, options.targetURL) + + return window +} diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 8561253..bee1caf 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -38,6 +38,16 @@ interface DesktopLoginCredentials { password: string } +interface WorkbenchNavigationState { + canGoBack: boolean + canGoForward: boolean + loading: boolean + url: string + title: string + host: string + secure: boolean +} + const rendererProxyRequestChannel = 'desktop:renderer-devtools-proxy:request' const rendererProxyResponseChannel = 'desktop:renderer-devtools-proxy:response' @@ -196,6 +206,25 @@ const desktopBridge = { } }, }, + workbenchNavigation: { + getState: () => + ipcRenderer.invoke( + 'desktop:workbench-navigation:get-state', + ) as Promise, + goBack: () => ipcRenderer.invoke('desktop:workbench-navigation:go-back') as Promise, + goForward: () => + ipcRenderer.invoke('desktop:workbench-navigation:go-forward') as Promise, + reload: () => ipcRenderer.invoke('desktop:workbench-navigation:reload') as Promise, + onStateChanged: (listener: (state: WorkbenchNavigationState) => void) => { + const wrapped = (_event: IpcRendererEvent, payload: WorkbenchNavigationState) => { + listener(payload) + } + ipcRenderer.on('desktop:workbench-navigation-state', wrapped) + return () => { + ipcRenderer.off('desktop:workbench-navigation-state', wrapped) + } + }, + }, } contextBridge.exposeInMainWorld('desktopBridge', desktopBridge) diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index dcc7fa7..a7f73cd 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -22,6 +22,16 @@ declare global { password: string } + interface WorkbenchNavigationState { + canGoBack: boolean + canGoForward: boolean + loading: boolean + url: string + title: string + host: string + secure: boolean + } + interface Window { desktopBridge: { app: { @@ -85,6 +95,13 @@ declare global { ): () => void onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void } + workbenchNavigation: { + getState(): Promise + goBack(): Promise + goForward(): Promise + reload(): Promise + onStateChanged(listener: (state: WorkbenchNavigationState) => void): () => void + } } } } diff --git a/deploy/config.yaml b/deploy/config.yaml index c757dc4..d206bc0 100644 --- a/deploy/config.yaml +++ b/deploy/config.yaml @@ -151,14 +151,14 @@ qdrant: timeout: 15s object_storage: - provider: minio - endpoint: minio:9000 - access_key: minioadmin - secret_key: minioadmin - bucket: geo-private - use_ssl: false + provider: aliyun + endpoint: https://oss-cn-shanghai.aliyuncs.com + access_key: LTAI5tAEj8euR8B1gXzMoG84 + secret_key: lJPrKo9ViyHJE8UWIMmMCY0B6Je8v2 + bucket: shengxintui + use_ssl: true public_base_url: "" - region: "" + region: cn-shanghai cache: driver: redis