feat: app's version history log (#11097)

* feat: integrate version tracking in app initialization

- Added versionService to record the current version during app startup.
- This change prepares for upcoming data refactoring in version 2.

* fix: lint from other PRs & format

* feat: enhance version tracking with meaningful change detection

- Updated VersionService to check for changes in version, OS, environment, packaged status, and install mode before recording a new entry.
- Improved logging to reflect whether version information has changed or remained the same.
This commit is contained in:
fullex 2025-11-04 14:13:07 +08:00 committed by GitHub
parent 346af4d338
commit 7dce1d776b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 290 additions and 0 deletions

View File

@ -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')

View File

@ -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()