diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a35542e1..092a4b01 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1695,6 +1695,7 @@ function App() {
aboutLoading,
aboutUpdateStatus,
canShowProgressEntry,
+ changeUpdateChannel,
checkForUpdates,
downloadUpdate,
formatBytes,
@@ -1703,11 +1704,14 @@ function App() {
isAboutOpen,
isBackgroundProgressForLatestUpdate,
isLatestUpdateDownloaded,
+ isUpdateChannelLoading,
+ isUpdateChannelSaving,
lastUpdateInfo,
markUpdateProgressDismissed,
muteLatestUpdate,
setIsAboutOpen,
showUpdateDownloadProgress,
+ updateChannel,
updateDownloadProgress,
} = useAppUpdateManager({
isMacRuntime,
@@ -4998,6 +5002,27 @@ function App() {
{t('app.about.field.update_status')}
{aboutUpdateStatus || t('app.about.update_status.not_checked')}
+
+
{t('app.about.field.update_channel')}
+
{aboutInfo?.communityUrl ? (
{t('app.about.field.community')}
diff --git a/frontend/src/hooks/useAppUpdateManager.test.tsx b/frontend/src/hooks/useAppUpdateManager.test.tsx
index 6b1296f2..2d9703d8 100644
--- a/frontend/src/hooks/useAppUpdateManager.test.tsx
+++ b/frontend/src/hooks/useAppUpdateManager.test.tsx
@@ -24,8 +24,10 @@ type BackendAppMock = {
CheckForUpdates: ReturnType
;
CheckForUpdatesSilently: ReturnType;
DownloadUpdate: ReturnType;
+ GetUpdateChannel: ReturnType;
InstallUpdateAndRestart: ReturnType;
OpenDownloadedUpdateDirectory: ReturnType;
+ SetUpdateChannel: ReturnType;
GetAppInfo: ReturnType;
};
@@ -33,8 +35,10 @@ const createBackendAppMock = (): BackendAppMock => ({
CheckForUpdates: vi.fn(),
CheckForUpdatesSilently: vi.fn(),
DownloadUpdate: vi.fn(),
+ GetUpdateChannel: vi.fn(async () => ({ success: true, data: { channel: 'latest' } })),
InstallUpdateAndRestart: vi.fn(),
OpenDownloadedUpdateDirectory: vi.fn(),
+ SetUpdateChannel: vi.fn(async (channel: string) => ({ success: true, data: { channel } })),
GetAppInfo: vi.fn(async () => ({ success: true, data: { version: '0.8.1', author: 'Syngnat' } })),
});
@@ -156,4 +160,28 @@ describe('useAppUpdateManager', () => {
expect(backendApp.OpenDownloadedUpdateDirectory).not.toHaveBeenCalled();
expect(hook?.lastUpdateInfo?.downloaded).toBe(true);
});
+
+ it('switches update channel and re-checks against the selected channel', async () => {
+ backendApp.SetUpdateChannel.mockResolvedValue({ success: true, data: { channel: 'dev' } });
+ backendApp.CheckForUpdates.mockResolvedValue({
+ success: true,
+ data: {
+ hasUpdate: false,
+ channel: 'dev',
+ currentVersion: '0.8.1',
+ latestVersion: 'dev-a1b2c3d',
+ },
+ });
+
+ renderHook(false);
+
+ await act(async () => {
+ await hook?.changeUpdateChannel('dev');
+ });
+
+ expect(backendApp.SetUpdateChannel).toHaveBeenCalledWith('dev');
+ expect(backendApp.CheckForUpdates).toHaveBeenCalledTimes(1);
+ expect(hook?.updateChannel).toBe('dev');
+ expect(hook?.lastUpdateInfo?.channel).toBe('dev');
+ });
});
diff --git a/frontend/src/hooks/useAppUpdateManager.ts b/frontend/src/hooks/useAppUpdateManager.ts
index 6f36d333..1c947cba 100644
--- a/frontend/src/hooks/useAppUpdateManager.ts
+++ b/frontend/src/hooks/useAppUpdateManager.ts
@@ -5,8 +5,11 @@ import { resolveAboutDisplayVersion } from '../utils/appVersionDisplay';
type Translator = (key: string, params?: Record) => string;
+export type UpdateChannel = 'latest' | 'dev';
+
export type UpdateInfo = {
hasUpdate: boolean;
+ channel?: UpdateChannel | string;
currentVersion: string;
latestVersion: string;
releaseName?: string;
@@ -52,8 +55,16 @@ const createEmptyDownloadProgress = () => ({
message: '',
});
+const normalizeUpdateChannel = (value: unknown): UpdateChannel =>
+ String(value || '').trim().toLowerCase() === 'dev' ? 'dev' : 'latest';
+
+const buildUpdateKey = (info: Pick | null | undefined): string =>
+ info?.latestVersion
+ ? `${normalizeUpdateChannel(info.channel)}:${String(info.latestVersion || '').trim()}`
+ : '';
+
export const useAppUpdateManager = ({
- isMacRuntime,
+ isMacRuntime: _isMacRuntime,
runtimeBuildType,
t,
}: UseAppUpdateManagerOptions) => {
@@ -68,6 +79,9 @@ export const useAppUpdateManager = ({
const [isAboutOpen, setIsAboutOpen] = useState(false);
const isAboutOpenRef = useRef(false);
const [aboutLoading, setAboutLoading] = useState(false);
+ const [updateChannel, setUpdateChannelState] = useState('latest');
+ const [isUpdateChannelLoading, setIsUpdateChannelLoading] = useState(false);
+ const [isUpdateChannelSaving, setIsUpdateChannelSaving] = useState(false);
const [aboutInfo, setAboutInfo] = useState<{
version: string;
author: string;
@@ -81,13 +95,14 @@ export const useAppUpdateManager = ({
const [aboutUpdateStatus, setAboutUpdateStatus] = useState('');
const [lastUpdateInfo, setLastUpdateInfo] = useState(null);
const [updateDownloadProgress, setUpdateDownloadProgress] = useState(createEmptyDownloadProgress);
+ const lastUpdateKey = buildUpdateKey(lastUpdateInfo);
const formatAboutUpdateStatus = useCallback((info: UpdateInfo | null): string => {
if (!info) {
return t('app.about.update_status.not_checked');
}
if (info.hasUpdate) {
- const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
+ const localDownloaded = updateDownloadedVersionRef.current === buildUpdateKey(info);
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
return hasDownloaded
? t('app.about.update_status.new_version_downloaded', { version: info.latestVersion })
@@ -108,9 +123,17 @@ export const useAppUpdateManager = ({
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
}, []);
+ const resetLocalUpdateArtifacts = useCallback(() => {
+ updateDownloadedVersionRef.current = null;
+ updateInstallTriggeredVersionRef.current = null;
+ updateDownloadMetaRef.current = null;
+ setUpdateDownloadProgress(createEmptyDownloadProgress());
+ }, []);
+
const downloadUpdate = useCallback(async (info: UpdateInfo, silent: boolean) => {
if (updateDownloadInFlightRef.current) return;
- if (updateDownloadedVersionRef.current === info.latestVersion) {
+ const targetKey = buildUpdateKey(info);
+ if (updateDownloadedVersionRef.current === targetKey) {
if (!silent) {
const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath;
void message.info(cachedDownloadPath
@@ -125,7 +148,7 @@ export const useAppUpdateManager = ({
updateDownloadMetaRef.current = null;
setUpdateDownloadProgress({
open: true,
- version: info.latestVersion,
+ version: targetKey,
status: 'start',
percent: 0,
downloaded: 0,
@@ -142,7 +165,7 @@ export const useAppUpdateManager = ({
if (res?.success) {
const resultData = (res?.data || {}) as UpdateDownloadResultData;
updateDownloadMetaRef.current = resultData;
- updateDownloadedVersionRef.current = info.latestVersion;
+ updateDownloadedVersionRef.current = targetKey;
setUpdateDownloadProgress((prev) => {
const total = prev.total > 0 ? prev.total : (info.assetSize || 0);
return { ...prev, status: 'done', percent: 100, downloaded: total, total, message: '', open: false };
@@ -151,12 +174,14 @@ export const useAppUpdateManager = ({
if (!prev || prev.latestVersion !== info.latestVersion) {
return {
...info,
+ channel: normalizeUpdateChannel(info.channel),
downloaded: true,
downloadPath: resultData?.downloadPath || info.downloadPath,
};
}
return {
...prev,
+ channel: normalizeUpdateChannel(prev.channel || info.channel),
downloaded: true,
downloadPath: resultData?.downloadPath || prev.downloadPath || info.downloadPath,
};
@@ -166,7 +191,7 @@ export const useAppUpdateManager = ({
} else {
void message.success({ content: t('app.about.message.download_completed'), duration: 2 });
}
- setAboutUpdateStatus(formatAboutUpdateStatus({ ...info, downloaded: true }));
+ setAboutUpdateStatus(formatAboutUpdateStatus({ ...info, channel: normalizeUpdateChannel(info.channel), downloaded: true }));
} else {
setUpdateDownloadProgress((prev) => ({
...prev,
@@ -175,7 +200,7 @@ export const useAppUpdateManager = ({
}));
void message.error({ content: t('app.about.message.download_failed_with_error', { error: res?.message || t('common.unknown') }), duration: 4 });
}
- }, [formatAboutUpdateStatus, isMacRuntime, t]);
+ }, [formatAboutUpdateStatus, t]);
const showUpdateDownloadProgress = useCallback(() => {
setUpdateDownloadProgress((prev) => {
@@ -190,21 +215,21 @@ export const useAppUpdateManager = ({
const isLatestUpdateDownloaded = Boolean(lastUpdateInfo?.hasUpdate) && (
Boolean(lastUpdateInfo?.downloaded)
- || (Boolean(lastUpdateInfo?.latestVersion) && updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion)
+ || (Boolean(lastUpdateKey) && updateDownloadedVersionRef.current === lastUpdateKey)
);
const isBackgroundProgressForLatestUpdate = Boolean(lastUpdateInfo?.hasUpdate)
- && Boolean(lastUpdateInfo?.latestVersion)
- && updateDownloadProgress.version === lastUpdateInfo?.latestVersion
+ && Boolean(lastUpdateKey)
+ && updateDownloadProgress.version === lastUpdateKey
&& (updateDownloadProgress.status === 'start'
|| updateDownloadProgress.status === 'downloading'
|| updateDownloadProgress.status === 'done'
|| updateDownloadProgress.status === 'error');
const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate)
- && updateInstallTriggeredVersionRef.current !== (lastUpdateInfo?.latestVersion || null);
+ && updateInstallTriggeredVersionRef.current !== (lastUpdateKey || null);
const handleInstallFromProgress = useCallback(async () => {
const canInstall = updateDownloadProgress.status === 'done'
- || (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion));
+ || (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateKey));
if (!canInstall) {
return;
}
@@ -213,9 +238,9 @@ export const useAppUpdateManager = ({
void message.error(t('app.about.message.install_failed_with_error', { error: res?.message || t('common.unknown') }));
return;
}
- updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null;
+ updateInstallTriggeredVersionRef.current = lastUpdateKey || null;
hideUpdateDownloadProgress();
- }, [hideUpdateDownloadProgress, lastUpdateInfo, updateDownloadProgress.status, updateDownloadProgress.version, t]);
+ }, [hideUpdateDownloadProgress, lastUpdateInfo, lastUpdateKey, t, updateDownloadProgress.status]);
const checkForUpdates = useCallback(async (silent: boolean) => {
if (updateCheckInFlightRef.current) return;
@@ -237,19 +262,24 @@ export const useAppUpdateManager = ({
}
return;
}
- const info: UpdateInfo = res.data;
+ const info: UpdateInfo = {
+ ...(res.data || {}),
+ channel: normalizeUpdateChannel(res?.data?.channel),
+ };
if (!info) return;
+ setUpdateChannelState(normalizeUpdateChannel(info.channel));
const aboutOpen = isAboutOpenRef.current;
if (info.hasUpdate) {
- if (!info.downloaded && updateDownloadedVersionRef.current === info.latestVersion) {
+ const infoKey = buildUpdateKey(info);
+ if (!info.downloaded && updateDownloadedVersionRef.current === infoKey) {
updateDownloadedVersionRef.current = null;
updateDownloadMetaRef.current = null;
}
- const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
+ const localDownloaded = updateDownloadedVersionRef.current === infoKey;
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
if (hasDownloaded) {
const downloadPath = info.downloadPath || updateDownloadMetaRef.current?.downloadPath || '';
- updateDownloadedVersionRef.current = info.latestVersion;
+ updateDownloadedVersionRef.current = infoKey;
updateDownloadMetaRef.current = {
...(updateDownloadMetaRef.current || {}),
info,
@@ -262,8 +292,8 @@ export const useAppUpdateManager = ({
const total = info.assetSize || prev.total || 0;
return {
...prev,
- open: prev.open && prev.version === info.latestVersion,
- version: info.latestVersion,
+ open: prev.open && prev.version === infoKey,
+ version: infoKey,
status: 'done',
percent: 100,
downloaded: total,
@@ -277,7 +307,7 @@ export const useAppUpdateManager = ({
downloadPath: downloadPath || undefined,
});
} else {
- if (updateDownloadedVersionRef.current !== info.latestVersion) {
+ if (updateDownloadedVersionRef.current !== infoKey) {
updateDownloadMetaRef.current = null;
}
setUpdateDownloadProgress((prev) => {
@@ -287,7 +317,7 @@ export const useAppUpdateManager = ({
return {
...prev,
open: false,
- version: info.latestVersion,
+ version: infoKey,
status: 'idle',
percent: 0,
downloaded: 0,
@@ -305,8 +335,8 @@ export const useAppUpdateManager = ({
if (silent && aboutOpen) {
setAboutUpdateStatus(statusText);
}
- if (silent && !aboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
- updateNotifiedVersionRef.current = info.latestVersion;
+ if (silent && !aboutOpen && updateMutedVersionRef.current !== infoKey && updateNotifiedVersionRef.current !== infoKey) {
+ updateNotifiedVersionRef.current = infoKey;
setIsAboutOpen(true);
}
} else if (!silent) {
@@ -346,12 +376,63 @@ export const useAppUpdateManager = ({
setAboutLoading(false);
}, [t]);
+ const loadUpdateChannel = useCallback(async () => {
+ const backendApp = (window as any).go?.app?.App;
+ if (typeof backendApp?.GetUpdateChannel !== 'function') {
+ return;
+ }
+ setIsUpdateChannelLoading(true);
+ try {
+ const res = await backendApp.GetUpdateChannel();
+ if (res?.success) {
+ setUpdateChannelState(normalizeUpdateChannel(res?.data?.channel));
+ }
+ } catch (e) {
+ console.warn('Wails API: GetUpdateChannel unavailable', e);
+ } finally {
+ setIsUpdateChannelLoading(false);
+ }
+ }, []);
+
+ const changeUpdateChannel = useCallback(async (nextChannel: UpdateChannel | string) => {
+ const normalizedChannel = normalizeUpdateChannel(nextChannel);
+ const backendApp = (window as any).go?.app?.App;
+ if (typeof backendApp?.SetUpdateChannel !== 'function') {
+ setUpdateChannelState(normalizedChannel);
+ resetLocalUpdateArtifacts();
+ setLastUpdateInfo(null);
+ setAboutUpdateStatus(t('app.about.update_status.not_checked'));
+ return;
+ }
+
+ setIsUpdateChannelSaving(true);
+ try {
+ const res = await backendApp.SetUpdateChannel(normalizedChannel);
+ if (!res?.success) {
+ void message.error(t('app.about.message.channel_switch_failed_with_error', { error: res?.message || t('common.unknown') }));
+ return;
+ }
+
+ const effectiveChannel = normalizeUpdateChannel(res?.data?.channel || normalizedChannel);
+ setUpdateChannelState(effectiveChannel);
+ resetLocalUpdateArtifacts();
+ setLastUpdateInfo(null);
+ setAboutUpdateStatus(t('app.about.update_status.not_checked'));
+ await checkForUpdates(false);
+ } catch (e: any) {
+ const error = e?.message || t('common.unknown');
+ void message.error(t('app.about.message.channel_switch_failed_with_error', { error }));
+ } finally {
+ setIsUpdateChannelSaving(false);
+ }
+ }, [checkForUpdates, resetLocalUpdateArtifacts, t]);
+
const muteLatestUpdate = useCallback(() => {
- if (lastUpdateInfo?.latestVersion) {
- updateMutedVersionRef.current = lastUpdateInfo.latestVersion;
+ if (lastUpdateKey) {
+ updateMutedVersionRef.current = lastUpdateKey;
}
setIsAboutOpen(false);
- }, [lastUpdateInfo?.latestVersion]);
+ }, [lastUpdateKey]);
const markUpdateProgressDismissed = useCallback(() => {
updateUserDismissedRef.current = true;
@@ -368,6 +449,10 @@ export const useAppUpdateManager = ({
}
}, [formatAboutUpdateStatus, isAboutOpen, lastUpdateInfo, loadAboutInfo]);
+ useEffect(() => {
+ void loadUpdateChannel();
+ }, [loadUpdateChannel]);
+
useEffect(() => {
const startupTimer = window.setTimeout(() => {
void checkForUpdates(true);
@@ -421,6 +506,7 @@ export const useAppUpdateManager = ({
aboutLoading,
aboutUpdateStatus,
canShowProgressEntry,
+ changeUpdateChannel,
checkForUpdates,
downloadUpdate,
formatBytes,
@@ -429,11 +515,14 @@ export const useAppUpdateManager = ({
isAboutOpen,
isBackgroundProgressForLatestUpdate,
isLatestUpdateDownloaded,
+ isUpdateChannelLoading,
+ isUpdateChannelSaving,
lastUpdateInfo,
markUpdateProgressDismissed,
muteLatestUpdate,
setIsAboutOpen,
showUpdateDownloadProgress,
+ updateChannel,
updateDownloadProgress,
};
};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index e4256e74..b07e0235 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -105,6 +105,7 @@ if (
];
let mockSkills: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
+ let mockUpdateChannel: 'latest' | 'dev' = 'latest';
let mockDataRootInfo: any = {
path: 'C:/mock/.gonavi',
defaultPath: 'C:/mock/.gonavi',
@@ -343,10 +344,27 @@ if (
bindingStatus: 'active',
});
},
- GetAppInfo: async () => ({}),
+ GetAppInfo: async () => ({ success: true, data: { version: '0.0.0', author: 'GoNavi' } }),
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
- CheckForUpdates: async () => ({ success: false }),
- CheckForUpdatesSilently: async () => ({ success: false }),
+ CheckForUpdates: async () => ({
+ success: true,
+ data: {
+ hasUpdate: false,
+ channel: mockUpdateChannel,
+ currentVersion: '0.0.0',
+ latestVersion: mockUpdateChannel === 'dev' ? 'dev-browser-mock' : '0.0.0',
+ },
+ }),
+ CheckForUpdatesSilently: async () => ({
+ success: true,
+ data: {
+ hasUpdate: false,
+ channel: mockUpdateChannel,
+ currentVersion: '0.0.0',
+ latestVersion: mockUpdateChannel === 'dev' ? 'dev-browser-mock' : '0.0.0',
+ },
+ }),
+ GetUpdateChannel: async () => ({ success: true, data: { channel: mockUpdateChannel } }),
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
OpenDataRootDirectory: async () => ({ success: true }),
@@ -408,6 +426,10 @@ if (
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: t('app.browser_mock.export_connection_package_unsupported') }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
+ SetUpdateChannel: async (channel: string) => {
+ mockUpdateChannel = String(channel || '').trim().toLowerCase() === 'dev' ? 'dev' : 'latest';
+ return { success: true, data: { channel: mockUpdateChannel } };
+ },
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }),
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index c49bd8e9..7bd17344 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -170,6 +170,8 @@ export function GetSlowQueries(arg1:connection.ConnectionConfig,arg2:string,arg3
export function GetUnboundSavedQueries():Promise>;
+export function GetUpdateChannel():Promise;
+
export function ImportConfigFile():Promise;
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise>;
@@ -352,12 +354,16 @@ export function SelectDriverPackageFile(arg1:string):Promise;
+export function SelectSQLFileForExecution():Promise;
+
export function SelectSSHKeyFile(arg1:string):Promise;
export function SetLanguage(arg1:string):Promise;
export function SetMacNativeWindowControls(arg1:boolean):Promise;
+export function SetUpdateChannel(arg1:string):Promise;
+
export function SetWindowTranslucency(arg1:number,arg2:number):Promise;
export function Shutdown():Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index d3847a99..49d7e794 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -330,6 +330,10 @@ export function GetUnboundSavedQueries() {
return window['go']['app']['App']['GetUnboundSavedQueries']();
}
+export function GetUpdateChannel() {
+ return window['go']['app']['App']['GetUpdateChannel']();
+}
+
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
@@ -694,6 +698,10 @@ export function SelectSQLDirectory(arg1) {
return window['go']['app']['App']['SelectSQLDirectory'](arg1);
}
+export function SelectSQLFileForExecution() {
+ return window['go']['app']['App']['SelectSQLFileForExecution']();
+}
+
export function SelectSSHKeyFile(arg1) {
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
}
@@ -706,6 +714,10 @@ export function SetMacNativeWindowControls(arg1) {
return window['go']['app']['App']['SetMacNativeWindowControls'](arg1);
}
+export function SetUpdateChannel(arg1) {
+ return window['go']['app']['App']['SetUpdateChannel'](arg1);
+}
+
export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
diff --git a/go.mod b/go.mod
index 6870fe78..4e53a850 100644
--- a/go.mod
+++ b/go.mod
@@ -23,6 +23,7 @@ require (
github.com/redis/go-redis/v9 v9.17.3
github.com/segmentio/kafka-go v0.4.51
github.com/sijms/go-ora/v2 v2.9.0
+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/taosdata/driver-go/v3 v3.7.8
github.com/trinodb/trino-go-client v0.333.0
github.com/wailsapp/wails/v2 v2.11.0
@@ -59,7 +60,6 @@ require (
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.14.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go
index 1fc8bd95..4dbe8524 100644
--- a/internal/app/methods_update.go
+++ b/internal/app/methods_update.go
@@ -27,13 +27,15 @@ import (
const (
updateRepo = "Syngnat/GoNavi"
- updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
+ updateLatestAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
+ updateDevAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/tags/" + updateDevReleaseTag
updateChecksumAsset = "SHA256SUMS"
updateDownloadProgressEvent = "update:download-progress"
)
var (
updateFetchLatestRelease = fetchLatestRelease
+ updateFetchDevRelease = fetchDevRelease
updateFetchReleaseSHA256 = fetchReleaseSHA256
updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") }
)
@@ -46,6 +48,7 @@ type updateState struct {
type UpdateInfo struct {
HasUpdate bool `json:"hasUpdate"`
+ Channel string `json:"channel"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseName string `json:"releaseName"`
@@ -86,6 +89,7 @@ type updateDownloadProgressPayload struct {
}
type stagedUpdate struct {
+ Channel updateChannel
Version string
AssetName string
FilePath string
@@ -138,7 +142,8 @@ func (a *App) CheckForUpdatesSilently() connection.QueryResult {
}
func (a *App) checkForUpdates(logFailure bool) connection.QueryResult {
- info, err := fetchLatestUpdateInfo()
+ channel := a.currentUpdateChannel()
+ info, err := fetchLatestUpdateInfo(channel)
if err != nil {
if logFailure {
updateLogCheckError(err)
@@ -157,7 +162,7 @@ func (a *App) checkForUpdates(logFailure bool) connection.QueryResult {
info.Downloaded = true
info.DownloadPath = reusable.FilePath
currentStaged = reusable
- } else if currentStaged != nil && currentStaged.Version != info.LatestVersion {
+ } else if currentStaged != nil && (currentStaged.Version != info.LatestVersion || currentStaged.Channel != updateChannel(info.Channel)) {
currentStaged = nil
}
} else {
@@ -336,7 +341,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
}
// 使用版本号命名的工作目录,便于识别和调试
- stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
+ stagedDir := filepath.Join(workspaceDir, buildUpdateStageDirName(info.Channel, info.LatestVersion))
// 清理可能残留的旧目录(上次下载失败后未清理)
// Windows 上文件可能被杀毒软件/索引服务占用,需要重试
for retry := 0; retry < 5; retry++ {
@@ -348,7 +353,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
} else {
// 最后一次仍然失败,换一个带时间戳的目录名避免冲突
- stagedDir = filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s-%d", stdRuntime.GOOS, info.LatestVersion, time.Now().UnixNano()))
+ stagedDir = filepath.Join(workspaceDir, fmt.Sprintf("%s-%d", buildUpdateStageDirName(info.Channel, info.LatestVersion), time.Now().UnixNano()))
}
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
@@ -390,6 +395,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
}
staged := &stagedUpdate{
+ Channel: updateChannel(info.Channel),
Version: info.LatestVersion,
AssetName: info.AssetName,
FilePath: assetPath,
@@ -406,22 +412,31 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
return connection.QueryResult{Success: true, Message: a.appText("app.update.backend.message.package_downloaded", nil), Data: buildUpdateDownloadResult(info, staged)}
}
-func fetchLatestUpdateInfo() (UpdateInfo, error) {
- release, err := updateFetchLatestRelease()
+func fetchLatestUpdateInfo(channel updateChannel) (UpdateInfo, error) {
+ if channel != updateChannelDev {
+ channel = updateChannelLatest
+ }
+ release, err := fetchReleaseForChannel(channel)
if err != nil {
return UpdateInfo{}, err
}
currentVersion := getCurrentVersion()
- latestVersion := normalizeVersion(release.TagName)
+ latestVersion := resolveReleaseVersion(channel, release)
if latestVersion == "" {
return UpdateInfo{}, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
}
- hasUpdate := compareVersion(currentVersion, latestVersion) < 0
+ hasUpdate := false
+ if channel == updateChannelDev {
+ hasUpdate = normalizeVersion(currentVersion) != latestVersion
+ } else {
+ hasUpdate = compareVersion(currentVersion, latestVersion) < 0
+ }
if !hasUpdate {
return UpdateInfo{
HasUpdate: false,
+ Channel: string(channel),
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseName: release.Name,
@@ -430,7 +445,7 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
}
assetVersion := strings.TrimSpace(release.TagName)
- if assetVersion == "" {
+ if assetVersion == "" || strings.EqualFold(normalizeVersion(assetVersion), updateDevReleaseTag) {
assetVersion = latestVersion
}
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, assetVersion)
@@ -452,6 +467,7 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
}
return UpdateInfo{
HasUpdate: hasUpdate,
+ Channel: string(channel),
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseName: release.Name,
@@ -463,6 +479,13 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
}, nil
}
+func fetchReleaseForChannel(channel updateChannel) (*githubRelease, error) {
+ if channel == updateChannelDev {
+ return updateFetchDevRelease()
+ }
+ return updateFetchLatestRelease()
+}
+
func swapUpdateFetchLatestRelease(next func() (*githubRelease, error)) func() {
original := updateFetchLatestRelease
updateFetchLatestRelease = next
@@ -471,6 +494,14 @@ func swapUpdateFetchLatestRelease(next func() (*githubRelease, error)) func() {
}
}
+func swapUpdateFetchDevRelease(next func() (*githubRelease, error)) func() {
+ original := updateFetchDevRelease
+ updateFetchDevRelease = next
+ return func() {
+ updateFetchDevRelease = original
+ }
+}
+
func swapUpdateFetchReleaseSHA256(next func([]githubAsset) (map[string]string, error)) func() {
original := updateFetchReleaseSHA256
updateFetchReleaseSHA256 = next
@@ -499,8 +530,16 @@ func getCurrentAuthor() string {
}
func fetchLatestRelease() (*githubRelease, error) {
+ return fetchReleaseByURL(updateLatestAPIURL)
+}
+
+func fetchDevRelease() (*githubRelease, error) {
+ return fetchReleaseByURL(updateDevAPIURL)
+}
+
+func fetchReleaseByURL(apiURL string) (*githubRelease, error) {
client := newHTTPClientWithGlobalProxy(15 * time.Second)
- req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil)
+ req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
@@ -806,6 +845,84 @@ func buildUpdateInstallLogPath(baseDir string) string {
return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano()))
}
+func buildUpdateStageDirName(channel string, version string) string {
+ normalizedChannel, err := normalizeUpdateChannel(channel)
+ if err != nil {
+ normalizedChannel = updateChannelLatest
+ }
+ return fmt.Sprintf(
+ ".gonavi-update-%s-%s-%s",
+ stdRuntime.GOOS,
+ sanitizeVersionForPath(string(normalizedChannel)),
+ sanitizeVersionForPath(version),
+ )
+}
+
+func resolveReleaseVersion(channel updateChannel, release *githubRelease) string {
+ if release == nil {
+ return ""
+ }
+
+ tagVersion := normalizeVersion(release.TagName)
+ if channel != updateChannelDev && tagVersion != "" && !strings.EqualFold(tagVersion, updateDevReleaseTag) {
+ return tagVersion
+ }
+
+ if nameVersion := extractVersionFromReleaseName(release.Name); nameVersion != "" {
+ return nameVersion
+ }
+ if assetVersion := extractVersionFromReleaseAssets(release.Assets); assetVersion != "" {
+ return assetVersion
+ }
+ if tagVersion != "" && !strings.EqualFold(tagVersion, updateDevReleaseTag) {
+ return tagVersion
+ }
+ return ""
+}
+
+func extractVersionFromReleaseName(name string) string {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ return ""
+ }
+
+ if strings.HasPrefix(strings.ToLower(trimmed), "dev-") {
+ return normalizeVersion(trimmed)
+ }
+
+ if left := strings.LastIndex(trimmed, "("); left >= 0 && strings.HasSuffix(trimmed, ")") {
+ candidate := strings.TrimSpace(trimmed[left+1 : len(trimmed)-1])
+ if candidate != "" {
+ return normalizeVersion(candidate)
+ }
+ }
+ return ""
+}
+
+func extractVersionFromReleaseAssets(assets []githubAsset) string {
+ const assetPrefix = "GoNavi-"
+ osMarkers := []string{"-Windows-", "-MacOS-", "-Linux-"}
+
+ for _, asset := range assets {
+ name := strings.TrimSpace(asset.Name)
+ if !strings.HasPrefix(name, assetPrefix) {
+ continue
+ }
+ rest := strings.TrimPrefix(name, assetPrefix)
+ for _, marker := range osMarkers {
+ index := strings.Index(rest, marker)
+ if index <= 0 {
+ continue
+ }
+ candidate := normalizeVersion(rest[:index])
+ if candidate != "" {
+ return candidate
+ }
+ }
+ }
+ return ""
+}
+
func sanitizeVersionForPath(version string) string {
trimmed := strings.TrimSpace(version)
if trimmed == "" {
@@ -877,19 +994,30 @@ func isExistingDownloadedAsset(filePath string, expectedSize int64) bool {
}
func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *stagedUpdate {
+ channel, err := normalizeUpdateChannel(info.Channel)
+ if err != nil {
+ channel = updateChannelLatest
+ }
version := strings.TrimSpace(info.LatestVersion)
assetName := strings.TrimSpace(info.AssetName)
if version == "" || assetName == "" {
return nil
}
- if current != nil && strings.TrimSpace(current.Version) == version {
- currentPath := strings.TrimSpace(current.FilePath)
- if isExistingDownloadedAsset(currentPath, info.AssetSize) {
- if strings.TrimSpace(current.InstallLogPath) == "" {
- current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
+ if current != nil {
+ currentChannel := current.Channel
+ if currentChannel == "" {
+ currentChannel = updateChannelLatest
+ }
+ if currentChannel == channel && strings.TrimSpace(current.Version) == version {
+ currentPath := strings.TrimSpace(current.FilePath)
+ if isExistingDownloadedAsset(currentPath, info.AssetSize) {
+ if strings.TrimSpace(current.InstallLogPath) == "" {
+ current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
+ }
+ current.Channel = channel
+ return current
}
- return current
}
}
@@ -898,7 +1026,10 @@ func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *staged
stagedDir string
assetPath string
}
- stagedDirName := fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, version)
+ stagedDirNames := []string{
+ buildUpdateStageDirName(string(channel), version),
+ fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, version),
+ }
workspaceCandidates := []string{
resolveUpdateWorkspaceDir(version),
resolveLegacyUpdateWorkspaceDir(),
@@ -915,20 +1046,22 @@ func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *staged
}
seenWorkspace[workspaceDir] = struct{}{}
- stagedDir := filepath.Join(workspaceDir, stagedDirName)
- assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, assetName)
- candidates = append(candidates, pathCandidate{
- workspaceDir: workspaceDir,
- stagedDir: stagedDir,
- assetPath: assetPath,
- })
- legacyAssetPath := filepath.Join(stagedDir, assetName)
- if legacyAssetPath != assetPath {
+ for _, stagedDirName := range stagedDirNames {
+ stagedDir := filepath.Join(workspaceDir, stagedDirName)
+ assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, assetName)
candidates = append(candidates, pathCandidate{
workspaceDir: workspaceDir,
stagedDir: stagedDir,
- assetPath: legacyAssetPath,
+ assetPath: assetPath,
})
+ legacyAssetPath := filepath.Join(stagedDir, assetName)
+ if legacyAssetPath != assetPath {
+ candidates = append(candidates, pathCandidate{
+ workspaceDir: workspaceDir,
+ stagedDir: stagedDir,
+ assetPath: legacyAssetPath,
+ })
+ }
}
}
@@ -937,6 +1070,7 @@ func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *staged
continue
}
return &stagedUpdate{
+ Channel: channel,
Version: version,
AssetName: assetName,
FilePath: candidate.assetPath,
diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go
index ef206a3c..7f197f05 100644
--- a/internal/app/methods_update_test.go
+++ b/internal/app/methods_update_test.go
@@ -2,6 +2,8 @@ package app
import (
"errors"
+ "os"
+ "path/filepath"
stdRuntime "runtime"
"strings"
"testing"
@@ -44,7 +46,7 @@ func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *
})
defer restoreChecksum()
- info, err := fetchLatestUpdateInfo()
+ info, err := fetchLatestUpdateInfo(updateChannelLatest)
if err != nil {
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
@@ -99,7 +101,7 @@ func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T)
})
defer restoreChecksum()
- info, err := fetchLatestUpdateInfo()
+ info, err := fetchLatestUpdateInfo(updateChannelLatest)
if err != nil {
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
@@ -160,6 +162,171 @@ func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) {
}
}
+func TestFetchLatestUpdateInfoForDevChannelUsesReleaseBuildVersion(t *testing.T) {
+ assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "dev-a1b2c3d")
+ if err != nil {
+ t.Fatalf("expectedAssetName returned error: %v", err)
+ }
+
+ originalVersion := AppVersion
+ AppVersion = "0.6.5"
+ defer func() {
+ AppVersion = originalVersion
+ }()
+
+ restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) {
+ return &githubRelease{
+ TagName: "dev-latest",
+ Name: "🧪 Dev Build (dev-a1b2c3d)",
+ HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/dev-latest",
+ Assets: []githubAsset{
+ {
+ Name: assetName,
+ BrowserDownloadURL: "https://example.com/" + assetName,
+ Size: 8192,
+ },
+ },
+ }, nil
+ })
+ defer restoreRelease()
+
+ checksumCalled := false
+ restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) {
+ checksumCalled = true
+ return map[string]string{
+ assetName: "def456",
+ }, nil
+ })
+ defer restoreChecksum()
+
+ info, err := fetchLatestUpdateInfo(updateChannelDev)
+ if err != nil {
+ t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
+ }
+ if !checksumCalled {
+ t.Fatal("expected dev channel update check to fetch SHA256 when build version differs")
+ }
+ if !info.HasUpdate {
+ t.Fatalf("expected HasUpdate=true, got %#v", info)
+ }
+ if info.Channel != string(updateChannelDev) {
+ t.Fatalf("expected dev channel, got %#v", info)
+ }
+ if info.LatestVersion != "dev-a1b2c3d" {
+ t.Fatalf("expected dev build version from release metadata, got %#v", info)
+ }
+ if info.AssetName != assetName || info.SHA256 != "def456" {
+ t.Fatalf("unexpected dev update info: %#v", info)
+ }
+}
+
+func TestFetchLatestUpdateInfoForDevChannelSkipsChecksumWhenBuildMatches(t *testing.T) {
+ assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "dev-a1b2c3d")
+ if err != nil {
+ t.Fatalf("expectedAssetName returned error: %v", err)
+ }
+
+ originalVersion := AppVersion
+ AppVersion = "dev-a1b2c3d"
+ defer func() {
+ AppVersion = originalVersion
+ }()
+
+ restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) {
+ return &githubRelease{
+ TagName: "dev-latest",
+ Name: "🧪 Dev Build (dev-a1b2c3d)",
+ HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/dev-latest",
+ Assets: []githubAsset{
+ {
+ Name: assetName,
+ BrowserDownloadURL: "https://example.com/" + assetName,
+ Size: 2048,
+ },
+ },
+ }, nil
+ })
+ defer restoreRelease()
+
+ checksumCalled := false
+ restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) {
+ checksumCalled = true
+ return nil, errors.New("checksum should not be fetched when dev build is already current")
+ })
+ defer restoreChecksum()
+
+ info, err := fetchLatestUpdateInfo(updateChannelDev)
+ if err != nil {
+ t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
+ }
+ if checksumCalled {
+ t.Fatal("expected dev channel checksum fetch to be skipped when build already matches")
+ }
+ if info.HasUpdate {
+ t.Fatalf("expected HasUpdate=false, got %#v", info)
+ }
+ if info.Channel != string(updateChannelDev) || info.LatestVersion != "dev-a1b2c3d" {
+ t.Fatalf("unexpected dev latest info: %#v", info)
+ }
+}
+
+func TestSetUpdateChannelPersistsAndClearsCachedUpdateState(t *testing.T) {
+ app := NewApp()
+ app.configDir = t.TempDir()
+ app.updateState.lastCheck = &UpdateInfo{
+ HasUpdate: true,
+ Channel: string(updateChannelLatest),
+ LatestVersion: "0.6.5",
+ }
+ app.updateState.staged = &stagedUpdate{
+ Channel: updateChannelLatest,
+ Version: "0.6.5",
+ AssetName: "GoNavi-0.6.5-Windows-Amd64.exe",
+ }
+
+ result := app.SetUpdateChannel("dev")
+ if !result.Success {
+ t.Fatalf("SetUpdateChannel returned failure: %#v", result)
+ }
+
+ stored, err := app.loadStoredUpdateChannel()
+ if err != nil {
+ t.Fatalf("loadStoredUpdateChannel returned error: %v", err)
+ }
+ if stored != updateChannelDev {
+ t.Fatalf("expected stored dev channel, got %q", stored)
+ }
+ if app.updateState.lastCheck != nil || app.updateState.staged != nil {
+ t.Fatalf("expected update cache to be cleared after channel switch, got %#v %#v", app.updateState.lastCheck, app.updateState.staged)
+ }
+}
+
+func TestResolveReusableStagedUpdateDoesNotReuseDifferentChannelPackage(t *testing.T) {
+ tempDir := t.TempDir()
+ assetPath := filepath.Join(tempDir, "GoNavi-0.6.5-Windows-Amd64.exe")
+ if err := os.WriteFile(assetPath, []byte("12345678"), 0o644); err != nil {
+ t.Fatalf("WriteFile returned error: %v", err)
+ }
+
+ reused := resolveReusableStagedUpdate(
+ UpdateInfo{
+ Channel: string(updateChannelLatest),
+ LatestVersion: "0.6.5",
+ AssetName: filepath.Base(assetPath),
+ AssetSize: 8,
+ },
+ &stagedUpdate{
+ Channel: updateChannelDev,
+ Version: "0.6.5",
+ AssetName: filepath.Base(assetPath),
+ FilePath: assetPath,
+ },
+ )
+ if reused != nil {
+ t.Fatalf("expected staged update from another channel to be ignored, got %#v", reused)
+ }
+}
+
func TestDownloadUpdateUsesCurrentLanguageForBackendMessage(t *testing.T) {
app := NewApp()
app.SetLanguage("en-US")
diff --git a/internal/app/update_channel_state.go b/internal/app/update_channel_state.go
new file mode 100644
index 00000000..d9b88517
--- /dev/null
+++ b/internal/app/update_channel_state.go
@@ -0,0 +1,148 @@
+package app
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "GoNavi-Wails/internal/connection"
+ "GoNavi-Wails/internal/logger"
+)
+
+const (
+ updateChannelFileName = "update_channel.json"
+ updateDevReleaseTag = "dev-latest"
+)
+
+type updateChannel string
+
+const (
+ updateChannelLatest updateChannel = "latest"
+ updateChannelDev updateChannel = "dev"
+)
+
+type updateChannelStateFile struct {
+ Channel updateChannel `json:"channel"`
+}
+
+func normalizeUpdateChannel(value string) (updateChannel, error) {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "", string(updateChannelLatest):
+ return updateChannelLatest, nil
+ case string(updateChannelDev):
+ return updateChannelDev, nil
+ default:
+ return "", localizedUpdateError{
+ key: "app.update.backend.error.channel_invalid",
+ params: map[string]any{"channel": strings.TrimSpace(value)},
+ }
+ }
+}
+
+func defaultUpdateChannel() updateChannel {
+ version := strings.ToLower(strings.TrimSpace(getCurrentVersion()))
+ if strings.HasPrefix(version, "dev-") {
+ return updateChannelDev
+ }
+ return updateChannelLatest
+}
+
+func updateChannelMetadataPath(configDir string) string {
+ return filepath.Join(configDir, updateChannelFileName)
+}
+
+func (a *App) loadStoredUpdateChannel() (updateChannel, error) {
+ if strings.TrimSpace(a.configDir) == "" {
+ a.configDir = resolveAppConfigDir()
+ }
+
+ data, err := os.ReadFile(updateChannelMetadataPath(a.configDir))
+ if err != nil {
+ return "", err
+ }
+
+ var state updateChannelStateFile
+ if err := json.Unmarshal(data, &state); err != nil {
+ return "", err
+ }
+ return normalizeUpdateChannel(string(state.Channel))
+}
+
+func (a *App) persistUpdateChannel(channel updateChannel) error {
+ if strings.TrimSpace(a.configDir) == "" {
+ a.configDir = resolveAppConfigDir()
+ }
+ if err := os.MkdirAll(a.configDir, 0o755); err != nil {
+ return err
+ }
+
+ payload, err := json.MarshalIndent(updateChannelStateFile{Channel: channel}, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(updateChannelMetadataPath(a.configDir), payload, 0o644)
+}
+
+func (a *App) currentUpdateChannel() updateChannel {
+ if a == nil {
+ return defaultUpdateChannel()
+ }
+ channel, err := a.loadStoredUpdateChannel()
+ if err == nil {
+ return channel
+ }
+ if !os.IsNotExist(err) {
+ logger.Error(err, "加载更新通道配置失败")
+ }
+ return defaultUpdateChannel()
+}
+
+func (a *App) GetUpdateChannel() connection.QueryResult {
+ channel := a.currentUpdateChannel()
+ return connection.QueryResult{
+ Success: true,
+ Message: "OK",
+ Data: map[string]any{
+ "channel": string(channel),
+ },
+ }
+}
+
+func (a *App) SetUpdateChannel(channel string) connection.QueryResult {
+ normalized, err := normalizeUpdateChannel(channel)
+ if err != nil {
+ return connection.QueryResult{Success: false, Message: a.localizedUpdateError(err)}
+ }
+
+ a.updateMu.Lock()
+ if a.updateState.downloading {
+ a.updateMu.Unlock()
+ return connection.QueryResult{
+ Success: false,
+ Message: a.appText("app.update.backend.message.channel_change_blocked_downloading", nil),
+ }
+ }
+ a.updateMu.Unlock()
+
+ if err := a.persistUpdateChannel(normalized); err != nil {
+ logger.Error(err, "保存更新通道失败")
+ return connection.QueryResult{
+ Success: false,
+ Message: a.appText("app.update.backend.message.channel_change_failed", map[string]any{"detail": err.Error()}),
+ }
+ }
+
+ a.updateMu.Lock()
+ a.updateState.lastCheck = nil
+ a.updateState.staged = nil
+ a.updateMu.Unlock()
+
+ return connection.QueryResult{
+ Success: true,
+ Message: a.appText("app.update.backend.message.channel_changed", map[string]any{"channel": string(normalized)}),
+ Data: map[string]any{
+ "channel": string(normalized),
+ },
+ }
+}
diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json
index eb1caadb..b14c9cb4 100644
--- a/shared/i18n/de-DE.json
+++ b/shared/i18n/de-DE.json
@@ -2426,9 +2426,11 @@
"app.about.download_progress.title_with_version": "Update {{version}} herunterladen",
"app.about.field.author": "Autor",
"app.about.field.community": "Community",
+ "app.about.field.update_channel": "Update-Kanal",
"app.about.field.update_status": "Updatestatus",
"app.about.field.version": "Version",
"app.about.message.check_failed_with_error": "Updateprüfung fehlgeschlagen: {{error}}",
+ "app.about.message.channel_switch_failed_with_error": "Wechsel des Update-Kanals fehlgeschlagen: {{error}}",
"app.about.message.download_completed": "Update-Download abgeschlossen",
"app.about.message.download_completed_with_path": "Update-Download abgeschlossen. Paketpfad: {{path}}",
"app.about.message.download_failed_with_error": "Update-Download fehlgeschlagen: {{error}}",
@@ -2441,6 +2443,8 @@
"app.about.message.update_package_ready_with_path": "Updatepaket ist bereit ({{version}}), Pfad: {{path}}",
"app.about.project_links": "Projektlinks",
"app.about.title": "Über GoNavi",
+ "app.about.update_channel.dev": "Dev-Kanal",
+ "app.about.update_channel.latest": "Latest-Kanal",
"app.about.update_status.check_failed": "Updateprüfung fehlgeschlagen: {{error}}",
"app.about.update_status.checking": "Suche nach Updates...",
"app.about.update_status.latest": "Sie verwenden die neueste Version ({{version}})",
@@ -2862,6 +2866,7 @@
"app.update.action.open_install_directory": "Installationsverzeichnis öffnen",
"app.update.backend.error.check_failed": "Updateprüfung fehlgeschlagen: {{detail}}",
"app.update.backend.error.check_http_status": "Updateprüfung fehlgeschlagen: HTTP {{status}}",
+ "app.update.backend.error.channel_invalid": "Ungültiger Update-Kanal: {{channel}}",
"app.update.backend.error.download_failed": "Updatedownload fehlgeschlagen: {{detail}}",
"app.update.backend.error.install_unsupported": "Updateinstallation wird auf dieser Plattform nicht unterstützt: {{platform}}",
"app.update.backend.error.latest_version_unparseable": "Neueste Versionsnummer konnte nicht gelesen werden",
@@ -2875,6 +2880,9 @@
"app.update.backend.error.update_package_not_found": "Updatepaket nicht gefunden: {{name}}",
"app.update.backend.message.app_directory_unavailable": "Auf das App-Verzeichnis kann nicht zugegriffen werden: {{path}}",
"app.update.backend.message.app_directory_unresolved_download": "Das aktuelle App-Verzeichnis kann nicht bestimmt werden, daher kann das Update nicht heruntergeladen werden",
+ "app.update.backend.message.channel_change_blocked_downloading": "Ein Updatepaket wird gerade heruntergeladen, daher kann der Update-Kanal momentan nicht gewechselt werden",
+ "app.update.backend.message.channel_change_failed": "Speichern des Update-Kanals fehlgeschlagen: {{detail}}",
+ "app.update.backend.message.channel_changed": "Update-Kanal gewechselt zu: {{channel}}",
"app.update.backend.message.check_first": "Prüfen Sie zuerst auf Updates",
"app.update.backend.message.checksum_failed": "Prüfsumme des Updatepakets ist fehlgeschlagen. Versuchen Sie es erneut.",
"app.update.backend.message.checksum_missing": "Prüfsumme des Updatepakets fehlt (SHA256SUMS)",
diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json
index 3f69d9df..5f197b86 100644
--- a/shared/i18n/en-US.json
+++ b/shared/i18n/en-US.json
@@ -2426,9 +2426,11 @@
"app.about.download_progress.title_with_version": "Download Update {{version}}",
"app.about.field.author": "Author",
"app.about.field.community": "Community",
+ "app.about.field.update_channel": "Update Channel",
"app.about.field.update_status": "Update Status",
"app.about.field.version": "Version",
"app.about.message.check_failed_with_error": "Update check failed: {{error}}",
+ "app.about.message.channel_switch_failed_with_error": "Failed to switch update channel: {{error}}",
"app.about.message.download_completed": "Update download completed",
"app.about.message.download_completed_with_path": "Update download completed. Package path: {{path}}",
"app.about.message.download_failed_with_error": "Update download failed: {{error}}",
@@ -2441,6 +2443,8 @@
"app.about.message.update_package_ready_with_path": "Update package is ready ({{version}}), path: {{path}}",
"app.about.project_links": "Project Links",
"app.about.title": "About GoNavi",
+ "app.about.update_channel.dev": "Dev Channel",
+ "app.about.update_channel.latest": "Latest Channel",
"app.about.update_status.check_failed": "Update check failed: {{error}}",
"app.about.update_status.checking": "Checking for updates...",
"app.about.update_status.latest": "You are on the latest version ({{version}})",
@@ -2862,6 +2866,7 @@
"app.update.action.open_install_directory": "Open Install Directory",
"app.update.backend.error.check_failed": "Check for updates failed: {{detail}}",
"app.update.backend.error.check_http_status": "Check for updates failed: HTTP {{status}}",
+ "app.update.backend.error.channel_invalid": "Invalid update channel: {{channel}}",
"app.update.backend.error.download_failed": "Update download failed: {{detail}}",
"app.update.backend.error.install_unsupported": "Update installation is not supported on this platform: {{platform}}",
"app.update.backend.error.latest_version_unparseable": "Unable to parse the latest version",
@@ -2875,6 +2880,9 @@
"app.update.backend.error.update_package_not_found": "Update package not found: {{name}}",
"app.update.backend.message.app_directory_unavailable": "Cannot access app directory: {{path}}",
"app.update.backend.message.app_directory_unresolved_download": "Cannot determine the current app directory, so the update cannot be downloaded",
+ "app.update.backend.message.channel_change_blocked_downloading": "An update package is downloading, so the update channel cannot be changed right now",
+ "app.update.backend.message.channel_change_failed": "Failed to save update channel: {{detail}}",
+ "app.update.backend.message.channel_changed": "Update channel switched to: {{channel}}",
"app.update.backend.message.check_first": "Check for updates first",
"app.update.backend.message.checksum_failed": "Update package checksum failed. Try again.",
"app.update.backend.message.checksum_missing": "Update package checksum is missing (SHA256SUMS)",
diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json
index 7300a2f4..e5b9c244 100644
--- a/shared/i18n/ja-JP.json
+++ b/shared/i18n/ja-JP.json
@@ -2426,9 +2426,11 @@
"app.about.download_progress.title_with_version": "更新をダウンロード {{version}}",
"app.about.field.author": "作者",
"app.about.field.community": "コミュニティ",
+ "app.about.field.update_channel": "更新チャネル",
"app.about.field.update_status": "更新状況",
"app.about.field.version": "バージョン",
"app.about.message.check_failed_with_error": "更新確認に失敗しました: {{error}}",
+ "app.about.message.channel_switch_failed_with_error": "更新チャネルの切り替えに失敗しました: {{error}}",
"app.about.message.download_completed": "更新のダウンロードが完了しました",
"app.about.message.download_completed_with_path": "更新のダウンロードが完了しました。パッケージパス: {{path}}",
"app.about.message.download_failed_with_error": "更新のダウンロードに失敗しました: {{error}}",
@@ -2441,6 +2443,8 @@
"app.about.message.update_package_ready_with_path": "更新パッケージの準備ができました({{version}})。パス: {{path}}",
"app.about.project_links": "プロジェクトリンク",
"app.about.title": "GoNavi について",
+ "app.about.update_channel.dev": "Dev チャネル",
+ "app.about.update_channel.latest": "Latest チャネル",
"app.about.update_status.check_failed": "更新確認に失敗しました: {{error}}",
"app.about.update_status.checking": "更新を確認しています...",
"app.about.update_status.latest": "現在は最新バージョンです({{version}})",
@@ -2862,6 +2866,7 @@
"app.update.action.open_install_directory": "インストールディレクトリを開く",
"app.update.backend.error.check_failed": "更新確認に失敗しました: {{detail}}",
"app.update.backend.error.check_http_status": "更新確認に失敗しました: HTTP {{status}}",
+ "app.update.backend.error.channel_invalid": "無効な更新チャネルです: {{channel}}",
"app.update.backend.error.download_failed": "更新のダウンロードに失敗しました: {{detail}}",
"app.update.backend.error.install_unsupported": "このプラットフォームでは更新のインストールに対応していません: {{platform}}",
"app.update.backend.error.latest_version_unparseable": "最新バージョン番号を解析できません",
@@ -2875,6 +2880,9 @@
"app.update.backend.error.update_package_not_found": "更新パッケージが見つかりません: {{name}}",
"app.update.backend.message.app_directory_unavailable": "アプリディレクトリにアクセスできません: {{path}}",
"app.update.backend.message.app_directory_unresolved_download": "現在のアプリディレクトリを特定できないため、更新をダウンロードできません",
+ "app.update.backend.message.channel_change_blocked_downloading": "更新パッケージをダウンロード中のため、今は更新チャネルを切り替えられません",
+ "app.update.backend.message.channel_change_failed": "更新チャネルの保存に失敗しました: {{detail}}",
+ "app.update.backend.message.channel_changed": "更新チャネルを切り替えました: {{channel}}",
"app.update.backend.message.check_first": "先に更新を確認してください",
"app.update.backend.message.checksum_failed": "更新パッケージのチェックサム検証に失敗しました。もう一度お試しください。",
"app.update.backend.message.checksum_missing": "更新パッケージのチェックサムがありません (SHA256SUMS)",
diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json
index d1b848d5..ecfdb87e 100644
--- a/shared/i18n/ru-RU.json
+++ b/shared/i18n/ru-RU.json
@@ -2426,9 +2426,11 @@
"app.about.download_progress.title_with_version": "Скачать обновление {{version}}",
"app.about.field.author": "Автор",
"app.about.field.community": "Сообщество",
+ "app.about.field.update_channel": "Канал обновления",
"app.about.field.update_status": "Статус обновления",
"app.about.field.version": "Версия",
"app.about.message.check_failed_with_error": "Проверка обновлений не удалась: {{error}}",
+ "app.about.message.channel_switch_failed_with_error": "Не удалось переключить канал обновления: {{error}}",
"app.about.message.download_completed": "Загрузка обновления завершена",
"app.about.message.download_completed_with_path": "Загрузка обновления завершена. Путь к пакету: {{path}}",
"app.about.message.download_failed_with_error": "Не удалось скачать обновление: {{error}}",
@@ -2441,6 +2443,8 @@
"app.about.message.update_package_ready_with_path": "Пакет обновления готов ({{version}}), путь: {{path}}",
"app.about.project_links": "Ссылки проекта",
"app.about.title": "О GoNavi",
+ "app.about.update_channel.dev": "Dev канал",
+ "app.about.update_channel.latest": "Latest канал",
"app.about.update_status.check_failed": "Проверка обновлений не удалась: {{error}}",
"app.about.update_status.checking": "Проверка обновлений...",
"app.about.update_status.latest": "У вас установлена последняя версия ({{version}})",
@@ -2862,6 +2866,7 @@
"app.update.action.open_install_directory": "Открыть каталог установки",
"app.update.backend.error.check_failed": "Не удалось проверить обновления: {{detail}}",
"app.update.backend.error.check_http_status": "Не удалось проверить обновления: HTTP {{status}}",
+ "app.update.backend.error.channel_invalid": "Недопустимый канал обновления: {{channel}}",
"app.update.backend.error.download_failed": "Не удалось скачать обновление: {{detail}}",
"app.update.backend.error.install_unsupported": "Установка обновления не поддерживается на этой платформе: {{platform}}",
"app.update.backend.error.latest_version_unparseable": "Не удалось разобрать номер последней версии",
@@ -2875,6 +2880,9 @@
"app.update.backend.error.update_package_not_found": "Пакет обновления не найден: {{name}}",
"app.update.backend.message.app_directory_unavailable": "Нет доступа к каталогу приложения: {{path}}",
"app.update.backend.message.app_directory_unresolved_download": "Не удалось определить текущий каталог приложения, поэтому обновление нельзя скачать",
+ "app.update.backend.message.channel_change_blocked_downloading": "Пакет обновления сейчас скачивается, поэтому канал обновления пока нельзя переключить",
+ "app.update.backend.message.channel_change_failed": "Не удалось сохранить канал обновления: {{detail}}",
+ "app.update.backend.message.channel_changed": "Канал обновления переключен на: {{channel}}",
"app.update.backend.message.check_first": "Сначала проверьте обновления",
"app.update.backend.message.checksum_failed": "Проверка контрольной суммы пакета обновления не пройдена. Повторите попытку.",
"app.update.backend.message.checksum_missing": "Отсутствует контрольная сумма пакета обновления (SHA256SUMS)",
diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json
index d9409034..661dd277 100644
--- a/shared/i18n/zh-CN.json
+++ b/shared/i18n/zh-CN.json
@@ -2426,11 +2426,13 @@
"app.about.download_progress.title_with_version": "下载更新 {{version}}",
"app.about.field.author": "作者",
"app.about.field.community": "技术圈",
+ "app.about.field.update_channel": "更新通道",
"app.about.field.update_status": "更新状态",
"app.about.field.version": "版本",
"app.about.message.check_failed_with_error": "检查更新失败: {{error}}",
"app.about.message.download_completed": "更新下载完成",
"app.about.message.download_completed_with_path": "更新下载完成,更新包路径:{{path}}",
+ "app.about.message.channel_switch_failed_with_error": "切换更新通道失败:{{error}}",
"app.about.message.download_failed_with_error": "更新下载失败: {{error}}",
"app.about.message.install_directory_opened_manual_replace": "已打开安装目录,请手动完成替换",
"app.about.message.install_failed_with_error": "更新安装失败: {{error}}",
@@ -2441,6 +2443,8 @@
"app.about.message.update_package_ready_with_path": "更新包已就绪({{version}}),路径:{{path}}",
"app.about.project_links": "项目入口",
"app.about.title": "关于 GoNavi",
+ "app.about.update_channel.dev": "dev 通道",
+ "app.about.update_channel.latest": "latest 通道",
"app.about.update_status.check_failed": "检查更新失败: {{error}}",
"app.about.update_status.checking": "正在检查更新...",
"app.about.update_status.latest": "当前已是最新版本({{version}})",
@@ -2862,6 +2866,7 @@
"app.update.action.open_install_directory": "打开安装目录",
"app.update.backend.error.check_failed": "检查更新失败:{{detail}}",
"app.update.backend.error.check_http_status": "检查更新失败:HTTP {{status}}",
+ "app.update.backend.error.channel_invalid": "无效的更新通道:{{channel}}",
"app.update.backend.error.download_failed": "更新下载失败:{{detail}}",
"app.update.backend.error.install_unsupported": "当前平台暂不支持更新安装:{{platform}}",
"app.update.backend.error.latest_version_unparseable": "无法解析最新版本号",
@@ -2875,6 +2880,9 @@
"app.update.backend.error.update_package_not_found": "未找到更新包:{{name}}",
"app.update.backend.message.app_directory_unavailable": "无法访问应用目录:{{path}}",
"app.update.backend.message.app_directory_unresolved_download": "无法确定当前应用目录,无法下载更新",
+ "app.update.backend.message.channel_change_blocked_downloading": "更新包正在下载中,暂时不能切换更新通道",
+ "app.update.backend.message.channel_change_failed": "保存更新通道失败:{{detail}}",
+ "app.update.backend.message.channel_changed": "更新通道已切换为:{{channel}}",
"app.update.backend.message.check_first": "请先检查更新",
"app.update.backend.message.checksum_failed": "更新包校验失败,请重试",
"app.update.backend.message.checksum_missing": "缺少更新包校验值(SHA256SUMS)",
diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json
index 6d1e5ddb..1c3bb542 100644
--- a/shared/i18n/zh-TW.json
+++ b/shared/i18n/zh-TW.json
@@ -2426,11 +2426,13 @@
"app.about.download_progress.title_with_version": "下載更新 {{version}}",
"app.about.field.author": "作者",
"app.about.field.community": "技術圈",
+ "app.about.field.update_channel": "更新通道",
"app.about.field.update_status": "更新状态",
"app.about.field.version": "版本",
"app.about.message.check_failed_with_error": "檢查更新失敗: {{error}}",
"app.about.message.download_completed": "更新下載完成",
"app.about.message.download_completed_with_path": "更新下載完成,更新套件路徑:{{path}}",
+ "app.about.message.channel_switch_failed_with_error": "切換更新通道失敗:{{error}}",
"app.about.message.download_failed_with_error": "更新下載失敗: {{error}}",
"app.about.message.install_directory_opened_manual_replace": "已開啟安裝目錄,請手動完成替換",
"app.about.message.install_failed_with_error": "更新安裝失敗: {{error}}",
@@ -2441,6 +2443,8 @@
"app.about.message.update_package_ready_with_path": "更新套件已就緒({{version}}),路徑:{{path}}",
"app.about.project_links": "專案入口",
"app.about.title": "關於 GoNavi",
+ "app.about.update_channel.dev": "dev 通道",
+ "app.about.update_channel.latest": "latest 通道",
"app.about.update_status.check_failed": "檢查更新失敗: {{error}}",
"app.about.update_status.checking": "正在檢查更新...",
"app.about.update_status.latest": "目前已是最新版本({{version}})",
@@ -2862,6 +2866,7 @@
"app.update.action.open_install_directory": "開啟安裝目錄",
"app.update.backend.error.check_failed": "檢查更新失敗:{{detail}}",
"app.update.backend.error.check_http_status": "檢查更新失敗:HTTP {{status}}",
+ "app.update.backend.error.channel_invalid": "無效的更新通道:{{channel}}",
"app.update.backend.error.download_failed": "更新下載失敗:{{detail}}",
"app.update.backend.error.install_unsupported": "目前平台暫不支援更新安裝:{{platform}}",
"app.update.backend.error.latest_version_unparseable": "無法解析最新版本號",
@@ -2875,6 +2880,9 @@
"app.update.backend.error.update_package_not_found": "未找到更新套件:{{name}}",
"app.update.backend.message.app_directory_unavailable": "無法存取應用程式目錄:{{path}}",
"app.update.backend.message.app_directory_unresolved_download": "無法判斷目前應用程式目錄,因此無法下載更新",
+ "app.update.backend.message.channel_change_blocked_downloading": "更新套件正在下載中,暫時不能切換更新通道",
+ "app.update.backend.message.channel_change_failed": "保存更新通道失敗:{{detail}}",
+ "app.update.backend.message.channel_changed": "更新通道已切換為:{{channel}}",
"app.update.backend.message.check_first": "請先檢查更新",
"app.update.backend.message.checksum_failed": "更新套件校驗失敗,請重試",
"app.update.backend.message.checksum_missing": "缺少更新套件校驗值(SHA256SUMS)",