From ec4b3d9018971ab3005ac5fb9885fbf776ad13e8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 5 Feb 2026 16:50:44 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(updater):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20GitHub=20Release=20=E5=9C=A8=E7=BA=BF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=8E=E5=85=B3=E4=BA=8E=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增更新检查/下载/安装流程与应用信息接口 - 关于弹窗展示版本/作者/仓库/Issue/Release,并内置检查更新 - 构建/发布注入版本号并生成 SHA256SUMS - 顶部工具栏入口调整与新建查询补全默认空 SQL --- .github/workflows/release.yml | 7 +- README.md | 3 + build-release.sh | 38 +- frontend/src/App.tsx | 211 +++++++++- frontend/src/components/Sidebar.tsx | 9 +- internal/app/methods_update.go | 606 ++++++++++++++++++++++++++++ internal/app/version.go | 53 +++ 7 files changed, 900 insertions(+), 27 deletions(-) create mode 100644 internal/app/methods_update.go create mode 100644 internal/app/version.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bb1ae7..03f3204 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,7 +88,7 @@ jobs: - name: Build shell: bash run: | - wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.artifact_name }} + wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.artifact_name }} -ldflags "-X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}" # macOS Packaging - name: Package macOS DMG @@ -249,6 +249,11 @@ jobs: - name: List Assets run: ls -R release-assets + - name: Generate SHA256SUMS + run: | + cd release-assets + sha256sum * > SHA256SUMS + - name: Create Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') diff --git a/README.md b/README.md index 4cbcd72..470d143 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ - **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。 - **数据同步**:内置数据同步面板,支持跨库同步任务配置。 +### 🆙 在线更新 +- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。 + ### 🧾 可观测性 - **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。 diff --git a/build-release.sh b/build-release.sh index 6708d6f..ce9d56e 100755 --- a/build-release.sh +++ b/build-release.sh @@ -12,6 +12,7 @@ if [ -z "$VERSION" ]; then VERSION="0.0.0" fi echo "ℹ️ 检测到版本号: $VERSION" +LDFLAGS="-X GoNavi-Wails/internal/app.AppVersion=$VERSION" # 颜色配置 GREEN='\033[0;32m' @@ -27,7 +28,7 @@ mkdir -p $DIST_DIR # --- macOS ARM64 构建 --- echo -e "${GREEN}🍎 正在构建 macOS (arm64)...${NC}" -wails build -platform darwin/arm64 -clean +wails build -platform darwin/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app" APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app" @@ -81,7 +82,7 @@ fi # --- macOS AMD64 构建 --- echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}" -wails build -platform darwin/amd64 -clean +wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app" APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app" @@ -131,7 +132,7 @@ fi # --- Windows AMD64 构建 --- echo -e "${GREEN}🪟 正在构建 Windows (amd64)...${NC}" if command -v x86_64-w64-mingw32-gcc &> /dev/null; then - wails build -platform windows/amd64 -clean + wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe" echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe" @@ -145,7 +146,7 @@ fi # --- Windows ARM64 构建 --- echo -e "${GREEN}🪟 正在构建 Windows (arm64)...${NC}" if command -v aarch64-w64-mingw32-gcc &> /dev/null; then - wails build -platform windows/arm64 -clean + wails build -platform windows/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe" echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe" @@ -165,7 +166,7 @@ CURRENT_ARCH=$(uname -m) if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then # 本机 Linux amd64,直接构建 - wails build -platform linux/amd64 -clean + wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64" chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64" @@ -183,7 +184,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then export CC=x86_64-linux-gnu-gcc export CXX=x86_64-linux-gnu-g++ export CGO_ENABLED=1 - wails build -platform linux/amd64 -clean + wails build -platform linux/amd64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64" chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-amd64" @@ -205,7 +206,7 @@ fi echo -e "${GREEN}🐧 正在构建 Linux (arm64)...${NC}" if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then # 本机 Linux arm64,直接构建 - wails build -platform linux/arm64 -clean + wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64" chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64" @@ -222,7 +223,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then export CC=aarch64-linux-gnu-gcc export CXX=aarch64-linux-gnu-g++ export CGO_ENABLED=1 - wails build -platform linux/arm64 -clean + wails build -platform linux/arm64 -clean -ldflags "$LDFLAGS" if [ $? -eq 0 ]; then mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}" "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64" chmod +x "$DIST_DIR/${APP_NAME}-${VERSION}-linux-arm64" @@ -244,6 +245,27 @@ fi # 清理中间构建目录 rm -rf "build/bin" +echo -e "${GREEN}🔐 生成 SHA256SUMS...${NC}" +if command -v sha256sum &> /dev/null; then + cd "$DIST_DIR" + : > SHA256SUMS + for f in *; do + [ -f "$f" ] || continue + sha256sum "$f" >> SHA256SUMS + done + cd .. +elif command -v shasum &> /dev/null; then + cd "$DIST_DIR" + : > SHA256SUMS + for f in *; do + [ -f "$f" ] || continue + shasum -a 256 "$f" >> SHA256SUMS + done + cd .. +else + echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum,跳过校验文件生成。${NC}" +fi + echo "" echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}" ls -lh "$DIST_DIR" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7d5cfd..2ff6aca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message } from 'antd'; +import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin } from 'antd'; import zhCN from 'antd/locale/zh_CN'; -import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, BugOutlined, SettingOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons'; +import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined } from '@ant-design/icons'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -25,6 +25,105 @@ function App() { const addConnection = useStore(state => state.addConnection); const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); + const updateCheckInFlightRef = React.useRef(false); + const updateDownloadInFlightRef = React.useRef(false); + const updateDownloadedVersionRef = React.useRef(null); + const updateDeferredVersionRef = React.useRef(null); + const [isAboutOpen, setIsAboutOpen] = useState(false); + const [aboutLoading, setAboutLoading] = useState(false); + const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null); + + type UpdateInfo = { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + releaseName?: string; + releaseNotesUrl?: string; + assetName?: string; + assetUrl?: string; + assetSize?: number; + sha256?: string; + }; + + const promptRestartForUpdate = (info: UpdateInfo) => { + Modal.confirm({ + title: '更新已下载', + content: `版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`, + okText: '立即重启', + cancelText: '稍后', + onOk: async () => { + updateDeferredVersionRef.current = null; + const res = await (window as any).go.app.App.InstallUpdateAndRestart(); + if (!res?.success) { + message.error('更新安装失败: ' + (res?.message || '未知错误')); + } + }, + onCancel: () => { + updateDeferredVersionRef.current = info.latestVersion; + } + }); + }; + + const downloadUpdate = React.useCallback(async (info: UpdateInfo, silent: boolean) => { + if (updateDownloadInFlightRef.current) return; + if (updateDownloadedVersionRef.current === info.latestVersion) { + if (!silent) { + message.info(`更新包已就绪(${info.latestVersion})`); + } + if (!silent || updateDeferredVersionRef.current !== info.latestVersion) { + promptRestartForUpdate(info); + } + return; + } + updateDownloadInFlightRef.current = true; + const key = 'update-download'; + message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 }); + const res = await (window as any).go.app.App.DownloadUpdate(); + updateDownloadInFlightRef.current = false; + if (res?.success) { + updateDownloadedVersionRef.current = info.latestVersion; + message.success({ content: '更新下载完成', key, duration: 2 }); + if (!silent || updateDeferredVersionRef.current !== info.latestVersion) { + promptRestartForUpdate(info); + } + } else { + message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 }); + } + }, []); + + const checkForUpdates = React.useCallback(async (silent: boolean) => { + if (updateCheckInFlightRef.current) return; + updateCheckInFlightRef.current = true; + const res = await (window as any).go.app.App.CheckForUpdates(); + updateCheckInFlightRef.current = false; + if (!res?.success) { + if (!silent) { + message.error('检查更新失败: ' + (res?.message || '未知错误')); + } + return; + } + const info: UpdateInfo = res.data; + if (!info) return; + if (info.hasUpdate) { + if (!silent) { + message.info(`发现新版本 ${info.latestVersion},开始下载...`); + } + await downloadUpdate(info, silent); + } else if (!silent) { + message.success(`当前已是最新版本(${info.currentVersion || '未知'})`); + } + }, [downloadUpdate]); + + const loadAboutInfo = React.useCallback(async () => { + setAboutLoading(true); + const res = await (window as any).go.app.App.GetAppInfo(); + if (res?.success) { + setAboutInfo(res.data); + } else { + message.error('获取应用信息失败: ' + (res?.message || '未知错误')); + } + setAboutLoading(false); + }, []); const handleNewQuery = () => { let connId = activeContext?.connectionId || ''; @@ -44,7 +143,8 @@ function App() { title: '新建查询', type: 'query', connectionId: connId, - dbName: db + dbName: db, + query: '' }); }; @@ -86,13 +186,7 @@ function App() { } }; - const settingsMenu: MenuProps['items'] = [ - { - key: 'sync', - label: '数据同步', - icon: , - onClick: () => setIsSyncModalOpen(true) - }, + const toolsMenu: MenuProps['items'] = [ { key: 'import', label: '导入连接配置', @@ -104,9 +198,16 @@ function App() { label: '导出连接配置', icon: , onClick: handleExportConnections + }, + { + key: 'sync', + label: '数据同步', + icon: , + onClick: () => setIsSyncModalOpen(true) } ]; + // Log Panel const [logPanelHeight, setLogPanelHeight] = useState(200); const [isLogPanelOpen, setIsLogPanelOpen] = useState(false); @@ -230,6 +331,25 @@ function App() { } }, [darkMode]); + useEffect(() => { + if (isAboutOpen) { + loadAboutInfo(); + } + }, [isAboutOpen, loadAboutInfo]); + + useEffect(() => { + const startupTimer = window.setTimeout(() => { + checkForUpdates(true); + }, 2000); + const interval = window.setInterval(() => { + checkForUpdates(true); + }, 30 * 60 * 1000); + return () => { + window.clearTimeout(startupTimer); + window.clearInterval(interval); + }; + }, [checkForUpdates]); + return ( - + +
+ + + + +
+ : } onClick={toggleDarkMode} title="切换主题" /> , + + ]} + > + {aboutLoading ? ( +
+ +
+ ) : ( + + )} + {/* Ghost Resize Line for Sidebar */}
void }> title: `新建查询`, type: 'query', connectionId: node.key, - dbName: undefined + dbName: undefined, + query: '' }); } }, @@ -1115,7 +1116,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> title: `新建查询 (${node.title})`, type: 'query', connectionId: node.dataRef.id, - dbName: node.title + dbName: node.title, + query: '' }); } }, @@ -1138,7 +1140,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> title: `新建查询`, type: 'query', connectionId: node.dataRef.id, - dbName: node.dataRef.dbName + dbName: node.dataRef.dbName, + query: '' }); } }, diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go new file mode 100644 index 0000000..e07bf0d --- /dev/null +++ b/internal/app/methods_update.go @@ -0,0 +1,606 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + stdRuntime "runtime" + "strings" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const ( + updateRepo = "Syngnat/GoNavi" + updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest" + updateChecksumAsset = "SHA256SUMS" +) + +type updateState struct { + lastCheck *UpdateInfo + downloading bool + staged *stagedUpdate +} + +type UpdateInfo struct { + HasUpdate bool `json:"hasUpdate"` + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + ReleaseName string `json:"releaseName"` + ReleaseNotesURL string `json:"releaseNotesUrl"` + AssetName string `json:"assetName"` + AssetURL string `json:"assetUrl"` + AssetSize int64 `json:"assetSize"` + SHA256 string `json:"sha256"` +} + +type AppInfo struct { + Version string `json:"version"` + Author string `json:"author"` + RepoURL string `json:"repoUrl,omitempty"` + IssueURL string `json:"issueUrl,omitempty"` + ReleaseURL string `json:"releaseUrl,omitempty"` + BuildTime string `json:"buildTime,omitempty"` +} + +type stagedUpdate struct { + Version string + AssetName string + FilePath string + StagedDir string +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Prerelease bool `json:"prerelease"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +func (a *App) CheckForUpdates() connection.QueryResult { + info, err := fetchLatestUpdateInfo() + if err != nil { + logger.Error(err, "检查更新失败") + return connection.QueryResult{Success: false, Message: err.Error()} + } + + a.updateMu.Lock() + a.updateState.lastCheck = &info + a.updateMu.Unlock() + + msg := "已是最新版本" + if info.HasUpdate { + msg = fmt.Sprintf("发现新版本:%s", info.LatestVersion) + } + return connection.QueryResult{Success: true, Message: msg, Data: info} +} + +func (a *App) GetAppInfo() connection.QueryResult { + info := AppInfo{ + Version: getCurrentVersion(), + Author: getCurrentAuthor(), + RepoURL: "https://github.com/" + updateRepo, + IssueURL: "https://github.com/" + updateRepo + "/issues", + ReleaseURL: "https://github.com/" + updateRepo + "/releases", + BuildTime: strings.TrimSpace(AppBuildTime), + } + return connection.QueryResult{Success: true, Message: "OK", Data: info} +} + +func (a *App) DownloadUpdate() connection.QueryResult { + a.updateMu.Lock() + if a.updateState.downloading { + a.updateMu.Unlock() + return connection.QueryResult{Success: false, Message: "更新包正在下载中,请稍后重试"} + } + info := a.updateState.lastCheck + if info == nil { + a.updateMu.Unlock() + return connection.QueryResult{Success: false, Message: "请先检查更新"} + } + if !info.HasUpdate { + a.updateMu.Unlock() + return connection.QueryResult{Success: false, Message: "当前已是最新版本"} + } + if info.AssetURL == "" || info.AssetName == "" { + a.updateMu.Unlock() + return connection.QueryResult{Success: false, Message: "未找到可用的更新包"} + } + if a.updateState.staged != nil && a.updateState.staged.Version == info.LatestVersion { + a.updateMu.Unlock() + return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: info} + } + a.updateState.downloading = true + a.updateMu.Unlock() + + result := a.downloadAndStageUpdate(*info) + + a.updateMu.Lock() + a.updateState.downloading = false + a.updateMu.Unlock() + + return result +} + +func (a *App) InstallUpdateAndRestart() connection.QueryResult { + a.updateMu.Lock() + staged := a.updateState.staged + a.updateMu.Unlock() + if staged == nil { + return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"} + } + + if err := launchUpdateScript(staged); err != nil { + logger.Error(err, "启动更新脚本失败") + return connection.QueryResult{Success: false, Message: err.Error()} + } + + go func() { + time.Sleep(300 * time.Millisecond) + wailsRuntime.Quit(a.ctx) + }() + + return connection.QueryResult{Success: true, Message: "更新已开始安装"} +} + +func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { + stagedDir, err := os.MkdirTemp("", "gonavi-update-") + if err != nil { + return connection.QueryResult{Success: false, Message: "创建临时目录失败"} + } + + assetPath := filepath.Join(stagedDir, info.AssetName) + actualHash, err := downloadFileWithHash(info.AssetURL, assetPath) + if err != nil { + _ = os.RemoveAll(stagedDir) + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if info.SHA256 == "" { + _ = os.RemoveAll(stagedDir) + return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"} + } + if !strings.EqualFold(info.SHA256, actualHash) { + _ = os.RemoveAll(stagedDir) + return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"} + } + + a.updateMu.Lock() + a.updateState.staged = &stagedUpdate{ + Version: info.LatestVersion, + AssetName: info.AssetName, + FilePath: assetPath, + StagedDir: stagedDir, + } + a.updateMu.Unlock() + + return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: info} +} + +func fetchLatestUpdateInfo() (UpdateInfo, error) { + release, err := fetchLatestRelease() + if err != nil { + return UpdateInfo{}, err + } + + currentVersion := getCurrentVersion() + latestVersion := normalizeVersion(release.TagName) + if latestVersion == "" { + return UpdateInfo{}, errors.New("无法解析最新版本号") + } + + assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH) + if err != nil { + return UpdateInfo{}, err + } + asset, err := findReleaseAsset(release.Assets, assetName) + if err != nil { + return UpdateInfo{}, err + } + + hashMap, err := fetchReleaseSHA256(release.Assets) + if err != nil { + return UpdateInfo{}, err + } + sha256Value := strings.TrimSpace(hashMap[assetName]) + if sha256Value == "" { + return UpdateInfo{}, errors.New("SHA256SUMS 未包含当前平台更新包") + } + + hasUpdate := compareVersion(currentVersion, latestVersion) < 0 + + return UpdateInfo{ + HasUpdate: hasUpdate, + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + ReleaseName: release.Name, + ReleaseNotesURL: release.HTMLURL, + AssetName: asset.Name, + AssetURL: asset.BrowserDownloadURL, + AssetSize: asset.Size, + SHA256: sha256Value, + }, nil +} + +func getCurrentAuthor() string { + if env := strings.TrimSpace(os.Getenv("GONAVI_AUTHOR")); env != "" { + return env + } + parts := strings.Split(updateRepo, "/") + if len(parts) > 0 { + return parts[0] + } + return "" +} + +func fetchLatestRelease() (*githubRelease, error) { + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "GoNavi-Updater") + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("检查更新失败:HTTP %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} + +func expectedAssetName(goos, goarch string) (string, error) { + switch goos { + case "windows": + if goarch == "amd64" { + return "GoNavi-windows-amd64.exe", nil + } + if goarch == "arm64" { + return "GoNavi-windows-arm64.exe", nil + } + case "darwin": + if goarch == "amd64" { + return "GoNavi-mac-amd64.dmg", nil + } + if goarch == "arm64" { + return "GoNavi-mac-arm64.dmg", nil + } + case "linux": + if goarch == "amd64" { + return "GoNavi-linux-amd64.tar.gz", nil + } + } + return "", fmt.Errorf("当前平台暂不支持在线更新:%s/%s", goos, goarch) +} + +func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) { + for _, asset := range assets { + if asset.Name == name { + return &asset, nil + } + } + return nil, fmt.Errorf("未找到更新包:%s", name) +} + +func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) { + var checksumURL string + for _, asset := range assets { + if strings.EqualFold(asset.Name, updateChecksumAsset) || strings.Contains(strings.ToLower(asset.Name), "sha256sums") { + checksumURL = asset.BrowserDownloadURL + break + } + } + if checksumURL == "" { + return nil, errors.New("Release 未提供 SHA256SUMS") + } + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, checksumURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "GoNavi-Updater") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载 SHA256SUMS 失败:HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return parseSHA256Sums(string(body)), nil +} + +func parseSHA256Sums(content string) map[string]string { + result := make(map[string]string) + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + hash := fields[0] + name := fields[len(fields)-1] + name = strings.TrimPrefix(name, "*") + name = strings.TrimPrefix(name, "./") + result[name] = hash + } + return result +} + +func downloadFileWithHash(url, filePath string) (string, error) { + client := &http.Client{Timeout: 10 * time.Minute} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "GoNavi-Updater") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("下载更新包失败:HTTP %d", resp.StatusCode) + } + + out, err := os.Create(filePath) + if err != nil { + return "", err + } + defer out.Close() + + hasher := sha256.New() + writer := io.MultiWriter(out, hasher) + if _, err := io.Copy(writer, resp.Body); err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +func launchUpdateScript(staged *stagedUpdate) error { + exePath, err := os.Executable() + if err != nil { + return err + } + exePath, _ = filepath.EvalSymlinks(exePath) + pid := os.Getpid() + + switch stdRuntime.GOOS { + case "windows": + return launchWindowsUpdate(staged, exePath, pid) + case "darwin": + return launchMacUpdate(staged, exePath, pid) + case "linux": + return launchLinuxUpdate(staged, exePath, pid) + default: + return fmt.Errorf("当前平台暂不支持更新安装:%s", stdRuntime.GOOS) + } +} + +func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error { + scriptPath := filepath.Join(staged.StagedDir, "update.cmd") + content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, pid) + if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil { + return err + } + + cmd := exec.Command("cmd", "/C", "start", "", scriptPath) + return cmd.Start() +} + +func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error { + targetApp := detectMacAppPath(targetExe) + if targetApp == "" { + targetApp = "/Applications/GoNavi.app" + } + mountDir := filepath.Join(staged.StagedDir, "mnt") + if err := os.MkdirAll(mountDir, 0o755); err != nil { + return err + } + + scriptPath := filepath.Join(staged.StagedDir, "update.sh") + content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, pid) + if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil { + return err + } + + cmd := exec.Command("/bin/sh", scriptPath) + return cmd.Start() +} + +func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error { + scriptPath := filepath.Join(staged.StagedDir, "update.sh") + content := buildLinuxScript(staged.FilePath, targetExe, staged.StagedDir, pid) + if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil { + return err + } + + cmd := exec.Command("/bin/sh", scriptPath) + return cmd.Start() +} + +func buildWindowsScript(source, target, stagedDir string, pid int) string { + return fmt.Sprintf(`@echo off +setlocal +set "SOURCE=%s" +set "TARGET=%s" +set "STAGED=%s" +set PID=%d +:waitloop +tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul +if %%ERRORLEVEL%%==0 ( + timeout /t 1 /nobreak >nul + goto waitloop +) +move /Y "%%SOURCE%%" "%%TARGET%%" >nul +start "" "%%TARGET%%" +rmdir /S /Q "%%STAGED%%" +exit /b 0 +`, source, target, stagedDir, pid) +} + +func buildMacScript(dmgPath, targetApp, stagedDir, mountDir string, pid int) string { + return fmt.Sprintf(`#!/bin/bash +set -e +PID=%d +DMG="%s" +TARGET_APP="%s" +STAGED="%s" +MOUNT_DIR="%s" +while kill -0 $PID 2>/dev/null; do + sleep 1 +done +hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR" +APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1) +if [ -z "$APP_SRC" ]; then + hdiutil detach "$MOUNT_DIR" -quiet || true + exit 1 +fi +rm -rf "$TARGET_APP" +cp -R "$APP_SRC" "$TARGET_APP" +hdiutil detach "$MOUNT_DIR" -quiet +rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" +open "$TARGET_APP" +`, pid, dmgPath, targetApp, stagedDir, mountDir) +} + +func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string { + return fmt.Sprintf(`#!/bin/bash +set -e +PID=%d +ARCHIVE="%s" +TARGET="%s" +STAGED="%s" +while kill -0 $PID 2>/dev/null; do + sleep 1 +done +TMPDIR=$(mktemp -d) +tar -xzf "$ARCHIVE" -C "$TMPDIR" +NEWBIN="$TMPDIR/GoNavi" +if [ ! -f "$NEWBIN" ]; then + NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1) +fi +if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then + exit 1 +fi +cp -f "$NEWBIN" "$TARGET" +chmod +x "$TARGET" +rm -rf "$TMPDIR" "$ARCHIVE" "$STAGED" +"$TARGET" & +`, pid, tarPath, targetExe, stagedDir) +} + +func detectMacAppPath(exePath string) string { + parts := strings.Split(exePath, string(filepath.Separator)) + for i := len(parts) - 1; i >= 0; i-- { + if strings.HasSuffix(parts[i], ".app") { + return filepath.Join(parts[:i+1]...) + } + } + return "" +} + +func normalizeVersion(version string) string { + version = strings.TrimSpace(version) + version = strings.TrimPrefix(version, "v") + return version +} + +func compareVersion(current, latest string) int { + current = normalizeVersion(current) + latest = normalizeVersion(latest) + if current == "" { + return -1 + } + if current == latest { + return 0 + } + + curParts := splitVersionParts(current) + latParts := splitVersionParts(latest) + max := len(curParts) + if len(latParts) > max { + max = len(latParts) + } + for i := 0; i < max; i++ { + cur := 0 + lat := 0 + if i < len(curParts) { + cur = curParts[i] + } + if i < len(latParts) { + lat = latParts[i] + } + if cur < lat { + return -1 + } + if cur > lat { + return 1 + } + } + return 0 +} + +func splitVersionParts(version string) []int { + parts := strings.Split(version, ".") + result := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + result = append(result, 0) + continue + } + num := 0 + for _, ch := range part { + if ch < '0' || ch > '9' { + break + } + num = num*10 + int(ch-'0') + } + result = append(result, num) + } + return result +} diff --git a/internal/app/version.go b/internal/app/version.go new file mode 100644 index 0000000..4871a6e --- /dev/null +++ b/internal/app/version.go @@ -0,0 +1,53 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +var AppVersion = "0.0.0" +var AppBuildTime = "" + +func getCurrentVersion() string { + version := strings.TrimSpace(AppVersion) + if version == "" || version == "0.0.0" { + if env := strings.TrimSpace(os.Getenv("GONAVI_VERSION")); env != "" { + version = env + } else if pkgVersion, err := readPackageVersion(); err == nil && pkgVersion != "" { + version = pkgVersion + } + } + return normalizeVersion(version) +} + +func readPackageVersion() (string, error) { + paths := []string{ + filepath.Join("frontend", "package.json"), + } + exe, err := os.Executable() + if err == nil { + base := filepath.Dir(exe) + paths = append(paths, filepath.Join(base, "frontend", "package.json")) + paths = append(paths, filepath.Join(base, "..", "frontend", "package.json")) + } + + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + continue + } + var payload struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &payload); err != nil { + continue + } + if strings.TrimSpace(payload.Version) != "" { + return strings.TrimSpace(payload.Version), nil + } + } + + return "", os.ErrNotExist +}