-
Notifications
You must be signed in to change notification settings - Fork 3.9k
fix: add manual download option for macOS users with old code signing #13378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3403adf
cde4fd7
9387866
7ddeb8c
e0770d6
034e98e
f4da3f4
f40d306
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Additionally, import { exec, execSync, spawn } from 'child_process'Original ContentCode Style: import 和语句顺序混乱
另外, 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' | ||
|
|
||
|
|
@@ -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(), | ||
kangfenmao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 'App-Name': APP_NAME, | ||
| 'App-Version': `v${app.getVersion()}`, | ||
| OS: process.platform | ||
kangfenmao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| // Language markers constants for multi-language release notes | ||
| const LANG_MARKERS = { | ||
| EN_START: '<!--LANG:en-->', | ||
|
|
@@ -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) => { | ||
|
|
@@ -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' | ||
| } | ||
| }) | ||
|
|
||
|
|
@@ -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}` | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}"`) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note This comment was translated by Claude. Security: Although It is recommended to use 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, Original ContentSecurity: 虽然 建议使用 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}`)
}
}同理, |
||
| } | ||
|
|
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note This comment was translated by Claude. Robustness:
Suggestions:
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 ContentRobustness:
建议:
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 | ||
| */ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.