diff --git a/src/main/index.ts b/src/main/index.ts index d9554e1652..bcec606f4b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { import selectionService, { initSelectionService } from './services/SelectionService' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' +import { versionService } from './services/VersionService' import { windowService } from './services/WindowService' import { initWebviewHotkeys } from './services/WebviewService' @@ -110,6 +111,10 @@ if (!app.requestSingleInstanceLock()) { // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { + // Record current version for tracking + // A preparation for v2 data refactoring + versionService.recordCurrentVersion() + initWebviewHotkeys() // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') diff --git a/src/main/services/VersionService.ts b/src/main/services/VersionService.ts new file mode 100644 index 0000000000..a853b99074 --- /dev/null +++ b/src/main/services/VersionService.ts @@ -0,0 +1,285 @@ +import { loggerService } from '@logger' +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +const logger = loggerService.withContext('VersionService') + +type OS = 'win' | 'mac' | 'linux' | 'unknown' +type Environment = 'prod' | 'dev' +type Packaged = 'packaged' | 'unpackaged' +type Mode = 'install' | 'portable' + +/** + * Version record stored in version.log + */ +interface VersionRecord { + version: string + os: OS + environment: Environment + packaged: Packaged + mode: Mode + timestamp: string +} + +/** + * Service for tracking application version history + * Stores version information in userData/version.log for data migration and diagnostics + */ +class VersionService { + private readonly VERSION_LOG_FILE = 'version.log' + private versionLogPath: string | null = null + + constructor() { + // Lazy initialization of path since app.getPath may not be available during construction + } + + /** + * Gets the full path to version.log file + * @returns {string} Full path to version log file + */ + private getVersionLogPath(): string { + if (!this.versionLogPath) { + this.versionLogPath = path.join(app.getPath('userData'), this.VERSION_LOG_FILE) + } + return this.versionLogPath + } + + /** + * Gets current operating system identifier + * @returns {OS} OS identifier + */ + private getCurrentOS(): OS { + switch (process.platform) { + case 'win32': + return 'win' + case 'darwin': + return 'mac' + case 'linux': + return 'linux' + default: + return 'unknown' + } + } + + /** + * Gets current environment (production or development) + * @returns {Environment} Environment identifier + */ + private getCurrentEnvironment(): Environment { + return import.meta.env.MODE === 'production' ? 'prod' : 'dev' + } + + /** + * Gets packaging status + * @returns {Packaged} Packaging status + */ + private getPackagedStatus(): Packaged { + return app.isPackaged ? 'packaged' : 'unpackaged' + } + + /** + * Gets installation mode (install or portable) + * @returns {Mode} Installation mode + */ + private getInstallMode(): Mode { + return process.env.PORTABLE_EXECUTABLE_DIR !== undefined ? 'portable' : 'install' + } + + /** + * Generates version log line for current application state + * @returns {string} Pipe-separated version record line + */ + private generateCurrentVersionLine(): string { + const version = app.getVersion() + const os = this.getCurrentOS() + const environment = this.getCurrentEnvironment() + const packaged = this.getPackagedStatus() + const mode = this.getInstallMode() + const timestamp = new Date().toISOString() + + return `${version}|${os}|${environment}|${packaged}|${mode}|${timestamp}` + } + + /** + * Parses a version log line into a VersionRecord object + * @param {string} line - Pipe-separated version record line + * @returns {VersionRecord | null} Parsed version record or null if invalid + */ + private parseVersionLine(line: string): VersionRecord | null { + try { + const parts = line.trim().split('|') + if (parts.length !== 6) { + return null + } + + const [version, os, environment, packaged, mode, timestamp] = parts + + // Validate data + if ( + !version || + !['win', 'mac', 'linux', 'unknown'].includes(os) || + !['prod', 'dev'].includes(environment) || + !['packaged', 'unpackaged'].includes(packaged) || + !['install', 'portable'].includes(mode) || + !timestamp + ) { + return null + } + + return { + version, + os: os as OS, + environment: environment as Environment, + packaged: packaged as Packaged, + mode: mode as Mode, + timestamp + } + } catch (error) { + logger.warn(`Failed to parse version line: ${line}`, error as Error) + return null + } + } + + /** + * Reads the last 1KB from version.log and returns all lines + * Uses reverse reading from file end to avoid reading the entire file + * @returns {string[]} Array of version lines from the last 1KB + */ + private readLastVersionLines(): string[] { + const logPath = this.getVersionLogPath() + + try { + if (!fs.existsSync(logPath)) { + return [] + } + + const stats = fs.statSync(logPath) + const fileSize = stats.size + + if (fileSize === 0) { + return [] + } + + // Read from the end of the file, 1KB is enough to find previous version + // Typical line: "1.7.0-beta.3|win|prod|packaged|install|2025-01-15T08:30:00.000Z\n" (~70 bytes) + // 1KB can store ~14 lines, which is more than enough + const bufferSize = Math.min(1024, fileSize) + const buffer = Buffer.alloc(bufferSize) + + const fd = fs.openSync(logPath, 'r') + try { + const startPosition = Math.max(0, fileSize - bufferSize) + fs.readSync(fd, buffer, 0, bufferSize, startPosition) + + const content = buffer.toString('utf-8') + const lines = content + .trim() + .split('\n') + .filter((line) => line.trim()) + + return lines + } finally { + fs.closeSync(fd) + } + } catch (error) { + logger.error('Failed to read version log:', error as Error) + return [] + } + } + + /** + * Appends a version record line to version.log + * @param {string} line - Version record line to append + */ + private appendVersionLine(line: string): void { + const logPath = this.getVersionLogPath() + + try { + fs.appendFileSync(logPath, line + '\n', 'utf-8') + logger.debug(`Version recorded: ${line}`) + } catch (error) { + logger.error('Failed to append version log:', error as Error) + } + } + + /** + * Records the current version on application startup + * Only adds a new record if the version has changed since the last run + */ + recordCurrentVersion(): void { + try { + const currentLine = this.generateCurrentVersionLine() + const lines = this.readLastVersionLines() + + // Add new record if this is the first run or version has changed + if (lines.length === 0) { + logger.info('First run detected, creating version log') + this.appendVersionLine(currentLine) + return + } + + const lastLine = lines[lines.length - 1] + const lastRecord = this.parseVersionLine(lastLine) + const currentVersion = app.getVersion() + + // Check if any meaningful field has changed (version, os, environment, packaged, mode) + const currentOS = this.getCurrentOS() + const currentEnvironment = this.getCurrentEnvironment() + const currentPackaged = this.getPackagedStatus() + const currentMode = this.getInstallMode() + + const hasMeaningfulChange = + !lastRecord || + lastRecord.version !== currentVersion || + lastRecord.os !== currentOS || + lastRecord.environment !== currentEnvironment || + lastRecord.packaged !== currentPackaged || + lastRecord.mode !== currentMode + + if (hasMeaningfulChange) { + logger.info(`Version information changed, recording new entry`) + this.appendVersionLine(currentLine) + } else { + logger.debug(`Version information not changed, skip recording`) + } + } catch (error) { + logger.error('Failed to record current version:', error as Error) + } + } + + /** + * Gets the previous version record (last record with different version than current) + * Reads from the last 1KB of version.log to find the most recent different version + * Useful for detecting version upgrades and running migrations + * @returns {VersionRecord | null} Previous version record or null if not available + */ + getPreviousVersion(): VersionRecord | null { + try { + const lines = this.readLastVersionLines() + if (lines.length === 0) { + return null + } + + const currentVersion = app.getVersion() + + // Read from the end backwards to find the first different version + for (let i = lines.length - 1; i >= 0; i--) { + const record = this.parseVersionLine(lines[i]) + if (record && record.version !== currentVersion) { + return record + } + } + + return null + } catch (error) { + logger.error('Failed to get previous version:', error as Error) + return null + } + } +} + +/** + * Singleton instance of VersionService + */ +export const versionService = new VersionService()