diff --git a/apps/desktop-client/src/main/app-settings.ts b/apps/desktop-client/src/main/app-settings.ts index e3f0248..6a75a51 100644 --- a/apps/desktop-client/src/main/app-settings.ts +++ b/apps/desktop-client/src/main/app-settings.ts @@ -6,21 +6,25 @@ import { app } from 'electron/main' export interface DesktopAppSettings { openAtLogin: boolean keepRunningInBackground: boolean + autoCleanStorageWeekly: boolean } export type DesktopAppSettingKey = keyof DesktopAppSettings interface DesktopAppInternalFlags { hasShownBackgroundBalloon: boolean + lastStorageCleanupAt: number | null } const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = { openAtLogin: false, keepRunningInBackground: true, + autoCleanStorageWeekly: false, } const DEFAULT_INTERNAL_FLAGS: DesktopAppInternalFlags = { hasShownBackgroundBalloon: false, + lastStorageCleanupAt: null, } let cachedSettings: DesktopAppSettings = { ...DEFAULT_DESKTOP_APP_SETTINGS } @@ -54,12 +58,21 @@ function normalizePersistedFile(input: unknown): { typeof candidate.keepRunningInBackground === 'boolean' ? candidate.keepRunningInBackground : DEFAULT_DESKTOP_APP_SETTINGS.keepRunningInBackground, + autoCleanStorageWeekly: + typeof candidate.autoCleanStorageWeekly === 'boolean' + ? candidate.autoCleanStorageWeekly + : DEFAULT_DESKTOP_APP_SETTINGS.autoCleanStorageWeekly, }, flags: { hasShownBackgroundBalloon: typeof candidate.hasShownBackgroundBalloon === 'boolean' ? candidate.hasShownBackgroundBalloon : DEFAULT_INTERNAL_FLAGS.hasShownBackgroundBalloon, + lastStorageCleanupAt: + typeof candidate.lastStorageCleanupAt === 'number' && + Number.isFinite(candidate.lastStorageCleanupAt) + ? candidate.lastStorageCleanupAt + : DEFAULT_INTERNAL_FLAGS.lastStorageCleanupAt, }, } } @@ -182,3 +195,12 @@ export function markBackgroundBalloonShown(): void { cachedFlags = { ...cachedFlags, hasShownBackgroundBalloon: true } persistFile() } + +export function getLastStorageCleanupAt(): number | null { + return cachedFlags.lastStorageCleanupAt +} + +export function markStorageCleanupRun(at = Date.now()): void { + cachedFlags = { ...cachedFlags, lastStorageCleanupAt: at } + persistFile() +} diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 18cb8bf..f54a6b2 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -70,6 +70,11 @@ import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from './runtime-s import { initScheduler } from './scheduler' import { initSessionRegistry } from './session-registry' import { initSingleInstance } from './single-instance' +import { + cleanDesktopStorage, + getDesktopStorageSnapshot, + startStorageCleanupScheduler, +} from './storage-cleaner' import { cancelDesktopTask, checkDesktopClientRelease, @@ -1085,12 +1090,18 @@ function registerBridgeHandlers(): void { safeHandle( 'desktop:set-app-setting', async (_event, key: DesktopAppSettingKey, value: boolean) => { - if (key !== 'openAtLogin' && key !== 'keepRunningInBackground') { + if ( + key !== 'openAtLogin' && + key !== 'keepRunningInBackground' && + key !== 'autoCleanStorageWeekly' + ) { throw new Error('unsupported_app_setting') } return setDesktopAppSetting(key, value) }, ) + safeHandle('desktop:storage-snapshot', () => getDesktopStorageSnapshot()) + safeHandle('desktop:clean-storage', () => cleanDesktopStorage()) safeHandle( 'desktop:runtime-session-sync', (_event, session: DesktopRuntimeSessionSyncRequest | null) => { @@ -1168,6 +1179,7 @@ if (!hasSingleInstanceLock) { initAccountHealth() initTransport() initScheduler() + startStorageCleanupScheduler() initProcessMetricsSampler() startHotViewReaper() startHiddenPlaywrightReaper() diff --git a/apps/desktop-client/src/main/session-registry.ts b/apps/desktop-client/src/main/session-registry.ts index 76c3838..464a2f1 100644 --- a/apps/desktop-client/src/main/session-registry.ts +++ b/apps/desktop-client/src/main/session-registry.ts @@ -19,6 +19,11 @@ export interface SessionHandleSnapshot { partition: string } +export interface PersistedSessionPartitionSnapshot { + accountId: string + partition: string +} + const registry = new Map() const sessionsWithUA = new WeakSet() let persistedPartitionsCache: Record | null = null @@ -58,6 +63,13 @@ export function getPersistedPartition(accountId: string): string | null { return persistedPartitionFor(accountId) } +export function listPersistedSessionPartitions(): PersistedSessionPartitionSnapshot[] { + return Object.entries(readPersistedPartitions()).map(([accountId, partition]) => ({ + accountId, + partition, + })) +} + function rememberPersistedPartition(accountId: string, partition: string): void { const current = readPersistedPartitions() if (current[accountId] === partition) { @@ -83,6 +95,18 @@ function forgetPersistedPartition(accountId: string): string | null { return partition } +export function forgetPersistedSessionPartition(accountId: string, partition: string): boolean { + const current = readPersistedPartitions() + if (current[accountId] !== partition) { + return false + } + + const next = { ...current } + delete next[accountId] + writePersistedPartitions(next) + return true +} + function partitionFor(accountId: string): string { return `persist:acc-${accountId}` } diff --git a/apps/desktop-client/src/main/storage-cleaner.ts b/apps/desktop-client/src/main/storage-cleaner.ts new file mode 100644 index 0000000..4d0fc7d --- /dev/null +++ b/apps/desktop-client/src/main/storage-cleaner.ts @@ -0,0 +1,442 @@ +import { readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' + +import type { Session } from 'electron/main' +import { app, session as electronSession } from 'electron/main' + +import { + getDesktopAppSettings, + getLastStorageCleanupAt, + markStorageCleanupRun, +} from './app-settings' +import { + forgetPersistedSessionPartition, + listPersistedSessionPartitions, + listSessionHandles, +} from './session-registry' + +export type DesktopStorageEntryKind = 'app-cache' | 'bound-account' | 'unbound-session' + +export interface DesktopStorageEntry { + id: string + label: string + kind: DesktopStorageEntryKind + partition: string | null + accountId: string | null + sizeBytes: number + cleanableBytes: number + lastModifiedAt: number | null + cleanable: boolean + active: boolean +} + +export interface DesktopStorageSnapshot { + generatedAt: number + totalBytes: number + cleanableBytes: number + appCacheBytes: number + boundAccountBytes: number + boundAccountCleanableBytes: number + unboundSessionBytes: number + unboundSessionCount: number + boundAccountCount: number + entries: DesktopStorageEntry[] + autoCleanStorageWeekly: boolean + lastCleanupAt: number | null + nextCleanupAt: number | null +} + +export interface DesktopStorageCleanupResult { + cleanedAt: number + cleanedBytes: number + removedEntries: number + before: DesktopStorageSnapshot + after: DesktopStorageSnapshot +} + +interface DirectoryFootprint { + bytes: number + lastModifiedAt: number | null +} + +interface PartitionDirectory { + name: string + partition: string + path: string +} + +const weeklyCleanupIntervalMs = 7 * 24 * 60 * 60 * 1000 +const cleanupSchedulerIntervalMs = 6 * 60 * 60 * 1000 +const cleanableCacheRelativePaths = [ + 'Cache', + 'Code Cache', + 'GPUCache', + 'DawnGraphiteCache', + 'DawnWebGPUCache', + 'Shared Dictionary/cache', +] + +let storageCleanupTimer: ReturnType | null = null +let cleanupInFlight: Promise | null = null + +function userDataPath(): string { + return app.getPath('userData') +} + +function partitionsRootPath(): string { + return join(userDataPath(), 'Partitions') +} + +function partitionDirectoryName(partition: string): string | null { + if (!partition.startsWith('persist:')) { + return null + } + const name = partition.slice('persist:'.length) + return name ? name : null +} + +function accountIdFromPartition(partition: string): string | null { + const name = partitionDirectoryName(partition) + if (!name?.startsWith('acc-')) { + return null + } + const accountId = name.slice('acc-'.length) + return accountId ? accountId : null +} + +function partitionPath(partition: string): string | null { + const name = partitionDirectoryName(partition) + return name ? join(partitionsRootPath(), name) : null +} + +function maxModifiedAt(left: number | null, right: number | null): number | null { + if (left === null) { + return right + } + if (right === null) { + return left + } + return Math.max(left, right) +} + +async function directoryFootprint(target: string): Promise { + let info + try { + info = await stat(target) + } catch { + return { bytes: 0, lastModifiedAt: null } + } + + if (!info.isDirectory()) { + return { + bytes: info.size, + lastModifiedAt: info.mtimeMs, + } + } + + let bytes = 0 + let lastModifiedAt: number | null = info.mtimeMs + let entries + try { + entries = await readdir(target, { withFileTypes: true }) + } catch { + return { bytes, lastModifiedAt } + } + + for (const entry of entries) { + const child = join(target, entry.name) + if (entry.isDirectory()) { + const nested = await directoryFootprint(child) + bytes += nested.bytes + lastModifiedAt = maxModifiedAt(lastModifiedAt, nested.lastModifiedAt) + continue + } + + try { + const childInfo = await stat(child) + bytes += childInfo.size + lastModifiedAt = maxModifiedAt(lastModifiedAt, childInfo.mtimeMs) + } catch { + // Ignore files that disappear while Chromium is writing cache entries. + } + } + + return { bytes, lastModifiedAt } +} + +async function sumDirectoryFootprints(paths: string[]): Promise { + let bytes = 0 + let lastModifiedAt: number | null = null + for (const target of paths) { + const footprint = await directoryFootprint(target) + bytes += footprint.bytes + lastModifiedAt = maxModifiedAt(lastModifiedAt, footprint.lastModifiedAt) + } + return { bytes, lastModifiedAt } +} + +function cleanableCachePaths(root: string): string[] { + return cleanableCacheRelativePaths.map((relativePath) => join(root, relativePath)) +} + +async function listPartitionDirectories(): Promise { + let entries + try { + entries = await readdir(partitionsRootPath(), { withFileTypes: true }) + } catch { + return [] + } + + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + partition: `persist:${entry.name}`, + path: join(partitionsRootPath(), entry.name), + })) +} + +function activePartitionSet(): Set { + return new Set(listSessionHandles().map((handle) => handle.partition)) +} + +function persistedPartitionMap(): Map { + const result = new Map() + for (const item of listPersistedSessionPartitions()) { + result.set(item.partition, item.accountId) + } + return result +} + +function nextCleanupAt(lastCleanupAt: number | null, enabled: boolean): number | null { + if (!enabled) { + return null + } + return (lastCleanupAt ?? Date.now()) + weeklyCleanupIntervalMs +} + +export async function getDesktopStorageSnapshot(): Promise { + const activePartitions = activePartitionSet() + const persistedPartitions = persistedPartitionMap() + const partitionDirs = await listPartitionDirectories() + const entries: DesktopStorageEntry[] = [] + + const appCache = await sumDirectoryFootprints(cleanableCachePaths(userDataPath())) + entries.push({ + id: 'app-cache', + label: '应用临时缓存', + kind: 'app-cache', + partition: null, + accountId: null, + sizeBytes: appCache.bytes, + cleanableBytes: appCache.bytes, + lastModifiedAt: appCache.lastModifiedAt, + cleanable: appCache.bytes > 0, + active: true, + }) + + const boundPartitions = new Set() + for (const dir of partitionDirs) { + const accountId = + persistedPartitions.get(dir.partition) ?? accountIdFromPartition(dir.partition) + const isBound = Boolean(accountId) + if (isBound) { + boundPartitions.add(dir.partition) + continue + } + + const active = activePartitions.has(dir.partition) + if (active) { + continue + } + + const size = await directoryFootprint(dir.path) + entries.push({ + id: dir.partition, + label: '未绑定缓存', + kind: 'unbound-session', + partition: dir.partition, + accountId: null, + sizeBytes: size.bytes, + cleanableBytes: size.bytes, + lastModifiedAt: size.lastModifiedAt, + cleanable: size.bytes > 0, + active: false, + }) + } + + for (const [partition, accountId] of persistedPartitions) { + if (entries.some((entry) => entry.partition === partition)) { + continue + } + if (accountId) { + boundPartitions.add(partition) + } + } + + const totalBytes = entries.reduce((total, entry) => total + entry.sizeBytes, 0) + const cleanableBytes = entries.reduce((total, entry) => total + entry.cleanableBytes, 0) + const appCacheBytes = entries + .filter((entry) => entry.kind === 'app-cache') + .reduce((total, entry) => total + entry.sizeBytes, 0) + const boundAccountBytes = 0 + const boundAccountCleanableBytes = 0 + const unboundSessionEntries = entries.filter((entry) => entry.kind === 'unbound-session') + const unboundSessionBytes = unboundSessionEntries.reduce( + (total, entry) => total + entry.sizeBytes, + 0, + ) + const lastCleanupAt = getLastStorageCleanupAt() + const autoCleanStorageWeekly = getDesktopAppSettings().autoCleanStorageWeekly + + return { + generatedAt: Date.now(), + totalBytes, + cleanableBytes, + appCacheBytes, + boundAccountBytes, + boundAccountCleanableBytes, + unboundSessionBytes, + unboundSessionCount: unboundSessionEntries.length, + boundAccountCount: boundPartitions.size, + entries: entries.sort((left, right) => right.sizeBytes - left.sizeBytes), + autoCleanStorageWeekly, + lastCleanupAt, + nextCleanupAt: nextCleanupAt(lastCleanupAt, autoCleanStorageWeekly), + } +} + +async function clearBrowserCache(target: Session): Promise { + await target.clearCache().catch((error) => { + console.warn('[desktop-storage] clearCache failed', error) + }) + await target.clearCodeCaches({ urls: [] }).catch((error) => { + console.warn('[desktop-storage] clearCodeCaches failed', error) + }) + await target.clearSharedDictionaryCache().catch((error) => { + console.warn('[desktop-storage] clearSharedDictionaryCache failed', error) + }) + await target.clearHostResolverCache().catch((error) => { + console.warn('[desktop-storage] clearHostResolverCache failed', error) + }) +} + +async function removeCleanableCacheDirectories(root: string): Promise { + for (const target of cleanableCachePaths(root)) { + await rm(target, { recursive: true, force: true }).catch((error) => { + console.warn('[desktop-storage] remove cache directory failed', { target, error }) + }) + } +} + +async function removeUnboundPartition(partition: string): Promise { + const root = partitionPath(partition) + if (!root) { + return false + } + + const target = electronSession.fromPartition(partition) + await target.closeAllConnections().catch((error) => { + console.warn('[desktop-storage] close unbound session connections failed', { partition, error }) + }) + await target.clearData().catch((error) => { + console.warn('[desktop-storage] clear unbound session data failed', { partition, error }) + }) + await target.clearStorageData().catch((error) => { + console.warn('[desktop-storage] clear unbound storage data failed', { partition, error }) + }) + await clearBrowserCache(target) + await target.cookies.flushStore().catch((error) => { + console.warn('[desktop-storage] flush unbound session cookies failed', { partition, error }) + }) + await rm(root, { recursive: true, force: true }) + return true +} + +async function cleanDesktopStorageOnce(): Promise { + const before = await getDesktopStorageSnapshot() + const activePartitions = activePartitionSet() + const persistedPartitions = persistedPartitionMap() + + await clearBrowserCache(electronSession.defaultSession) + await removeCleanableCacheDirectories(userDataPath()) + + let removedEntries = 0 + for (const entry of before.entries) { + if ( + !entry.partition || + entry.kind !== 'unbound-session' || + activePartitions.has(entry.partition) + ) { + continue + } + + try { + if (await removeUnboundPartition(entry.partition)) { + removedEntries += 1 + } + } catch (error) { + console.warn('[desktop-storage] remove unbound partition failed', { + partition: entry.partition, + error, + }) + } + } + + for (const [partition, accountId] of persistedPartitions) { + const root = partitionPath(partition) + if (root && before.entries.some((entry) => entry.partition === partition)) { + continue + } + forgetPersistedSessionPartition(accountId, partition) + } + + markStorageCleanupRun() + const after = await getDesktopStorageSnapshot() + + return { + cleanedAt: after.generatedAt, + cleanedBytes: Math.max(0, before.totalBytes - after.totalBytes), + removedEntries, + before, + after, + } +} + +export function cleanDesktopStorage(): Promise { + if (!cleanupInFlight) { + cleanupInFlight = cleanDesktopStorageOnce().finally(() => { + cleanupInFlight = null + }) + } + return cleanupInFlight +} + +export async function runDueStorageCleanup(): Promise { + if (!getDesktopAppSettings().autoCleanStorageWeekly) { + return null + } + + const lastCleanupAt = getLastStorageCleanupAt() + if (lastCleanupAt && Date.now() - lastCleanupAt < weeklyCleanupIntervalMs) { + return null + } + + return cleanDesktopStorage() +} + +export function startStorageCleanupScheduler(): void { + if (storageCleanupTimer) { + clearInterval(storageCleanupTimer) + } + + storageCleanupTimer = setInterval(() => { + void runDueStorageCleanup().catch((error) => { + console.warn('[desktop-storage] scheduled cleanup failed', error) + }) + }, cleanupSchedulerIntervalMs) + + void runDueStorageCleanup().catch((error) => { + console.warn('[desktop-storage] startup cleanup failed', error) + }) +} diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 8438466..743a00e 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -42,6 +42,7 @@ interface WindowModeRequest { interface DesktopAppSettings { openAtLogin: boolean keepRunningInBackground: boolean + autoCleanStorageWeekly: boolean } interface DesktopLoginCredentials { @@ -49,6 +50,43 @@ interface DesktopLoginCredentials { password: string } +interface DesktopStorageEntry { + id: string + label: string + kind: 'app-cache' | 'bound-account' | 'unbound-session' + partition: string | null + accountId: string | null + sizeBytes: number + cleanableBytes: number + lastModifiedAt: number | null + cleanable: boolean + active: boolean +} + +interface DesktopStorageSnapshot { + generatedAt: number + totalBytes: number + cleanableBytes: number + appCacheBytes: number + boundAccountBytes: number + boundAccountCleanableBytes: number + unboundSessionBytes: number + unboundSessionCount: number + boundAccountCount: number + entries: DesktopStorageEntry[] + autoCleanStorageWeekly: boolean + lastCleanupAt: number | null + nextCleanupAt: number | null +} + +interface DesktopStorageCleanupResult { + cleanedAt: number + cleanedBytes: number + removedEntries: number + before: DesktopStorageSnapshot + after: DesktopStorageSnapshot +} + interface WorkbenchNavigationState { canGoBack: boolean canGoForward: boolean @@ -195,6 +233,10 @@ const desktopBridge = { ipcRenderer.invoke('desktop:clear-login-credentials') as Promise, setAppSetting: (key: keyof DesktopAppSettings, value: boolean) => ipcRenderer.invoke('desktop:set-app-setting', key, value) as Promise, + storageSnapshot: () => + ipcRenderer.invoke('desktop:storage-snapshot') as Promise, + cleanStorage: () => + ipcRenderer.invoke('desktop:clean-storage') as Promise, syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) => ipcRenderer.invoke('desktop:runtime-session-sync', session) as Promise, rotateRuntimeClientToken: () => diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index a25e283..9b88438 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -20,6 +20,7 @@ declare global { interface DesktopAppSettings { openAtLogin: boolean keepRunningInBackground: boolean + autoCleanStorageWeekly: boolean } interface DesktopLoginCredentials { @@ -27,6 +28,43 @@ declare global { password: string } + interface DesktopStorageEntry { + id: string + label: string + kind: 'app-cache' | 'bound-account' | 'unbound-session' + partition: string | null + accountId: string | null + sizeBytes: number + cleanableBytes: number + lastModifiedAt: number | null + cleanable: boolean + active: boolean + } + + interface DesktopStorageSnapshot { + generatedAt: number + totalBytes: number + cleanableBytes: number + appCacheBytes: number + boundAccountBytes: number + boundAccountCleanableBytes: number + unboundSessionBytes: number + unboundSessionCount: number + boundAccountCount: number + entries: DesktopStorageEntry[] + autoCleanStorageWeekly: boolean + lastCleanupAt: number | null + nextCleanupAt: number | null + } + + interface DesktopStorageCleanupResult { + cleanedAt: number + cleanedBytes: number + removedEntries: number + before: DesktopStorageSnapshot + after: DesktopStorageSnapshot + } + interface WorkbenchNavigationState { canGoBack: boolean canGoForward: boolean @@ -90,6 +128,8 @@ declare global { saveLoginCredentials(credentials: DesktopLoginCredentials): Promise clearLoginCredentials(): Promise setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise + storageSnapshot(): Promise + cleanStorage(): Promise syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise rotateRuntimeClientToken(): Promise releaseRuntimeSession(revoke?: boolean): Promise diff --git a/apps/desktop-client/src/renderer/views/SettingsView.vue b/apps/desktop-client/src/renderer/views/SettingsView.vue index 6f253b7..59b570f 100644 --- a/apps/desktop-client/src/renderer/views/SettingsView.vue +++ b/apps/desktop-client/src/renderer/views/SettingsView.vue @@ -1,5 +1,6 @@