From 70cb6148c63e842f41fe3ddb8c1bff13b78660f9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 6 Feb 2026 15:53:31 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20fix(app):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=B5=81=E7=A8=8B=E5=8F=AF=E7=94=A8=E6=80=A7?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E7=AA=97=E5=8F=A3=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补齐更新下载进度、下载路径和安装日志路径提示 - 修复更新重启后拉起不稳定问题并增加平台兜底 - 恢复标题栏双击切换窗口状态能力 - 调整透明度初始行为为 100% 并保留用户配置 --- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 196 +++++++++++++++++- frontend/src/store.ts | 42 +++- frontend/src/utils/appearance.ts | 2 +- internal/app/methods_update.go | 335 ++++++++++++++++++++++++++----- 5 files changed, 514 insertions(+), 63 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77e545d..dac62c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Popover } from 'antd'; +import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; +import { EventsOn } from '../wailsjs/runtime/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -52,6 +53,7 @@ function App() { const updateCheckInFlightRef = React.useRef(false); const updateDownloadInFlightRef = React.useRef(false); const updateDownloadedVersionRef = React.useRef(null); + const updateDownloadMetaRef = React.useRef(null); const updateDeferredVersionRef = React.useRef(null); const updateNotifiedVersionRef = React.useRef(null); const updateMutedVersionRef = React.useRef(null); @@ -60,6 +62,23 @@ function App() { const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null); const [aboutUpdateStatus, setAboutUpdateStatus] = useState(''); const [lastUpdateInfo, setLastUpdateInfo] = useState(null); + const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{ + open: boolean; + version: string; + status: 'idle' | 'start' | 'downloading' | 'done' | 'error'; + percent: number; + downloaded: number; + total: number; + message: string; + }>({ + open: false, + version: '', + status: 'idle', + percent: 0, + downloaded: 0, + total: 0, + message: '' + }); type UpdateInfo = { hasUpdate: boolean; @@ -73,10 +92,51 @@ function App() { sha256?: string; }; - const promptRestartForUpdate = (info: UpdateInfo) => { + type UpdateDownloadProgressEvent = { + status?: 'start' | 'downloading' | 'done' | 'error'; + percent?: number; + downloaded?: number; + total?: number; + message?: string; + }; + + type UpdateDownloadResultData = { + info?: UpdateInfo; + downloadPath?: string; + installLogPath?: string; + installTarget?: string; + platform?: string; + autoRelaunch?: boolean; + }; + + const formatBytes = (bytes?: number) => { + if (!bytes || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx++; + } + return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; + }; + + const promptRestartForUpdate = (info: UpdateInfo, resultData?: UpdateDownloadResultData) => { + const downloadPathHint = resultData?.downloadPath + ? `更新包路径:${resultData.downloadPath}` + : ''; + const installLogHint = resultData?.installLogPath + ? `安装日志:${resultData.installLogPath}` + : ''; Modal.confirm({ title: '更新已下载', - content: `版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`, + content: ( +
+
{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}
+ {downloadPathHint ?
{downloadPathHint}
: null} + {installLogHint ?
{installLogHint}
: null} +
+ ), okText: '立即重启', cancelText: '稍后', onOk: async () => { @@ -96,25 +156,49 @@ function App() { if (updateDownloadInFlightRef.current) return; if (updateDownloadedVersionRef.current === info.latestVersion) { if (!silent) { - message.info(`更新包已就绪(${info.latestVersion})`); + const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath; + message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`); } if (!silent || updateDeferredVersionRef.current !== info.latestVersion) { - promptRestartForUpdate(info); + promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined); } return; } updateDownloadInFlightRef.current = true; + updateDownloadMetaRef.current = null; const key = 'update-download'; + setUpdateDownloadProgress({ + open: true, + version: info.latestVersion, + status: 'start', + percent: 0, + downloaded: 0, + total: info.assetSize || 0, + message: '' + }); message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 }); const res = await (window as any).go.app.App.DownloadUpdate(); updateDownloadInFlightRef.current = false; if (res?.success) { + const resultData = (res?.data || {}) as UpdateDownloadResultData; + updateDownloadMetaRef.current = resultData; updateDownloadedVersionRef.current = info.latestVersion; - message.success({ content: '更新下载完成', key, duration: 2 }); + setUpdateDownloadProgress(prev => ({ ...prev, status: 'done', percent: 100, open: false })); + if (resultData?.downloadPath) { + message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, key, duration: 5 }); + } else { + message.success({ content: '更新下载完成', key, duration: 2 }); + } + setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,待重启安装)`); if (!silent || updateDeferredVersionRef.current !== info.latestVersion) { - promptRestartForUpdate(info); + promptRestartForUpdate(info, resultData); } } else { + setUpdateDownloadProgress(prev => ({ + ...prev, + status: 'error', + message: res?.message || '未知错误' + })); message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 }); } }, []); @@ -329,6 +413,14 @@ function App() { setIsModalOpen(false); setEditingConnection(null); }; + + const handleTitleBarDoubleClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (target?.closest('[data-no-titlebar-toggle="true"]')) { + return; + } + (window as any).runtime.WindowToggleMaximise(); + }; // Sidebar Resizing const [sidebarWidth, setSidebarWidth] = useState(300); @@ -422,6 +514,35 @@ function App() { }; }, [checkForUpdates]); + useEffect(() => { + const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => { + if (!event) return; + const status = event.status || 'downloading'; + const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' = + status === 'start' || status === 'downloading' || status === 'done' || status === 'error' + ? status + : 'downloading'; + const downloaded = typeof event.downloaded === 'number' ? event.downloaded : 0; + const total = typeof event.total === 'number' ? event.total : 0; + const percentRaw = typeof event.percent === 'number' + ? event.percent + : (total > 0 ? (downloaded / total) * 100 : 0); + const percent = Math.max(0, Math.min(100, percentRaw)); + setUpdateDownloadProgress(prev => ({ + open: nextStatus === 'start' || nextStatus === 'downloading' || nextStatus === 'error', + version: prev.version, + status: nextStatus, + percent, + downloaded, + total, + message: String(event.message || '') + })); + }); + return () => { + offDownloadProgress(); + }; + }, []); + return ( {/* Custom Title Bar */}
-
+
e.stopPropagation()} + style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any} + >
@@ -704,6 +830,56 @@ function App() {
+ + { + if (updateDownloadProgress.status === 'error') { + setUpdateDownloadProgress({ + open: false, + version: '', + status: 'idle', + percent: 0, + downloaded: 0, + total: 0, + message: '' + }); + } + }} + footer={updateDownloadProgress.status === 'error' ? [ + + ] : null} + > +
+ +
+ {`${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`} +
+ {updateDownloadProgress.message ? ( +
{updateDownloadProgress.message}
+ ) : null} +
+
{/* Ghost Resize Line for Sidebar */}
| undefined): boolean => { + if (!appearance) { + return true; + } + const opacity = typeof appearance.opacity === 'number' ? appearance.opacity : LEGACY_DEFAULT_OPACITY; + const blur = typeof appearance.blur === 'number' ? appearance.blur : 0; + return Math.abs(opacity - LEGACY_DEFAULT_OPACITY) < OPACITY_EPSILON && blur === 0; +}; + export interface SqlLog { id: string; timestamp: number; @@ -59,7 +72,7 @@ export const useStore = create()( activeContext: null, savedQueries: [], theme: 'light', - appearance: { opacity: 0.95, blur: 0 }, + appearance: { ...DEFAULT_APPEARANCE }, sqlFormatOptions: { keywordCase: 'upper' }, queryOptions: { maxRows: 5000 }, sqlLogs: [], @@ -138,6 +151,33 @@ export const useStore = create()( }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) + version: 2, + migrate: (persistedState: unknown, version: number) => { + if (!persistedState || typeof persistedState !== 'object') { + return persistedState as AppState; + } + const state = persistedState as Partial; + const nextState: Partial = { ...state }; + const appearance = state.appearance; + + if (!appearance || typeof appearance !== 'object') { + nextState.appearance = { ...DEFAULT_APPEARANCE }; + return nextState as AppState; + } + + const nextAppearance = { + opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity, + blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur, + }; + + if (version < 2 && isLegacyDefaultAppearance(appearance)) { + nextState.appearance = { ...DEFAULT_APPEARANCE }; + } else { + nextState.appearance = nextAppearance; + } + + return nextState as AppState; + }, partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs } ) diff --git a/frontend/src/utils/appearance.ts b/frontend/src/utils/appearance.ts index 02c739d..f140bf5 100644 --- a/frontend/src/utils/appearance.ts +++ b/frontend/src/utils/appearance.ts @@ -1,4 +1,4 @@ -const DEFAULT_OPACITY = 0.95; +const DEFAULT_OPACITY = 1.0; const MIN_OPACITY = 0.1; const MAX_OPACITY = 1.0; diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index e766e0d..893e3aa 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "os" "os/exec" @@ -22,9 +23,10 @@ import ( ) const ( - updateRepo = "Syngnat/GoNavi" - updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest" - updateChecksumAsset = "SHA256SUMS" + updateRepo = "Syngnat/GoNavi" + updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest" + updateChecksumAsset = "SHA256SUMS" + updateDownloadProgressEvent = "update:download-progress" ) type updateState struct { @@ -54,11 +56,29 @@ type AppInfo struct { BuildTime string `json:"buildTime,omitempty"` } +type updateDownloadResult struct { + Info UpdateInfo `json:"info"` + DownloadPath string `json:"downloadPath,omitempty"` + InstallLogPath string `json:"installLogPath,omitempty"` + InstallTarget string `json:"installTarget,omitempty"` + Platform string `json:"platform"` + AutoRelaunch bool `json:"autoRelaunch"` +} + +type updateDownloadProgressPayload struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + Downloaded int64 `json:"downloaded"` + Total int64 `json:"total"` + Message string `json:"message,omitempty"` +} + type stagedUpdate struct { - Version string - AssetName string - FilePath string - StagedDir string + Version string + AssetName string + FilePath string + StagedDir string + InstallLogPath string } type githubRelease struct { @@ -124,13 +144,15 @@ func (a *App) DownloadUpdate() connection.QueryResult { a.updateMu.Unlock() return connection.QueryResult{Success: false, Message: "未找到可用的更新包"} } - if a.updateState.staged != nil && a.updateState.staged.Version == info.LatestVersion { + staged := a.updateState.staged + if staged != nil && staged.Version == info.LatestVersion { a.updateMu.Unlock() - return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: info} + return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)} } a.updateState.downloading = true a.updateMu.Unlock() + a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "") result := a.downloadAndStageUpdate(*info) a.updateMu.Lock() @@ -143,6 +165,9 @@ func (a *App) DownloadUpdate() connection.QueryResult { func (a *App) InstallUpdateAndRestart() connection.QueryResult { a.updateMu.Lock() staged := a.updateState.staged + if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" { + staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + } a.updateMu.Unlock() if staged == nil { return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"} @@ -150,7 +175,17 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { if err := launchUpdateScript(staged); err != nil { logger.Error(err, "启动更新脚本失败") - return connection.QueryResult{Success: false, Message: err.Error()} + msg := err.Error() + if staged.InstallLogPath != "" { + msg = fmt.Sprintf("%s(更新日志:%s)", msg, staged.InstallLogPath) + } + return connection.QueryResult{ + Success: false, + Message: msg, + Data: map[string]any{ + "logPath": staged.InstallLogPath, + }, + } } go func() { @@ -161,41 +196,79 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { os.Exit(0) }() - return connection.QueryResult{Success: true, Message: "更新已开始安装"} + msg := "更新已开始安装" + if staged.InstallLogPath != "" { + msg = fmt.Sprintf("更新已开始安装,日志路径:%s", staged.InstallLogPath) + } + return connection.QueryResult{ + Success: true, + Message: msg, + Data: map[string]any{ + "logPath": staged.InstallLogPath, + }, + } } func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { - stagedDir, err := os.MkdirTemp("", "gonavi-update-") - if err != nil { - return connection.QueryResult{Success: false, Message: "创建临时目录失败"} + workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir()) + if workspaceDir == "" { + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "无法确定当前应用目录") + return connection.QueryResult{Success: false, Message: "无法确定当前应用目录,无法下载更新"} + } + if err := os.MkdirAll(workspaceDir, 0o755); err != nil { + errMsg := fmt.Sprintf("无法访问应用目录:%s", workspaceDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg) + return connection.QueryResult{Success: false, Message: errMsg} } - assetPath := filepath.Join(stagedDir, info.AssetName) - actualHash, err := downloadFileWithHash(info.AssetURL, assetPath) + stagedDir, err := os.MkdirTemp(workspaceDir, ".gonavi-update-work-") if err != nil { + errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", workspaceDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg) + return connection.QueryResult{Success: false, Message: errMsg} + } + + assetPath := filepath.Join(workspaceDir, info.AssetName) + actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) { + reportTotal := total + if reportTotal <= 0 { + reportTotal = info.AssetSize + } + a.emitUpdateDownloadProgress("downloading", downloaded, reportTotal, "") + }) + if err != nil { + _ = os.Remove(assetPath) _ = os.RemoveAll(stagedDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } if info.SHA256 == "" { + _ = os.Remove(assetPath) _ = os.RemoveAll(stagedDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "缺少更新包校验值(SHA256SUMS)") return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"} } if !strings.EqualFold(info.SHA256, actualHash) { + _ = os.Remove(assetPath) _ = os.RemoveAll(stagedDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "更新包校验失败,请重试") return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"} } - a.updateMu.Lock() - a.updateState.staged = &stagedUpdate{ - Version: info.LatestVersion, - AssetName: info.AssetName, - FilePath: assetPath, - StagedDir: stagedDir, + staged := &stagedUpdate{ + Version: info.LatestVersion, + AssetName: info.AssetName, + FilePath: assetPath, + StagedDir: stagedDir, + InstallLogPath: buildUpdateInstallLogPath(workspaceDir), } + a.updateMu.Lock() + a.updateState.staged = staged a.updateMu.Unlock() - return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: info} + a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "") + return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: buildUpdateDownloadResult(info, staged)} } func fetchLatestUpdateInfo() (UpdateInfo, error) { @@ -370,7 +443,32 @@ func parseSHA256Sums(content string) map[string]string { return result } -func downloadFileWithHash(url, filePath string) (string, error) { +type downloadProgressWriter struct { + total int64 + written int64 + lastEmit time.Time + emitEvery time.Duration + onProgress func(downloaded, total int64) +} + +func (w *downloadProgressWriter) Write(p []byte) (int, error) { + n := len(p) + if n == 0 { + return 0, nil + } + w.written += int64(n) + if w.onProgress == nil { + return n, nil + } + now := time.Now() + if w.lastEmit.IsZero() || now.Sub(w.lastEmit) >= w.emitEvery || (w.total > 0 && w.written >= w.total) { + w.lastEmit = now + w.onProgress(w.written, w.total) + } + return n, nil +} + +func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) { client := &http.Client{Timeout: 10 * time.Minute} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -395,14 +493,99 @@ func downloadFileWithHash(url, filePath string) (string, error) { defer out.Close() hasher := sha256.New() - writer := io.MultiWriter(out, hasher) - if _, err := io.Copy(writer, resp.Body); err != nil { + total := resp.ContentLength + progressWriter := &downloadProgressWriter{ + total: total, + emitEvery: 120 * time.Millisecond, + onProgress: onProgress, + } + writers := []io.Writer{out, hasher, progressWriter} + if onProgress != nil { + onProgress(0, total) + } + if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil { return "", err } + if onProgress != nil { + onProgress(progressWriter.written, total) + } return hex.EncodeToString(hasher.Sum(nil)), nil } +func buildUpdateDownloadResult(info UpdateInfo, staged *stagedUpdate) updateDownloadResult { + result := updateDownloadResult{ + Info: info, + Platform: stdRuntime.GOOS, + InstallTarget: resolveUpdateInstallTarget(), + AutoRelaunch: true, + } + if staged != nil { + result.DownloadPath = staged.FilePath + result.InstallLogPath = staged.InstallLogPath + } + return result +} + +func buildUpdateInstallLogPath(baseDir string) string { + platform := stdRuntime.GOOS + if platform == "darwin" { + platform = "macos" + } + logDir := strings.TrimSpace(baseDir) + if logDir == "" { + logDir = os.TempDir() + } + return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano())) +} + +func resolveUpdateWorkspaceDir() string { + exePath, err := os.Executable() + if err != nil { + return "" + } + exePath, _ = filepath.EvalSymlinks(exePath) + if stdRuntime.GOOS == "darwin" { + appPath := detectMacAppPath(exePath) + if appPath != "" { + return filepath.Dir(appPath) + } + } + return filepath.Dir(exePath) +} + +func resolveUpdateInstallTarget() string { + exePath, err := os.Executable() + if err != nil { + return "" + } + exePath, _ = filepath.EvalSymlinks(exePath) + if stdRuntime.GOOS == "darwin" { + return resolveMacUpdateTarget(exePath) + } + return exePath +} + +func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) { + if a.ctx == nil { + return + } + payload := updateDownloadProgressPayload{ + Status: status, + Percent: 0, + Downloaded: downloaded, + Total: total, + Message: strings.TrimSpace(message), + } + if total > 0 { + payload.Percent = math.Min(100, (float64(downloaded)/float64(total))*100) + } + if status == "done" && payload.Percent < 100 { + payload.Percent = 100 + } + wailsRuntime.EventsEmit(a.ctx, updateDownloadProgressEvent, payload) +} + func launchUpdateScript(staged *stagedUpdate) error { exePath, err := os.Executable() if err != nil { @@ -425,7 +608,11 @@ func launchUpdateScript(staged *stagedUpdate) error { func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error { scriptPath := filepath.Join(staged.StagedDir, "update.cmd") - logPath := filepath.Join(staged.StagedDir, "update.log") + logPath := strings.TrimSpace(staged.InstallLogPath) + if logPath == "" { + logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + staged.InstallLogPath = logPath + } content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid) if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil { return err @@ -442,7 +629,11 @@ func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error { if err := os.MkdirAll(mountDir, 0o755); err != nil { return err } - logPath := filepath.Join(staged.StagedDir, "update.log") + logPath := strings.TrimSpace(staged.InstallLogPath) + if logPath == "" { + logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + staged.InstallLogPath = logPath + } scriptPath := filepath.Join(staged.StagedDir, "update.sh") content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid) @@ -509,8 +700,12 @@ exit /b 1 :move_done start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1 if %%ERRORLEVEL%% NEQ 0 ( - call :log relaunch failed - exit /b 1 + call :log cmd start failed, trying powershell Start-Process + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1 + if %%ERRORLEVEL%% NEQ 0 ( + call :log relaunch failed + exit /b 1 + ) ) rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1 call :log update finished @@ -531,30 +726,69 @@ TARGET_APP="%s" STAGED="%s" MOUNT_DIR="%s" LOG_FILE="%s" +TMP_APP="${TARGET_APP}.new" +BACKUP_APP="${TARGET_APP}.backup" +APP_BIN_NAME=$(basename "$TARGET_APP" .app) +APP_BIN_REL="Contents/MacOS/$APP_BIN_NAME" log() { echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE" } -run_admin_install() { - /usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$LOG_FILE" +run_admin_replace() { + /usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$TMP_APP" "$BACKUP_APP" "$APP_BIN_REL" "$LOG_FILE" on run argv set srcPath to item 1 of argv set dstPath to item 2 of argv - set logPath to item 3 of argv - do shell script "rm -rf " & quoted form of dstPath & " && cp -R " & quoted form of srcPath & " " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1" with administrator privileges + set tmpPath to item 3 of argv + set bakPath to item 4 of argv + set binRel to item 5 of argv + set logPath to item 6 of argv + set cmd to "set -eu; " & ¬ + "rm -rf " & quoted form of tmpPath & " " & quoted form of bakPath & "; " & ¬ + "/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of tmpPath & "; " & ¬ + "if [ ! -x " & quoted form of (tmpPath & "/" & binRel) & " ]; then echo 'tmp app binary missing' >> " & quoted form of logPath & "; exit 1; fi; " & ¬ + "xattr -rd com.apple.quarantine " & quoted form of tmpPath & " >> " & quoted form of logPath & " 2>&1 || true; " & ¬ + "if [ -d " & quoted form of dstPath & " ]; then mv " & quoted form of dstPath & " " & quoted form of bakPath & "; fi; " & ¬ + "mv " & quoted form of tmpPath & " " & quoted form of dstPath & "; " & ¬ + "rm -rf " & quoted form of bakPath & "; " & ¬ + "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1 || true" + do shell script cmd with administrator privileges end run APPLESCRIPT } -run_admin_xattr() { - /usr/bin/osascript <<'APPLESCRIPT' "$TARGET_APP" "$LOG_FILE" -on run argv - set dstPath to item 1 of argv - set logPath to item 2 of argv - do shell script "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1" with administrator privileges -end run -APPLESCRIPT +replace_app_direct() { + rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true + /usr/bin/ditto "$APP_SRC" "$TMP_APP" >>"$LOG_FILE" 2>&1 + if [ ! -x "$TMP_APP/$APP_BIN_REL" ]; then + log "tmp app binary missing: $TMP_APP/$APP_BIN_REL" + return 1 + fi + xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true + if [ -d "$TARGET_APP" ]; then + mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 + fi + if ! mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then + log "move new app failed, trying rollback" + rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true + if [ -d "$BACKUP_APP" ]; then + mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true + fi + return 1 + fi + rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true + xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true + return 0 +} + +relaunch_app() { + if /usr/bin/open -n "$TARGET_APP" >>"$LOG_FILE" 2>&1; then + return 0 + fi + log "open -n failed, trying binary launch" + "$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 & + return 0 } log "updater started" @@ -571,21 +805,22 @@ if [ -z "$APP_SRC" ]; then fi log "install target: $TARGET_APP" -if ! rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || ! cp -R "$APP_SRC" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then - log "direct install failed, trying admin install" - run_admin_install >>"$LOG_FILE" 2>&1 +if ! replace_app_direct; then + log "direct replace failed, trying admin replace" + run_admin_replace >>"$LOG_FILE" 2>&1 fi -if ! xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1; then - log "direct xattr failed, trying admin xattr" - run_admin_xattr >>"$LOG_FILE" 2>&1 || true +if [ ! -x "$TARGET_APP/$APP_BIN_REL" ]; then + log "target app binary missing after replace: $TARGET_APP/$APP_BIN_REL" + hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true + exit 1 fi hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true -open "$TARGET_APP" >>"$LOG_FILE" 2>&1 +relaunch_app log "relaunch requested" -`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath) + `, pid, dmgPath, targetApp, stagedDir, mountDir, logPath) } func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {