feat: add desktop storage cleanup controls

This commit is contained in:
2026-06-18 23:50:56 +08:00
parent 889a575188
commit d8195bc935
7 changed files with 976 additions and 1 deletions
@@ -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()
}
+13 -1
View File
@@ -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()
@@ -19,6 +19,11 @@ export interface SessionHandleSnapshot {
partition: string
}
export interface PersistedSessionPartitionSnapshot {
accountId: string
partition: string
}
const registry = new Map<string, SessionHandle>()
const sessionsWithUA = new WeakSet<Session>()
let persistedPartitionsCache: Record<string, string> | 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}`
}
@@ -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<typeof setInterval> | null = null
let cleanupInFlight: Promise<DesktopStorageCleanupResult> | 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<DirectoryFootprint> {
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<DirectoryFootprint> {
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<PartitionDirectory[]> {
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<string> {
return new Set(listSessionHandles().map((handle) => handle.partition))
}
function persistedPartitionMap(): Map<string, string> {
const result = new Map<string, string>()
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<DesktopStorageSnapshot> {
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<string>()
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<void> {
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<void> {
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<boolean> {
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<DesktopStorageCleanupResult> {
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<DesktopStorageCleanupResult> {
if (!cleanupInFlight) {
cleanupInFlight = cleanDesktopStorageOnce().finally(() => {
cleanupInFlight = null
})
}
return cleanupInFlight
}
export async function runDueStorageCleanup(): Promise<DesktopStorageCleanupResult | null> {
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)
})
}