feat: add desktop storage cleanup controls
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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<null>,
|
||||
setAppSetting: (key: keyof DesktopAppSettings, value: boolean) =>
|
||||
ipcRenderer.invoke('desktop:set-app-setting', key, value) as Promise<DesktopAppSettings>,
|
||||
storageSnapshot: () =>
|
||||
ipcRenderer.invoke('desktop:storage-snapshot') as Promise<DesktopStorageSnapshot>,
|
||||
cleanStorage: () =>
|
||||
ipcRenderer.invoke('desktop:clean-storage') as Promise<DesktopStorageCleanupResult>,
|
||||
syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) =>
|
||||
ipcRenderer.invoke('desktop:runtime-session-sync', session) as Promise<null>,
|
||||
rotateRuntimeClientToken: () =>
|
||||
|
||||
+40
@@ -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<DesktopLoginCredentials>
|
||||
clearLoginCredentials(): Promise<null>
|
||||
setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise<DesktopAppSettings>
|
||||
storageSnapshot(): Promise<DesktopStorageSnapshot>
|
||||
cleanStorage(): Promise<DesktopStorageCleanupResult>
|
||||
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>
|
||||
rotateRuntimeClientToken(): Promise<DesktopClientRotateResponse>
|
||||
releaseRuntimeSession(revoke?: boolean): Promise<null>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClearOutlined,
|
||||
DownloadOutlined,
|
||||
InfoCircleOutlined,
|
||||
LoginOutlined,
|
||||
@@ -46,6 +47,7 @@ type SectionKey =
|
||||
const sections = [
|
||||
{ key: 'general' as const, title: '通用', icon: SlidersOutlined },
|
||||
{ key: 'login' as const, title: '登录设置', icon: LoginOutlined },
|
||||
{ key: 'storage' as const, title: '存储清理', icon: ClearOutlined },
|
||||
{ key: 'diagnostics' as const, title: '诊断', icon: ToolOutlined },
|
||||
{ key: 'about' as const, title: '关于GEO', icon: InfoCircleOutlined },
|
||||
]
|
||||
@@ -54,6 +56,7 @@ const activeSection = ref<SectionKey>('general')
|
||||
const appSettings = ref<DesktopAppSettings>({
|
||||
openAtLogin: false,
|
||||
keepRunningInBackground: true,
|
||||
autoCleanStorageWeekly: false,
|
||||
})
|
||||
const serverBaseURL = ref(apiBaseURL.value)
|
||||
const settingsLoaded = ref(false)
|
||||
@@ -61,6 +64,11 @@ const settingsError = ref<string | null>(null)
|
||||
const serverSettingsMessage = ref<string | null>(null)
|
||||
const serverSettingsError = ref<string | null>(null)
|
||||
const savingSetting = ref<keyof DesktopAppSettings | null>(null)
|
||||
const storageSnapshot = ref<DesktopStorageSnapshot | null>(null)
|
||||
const storageLoading = ref(false)
|
||||
const storageCleaning = ref(false)
|
||||
const storageError = ref<string | null>(null)
|
||||
const storageMessage = ref<string | null>(null)
|
||||
|
||||
const activeTitle = computed(
|
||||
() => sections.find((item) => item.key === activeSection.value)?.title ?? '设置',
|
||||
@@ -158,6 +166,53 @@ const generalRows = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const visibleStorageEntries = computed(() =>
|
||||
(storageSnapshot.value?.entries ?? []).filter(
|
||||
(entry) => entry.cleanable && entry.cleanableBytes > 0,
|
||||
),
|
||||
)
|
||||
|
||||
const storageMetricRows = computed(() => [
|
||||
{
|
||||
key: 'total',
|
||||
label: '可清理总量',
|
||||
value: formatBytes(storageSnapshot.value?.cleanableBytes ?? 0),
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
label: '应用临时缓存',
|
||||
value: formatBytes(storageSnapshot.value?.appCacheBytes ?? 0),
|
||||
},
|
||||
{
|
||||
key: 'unbound',
|
||||
label: '未绑定缓存',
|
||||
value: `${storageSnapshot.value?.unboundSessionCount ?? 0} 项 · ${formatBytes(
|
||||
storageSnapshot.value?.unboundSessionBytes ?? 0,
|
||||
)}`,
|
||||
},
|
||||
])
|
||||
|
||||
const storageUpdatedAtLabel = computed(() =>
|
||||
storageSnapshot.value ? formatDateTime(storageSnapshot.value.generatedAt) : '',
|
||||
)
|
||||
const lastStorageCleanupLabel = computed(() =>
|
||||
storageSnapshot.value?.lastCleanupAt
|
||||
? formatDateTime(storageSnapshot.value.lastCleanupAt)
|
||||
: '尚未清理',
|
||||
)
|
||||
const nextStorageCleanupLabel = computed(() =>
|
||||
storageSnapshot.value?.nextCleanupAt
|
||||
? formatDateTime(storageSnapshot.value.nextCleanupAt)
|
||||
: '未开启',
|
||||
)
|
||||
const runtimeAccountLabels = computed(() => {
|
||||
const labels = new Map<string, string>()
|
||||
for (const account of snapshot.value?.accounts ?? []) {
|
||||
labels.set(account.id, `${account.displayName || account.platformUid || account.id}`)
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
function copyLogs() {
|
||||
if (!snapshot.value) return
|
||||
@@ -224,6 +279,79 @@ function resetServerSettings() {
|
||||
saveServerSettings()
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let next = value
|
||||
let unitIndex = 0
|
||||
while (next >= 1024 && unitIndex < units.length - 1) {
|
||||
next /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${next >= 10 || unitIndex === 0 ? Math.round(next) : next.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatDateTime(value: number): string {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
function storageEntryKindLabel(kind: DesktopStorageEntry['kind']): string {
|
||||
switch (kind) {
|
||||
case 'app-cache':
|
||||
return '应用缓存'
|
||||
case 'bound-account':
|
||||
return '账号缓存'
|
||||
case 'unbound-session':
|
||||
return '未绑定缓存'
|
||||
default:
|
||||
return '缓存'
|
||||
}
|
||||
}
|
||||
|
||||
function storageEntryLabel(entry: DesktopStorageEntry): string {
|
||||
if (entry.accountId) {
|
||||
return runtimeAccountLabels.value.get(entry.accountId) ?? entry.label
|
||||
}
|
||||
return entry.label
|
||||
}
|
||||
|
||||
async function loadStorageSnapshot() {
|
||||
storageLoading.value = true
|
||||
storageError.value = null
|
||||
try {
|
||||
storageSnapshot.value = await window.desktopBridge.app.storageSnapshot()
|
||||
} catch (error) {
|
||||
storageError.value = error instanceof Error ? error.message : '缓存统计失败'
|
||||
} finally {
|
||||
storageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanStorageNow() {
|
||||
if (storageCleaning.value) {
|
||||
return
|
||||
}
|
||||
|
||||
storageCleaning.value = true
|
||||
storageError.value = null
|
||||
storageMessage.value = null
|
||||
try {
|
||||
const result = await window.desktopBridge.app.cleanStorage()
|
||||
storageSnapshot.value = result.after
|
||||
storageMessage.value = `已清理 ${formatBytes(result.cleanedBytes)},移除 ${result.removedEntries} 项未绑定缓存`
|
||||
} catch (error) {
|
||||
storageError.value = error instanceof Error ? error.message : '缓存清理失败'
|
||||
} finally {
|
||||
storageCleaning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppSettings() {
|
||||
settingsError.value = null
|
||||
try {
|
||||
@@ -248,6 +376,9 @@ async function toggleAppSetting(key: keyof DesktopAppSettings) {
|
||||
|
||||
try {
|
||||
appSettings.value = await window.desktopBridge.app.setAppSetting(key, value)
|
||||
if (key === 'autoCleanStorageWeekly') {
|
||||
await loadStorageSnapshot()
|
||||
}
|
||||
} catch (error) {
|
||||
appSettings.value = previous
|
||||
settingsError.value = error instanceof Error ? error.message : '设置保存失败'
|
||||
@@ -258,6 +389,7 @@ async function toggleAppSetting(key: keyof DesktopAppSettings) {
|
||||
|
||||
onMounted(() => {
|
||||
void loadAppSettings()
|
||||
void loadStorageSnapshot()
|
||||
void checkClientRelease('silent')
|
||||
})
|
||||
|
||||
@@ -433,6 +565,98 @@ watch(apiBaseURL, (next) => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSection === 'storage'" class="settings-form-page">
|
||||
<div class="storage-panel">
|
||||
<div class="storage-summary-grid">
|
||||
<div v-for="metric in storageMetricRows" :key="metric.key" class="storage-metric">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="storage-auto-card">
|
||||
<div
|
||||
class="settings-row storage-auto-row"
|
||||
:class="{
|
||||
'is-disabled':
|
||||
!settingsLoaded ||
|
||||
(savingSetting !== null && savingSetting !== 'autoCleanStorageWeekly'),
|
||||
}"
|
||||
role="switch"
|
||||
:aria-checked="appSettings.autoCleanStorageWeekly"
|
||||
tabindex="0"
|
||||
@click="toggleAppSetting('autoCleanStorageWeekly')"
|
||||
@keydown.enter.prevent="toggleAppSetting('autoCleanStorageWeekly')"
|
||||
@keydown.space.prevent="toggleAppSetting('autoCleanStorageWeekly')"
|
||||
>
|
||||
<span>每周自动清理未绑定缓存和无用缓存</span>
|
||||
<a-switch
|
||||
class="row-switch"
|
||||
:checked="appSettings.autoCleanStorageWeekly"
|
||||
:loading="savingSetting === 'autoCleanStorageWeekly'"
|
||||
:disabled="!settingsLoaded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="storage-actions-row">
|
||||
<div class="storage-status-copy">
|
||||
<strong>存储清理</strong>
|
||||
<span>上次清理: {{ lastStorageCleanupLabel }}</span>
|
||||
<span>下次自动清理: {{ nextStorageCleanupLabel }}</span>
|
||||
</div>
|
||||
<div class="storage-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="server-button"
|
||||
:disabled="storageLoading || storageCleaning"
|
||||
@click="loadStorageSnapshot"
|
||||
>
|
||||
<SyncOutlined />
|
||||
{{ storageLoading ? '统计中' : '刷新统计' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="server-button server-button--primary"
|
||||
:disabled="storageCleaning || storageLoading || !storageSnapshot?.cleanableBytes"
|
||||
@click="cleanStorageNow"
|
||||
>
|
||||
<ClearOutlined />
|
||||
{{ storageCleaning ? '清理中' : '立即清理' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="storage-detail-list">
|
||||
<div
|
||||
v-for="entry in visibleStorageEntries"
|
||||
:key="entry.id"
|
||||
class="storage-detail-row"
|
||||
>
|
||||
<div class="storage-detail-copy">
|
||||
<strong>{{ storageEntryKindLabel(entry.kind) }}</strong>
|
||||
<span>{{ storageEntryLabel(entry) }}</span>
|
||||
<span v-if="entry.lastModifiedAt">
|
||||
最近更新 {{ formatDateTime(entry.lastModifiedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="storage-detail-size">
|
||||
<strong>{{ formatBytes(entry.sizeBytes) }}</strong>
|
||||
<span>可清理 {{ formatBytes(entry.cleanableBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!visibleStorageEntries.length" class="storage-empty-row">
|
||||
<span>{{ storageLoading ? '正在统计缓存' : '暂无可清理缓存' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="storageUpdatedAtLabel" class="settings-subtle">
|
||||
统计时间 {{ storageUpdatedAtLabel }}
|
||||
</p>
|
||||
<p v-if="storageError" class="settings-error">{{ storageError }}</p>
|
||||
<p v-else-if="storageMessage" class="settings-success">{{ storageMessage }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSection === 'general'" class="settings-form-page">
|
||||
<div class="settings-list-card">
|
||||
<div
|
||||
@@ -864,6 +1088,144 @@ watch(apiBaseURL, (next) => {
|
||||
background: #0f6bea;
|
||||
}
|
||||
|
||||
.storage-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.storage-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.storage-metric {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.storage-metric span {
|
||||
color: #777777;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.storage-metric strong {
|
||||
overflow: hidden;
|
||||
color: #050505;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storage-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.storage-status-copy,
|
||||
.storage-detail-copy,
|
||||
.storage-detail-size {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storage-status-copy {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.storage-status-copy strong {
|
||||
color: #080808;
|
||||
font-size: 15px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.storage-status-copy span,
|
||||
.storage-detail-copy span,
|
||||
.storage-detail-size span {
|
||||
overflow: hidden;
|
||||
color: #777777;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storage-actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.storage-detail-list {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.storage-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
min-height: 68px;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid #eeeeef;
|
||||
}
|
||||
|
||||
.storage-detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.storage-detail-copy {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.storage-detail-copy strong {
|
||||
color: #111111;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.storage-detail-size {
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.storage-detail-size strong {
|
||||
color: #111111;
|
||||
font-size: 15px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.storage-empty-row {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
color: #777777;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -997,6 +1359,13 @@ watch(apiBaseURL, (next) => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-subtle {
|
||||
margin: 12px 4px 0;
|
||||
color: #777777;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.settings-shell {
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
@@ -1026,5 +1395,29 @@ watch(apiBaseURL, (next) => {
|
||||
.release-check-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.storage-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-actions-row,
|
||||
.storage-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-actions-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storage-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.storage-detail-size {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user