fix: add manual download option for macOS users with old code signing (#13378)

### What this PR does

Before this PR:
- macOS users with the old code signing Team ID (Q24M7JR***) cannot
auto-update to v1.8.0+ due to signing certificate change
- Update request headers lacked app identification information

After this PR:
- Detects old Team ID on macOS and shows a manual download button
directing users to the official website
- Adds `App-Name`, `App-Version`, and `OS` headers to update requests
for better server analytics
- Refactors duplicate headers into a shared `getCommonHeaders()`
function

### Why we need it and why it was done in this way

The following tradeoffs were made:
- Using `codesign -dv` to detect the Team ID at runtime is simple and
reliable on macOS

The following alternatives were considered:
- Checking the app version instead of Team ID, but this wouldn't
correctly identify users who haven't updated in a while

### Breaking changes

None

### Special notes for your reviewer

The old Team ID `Q24M7JR2C4` is hardcoded as a constant. This is
intentional since it represents the previous signing certificate that
will no longer be used.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [x] Documentation: A user-guide update was considered and is present
(link) or not required
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
Add manual download option for macOS users who cannot auto-update due to code signing certificate change
```

---------

Signed-off-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
亢奋猫
2026-03-18 19:26:14 +08:00
committed by GitHub
parent 622c39ea64
commit 45edebe851
20 changed files with 329 additions and 20 deletions

View File

@@ -23,7 +23,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**"],
"includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**", "!config/app-upgrade-segments.json"],
"maxSize": 2097152
},
"formatter": {

View File

@@ -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.8.1"
},
"lockedVersion": "1.8.1",
"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.8.1 - locked to v1.8.1",
"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.8.1 <2.0.0"
},
"minCompatibleVersion": "1.8.1",
"description": "Current latest v1.x release for users on v1.8.1+",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"rc": {
@@ -36,7 +54,7 @@
"exact": ["2.0.0"]
},
"lockedVersion": "2.0.0",
"minCompatibleVersion": "1.7.0",
"minCompatibleVersion": "1.9.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channelTemplates": {
"latest": {

View File

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

View File

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

View File

@@ -164,6 +164,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
@@ -185,6 +187,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) => {

View File

@@ -1,5 +1,6 @@
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'
@@ -7,6 +8,12 @@ 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
@@ -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

View File

@@ -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)
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(),
'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-->',
@@ -61,10 +77,7 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = false
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
...getCommonHeaders()
}
autoUpdater.on('error', (error) => {
@@ -145,11 +158,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'
}
})
@@ -313,6 +323,118 @@ export default class AppUpdater {
setImmediate(() => autoUpdater.quitAndInstall(true, true))
}
/**
* 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}`
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}"`)
}
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()
}
// 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
*/

View File

@@ -101,6 +101,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),
@@ -109,6 +111,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),

View File

@@ -1,5 +1,7 @@
import { DownloadOutlined, InfoCircleOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { TopView } from '@renderer/components/TopView'
import { isMac } from '@renderer/config/constant'
import { handleSaveData, useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { Button, Modal } from 'antd'
@@ -11,6 +13,10 @@ import styled from 'styled-components'
const logger = loggerService.withContext('UpdateDialog')
// Old Team ID that requires manual install
const OLD_TEAM_ID = 'Q24M7JR2C4'
const DOWNLOAD_URL = 'https://www.cherry-ai.com/download'
interface ShowParams {
releaseInfo: UpdateInfo | null
}
@@ -23,12 +29,25 @@ const PopupContainer: React.FC<Props> = ({ releaseInfo, resolve }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
const [isInstalling, setIsInstalling] = useState(false)
const [requiresManualInstall, setRequiresManualInstall] = useState(false)
const dispatch = useAppDispatch()
useEffect(() => {
if (releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
// Check if macOS user with old Team ID needs manual install
if (isMac) {
window.api.getSigningInfo().then((signingInfo) => {
if (signingInfo.teamId === OLD_TEAM_ID) {
setRequiresManualInstall(true)
logger.info('Manual install required', { teamId: signingInfo.teamId })
}
})
}
setRequiresManualInstall(true)
}, [releaseInfo])
const handleInstall = async () => {
@@ -44,6 +63,32 @@ const PopupContainer: React.FC<Props> = ({ releaseInfo, resolve }) => {
}
}
const handleManualInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
const result = await window.api.manualInstallUpdate()
if (!result.success) {
setIsInstalling(false)
if (result.error === 'User cancelled') {
// User cancelled password dialog, do nothing
return
}
logger.error('Manual install failed', { error: result.error })
window.toast.error(t('update.manualInstallError'))
// Fallback to download page
window.api.openWebsite(DOWNLOAD_URL)
}
// If success, app will relaunch automatically
} catch (error) {
logger.error('Manual install error', error as Error)
setIsInstalling(false)
window.toast.error(t('update.manualInstallError'))
window.api.openWebsite(DOWNLOAD_URL)
}
}
const onCancel = () => {
dispatch(setUpdateState({ manualCheck: false }))
setOpen(false)
@@ -80,11 +125,35 @@ const PopupContainer: React.FC<Props> = ({ releaseInfo, resolve }) => {
<Button key="later" onClick={onIgnore} disabled={isInstalling}>
{t('update.later')}
</Button>,
<Button key="install" type="primary" onClick={handleInstall} loading={isInstalling}>
{t('update.install')}
</Button>
requiresManualInstall ? (
<Button key="install" type="primary" onClick={handleManualInstall} loading={isInstalling}>
{t('update.install')}
</Button>
) : (
<Button key="install" type="primary" onClick={handleInstall} loading={isInstalling}>
{t('update.install')}
</Button>
)
]}>
<ModalBodyWrapper>
{requiresManualInstall && (
<div className="mb-4 flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800/50">
<InfoCircleOutlined className="shrink-0 text-base text-neutral-500 dark:text-neutral-400" />
<span className="flex-1 text-neutral-600 text-sm dark:text-neutral-300">
{t('update.manualInstallInfo')}
</span>
<button
type="button"
onClick={() => window.api.openWebsite(DOWNLOAD_URL)}
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-3 py-1 font-medium text-sm text-white transition-colors"
style={{ backgroundColor: 'var(--color-primary)' }}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.85')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}>
<DownloadOutlined className="text-xs" />
{t('update.manualDownload')}
</button>
</div>
)}
<ReleaseNotesWrapper className="markdown">
<Markdown>
{typeof releaseNotes === 'string'

View File

@@ -5632,8 +5632,12 @@
"show_window": "Show Window"
},
"update": {
"download": "Download",
"install": "Install",
"later": "Later",
"manualDownload": "Download",
"manualInstallError": "Installation failed, please download manually from the official website.",
"manualInstallInfo": "This version does not support automatic updates. Click download to complete the update.",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",

View File

@@ -5632,8 +5632,12 @@
"show_window": "显示窗口"
},
"update": {
"download": "前往下载",
"install": "立即安装",
"later": "稍后",
"manualDownload": "下载",
"manualInstallError": "安装失败,请前往官网手动下载安装。",
"manualInstallInfo": "该版本不支持自动更新,点击下载按钮完成更新。",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",

View File

@@ -5632,8 +5632,12 @@
"show_window": "顯示視窗"
},
"update": {
"download": "前往下載",
"install": "立即安裝",
"later": "稍後",
"manualDownload": "下載",
"manualInstallError": "安裝失敗,請前往官網手動下載安裝。",
"manualInstallInfo": "該版本不支援自動更新,點擊下載按鈕完成更新。",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "儲存資料失敗,請重試",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Fenster anzeigen"
},
"update": {
"download": "Herunterladen",
"install": "Jetzt installieren",
"later": "Später",
"manualDownload": "Herunterladen",
"manualInstallError": "Installation fehlgeschlagen, bitte manuell von der offiziellen Website herunterladen.",
"manualInstallInfo": "Diese Version unterstützt keine automatischen Updates. Klicken Sie auf Herunterladen, um das Update abzuschließen.",
"message": "Neue Version {{version}} gefunden. Jetzt installieren?",
"noReleaseNotes": "Kein Changelog verfügbar",
"saveDataError": "Speichern fehlgeschlagen, bitte erneut versuchen",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Εμφάνιση παραθύρου"
},
"update": {
"download": "Λήψη",
"install": "Εγκατάσταση",
"later": "Μετά",
"manualDownload": "Λήψη",
"manualInstallError": "Η εγκατάσταση απέτυχε, παρακαλώ κατεβάστε χειροκίνητα από την επίσημη ιστοσελίδα.",
"manualInstallInfo": "Αυτή η έκδοση δεν υποστηρίζει αυτόματες ενημερώσεις. Κάντε κλικ στη λήψη για να ολοκληρώσετε την ενημέρωση.",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Mostrar ventana"
},
"update": {
"download": "Descargar",
"install": "Instalar",
"later": "Más tarde",
"manualDownload": "Descargar",
"manualInstallError": "La instalación falló, por favor descarga manualmente desde el sitio web oficial.",
"manualInstallInfo": "Esta versión no admite actualizaciones automáticas. Haz clic en descargar para completar la actualización.",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Afficher la fenêtre"
},
"update": {
"download": "Télécharger",
"install": "Installer",
"later": "Plus tard",
"manualDownload": "Télécharger",
"manualInstallError": "L'installation a échoué, veuillez télécharger manuellement depuis le site officiel.",
"manualInstallInfo": "Cette version ne prend pas en charge les mises à jour automatiques. Cliquez sur télécharger pour terminer la mise à jour.",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",

View File

@@ -5632,8 +5632,12 @@
"show_window": "ウィンドウを表示"
},
"update": {
"download": "ダウンロード",
"install": "今すぐインストール",
"later": "後で",
"manualDownload": "ダウンロード",
"manualInstallError": "インストールに失敗しました。公式ウェブサイトから手動でダウンロードしてください。",
"manualInstallInfo": "このバージョンは自動更新に対応していません。ダウンロードをクリックして更新を完了してください。",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Exibir Janela"
},
"update": {
"download": "Baixar",
"install": "Instalar",
"later": "Mais tarde",
"manualDownload": "Baixar",
"manualInstallError": "A instalação falhou, por favor, faça o download manualmente no site oficial.",
"manualInstallInfo": "Esta versão não suporta atualizações automáticas. Clique em baixar para concluir a atualização.",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Afișează fereastra"
},
"update": {
"download": "Descărcare",
"install": "Instalează",
"later": "Mai târziu",
"manualDownload": "Descărcați",
"manualInstallError": "Instalarea a eșuat, vă rugăm să descărcați manual de pe site-ul oficial.",
"manualInstallInfo": "Această versiune nu suportă actualizări automate. Faceți clic pe descărcare pentru a finaliza actualizarea.",
"message": "Noua versiune {{version}} este gata, vrei să o instalezi acum?",
"noReleaseNotes": "Nicio notă de lansare",
"saveDataError": "Salvarea datelor a eșuat, te rugăm să încerci din nou.",

View File

@@ -5632,8 +5632,12 @@
"show_window": "Показать окно"
},
"update": {
"download": "Скачать",
"install": "Установить",
"later": "Позже",
"manualDownload": "Скачать",
"manualInstallError": "Установка не удалась, пожалуйста, загрузите вручную с официального сайта.",
"manualInstallInfo": "Эта версия не поддерживает автоматическое обновление. Нажмите «Скачать», чтобы завершить обновление.",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",