feat(settings): replicate the WorkBuddy general settings panel (TODO 10)
This commit is contained in:
1 parent
401926d7eb
commit
7b68d9eb25
21 files changed
+1150
-11
No files matched your search
@@ -22,6 +22,10 @@ interface StoredConfig {
|
||||
apiKeyEnc?: string
|
||||
apiKeyPlain?: string
|
||||
workspaceRoot?: string
|
||||
/** 运行任务时阻止系统休眠(通用设置;默认开启,与原版初始态一致)。 */
|
||||
preventSleep?: boolean
|
||||
/** 开机自动启动(通用设置;默认关闭)。 */
|
||||
autoLaunch?: boolean
|
||||
}
|
||||
|
||||
const DEFAULTS: StoredConfig = {
|
||||
@@ -92,6 +96,28 @@ export async function setWorkspaceRoot(root: string): Promise<void> {
|
||||
await persist({ ...stored, workspaceRoot: root })
|
||||
}
|
||||
|
||||
/** 通用设置:运行任务时阻止系统休眠,默认开启。 */
|
||||
export async function getPreventSleep(): Promise<boolean> {
|
||||
const stored = await load()
|
||||
return stored.preventSleep !== false
|
||||
}
|
||||
|
||||
export async function setPreventSleep(enabled: boolean): Promise<void> {
|
||||
const stored = await load()
|
||||
await persist({ ...stored, preventSleep: enabled })
|
||||
}
|
||||
|
||||
/** 通用设置:开机自动启动,默认关闭(由系统登录项反映实际状态)。 */
|
||||
export async function getAutoLaunch(): Promise<boolean> {
|
||||
const stored = await load()
|
||||
return stored.autoLaunch === true
|
||||
}
|
||||
|
||||
export async function setAutoLaunch(enabled: boolean): Promise<void> {
|
||||
const stored = await load()
|
||||
await persist({ ...stored, autoLaunch: enabled })
|
||||
}
|
||||
|
||||
/** 供渲染进程使用:脱敏视图 */
|
||||
export async function getConfigView(): Promise<GatewayConfigView> {
|
||||
const stored = await load()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 通用设置的真实运行链路(REA 对照原版「通用」页面):
|
||||
*
|
||||
* - 数据目录用量与所在磁盘容量:真实扫描 userData 目录并读取磁盘统计。
|
||||
* - 开机自动启动:写入/读取系统登录项(可用 YCODE_DISABLE_SYSTEM_INTEGRATION=1 隔离验收)。
|
||||
* - 运行时防止系统休眠:设置项开启且存在运行中的任务时,持有 powerSaveBlocker。
|
||||
*
|
||||
* 未接入的原版设置项(语言、链接打开方式、字体大小、锁屏运行、网络代理、
|
||||
* 技能/套件自动更新、通知提示音、定时任务产物通知、体验优化计划)只做视觉占位,
|
||||
* 不在此模块伪造行为。
|
||||
*/
|
||||
|
||||
import { app, powerSaveBlocker } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let preventSleepEnabled = true
|
||||
let activeRuns = 0
|
||||
let blockerId: number | null = null
|
||||
|
||||
function systemIntegrationDisabled(): boolean {
|
||||
return process.env.YCODE_DISABLE_SYSTEM_INTEGRATION === '1' || !app.isReady()
|
||||
}
|
||||
|
||||
/** 隔离验收环境:不触碰真实系统设置(登录项 / 系统设置页)。 */
|
||||
export function systemIntegrationSuppressed(): boolean {
|
||||
return process.env.YCODE_DISABLE_SYSTEM_INTEGRATION === '1'
|
||||
}
|
||||
|
||||
function syncBlocker(): void {
|
||||
const shouldBlock = preventSleepEnabled && activeRuns > 0
|
||||
if (shouldBlock && blockerId === null) blockerId = powerSaveBlocker.start('prevent-display-sleep')
|
||||
if (!shouldBlock && blockerId !== null) { powerSaveBlocker.stop(blockerId); blockerId = null }
|
||||
}
|
||||
|
||||
/** 主进程启动时同步一次持久化的设置。 */
|
||||
export function configurePreventSleep(enabled: boolean): void {
|
||||
preventSleepEnabled = enabled
|
||||
syncBlocker()
|
||||
}
|
||||
|
||||
/** 任务开始/结束时更新运行计数;计数>0 且设置开启时阻止系统休眠。 */
|
||||
export function updateActiveRuns(delta: number): void {
|
||||
activeRuns = Math.max(0, activeRuns + delta)
|
||||
syncBlocker()
|
||||
}
|
||||
|
||||
export function isPreventSleepActive(): boolean {
|
||||
return blockerId !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 开机自启动的实际生效状态。
|
||||
* - 正常环境读系统登录项(外部删除登录项时如实反映为 false)。
|
||||
* - 隔离验收环境(YCODE_DISABLE_SYSTEM_INTEGRATION=1)不触碰系统,直接回显持久化偏好。
|
||||
*/
|
||||
export function resolveAutoLaunch(stored: boolean): boolean {
|
||||
if (systemIntegrationDisabled()) return stored
|
||||
try { return app.getLoginItemSettings().openAtLogin } catch { return stored }
|
||||
}
|
||||
|
||||
/** 把开机自启动写入系统登录项,返回实际生效状态。 */
|
||||
export function applyAutoLaunch(enabled: boolean): boolean {
|
||||
if (systemIntegrationDisabled()) return enabled
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
path: process.execPath,
|
||||
args: app.isPackaged ? [] : [app.getAppPath()]
|
||||
})
|
||||
return app.getLoginItemSettings().openAtLogin
|
||||
} catch {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
|
||||
let usageCache: { at: number; bytes: number; partial: boolean } | null = null
|
||||
|
||||
/** 递归统计目录占用;限制条数与耗时,避免设置页卡顿。 */
|
||||
export async function directoryBytes(root: string, maxEntries = 200_000, budgetMs = 1500): Promise<{ bytes: number; partial: boolean }> {
|
||||
const started = Date.now()
|
||||
let bytes = 0
|
||||
let entries = 0
|
||||
let partial = false
|
||||
const queue: string[] = [root]
|
||||
while (queue.length) {
|
||||
if (entries >= maxEntries || Date.now() - started > budgetMs) { partial = true; break }
|
||||
const current = queue.pop() as string
|
||||
let dirents
|
||||
try { dirents = await fs.readdir(current, { withFileTypes: true }) }
|
||||
catch { continue }
|
||||
for (const dirent of dirents) {
|
||||
entries++
|
||||
const target = join(current, dirent.name)
|
||||
if (dirent.isDirectory()) { queue.push(target); continue }
|
||||
if (!dirent.isFile()) continue
|
||||
try { bytes += (await fs.stat(target)).size } catch { /* 忽略被占用的文件 */ }
|
||||
if (entries >= maxEntries || Date.now() - started > budgetMs) { partial = true; break }
|
||||
}
|
||||
}
|
||||
return { bytes, partial }
|
||||
}
|
||||
|
||||
export async function dataUsage(root: string, refresh = false): Promise<{ bytes: number; partial: boolean }> {
|
||||
if (!refresh && usageCache && Date.now() - usageCache.at < 30_000) return usageCache
|
||||
const result = await directoryBytes(root)
|
||||
usageCache = { at: Date.now(), ...result }
|
||||
return result
|
||||
}
|
||||
|
||||
export async function diskUsage(path: string): Promise<{ totalBytes: number; freeBytes: number }> {
|
||||
try {
|
||||
const stats = await fs.statfs(path)
|
||||
return { totalBytes: stats.blocks * stats.bsize, freeBytes: stats.bavail * stats.bsize }
|
||||
} catch {
|
||||
return { totalBytes: 0, freeBytes: 0 }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { disposeOmpKernels, registerIpc, setTaskWindowFactory } from './ipc'
|
||||
import { disposeMcpServers } from './capabilities/mcp'
|
||||
import { getPreventSleep } from './config'
|
||||
import { configurePreventSleep } from './general-settings'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let settingsWindow: BrowserWindow | null = null
|
||||
@@ -161,6 +163,8 @@ if (process.env.YCODE_DISABLE_GPU === '1') {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// 通用设置:启动时套用持久化偏好(运行任务时阻止系统休眠)。
|
||||
void getPreventSleep().then((enabled) => configurePreventSleep(enabled)).catch(() => undefined)
|
||||
registerIpc(() => mainWindow)
|
||||
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
|
||||
ipcMain.handle('app:openSettings', async (_event, section = 'model', action?: 'new') => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { restoreWorkspaceFiles, rollbackEditedArtifacts } from './workspace-reve
|
||||
import { assistantWireHistory } from './agent/history'
|
||||
import * as models from './models'
|
||||
import * as workspaces from './workspaces'
|
||||
import * as generalSettings from './general-settings'
|
||||
import { prepareOmpModel } from './omp-model'
|
||||
import type { CustomModelInput, ResolvedModel } from '@shared/models'
|
||||
import type { SessionContext } from '@shared/workspaces'
|
||||
@@ -384,6 +385,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
// ---------- 配置 ----------
|
||||
ipcMain.handle('config:get', () => config.getConfigView())
|
||||
ipcMain.handle('config:update', (_event, patch: ConfigPatchLike) => config.updateConfig(patch))
|
||||
|
||||
// ---------- 通用设置(设置 → 通用) ----------
|
||||
const generalView = async (refreshUsage = false) => {
|
||||
const dataDir = app.getPath('userData')
|
||||
const [usage, disk, workspaceRoot, autoLaunch, preventSleep] = await Promise.all([
|
||||
generalSettings.dataUsage(dataDir, refreshUsage),
|
||||
generalSettings.diskUsage(dataDir),
|
||||
config.getWorkspaceRoot(),
|
||||
config.getAutoLaunch(),
|
||||
config.getPreventSleep()
|
||||
])
|
||||
return {
|
||||
autoLaunch: generalSettings.resolveAutoLaunch(autoLaunch),
|
||||
preventSleep,
|
||||
preventSleepActive: generalSettings.isPreventSleepActive(),
|
||||
dataDir,
|
||||
dataBytes: usage.bytes,
|
||||
dataUsagePartial: usage.partial,
|
||||
disk,
|
||||
workspaceRoot
|
||||
}
|
||||
}
|
||||
ipcMain.handle('general:get', (_event, options?: { refreshUsage?: boolean }) => generalView(options?.refreshUsage === true))
|
||||
ipcMain.handle('general:update', async (_event, patch: { autoLaunch?: boolean; preventSleep?: boolean }) => {
|
||||
if (patch.preventSleep !== undefined) {
|
||||
await config.setPreventSleep(patch.preventSleep)
|
||||
generalSettings.configurePreventSleep(patch.preventSleep)
|
||||
}
|
||||
if (patch.autoLaunch !== undefined) {
|
||||
await config.setAutoLaunch(patch.autoLaunch)
|
||||
const applied = generalSettings.applyAutoLaunch(patch.autoLaunch)
|
||||
if (applied !== patch.autoLaunch) await config.setAutoLaunch(applied)
|
||||
}
|
||||
return generalView()
|
||||
})
|
||||
ipcMain.handle('general:openFolder', async (_event, kind: 'data' | 'workspace') => {
|
||||
const target = kind === 'workspace' ? await config.getWorkspaceRoot() : app.getPath('userData')
|
||||
await shell.openPath(target)
|
||||
})
|
||||
ipcMain.handle('general:changeWorkspaceRoot', async (_event, next?: string) => {
|
||||
let target = next
|
||||
if (!target) {
|
||||
const options: OpenDialogOptions = { title: '选择默认工作空间存储路径', properties: ['openDirectory', 'createDirectory'] }
|
||||
const result = await dialog.showOpenDialog(options)
|
||||
if (result.canceled || !result.filePaths.length) return generalView()
|
||||
target = result.filePaths[0]
|
||||
}
|
||||
await config.setWorkspaceRoot(target)
|
||||
changed()
|
||||
return generalView()
|
||||
})
|
||||
ipcMain.handle('general:open-system-notification-settings', async () => {
|
||||
if (generalSettings.systemIntegrationSuppressed()) return
|
||||
await shell.openExternal(process.platform === 'win32' ? 'ms-settings:notifications' : 'x-apple.systempreferences:com.apple.preference.notifications')
|
||||
})
|
||||
|
||||
ipcMain.handle('models:list', () => models.listModels())
|
||||
ipcMain.handle('models:save', async (_event, input: CustomModelInput, overwrite?: boolean) => {
|
||||
const result = await models.saveModel(input, overwrite)
|
||||
@@ -644,6 +701,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
// 回合开始时冻结配置:运行中改设置不影响本回合
|
||||
const controller = new AbortController()
|
||||
activeRuns.set(runId, controller)
|
||||
// 通用设置「运行时防止系统休眠」:任务运行期间持有 powerSaveBlocker。
|
||||
generalSettings.updateActiveRuns(1)
|
||||
|
||||
const publish = (event: ChatEvent): void => {
|
||||
if (!win?.isDestroyed?.() && !win?.webContents.isDestroyed?.()) {
|
||||
@@ -760,6 +819,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
publish({ runId, type: 'error', message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` })
|
||||
} finally {
|
||||
activeRuns.delete(runId)
|
||||
generalSettings.updateActiveRuns(-1)
|
||||
runEmitters.delete(runId)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user