feat(updater): 接入 GitHub Release 在线更新与关于信息展示

- 后端新增更新检查/下载/安装流程与应用信息接口
  - 关于弹窗展示版本/作者/仓库/Issue/Release,并内置检查更新
  - 构建/发布注入版本号并生成 SHA256SUMS
  - 顶部工具栏入口调整与新建查询补全默认空 SQL
This commit is contained in:
Syngnat
2026-02-05 16:50:44 +08:00
parent 8654485cfe
commit ec4b3d9018
7 changed files with 900 additions and 27 deletions

View File

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

View File

@@ -64,6 +64,9 @@
- **连接配置导入/导出**:支持配置 JSON 导入导出,便于团队共享。
- **数据同步**:内置数据同步面板,支持跨库同步任务配置。
### 🆙 在线更新
- **自动更新**:启动/定时/手动检查更新,自动下载并提示重启完成更新。
### 🧾 可观测性
- **SQL 执行日志**:实时查看 SQL 与执行耗时,便于排障与优化。

View File

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

View File

@@ -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<string | null>(null);
const updateDeferredVersionRef = React.useRef<string | null>(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: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
const toolsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
@@ -104,9 +198,16 @@ function App() {
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
},
{
key: 'sync',
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
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 (
<ConfigProvider
locale={zhCN}
@@ -237,7 +357,26 @@ function App() {
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Layout style={{ height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div
style={{
height: 36,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 4,
padding: '0 8px',
borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
background: darkMode ? '#141414' : '#fff'
}}
>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
<Button type="text" icon={<ToolOutlined />} title="工具"></Button>
</Dropdown>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}></Button>
</div>
<Layout style={{ flex: 1, minHeight: 0 }}>
<Sider
theme={darkMode ? "dark" : "light"}
width={sidebarWidth}
@@ -253,11 +392,8 @@ function App() {
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
<Dropdown menu={{ items: settingsMenu }} placement="bottomRight">
<Button type="text" icon={<SettingOutlined />} title="更多设置" />
</Dropdown>
</div>
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Sidebar onEditConnection={handleEditConnection} />
@@ -304,6 +440,7 @@ function App() {
/>
)}
</Content>
</Layout>
<ConnectionModal
open={isModalOpen}
onClose={handleCloseModal}
@@ -313,6 +450,50 @@ function App() {
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}
/>
<Modal
title="关于 GoNavi"
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
footer={[
<Button key="check" icon={<CloudDownloadOutlined />} onClick={() => checkForUpdates(false)}></Button>,
<Button key="close" type="primary" onClick={() => setIsAboutOpen(false)}></Button>
]}
>
{aboutLoading ? (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<Spin />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>{aboutInfo?.version || '未知'}</div>
<div>{aboutInfo?.author || '未知'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<GithubOutlined />
{aboutInfo?.repoUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
{aboutInfo.repoUrl}
</a>
) : '未知'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BugOutlined />
{aboutInfo?.issueUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
{aboutInfo.issueUrl}
</a>
) : '未知'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CloudDownloadOutlined />
{aboutInfo?.releaseUrl ? (
<a onClick={(e) => { e.preventDefault(); (window as any).runtime.BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
{aboutInfo.releaseUrl}
</a>
) : '未知'}
</div>
</div>
)}
</Modal>
{/* Ghost Resize Line for Sidebar */}
<div

View File

@@ -979,7 +979,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => 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: ''
});
}
},

View File

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

53
internal/app/version.go Normal file
View File

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