Skip to content
Open
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}
},
"enabled": true,
"includes": ["**/*.json", "!*.json", "!**/package.json", "!coverage/**"]
"includes": ["**/*.json", "!*.json", "!**/package.json", "!coverage/**", "!config/app-upgrade-segments.json"]
},
"css": {
"formatter": {
Expand Down
26 changes: 22 additions & 4 deletions config/app-upgrade-segments.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
{
"segments": [
{
"id": "legacy-v1",
"id": "legacy-v1-locked",
"type": "legacy",
"match": {
"range": ">=1.0.0 <2.0.0"
"range": ">=1.0.0 <1.7.25"
},
"lockedVersion": "1.7.25",
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"description": "Gateway version for users below v1.7.25 - locked to v1.7.25",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://releases.cherry-ai.com"
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
},
{
"id": "current-v1",
"type": "latest",
"match": {
"range": ">=1.7.25 <2.0.0"
},
"minCompatibleVersion": "1.7.25",
"description": "Current latest v1.x release for users on v1.7.25+",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"rc": {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/IpcChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ export enum IpcChannel {
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_ManualInstallUpdate = 'app:manual-install-update',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
App_GetSigningInfo = 'app:get-signing-info',
App_Proxy = 'app:proxy',
App_SetLaunchToTray = 'app:set-launch-to-tray',
App_SetTray = 'app:set-tray',
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/config/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,5 @@ export const CHERRYIN_CONFIG = {
REDIRECT_URI: 'cherrystudio://oauth/callback',
SCOPES: 'openid profile email offline_access balance:read usage:read tokens:read tokens:write'
}

export const APP_NAME = 'Cherry Studio'
3 changes: 3 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
installPath: path.dirname(app.getPath('exe'))
}))

ipcMain.handle(IpcChannel.App_GetSigningInfo, () => appService.getSigningInfo())

ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string, bypassRules?: string) => {
let proxyConfig: ProxyConfig

Expand All @@ -187,6 +189,7 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)

// Update
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
ipcMain.handle(IpcChannel.App_ManualInstallUpdate, () => appUpdater.manualInstallUpdate())

// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
Expand Down
45 changes: 45 additions & 0 deletions src/main/services/AppService.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { loggerService } from '@logger'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { spawnSync } from 'child_process'
import { app } from 'electron'
import fs from 'fs'
import os from 'os'
import path from 'path'

const logger = loggerService.withContext('AppService')

export interface SigningInfo {
teamId: string | null
bundleId: string | null
authority: string | null
}

export class AppService {
private static instance: AppService

Expand All @@ -21,6 +28,44 @@ export class AppService {
return AppService.instance
}

/**
* Get macOS app signing information (team ID, bundle ID, authority)
* Returns null values for non-macOS platforms or unsigned apps
*/
public getSigningInfo(): SigningInfo {
if (!isMac) {
return { teamId: null, bundleId: null, authority: null }
}

const exePath = app.getPath('exe')
// /path/to/App.app/Contents/MacOS/AppName -> /path/to/App.app
const appPath = exePath.replace(/\/Contents\/MacOS\/.*$/, '')

try {
const result = spawnSync('codesign', ['-dv', '--verbose=4', appPath], { encoding: 'utf-8', timeout: 5000 })

if (result.error || result.status !== 0) {
logger.warn('codesign check failed', { error: result.error, status: result.status })
return { teamId: null, bundleId: null, authority: null }
}

const output = result.stderr || result.stdout

const teamIdMatch = output.match(/^TeamIdentifier=(.+)$/m)
const identifierMatch = output.match(/^Identifier=(.+)$/m)
const authorityMatch = output.match(/^Authority=([^\n]+)$/m)

return {
teamId: teamIdMatch?.[1] || null,
bundleId: identifierMatch?.[1] || null,
authority: authorityMatch?.[1] || null
}
} catch (error) {
logger.error('Failed to get signing info', error as Error)
return { teamId: null, bundleId: null, authority: null }
}
}

public async setAppLaunchOnBoot(isLaunchOnBoot: boolean): Promise<void> {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
Expand Down
144 changes: 133 additions & 11 deletions src/main/services/AppUpdater.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { isMac, isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpdateConfigUrl, UpdateMirror, UpgradeChannel } from '@shared/config/constant'
import { APP_NAME, FeedUrl, UpdateConfigUrl, UpdateMirror, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import type { UpdateInfo } from 'builder-util-runtime'
import { CancellationToken } from 'builder-util-runtime'
import { exec, execSync } from 'child_process'
import { promisify } from 'util'

const execAsync = promisify(exec)
Comment on lines +10 to +12
Copy link
Collaborator

@EurFelux EurFelux Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This comment was translated by Claude.

Code Style: Import and statement order mixed up

const execAsync = promisify(exec) is placed between two groups of import statements, which will cause lint errors (simple-import-sort). It is recommended to put all imports together, and place const execAsync after the import block.

Additionally, spawn is dynamically imported at line 401 via require('child_process'), but child_process is already imported at the top. It is recommended to add spawn directly to the top import:

import { exec, execSync, spawn } from 'child_process'

Original Content

Code Style: import 和语句顺序混乱

const execAsync = promisify(exec) 被放在了两组 import 语句之间,这会导致 lint 报错(simple-import-sort)。建议把所有 import 放在一起,const execAsync 放在 import 块之后。

另外,spawn 在第 401 行通过 require('child_process') 动态引入,但顶部已经 import 了 child_process。建议直接在顶部 import 中添加 spawn

import { exec, execSync, spawn } from 'child_process'

import { app, net } from 'electron'
import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import { autoUpdater } from 'electron-updater'
import fs from 'fs'
import path from 'path'
import semver from 'semver'

Expand All @@ -17,6 +22,17 @@ import { windowService } from './WindowService'

const logger = loggerService.withContext('AppUpdater')

function getCommonHeaders() {
return {
'User-Agent': generateUserAgent(),
'Cache-Control': 'no-cache',
'Client-Id': configManager.getClientId(),
'App-Name': APP_NAME,
'App-Version': `v${app.getVersion()}`,
OS: process.platform
}
}

// Language markers constants for multi-language release notes
const LANG_MARKERS = {
EN_START: '<!--LANG:en-->',
Expand Down Expand Up @@ -58,10 +74,7 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
...getCommonHeaders()
}

autoUpdater.on('error', (error) => {
Expand Down Expand Up @@ -142,11 +155,8 @@ export default class AppUpdater {
logger.info(`Fetching update config from ${configUrl} (mirror: ${mirror})`)
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
Accept: 'application/json',
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
...getCommonHeaders(),
Accept: 'application/json'
}
})

Expand Down Expand Up @@ -310,6 +320,118 @@ export default class AppUpdater {
setImmediate(() => autoUpdater.quitAndInstall())
}

/**
* Manual install update for macOS users with old code signing
* This bypasses Squirrel.Mac which validates code signing
*/
public async manualInstallUpdate(): Promise<{ success: boolean; error?: string }> {
if (!isMac) {
return { success: false, error: 'Manual install is only supported on macOS' }
}

// Constants
const ZIP_PATTERN = /^Cherry-Studio-\d+\.\d+\.\d+(-arm64)?\.zip$/
const APP_NAME = 'Cherry Studio.app'
const TARGET_PATH = `/Applications/${APP_NAME}`
Copy link
Collaborator

@beyondkmp beyondkmp Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This issue/comment/review was translated by Claude.

Don't hardcode this. Many users don't have admin privileges, so they won't install in this directory, but rather in the Applications directory under their user directory, or in other directories under their user directory.


Original Content

不要写死,很多用户没有admin权限,就不会安装在这个目录,而是安装用户目录的application目录下面,或者用户目录下的其它目录。

const cacheDir = path.join(app.getPath('home'), 'Library/Caches', 'cherrystudio-updater', 'pending')
const extractDir = path.join(app.getPath('temp'), 'cherry-studio-update')
const newAppPath = path.join(extractDir, APP_NAME)

// Helper functions
const findUpdateZip = (): string | null => {
if (!fs.existsSync(cacheDir)) return null
const zipFile = fs.readdirSync(cacheDir).find((f) => ZIP_PATTERN.test(f))
return zipFile ? path.join(cacheDir, zipFile) : null
}

const extractZip = (zipPath: string): void => {
fs.rmSync(extractDir, { recursive: true, force: true })
fs.mkdirSync(extractDir, { recursive: true })
execSync(`unzip -o "${zipPath}" -d "${extractDir}"`)
Copy link
Collaborator

@EurFelux EurFelux Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This comment was translated by Claude.

Security: execSync uses string concatenation to execute shell commands

Although zipPath and extractDir currently come from controlled paths, using shell string concatenation is an unsafe pattern. If filenames contain special characters (such as spaces, quotes), it may lead to unexpected behavior.

It is recommended to use spawnSync to avoid shell interpretation:

const extractZip = (zipPath: string): void => {
  fs.rmSync(extractDir, { recursive: true, force: true })
  fs.mkdirSync(extractDir, { recursive: true })
  const result = spawnSync('unzip', ['-o', zipPath, '-d', extractDir], { timeout: 30000 })
  if (result.error || result.status !== 0) {
    throw new Error(`unzip failed: ${result.stderr?.toString() || result.error?.message}`)
  }
}

Similarly, execAsync(\osascript "${scriptPath}"`)inreplaceAppWithAdminPrivilegesshould also be considered to be replaced withexecFile`.


Original Content

Security: execSync 使用字符串拼接执行 shell 命令

虽然 zipPathextractDir 目前来自受控路径,但使用 shell 字符串拼接是不安全的模式。如果文件名包含特殊字符(如空格、引号),可能导致意外行为。

建议使用 spawnSync 避免 shell 解释:

const extractZip = (zipPath: string): void => {
  fs.rmSync(extractDir, { recursive: true, force: true })
  fs.mkdirSync(extractDir, { recursive: true })
  const result = spawnSync('unzip', ['-o', zipPath, '-d', extractDir], { timeout: 30000 })
  if (result.error || result.status !== 0) {
    throw new Error(`unzip failed: ${result.stderr?.toString() || result.error?.message}`)
  }
}

同理,replaceAppWithAdminPrivileges 中的 execAsync(\osascript "${scriptPath}"`)也应考虑用execFile` 替代。

}

const isValidApp = (): boolean => {
return (
fs.existsSync(newAppPath) &&
fs.existsSync(path.join(newAppPath, 'Contents', 'Info.plist')) &&
fs.existsSync(path.join(newAppPath, 'Contents', 'MacOS'))
)
}

const replaceAppWithAdminPrivileges = async (): Promise<{ success: boolean; error?: string }> => {
const language = configManager.getLanguage()
const prompt =
language === 'zh-CN' || language === 'zh-TW'
? 'Cherry Studio 需要管理员权限来安装更新'
: 'Cherry Studio needs administrator privileges to install the update.'

const scriptPath = path.join(app.getPath('temp'), `cherry-update-${Date.now()}.scpt`)
const scriptContent = `do shell script "rm -rf \\"${TARGET_PATH}\\" && mv \\"${newAppPath}\\" \\"${TARGET_PATH}\\"" with administrator privileges with prompt "${prompt}"`

try {
fs.writeFileSync(scriptPath, scriptContent, { encoding: 'utf-8', mode: 0o600 })
await execAsync(`osascript "${scriptPath}"`, { timeout: 60000 })
logger.info('Manual install: app replaced successfully')
return { success: true }
} catch (error) {
const msg = (error as Error).message
logger.error('Manual install: replace failed', error as Error)
if (msg.includes('User canceled') || msg.includes('-128')) {
return { success: false, error: 'User cancelled' }
}
return { success: false, error: msg }
} finally {
fs.rmSync(scriptPath, { force: true })
}
}

const scheduleRelaunch = (): void => {
const pid = process.pid
const scriptPath = path.join(app.getPath('temp'), `cherry-relaunch-${Date.now()}.sh`)
const script = `#!/bin/sh
sleep 1
kill -9 ${pid} 2>/dev/null
# Wait for process exit (max 30s)
for i in $(seq 1 60); do kill -0 ${pid} 2>/dev/null || break; sleep 0.5; done
open "${TARGET_PATH}"
rm -f "${scriptPath}"
`
fs.writeFileSync(scriptPath, script, { mode: 0o755 })

const { spawn } = require('child_process')
spawn('/bin/sh', [scriptPath], { detached: true, stdio: 'ignore' }).unref()
Comment on lines +388 to +402
Copy link
Collaborator

@EurFelux EurFelux Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This comment was translated by Claude.

Robustness: scheduleRelaunch uses kill -9 to forcefully kill its own process

kill -9 doesn't give the process any chance to clean up (doesn't trigger Electron lifecycle events like before-quit, will-quit, etc.), which could lead to data loss or leftover file locks.

Suggestions:

  1. Prefer using app.quit() or app.exit() to let Electron exit normally, with kill only as a fallback in the shell script
  2. Or call app.quit() before invoking scheduleRelaunch, so the shell script only waits for exit and relaunches
const scheduleRelaunch = (): void => {
  const pid = process.pid
  // ...write relaunch script...
  spawn('/bin/sh', [scriptPath], { detached: true, stdio: 'ignore' }).unref()
  // Let Electron shut down gracefully
  app.quit()
}

Original Content

Robustness: scheduleRelaunch 使用 kill -9 强杀自身进程

kill -9 不给进程任何清理机会(不触发 before-quitwill-quit 等 Electron 生命周期事件),可能导致数据丢失或文件锁残留。

建议:

  1. 优先使用 app.quit()app.exit() 让 Electron 正常退出,在 shell 脚本中只做 fallback kill
  2. 或者在调用 scheduleRelaunch 之前先调用 app.quit(),shell 脚本只负责等待退出后重启
const scheduleRelaunch = (): void => {
  const pid = process.pid
  // ...write relaunch script...
  spawn('/bin/sh', [scriptPath], { detached: true, stdio: 'ignore' }).unref()
  // Let Electron shut down gracefully
  app.quit()
}

}

// Main logic
const updateZip = findUpdateZip()
if (!updateZip) {
logger.error('Manual install: valid zip file not found', { cacheDir })
return { success: false, error: 'Update file not found' }
}

logger.info('Manual install: found update zip', { updateZip })

try {
extractZip(updateZip)

if (!isValidApp()) {
logger.error('Manual install: extracted app invalid', { newAppPath })
return { success: false, error: 'Extracted app not found' }
}

const result = await replaceAppWithAdminPrivileges()
if (!result.success) {
return result
}

scheduleRelaunch()
return { success: true }
} catch (error) {
logger.error('Manual install failed', error as Error)
return { success: false, error: (error as Error).message }
}
}

/**
* Check if release notes contain multi-language markers
*/
Expand Down
4 changes: 4 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export function tracedInvoke(channel: string, spanContext: SpanContext | undefin
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
getSigningInfo: (): Promise<{ teamId: string | null; bundleId: string | null; authority: string | null }> =>
ipcRenderer.invoke(IpcChannel.App_GetSigningInfo),
getDiskInfo: (directoryPath: string): Promise<{ free: number; size: number } | null> =>
ipcRenderer.invoke(IpcChannel.App_GetDiskInfo, directoryPath),
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
Expand All @@ -111,6 +113,8 @@ const api = {
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
manualInstallUpdate: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke(IpcChannel.App_ManualInstallUpdate),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
Expand Down
Loading
Loading