+
+ {{ t("updates.betaChannelBadge") }}
+
+
+ {{ t("updates.stableDowngradeBlocked") }}
+
{{
t("updates.availableMessage", {
diff --git a/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts b/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
index 97bb027468..086797c216 100644
--- a/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
@@ -2,7 +2,7 @@
import { createApp, defineComponent, h, reactive, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
-import { effectiveUpdateInfo, isNewerRemoteVersion, isPreparedUpdateObsolete, resolveReleaseTagUrl, useAppUpdater } from "@/composables/useAppUpdater";
+import { effectiveUpdateInfo, isNewerRemoteVersion, isPreparedUpdateObsolete, resolveReleaseTagUrl, resolveUpdateReleaseUrl, useAppUpdater } from "@/composables/useAppUpdater";
const mocks = vi.hoisted(() => ({
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
@@ -20,7 +20,7 @@ vi.mock("@/lib/backend/api", () => mocks);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
-const settings = reactive({ updateDownloadSource: "official", ignoredUpdateVersion: "", updateNotificationsEnabled: true, autoDownloadUpdates: true, autoUpdateApp: true, toolbarItems: { checkUpdates: true } });
+const settings = reactive({ updateDownloadSource: "official", updateChannel: "stable", ignoredUpdateVersion: "", updateNotificationsEnabled: true, autoDownloadUpdates: true, autoUpdateApp: true, toolbarItems: { checkUpdates: true } });
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: settings, updateEditorSettingsAndPersist: mocks.persist }) }));
const info = { current_version: "1.0.0", latest_version: "1.1.0", update_available: true, portable_mode: false, manual_update_only: false, release_name: "v1.1.0", release_url: "https://example.com", release_notes: "Changes" };
const cache = { cache_id: "cached", version: "1.1.0", portable_mode: false, release_url: "https://example.com", release_notes: "Changes", downloaded_at: 1 };
@@ -56,6 +56,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("VITE_DBX_DISABLE_UPDATER", "");
settings.updateDownloadSource = "official";
+ settings.updateChannel = "stable";
settings.ignoredUpdateVersion = "";
settings.updateNotificationsEnabled = true;
settings.autoDownloadUpdates = true;
@@ -74,6 +75,15 @@ afterEach(() => {
vi.unstubAllEnvs();
});
describe("older version release pages", () => {
+ it("opens the GitHub beta release before it has been mirrored to CNB", () => {
+ const releaseUrl = "https://github.com/t8y2/dbx/releases/tag/v1.1.0-beta.20261028.1";
+ expect(resolveUpdateReleaseUrl({ ...info, channel: "beta", release_tag: "v1.1.0-beta.20261028.1", release_url: releaseUrl }, "cnb", "fallback")).toBe(releaseUrl);
+ });
+
+ it("uses the original beta tag on CNB after promotion to stable", () => {
+ expect(resolveUpdateReleaseUrl({ ...info, channel: "stable", release_tag: "v1.1.0-beta.20261028.1" }, "cnb", "fallback")).toBe("https://cnb.cool/dbxio.com/dbx/-/releases/tag/v1.1.0-beta.20261028.1");
+ });
+
it("maps a historical version to the matching GitHub release page", () => {
expect(resolveReleaseTagUrl("0.5.59", "official")).toBe("https://github.com/t8y2/dbx/releases/tag/v0.5.59");
expect(resolveReleaseTagUrl("v0.5.59", "official")).toBe("https://github.com/t8y2/dbx/releases/tag/v0.5.59");
@@ -83,6 +93,15 @@ describe("older version release pages", () => {
expect(resolveReleaseTagUrl("v0.5.59", "cnb")).toBe("https://cnb.cool/dbxio.com/dbx/-/releases/tag/v0.5.59");
});
+ it("passes the beta release tag through to the download cache", async () => {
+ settings.updateChannel = "beta";
+ const betaInfo = { ...info, channel: "beta" as const, release_tag: "v1.1.0-beta.20261028.1" };
+ mocks.checkForUpdates.mockResolvedValue(betaInfo);
+ const updater = mount();
+ await updater.checkUpdates({ silent: true });
+ expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes", "beta", "v1.1.0-beta.20261028.1");
+ });
+
it("treats only strictly older releases as rollback candidates", () => {
expect(isNewerRemoteVersion("0.5.60", "v0.5.59")).toBe(true);
expect(isNewerRemoteVersion("0.5.60", "v0.5.60")).toBe(false);
@@ -97,10 +116,28 @@ describe("prepared update reconciliation (#13562)", () => {
expect(isPreparedUpdateObsolete("0.5.61", "0.5.60")).toBe(false);
expect(isPreparedUpdateObsolete("", "0.5.60")).toBe(false);
expect(isPreparedUpdateObsolete("0.5.61", "")).toBe(false);
+ expect(isPreparedUpdateObsolete("0.6.60-beta.20261029.1", "0.6.60", "beta")).toBe(false);
+ expect(isPreparedUpdateObsolete("0.6.60-beta.20261029.1", "0.6.60", "stable")).toBe(true);
+ expect(isPreparedUpdateObsolete("0.6.60", "0.6.60-beta.20261029.1", "stable")).toBe(true);
+ expect(isPreparedUpdateObsolete("0.6.61", "0.6.60-beta.20261029.1", "stable")).toBe(false);
});
});
describe("silent update lifecycle", () => {
+ it("discards the promoted stable cache when its beta binary is already running", async () => {
+ mocks.getAppVersion.mockResolvedValue("1.1.0-beta.20261029.1");
+ mocks.getDownloadedUpdate.mockResolvedValue(cache);
+ mocks.checkForUpdates.mockResolvedValue({ ...info, current_version: "1.1.0-beta.20261029.1", channel: "stable", update_available: false });
+ const updater = mount();
+ await updater.initialize();
+ await flush();
+
+ expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith(cache.cache_id);
+ expect(updater.updateDownloaded.value).toBe(false);
+ expect(updater.hasUpdateAvailable.value).toBe(false);
+ expect(mocks.downloadUpdate).not.toHaveBeenCalled();
+ });
+
it("does not check or download updates when disabled for an isolated dev client", async () => {
vi.stubEnv("VITE_DBX_DISABLE_UPDATER", "1");
const updater = mount();
@@ -149,7 +186,7 @@ describe("silent update lifecycle", () => {
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
mocks.downloadUpdate.mockResolvedValue({ ...cache, version: "1.2.0" });
await updater.downloadUpdateInBackground();
- expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes");
+ expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes", "stable");
expect(updater.updateDownloaded.value).toBe(true);
});
it("preserves a prepared package with automatic downloads disabled", async () => {
@@ -209,7 +246,7 @@ describe("silent update lifecycle", () => {
it("automatically downloads without surfacing or installing, even while idle", async () => {
const updater = mount();
await updater.checkUpdates({ silent: true });
- expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes");
+ expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes", "stable");
expect(updater.phase.value).toBe("ready");
expect(updater.hasUpdateAvailable.value).toBe(true);
expect(updater.showUpdateDialog.value).toBe(false);
@@ -346,6 +383,26 @@ describe("silent update lifecycle", () => {
expect(mocks.downloadUpdate).toHaveBeenCalledOnce();
expect(mocks.checkForUpdates).toHaveBeenCalledOnce();
});
+ it("rechecks immediately when the persisted update channel changes", async () => {
+ mocks.checkForUpdates.mockResolvedValue({ ...info, update_available: false });
+ const updater = mount();
+ await updater.initialize();
+ mocks.checkForUpdates.mockClear();
+
+ settings.updateChannel = "beta";
+ await vi.waitFor(() => expect(mocks.checkForUpdates).toHaveBeenCalled());
+
+ expect(mocks.checkForUpdates).toHaveBeenLastCalledWith("en", "official", "beta");
+ });
+ it("discards a cached update from another channel before checking", async () => {
+ mocks.checkForUpdates.mockResolvedValue({ ...info, update_available: false });
+ mocks.getDownloadedUpdate.mockResolvedValue({ ...cache, channel: "beta" });
+ const updater = mount();
+ await updater.initialize();
+
+ expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith(cache.cache_id);
+ expect(updater.updateDownloaded.value).toBe(false);
+ });
it("reconciles a download that commits just before cancellation without installing it", async () => {
const pending = deferred();
mocks.downloadUpdate.mockReturnValue(pending.promise);
@@ -447,7 +504,7 @@ describe("silent update lifecycle", () => {
await updater.initialize();
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalled());
expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith("cached");
- expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes");
+ expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes", "stable");
expect(updater.updateDownloaded.value).toBe(true);
expect(updater.updateInfo.value?.latest_version).toBe("1.2.0");
expect(updater.phase.value).toBe("ready");
diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts
index 118f672b33..4ea67bd896 100644
--- a/apps/desktop/src/composables/useAppUpdater.ts
+++ b/apps/desktop/src/composables/useAppUpdater.ts
@@ -3,7 +3,7 @@ import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { useSettingsStore } from "@/stores/settingsStore";
-import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore";
+import type { UpdateChannel as SettingsUpdateChannel, UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore";
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
import { currentLocale } from "@/i18n";
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
@@ -107,8 +107,16 @@ export function isNewerRemoteVersion(latest: string, cached: string): boolean {
* update cache, and the badge then advertises a version the app already runs
* (#13562). An unknown version is never treated as obsolete.
*/
-export function isPreparedUpdateObsolete(preparedVersion: string, currentVersion: string): boolean {
+export function isPreparedUpdateObsolete(preparedVersion: string, currentVersion: string, channel: SettingsUpdateChannel = "stable"): boolean {
if (!preparedVersion || !currentVersion) return false;
+ const prepared = parseUpdateVersion(preparedVersion);
+ const current = parseUpdateVersion(currentVersion);
+ if (prepared && current && prepared.core.join(".") === current.core.join(".")) {
+ if (channel === "beta" && prepared.prerelease.length && !current.prerelease.length) return false;
+ // Promotion keeps the beta binary, so a stable manifest with the same
+ // base version does not represent a package the running build needs.
+ if (channel === "stable" && !prepared.prerelease.length && current.prerelease.length) return true;
+ }
return !isNewerRemoteVersion(preparedVersion, currentVersion);
}
@@ -124,9 +132,12 @@ export function tagVersion(version: string): string {
}
export function resolveUpdateReleaseUrl(info: api.UpdateInfo | null, source: unknown, fallbackUrl: string): string {
+ // Beta releases are mirrored to CNB only after promotion to stable.
+ if (info?.channel === "beta") return info.release_url || fallbackUrl;
const normalizedSource = normalizeUpdateDownloadSource(source);
- if (normalizedSource === "cnb" && info?.latest_version) {
- return `https://cnb.cool/dbxio.com/dbx/-/releases/tag/${tagVersion(info.latest_version)}`;
+ if (normalizedSource === "cnb" && (info?.release_tag || info?.latest_version)) {
+ const tag = info.release_tag ?? tagVersion(info.latest_version);
+ return `https://cnb.cool/dbxio.com/dbx/-/releases/tag/${encodeURIComponent(tag)}`;
}
if (normalizedSource === "cnb") return "https://cnb.cool/dbxio.com/dbx/-/releases";
return info?.release_url || fallbackUrl;
@@ -176,6 +187,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const updateReady = computed(() => phase.value === "restart");
const activeTaskCount = computed(() => Math.max(0, Math.trunc(options.getActiveTaskCount?.() ?? 0)));
const autoUpdateEnabled = computed(() => settingsStore.editorSettings.autoUpdateApp !== false);
+ const updateChannel = computed(() => settingsStore.editorSettings.updateChannel ?? "stable");
// Hiding the toolbar update entry opts the app out of background update
// nagging (#11041): silent startup/hourly/retry checks stop, while the
// explicit "Check for updates" action in settings keeps working.
@@ -230,6 +242,9 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
release_name: tagVersion(cache.version),
release_url: cache.release_url,
release_notes: cache.release_notes,
+ channel: cache.channel ?? "stable",
+ release_tag: cache.release_tag ?? tagVersion(cache.version),
+ is_promoted: cache.is_promoted ?? cache.channel !== "beta",
};
phase.value = "ready";
downloadProgress.value = 100;
@@ -276,7 +291,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
phase.value = "idle";
return;
}
- const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource));
+ const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), updateChannel.value);
if (token !== generation || disposed) return;
updateInfo.value = info;
// Installation may have started while the check was in flight; only restore
@@ -290,7 +305,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
// and the dialog claims an update is downloaded with no way to install
// it (#13562).
const prepared = downloaded.value;
- if (prepared && isPreparedUpdateObsolete(prepared.version, info.current_version)) {
+ if (prepared && isPreparedUpdateObsolete(prepared.version, info.current_version, info.channel)) {
await discardSupersededUpdate(prepared);
if (token !== generation || disposed) return;
}
@@ -351,7 +366,9 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
downloadProgress.value = payload.total && payload.total > 0 ? Math.min(100, Math.round((payload.downloaded / payload.total) * 100)) : null;
});
if (token !== generation || disposed) return;
- const cache = await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), version, attemptId, updateInfo.value?.release_notes);
+ const source = normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource);
+ const releaseTag = updateInfo.value?.release_tag;
+ const cache = releaseTag ? await api.downloadUpdate(source, version, attemptId, updateInfo.value?.release_notes, updateChannel.value, releaseTag) : await api.downloadUpdate(source, version, attemptId, updateInfo.value?.release_notes, updateChannel.value);
if (token !== generation || disposed) return;
if (cache.version !== version) throw new Error("Downloaded update version changed; check for updates again.");
setDownloaded(cache);
@@ -415,6 +432,21 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
fail(error);
}
}
+ async function changeUpdateChannel(channel: SettingsUpdateChannel) {
+ if (isIgnoringUpdate.value || isInstallingUpdate.value) return;
+ try {
+ await cancelDownload();
+ const cache = downloaded.value;
+ downloaded.value = null;
+ updateInfo.value = null;
+ phase.value = "idle";
+ if (cache) await api.discardDownloadedUpdate(cache.cache_id);
+ if (updateChannel.value !== channel) await settingsStore.updateEditorSettingsAndPersist({ updateChannel: channel });
+ await checkUpdates();
+ } catch (error) {
+ fail(error);
+ }
+ }
async function ignoreCurrentVersion() {
const version = updateInfo.value?.latest_version;
if (!version || isIgnoringUpdate.value || isInstallingUpdate.value || updateReady.value) return;
@@ -503,16 +535,20 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const cache = await api.getDownloadedUpdate();
if (disposed) return;
if (cache) {
- const ignored = isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion);
- if (ignored) await api.discardDownloadedUpdate(cache.cache_id);
- else {
- setDownloaded(cache);
- updateInfo.value!.current_version = await api.getAppVersion();
- // An upgrade that happened outside this process leaves the previous
- // package in the cache. Adopting it would pin the dialog to "already
- // up to date" while still advertising a downloaded update (#13562).
- if (isPreparedUpdateObsolete(cache.version, updateInfo.value!.current_version)) {
- await discardSupersededUpdate(cache);
+ if ((cache.channel ?? "stable") !== updateChannel.value) {
+ await api.discardDownloadedUpdate(cache.cache_id);
+ } else {
+ const ignored = isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion);
+ if (ignored) await api.discardDownloadedUpdate(cache.cache_id);
+ else {
+ setDownloaded(cache);
+ updateInfo.value!.current_version = await api.getAppVersion();
+ // An upgrade that happened outside this process leaves the previous
+ // package in the cache. Adopting it would pin the dialog to "already
+ // up to date" while still advertising a downloaded update (#13562).
+ if (isPreparedUpdateObsolete(cache.version, updateInfo.value!.current_version, cache.channel ?? "stable")) {
+ await discardSupersededUpdate(cache);
+ }
}
}
}
@@ -545,6 +581,10 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
})
.catch(fail);
});
+ const stopChannelWatch = watch(updateChannel, (channel, previous) => {
+ if (disposed || channel === previous || isIgnoringUpdate.value) return;
+ void changeUpdateChannel(channel).catch(fail);
+ });
// Hiding the toolbar entry mid-session must also stop background activity,
// and re-showing it resumes the silent checks.
const stopUpdateEntryWatch = watch(updateEntryVisible, (visible) => {
@@ -566,6 +606,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
clearRetry();
clearInterval(hourlyTimer);
stopSettingsWatch();
+ stopChannelWatch();
stopUpdateEntryWatch();
updatePreparationRelease?.();
updatePreparationRelease = undefined;
@@ -596,6 +637,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
formatUpdateError,
openLatestRelease,
changeUpdateDownloadSource,
+ changeUpdateChannel,
ignoreCurrentVersion,
downloadUpdateInBackground,
cancelDownload,
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts
index b2c326826c..0c5c0eb47b 100644
--- a/apps/desktop/src/i18n/locales/az.ts
+++ b/apps/desktop/src/i18n/locales/az.ts
@@ -442,6 +442,8 @@ export default withEnglishFallback({
retryDownload: "Endirməni yenidən sınayın",
centerTitle: "Yeniləmə mərkəzi",
centerAvailable: "{count} yeniləmə mövcuddur",
+ betaChannelBadge: "Dev Beta kanalı",
+ stableDowngradeBlocked: "Stabil kanal cari beta quruluşunuzdan köhnədir. Kanalı dəyişmək avtomatik endirmə etməyəcək; versiya tarixçəsindən stabil quraşdırıcını seçin.",
check: "Yeniləmələri yoxla",
checking: "Yeniləmələr yoxlanılır…",
restartRequiredTooltip: "Yeniləmə quraşdırılıb — tamamlamaq üçün yenidən başladın",
@@ -7863,6 +7865,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Tətbiq yeniləmələrinin, verilənlər bazası agentlərinin, drayverlərin və idarə olunan JRE-lərin haradan endiriləcəyini seçin. Rəsmi mənbə tövsiyə olunur; CNB materik Çin şəbəkələrində daha sürətli ola bilər.",
updateDownloadSourceOfficial: "Rəsmi mənbə (tövsiyə olunur)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Yeniləmə kanalı",
+ updateChannelDescription: "Stabil versiya tövsiyə olunur. Beta daha yeni quruluşları tez alır, lakin daha qeyri-sabit ola bilər.",
+ updateChannelStable: "Stabil (tövsiyə olunur)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Sazlama jurnallarını aktiv et",
debugLoggingEnabledDescription: "Bildirilən problemləri araşdırarkən istifadəçi tərəfində daha ətraflı yerli diaqnostika jurnalları qeydə alın. Standart olaraq deaktivdir.",
metadataCacheMemoryLimit: "Metaverilənlərin yaddaş keşinin həddi",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index bd8e1a2168..b4e603d353 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1325,6 +1325,8 @@ export default {
retryDownload: "Retry Download",
centerTitle: "Update Center",
centerAvailable: "{count} available",
+ betaChannelBadge: "Dev Beta channel",
+ stableDowngradeBlocked: "The stable channel is older than your current beta build. Switching channels will not downgrade automatically; use the version history to choose a stable installer.",
check: "Check for updates",
checking: "Checking for updates…",
restartRequiredTooltip: "Update installed — restart to finish",
@@ -10392,6 +10394,10 @@ export default {
updateDownloadSourceDescription: "Choose where app updates, database agents, drivers, and managed JREs are downloaded from. The official source is recommended; CNB can be faster on mainland China networks.",
updateDownloadSourceOfficial: "Official source (recommended)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Update channel",
+ updateChannelDescription: "Stable is recommended. Beta receives newer builds earlier and may be less stable.",
+ updateChannelStable: "Stable (recommended)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Enable debug logs",
debugLoggingEnabledDescription: "Record more detailed local user-side diagnostic logs while troubleshooting reports. Disabled by default.",
metadataCacheMemoryLimit: "Metadata memory cache limit",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 190d2f0c56..b36b7f7f9c 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1122,6 +1122,8 @@ export default withEnglishFallback({
retryDownload: "Reintentar descarga",
centerTitle: "Centro de actualizaciones",
centerAvailable: "{count} disponibles",
+ betaChannelBadge: "Canal Dev Beta",
+ stableDowngradeBlocked: "El canal estable es anterior a tu versión beta actual. Cambiar de canal no hará una degradación automática; elige un instalador estable en el historial de versiones.",
check: "Buscar actualizaciones",
checking: "Buscando actualizaciones…",
restartRequiredTooltip: "Actualización instalada, reinicia para finalizar",
@@ -9187,6 +9189,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Elige de dónde se descargan los instaladores de actualización. Se recomienda la fuente oficial; CNB puede ser más rápido en redes de China continental.",
updateDownloadSourceOfficial: "Fuente oficial (recomendada)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Canal de actualización",
+ updateChannelDescription: "Se recomienda Estable. Beta recibe versiones nuevas antes, pero puede ser menos estable.",
+ updateChannelStable: "Estable (recomendado)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Activar logs de depuración",
debugLoggingEnabledDescription: "Registra logs de diagnóstico locales del lado del usuario al investigar reportes. Desactivado por defecto.",
metadataCacheMemoryLimit: "Límite de memoria de la caché de metadatos",
diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts
index 0ff38024fb..bd914e9f3a 100644
--- a/apps/desktop/src/i18n/locales/id.ts
+++ b/apps/desktop/src/i18n/locales/id.ts
@@ -629,6 +629,8 @@ export default withEnglishFallback({
retryDownload: "Coba Unduh Lagi",
centerTitle: "Update Center",
centerAvailable: "{count} tersedia",
+ betaChannelBadge: "Saluran Dev Beta",
+ stableDowngradeBlocked: "Saluran stabil lebih lama daripada build beta saat ini. Mengganti saluran tidak akan menurunkan versi otomatis; pilih penginstal stabil dari riwayat versi.",
check: "Periksa pembaruan",
checking: "Memeriksa pembaruan…",
restartRequiredTooltip: "Pembaruan terpasang — restart untuk menyelesaikan",
@@ -8603,6 +8605,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Pilih tempat pembaruan aplikasi, agen database, driver, dan JRE terkelola diunduh. Sumber resmi direkomendasikan; CNB bisa lebih cepat untuk jaringan di Tiongkok daratan.",
updateDownloadSourceOfficial: "Sumber resmi (direkomendasikan)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Saluran pembaruan",
+ updateChannelDescription: "Stabil direkomendasikan. Beta menerima build baru lebih awal dan mungkin kurang stabil.",
+ updateChannelStable: "Stabil (disarankan)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Aktifkan log debug",
debugLoggingEnabledDescription: "Mencatat log diagnostik sisi pengguna lokal yang lebih terperinci saat menangani laporan. Dinonaktifkan secara default.",
metadataCacheMemoryLimit: "Batas cache memori metadata",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 0f69d4ff02..c18cc30b16 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1110,6 +1110,8 @@ export default withEnglishFallback({
retryDownload: "Riprova download",
centerTitle: "Centro aggiornamenti",
centerAvailable: "{count} disponibili",
+ betaChannelBadge: "Canale Dev Beta",
+ stableDowngradeBlocked: "Il canale stabile è precedente alla build beta attuale. Il cambio di canale non esegue il downgrade automatico; scegli un installer stabile dalla cronologia versioni.",
check: "Verifica aggiornamenti",
checking: "Verifica degli aggiornamenti in corso…",
restartRequiredTooltip: "Aggiornamento installato, riavvia per completare",
@@ -8966,6 +8968,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Scegli da dove scaricare gli installer degli aggiornamenti. La fonte ufficiale è consigliata; CNB può essere più veloce sulle reti della Cina continentale.",
updateDownloadSourceOfficial: "Fonte ufficiale (consigliata)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Canale di aggiornamento",
+ updateChannelDescription: "Stabile è consigliato. Beta riceve prima le nuove build, ma può essere meno stabile.",
+ updateChannelStable: "Stabile (consigliato)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Abilita log di debug",
debugLoggingEnabledDescription: "Registra log diagnostici locali lato utente durante l'analisi delle segnalazioni. Disattivato per impostazione predefinita.",
metadataCacheMemoryLimit: "Limite di memoria della cache dei metadati",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index e10bec844c..bfea6aed9e 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1111,6 +1111,8 @@ export default withEnglishFallback({
retryDownload: "ダウンロードを再試行",
centerTitle: "アップデートセンター",
centerAvailable: "{count} 件更新可能",
+ betaChannelBadge: "Dev Beta チャンネル",
+ stableDowngradeBlocked: "安定版チャンネルは現在の Beta ビルドより古い状態です。チャンネルを切り替えても自動でダウングレードされません。バージョン履歴から安定版インストーラーを選択してください。",
check: "アップデートを確認",
checking: "アップデートを確認中…",
restartRequiredTooltip: "アップデートがインストールされました。再起動して完了してください",
@@ -10340,6 +10342,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "アプリ内アップデートのインストーラーのダウンロード元を選択します。公式ソース推奨。中国本土ではCNBの方が高速な場合があります。",
updateDownloadSourceOfficial: "公式ソース(推奨)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "更新チャンネル",
+ updateChannelDescription: "安定版を推奨します。Beta は新しいビルドを早く受け取れますが、不安定な場合があります。",
+ updateChannelStable: "安定版(推奨)",
+ updateChannelBeta: "Dev Beta",
infiniteScroll: "無限スクロール読み込み",
autoCalculateTotalRows: "総行数を自動計算",
autoCalculateTotalRowsDescription: "クエリごとに COUNT(*) を自動実行し、一致する総行数を表示します。大きなクエリを高速に保つため既定はオフです。結果フッターから必要に応じて計算できます。",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index ea5da2f62e..2f780caf9b 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1117,6 +1117,8 @@ export default withEnglishFallback({
retryDownload: "다운로드 재시도",
centerTitle: "업데이트 센터",
centerAvailable: "{count}개 사용 가능",
+ betaChannelBadge: "Dev Beta 채널",
+ stableDowngradeBlocked: "안정 채널이 현재 Beta 빌드보다 이전 버전입니다. 채널을 바꿔도 자동으로 다운그레이드되지 않으므로 버전 기록에서 안정 버전 설치 파일을 선택하세요.",
check: "업데이트 확인",
checking: "업데이트 확인 중…",
restartRequiredTooltip: "업데이트 설치 완료 — 다시 시작하여 완료하세요",
@@ -9089,6 +9091,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "앱 업데이트, 데이터베이스 에이전트, 드라이버, 관리 JRE를 다운로드할 위치를 선택하세요. 공식 소스를 권장합니다. 중국 본토 네트워크에서는 CNB가 더 빠를 수 있습니다.",
updateDownloadSourceOfficial: "공식 소스 (권장)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "업데이트 채널",
+ updateChannelDescription: "안정 버전을 권장합니다. Beta는 새 빌드를 먼저 받지만 불안정할 수 있습니다.",
+ updateChannelStable: "안정 버전(권장)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "디버그 로그 활성화",
debugLoggingEnabledDescription: "문제 해결 보고서를 작성하는 동안 더 자세한 로컬 사용자 진단 로그를 기록합니다. 기본적으로 비활성화됩니다.",
duckDbWorkerProcessIsolation: "DuckDB 프로세스 격리 모드 (실험적)",
diff --git a/apps/desktop/src/i18n/locales/pl.ts b/apps/desktop/src/i18n/locales/pl.ts
index 68953cbd06..6e1345d954 100644
--- a/apps/desktop/src/i18n/locales/pl.ts
+++ b/apps/desktop/src/i18n/locales/pl.ts
@@ -689,6 +689,8 @@ export default withEnglishFallback({
retryDownload: "Ponów pobieranie",
centerTitle: "Centrum aktualizacji",
centerAvailable: "Dostępne: {count}",
+ betaChannelBadge: "Kanał Dev Beta",
+ stableDowngradeBlocked: "Kanał stabilny jest starszy niż bieżąca kompilacja beta. Zmiana kanału nie obniży wersji automatycznie; wybierz stabilny instalator w historii wersji.",
check: "Sprawdź aktualizacje",
checking: "Sprawdzanie aktualizacji…",
restartRequiredTooltip: "Zainstalowano aktualizację — uruchom ponownie, aby zakończyć",
@@ -9029,6 +9031,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Wybierz, skąd pobierane są aktualizacje aplikacji, agenci baz danych, sterowniki i zarządzane środowiska JRE. Zalecane jest oficjalne źródło; CNB może być szybsze w sieciach w Chinach kontynentalnych.",
updateDownloadSourceOfficial: "Oficjalne źródło (zalecane)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Kanał aktualizacji",
+ updateChannelDescription: "Zalecany jest kanał stabilny. Beta otrzymuje nowsze kompilacje wcześniej, ale może być mniej stabilna.",
+ updateChannelStable: "Stabilny (zalecany)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Włącz dziennik debugowania (Debug Logs)",
debugLoggingEnabledDescription: "Zapisuj bardziej szczegółowe lokalne dzienniki diagnostyczne podczas rozwiązywania problemów. Domyślnie wyłączone.",
metadataCacheMemoryLimit: "Limit pamięci podręcznej metadanych",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 0ec6164a20..15dc94f6fc 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1111,6 +1111,8 @@ export default withEnglishFallback({
retryDownload: "Tentar baixar novamente",
centerTitle: "Central de atualizações",
centerAvailable: "{count} disponíveis",
+ betaChannelBadge: "Canal Dev Beta",
+ stableDowngradeBlocked: "O canal estável é anterior ao build beta atual. Trocar de canal não fará downgrade automático; escolha um instalador estável no histórico de versões.",
check: "Verificar atualizações",
checking: "Verificando atualizações…",
restartRequiredTooltip: "Atualização instalada, reinicie para concluir",
@@ -8963,6 +8965,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Escolha de onde os instaladores de atualização no aplicativo são baixados. A fonte oficial é recomendada; CNB pode ser mais rápido em redes da China continental.",
updateDownloadSourceOfficial: "Fonte oficial (recomendada)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Canal de atualização",
+ updateChannelDescription: "Estável é recomendado. Beta recebe builds novos antes, mas pode ser menos estável.",
+ updateChannelStable: "Estável (recomendado)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Ativar logs de depuração",
debugLoggingEnabledDescription: "Registra logs locais de diagnóstico do lado do usuário ao investigar relatos. Desativado por padrão.",
metadataCacheMemoryLimit: "Limite de memória do cache de metadados",
diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts
index 6c4f16c98b..cc00724041 100644
--- a/apps/desktop/src/i18n/locales/ru.ts
+++ b/apps/desktop/src/i18n/locales/ru.ts
@@ -1084,6 +1084,8 @@ export default withEnglishFallback({
retryDownload: "Повторить загрузку",
centerTitle: "Центр обновлений",
centerAvailable: "Доступно: {count}",
+ betaChannelBadge: "Канал Dev Beta",
+ stableDowngradeBlocked: "Стабильный канал старше текущей beta-сборки. Переключение канала не выполнит автоматический откат; выберите стабильный установщик в истории версий.",
check: "Проверить обновления",
checking: "Проверка обновлений…",
restartRequiredTooltip: "Обновление установлено — перезапустите для завершения",
@@ -10549,6 +10551,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Выберите, откуда скачиваются обновления приложения, агенты баз данных, драйверы и управляемые JRE. Рекомендуется официальный источник; CNB может быть быстрее в сетях материкового Китая.",
updateDownloadSourceOfficial: "Официальный источник (рекомендуется)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Канал обновлений",
+ updateChannelDescription: "Рекомендуется стабильный канал. Beta получает новые сборки раньше, но может быть менее стабильной.",
+ updateChannelStable: "Стабильный (рекомендуется)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Включить отладочные журналы",
debugLoggingEnabledDescription: "Записывать более подробные локальные диагностические журналы на стороне пользователя при устранении неполадок. По умолчанию отключено.",
metadataCacheMemoryLimit: "Лимит кэша метаданных в памяти",
diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts
index 8bf16c7dcc..f6e8d541bb 100644
--- a/apps/desktop/src/i18n/locales/th.ts
+++ b/apps/desktop/src/i18n/locales/th.ts
@@ -1184,6 +1184,8 @@ export default withEnglishFallback({
retryDownload: "ลองดาวน์โหลดอีกครั้ง",
centerTitle: "ศูนย์อัปเดต",
centerAvailable: "พร้อมใช้งาน {count} รายการ",
+ betaChannelBadge: "ช่อง Dev Beta",
+ stableDowngradeBlocked: "ช่องเสถียรเก่ากว่าบิลด์ Beta ปัจจุบัน การเปลี่ยนช่องจะไม่ดาวน์เกรดอัตโนมัติ โปรดเลือกตัวติดตั้งรุ่นเสถียรจากประวัติเวอร์ชัน",
check: "ตรวจสอบอัปเดต",
checking: "กำลังตรวจสอบอัปเดต…",
restartRequiredTooltip: "ติดตั้งอัปเดตแล้ว — รีสตาร์ตเพื่อเสร็จสิ้น",
@@ -10926,6 +10928,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "เลือกแหล่งดาวน์โหลดการอัปเดตแอป Agent ฐานข้อมูล Driver และ JRE ที่จัดการอยู่ แนะนำแหล่งทางการ; CNB อาจเร็วกว่าในเครือข่ายจีนแผ่นดินใหญ่",
updateDownloadSourceOfficial: "แหล่งทางการ (แนะนำ)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "ช่องทางการอัปเดต",
+ updateChannelDescription: "แนะนำให้ใช้รุ่นเสถียร Beta จะได้รับบิลด์ใหม่ก่อน แต่อาจไม่เสถียร",
+ updateChannelStable: "รุ่นเสถียร (แนะนำ)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "เปิดใช้ Debug Log",
debugLoggingEnabledDescription: "บันทึก Diagnostic Log ฝั่งผู้ใช้แบบละเอียดขึ้นระหว่างแก้ไขปัญหารายงาน ปิดไว้โดยค่าเริ่มต้น",
metadataCacheMemoryLimit: "ขีดจำกัด Cache หน่วยความจำ Metadata",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index 6668b44274..0800834824 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -442,6 +442,8 @@ export default withEnglishFallback({
retryDownload: "İndirmeyi yeniden dene",
centerTitle: "Güncelleme Merkezi",
centerAvailable: "{count} güncelleme mevcut",
+ betaChannelBadge: "Dev Beta kanalı",
+ stableDowngradeBlocked: "Kararlı kanal mevcut beta derlemenizden daha eski. Kanalı değiştirmek otomatik düşürme yapmaz; sürüm geçmişinden kararlı yükleyiciyi seçin.",
check: "Güncellemeleri denetle",
checking: "Güncellemeler denetleniyor…",
restartRequiredTooltip: "Güncelleme kuruldu — bitirmek için yeniden başlatın",
@@ -7744,6 +7746,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "Uygulama güncellemelerinin, veritabanı agent'larının, sürücülerin ve yönetilen JRE'lerin nereden indirileceğini seçin. Resmî kaynak önerilir; Çin anakarası ağlarında CNB daha hızlı olabilir.",
updateDownloadSourceOfficial: "Resmî kaynak (önerilen)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "Güncelleme kanalı",
+ updateChannelDescription: "Kararlı kanal önerilir. Beta yeni derlemeleri daha erken alır ancak daha az kararlı olabilir.",
+ updateChannelStable: "Kararlı (önerilen)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "Hata ayıklama günlüklerini etkinleştir",
debugLoggingEnabledDescription: "Sorun bildirimlerini incelerken daha ayrıntılı yerel kullanıcı tarafı tanılama günlükleri kaydeder. Varsayılan olarak kapalıdır.",
metadataCacheMemoryLimit: "Meta veri bellek önbelleği sınırı",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index bd2bec0431..4e5c4b1208 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1261,6 +1261,8 @@ export default withEnglishFallback({
retryDownload: "重试下载",
centerTitle: "更新中心",
centerAvailable: "{count} 项可用更新",
+ betaChannelBadge: "Dev Beta 渠道",
+ stableDowngradeBlocked: "稳定版渠道低于当前 Beta 版本,切换渠道不会自动降级;请在版本历史中选择稳定版安装包。",
check: "检查更新",
checking: "正在检查更新…",
restartRequiredTooltip: "更新已安装,重启以完成更新",
@@ -10358,6 +10360,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "选择应用更新、数据库 Agent、驱动和托管 JRE 的下载来源。官方源为推荐选项,CNB 适合国内网络环境。",
updateDownloadSourceOfficial: "官方源(推荐)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "更新渠道",
+ updateChannelDescription: "稳定版适合日常使用;Dev Beta 会更早收到新版本,但可能存在不稳定问题。",
+ updateChannelStable: "稳定版(推荐)",
+ updateChannelBeta: "Dev Beta",
debugLoggingEnabled: "启用调试日志",
debugLoggingEnabledDescription: "开启后在本机记录更详细的用户侧诊断日志,反馈问题时可临时开启;默认关闭。",
metadataCacheMemoryLimit: "元数据内存缓存上限",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 6717216d44..2f333b4da9 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1096,6 +1096,8 @@ export default withEnglishFallback({
retryDownload: "重試下載",
centerTitle: "更新中心",
centerAvailable: "{count} 項可用更新",
+ betaChannelBadge: "Dev Beta 頻道",
+ stableDowngradeBlocked: "穩定版頻道低於目前 Beta 版本,切換頻道不會自動降級;請在版本歷史中選擇穩定版安裝套件。",
check: "檢查更新",
checking: "正在檢查更新…",
restartRequiredTooltip: "更新已安裝,重新啟動以完成更新",
@@ -9513,6 +9515,10 @@ export default withEnglishFallback({
updateDownloadSourceDescription: "選擇應用程式內更新安裝程式的下載來源。建議使用官方來源;CNB 在中國大陸網路環境下可能更快。",
updateDownloadSourceOfficial: "官方來源(建議)",
updateDownloadSourceCnb: "CNB",
+ updateChannel: "更新頻道",
+ updateChannelDescription: "穩定版適合日常使用;Dev Beta 會更早收到新版本,但可能存在不穩定問題。",
+ updateChannelStable: "穩定版(推薦)",
+ updateChannelBeta: "Dev Beta",
exportSection: "匯出",
csvQuoteMode: "CSV 欄位引號",
csvQuoteModeDescription: "選擇 CSV 匯出時欄位使用雙引號的方式。",
diff --git a/apps/desktop/src/lib/__tests__/windowsInstallerTemplate.spec.ts b/apps/desktop/src/lib/__tests__/windowsInstallerTemplate.spec.ts
index d929f7ab2d..1b3a5bc8bf 100644
--- a/apps/desktop/src/lib/__tests__/windowsInstallerTemplate.spec.ts
+++ b/apps/desktop/src/lib/__tests__/windowsInstallerTemplate.spec.ts
@@ -144,15 +144,19 @@ describe("Windows 7 fixed WebView2 runtime bundle", () => {
const releaseWin7Job = releaseWorkflow.slice(releaseWorkflow.indexOf(" build-windows-7-offline:"), releaseWorkflow.indexOf(" static-browser:"));
const ciWin7Job = ciWorkflow.slice(ciWorkflow.indexOf(" windows-win7-bundle:"), ciWorkflow.indexOf(" duckdb-windows-driver:"));
- for (const job of [releaseWin7Job, ciWin7Job]) {
- expect(job).toContain("RUSTC_WRAPPER: sccache");
- expect(job).toContain("SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}");
- expect(job).toContain("SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1");
- expect(job).toContain('SCCACHE_IDLE_TIMEOUT: "0"');
- expect(job).toContain("mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba");
- expect(job).toContain('version: "v0.16.0"');
- expect(job).not.toMatch(/^\s+(?:CC|CXX):/m);
+ expect(releaseWin7Job).toContain("swatinem/rust-cache@v2");
+ expect(releaseWin7Job).not.toMatch(/RUSTC_WRAPPER|SCCACHE_|sccache/i);
+ for (const line of [
+ "RUSTC_WRAPPER: sccache",
+ "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}",
+ "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1",
+ 'SCCACHE_IDLE_TIMEOUT: "0"',
+ "mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba",
+ 'version: "v0.16.0"',
+ ]) {
+ expect(ciWin7Job).toContain(line);
}
+ expect(ciWin7Job).not.toMatch(/^\s+(?:CC|CXX):/m);
expect(releaseWin7Job).not.toContain("CARGO_PROFILE_RELEASE_");
expect(ciWin7Job).toContain('CARGO_PROFILE_RELEASE_LTO: "off"');
expect(ciWin7Job).toContain('CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"');
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index a4e613b948..0c6b9c0bd9 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -174,6 +174,7 @@ import type {
UpdateInfo,
DownloadedUpdate,
UpdateDownloadSource,
+ UpdateChannel,
RedisCollectionPage,
RedisBlob,
RedisDatabaseInfo,
@@ -5837,10 +5838,11 @@ export async function deleteHistoryEntry(id: string): Promise {
// Updates
// ---------------------------------------------------------------------------
-export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise {
+export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource, channel: UpdateChannel = "stable"): Promise {
const params = new URLSearchParams();
if (locale) params.set("locale", locale);
if (source) params.set("source", source);
+ params.set("channel", channel);
const query = params.size > 0 ? `?${params.toString()}` : "";
return get(`/api/update/check${query}`);
}
@@ -5910,7 +5912,7 @@ export async function getSystemProxyUrl(): Promise {
return null;
}
-export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion: string, _attemptId: string, _releaseNotes?: string): Promise {
+export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion: string, _attemptId: string, _releaseNotes?: string, _channel: UpdateChannel = "stable", _releaseTag?: string): Promise {
throw new Error("In-app update downloads are only available in the desktop app.");
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 2a6c587461..31b655560e 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -3640,9 +3640,13 @@ export interface UpdateInfo {
release_name: string;
release_url: string;
release_notes: string;
+ channel?: "stable" | "beta";
+ release_tag?: string;
+ is_promoted?: boolean;
}
export type UpdateDownloadSource = "official" | "cnb";
+export type UpdateChannel = "stable" | "beta";
export interface DownloadedUpdate {
cache_id: string;
@@ -3651,6 +3655,9 @@ export interface DownloadedUpdate {
release_url: string;
release_notes: string;
downloaded_at: number;
+ channel?: UpdateChannel;
+ release_tag?: string;
+ is_promoted?: boolean;
}
export interface UpdateDownloadProgress {
@@ -3710,8 +3717,8 @@ export async function uninstallNpmMcpServer(): Promise {
return invoke("uninstall_npm_mcp_server");
}
-export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise {
- return invoke("check_for_updates", { locale, source });
+export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource, channel: UpdateChannel = "stable"): Promise {
+ return invoke("check_for_updates", { locale, source, channel });
}
export async function fetchChangelog(lang?: string): Promise {
@@ -3730,8 +3737,8 @@ export async function getSystemProxyUrl(): Promise {
return invoke("get_system_proxy_url");
}
-export async function downloadUpdate(source: UpdateDownloadSource, latestVersion: string, attemptId: string, releaseNotes?: string): Promise {
- return invoke("download_update", { source, latestVersion, attemptId, releaseNotes });
+export async function downloadUpdate(source: UpdateDownloadSource, latestVersion: string, attemptId: string, releaseNotes?: string, channel: UpdateChannel = "stable", releaseTag?: string): Promise {
+ return invoke("download_update", { source, channel, latestVersion, attemptId, releaseNotes, releaseTag });
}
export async function cancelUpdateDownload(): Promise {
diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
index eb24845540..5cea1994d3 100644
--- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts
+++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts
@@ -171,6 +171,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"globalDateTimeExportFormat",
"globalDateTimeImportFormat",
"updateDownloadSource",
+ "updateChannel",
"toolbarItems",
"snippets",
"sqlShortcuts",
diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts
index 6183702996..ed3dd0ad2c 100644
--- a/apps/desktop/src/lib/settings/settingsSearch.ts
+++ b/apps/desktop/src/lib/settings/settingsSearch.ts
@@ -728,6 +728,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "updates-jdbc", category: "updates", titleKey: "settings.autoUpdateJdbc", descriptionKey: "settings.autoUpdateJdbcDescription", targetId: "updates" },
{ id: "updates-mcp", category: "updates", titleKey: "settings.autoUpdateMcp", descriptionKey: "settings.autoUpdateMcpDescription", targetId: "updates" },
{ id: "updates-plugins", category: "updates", titleKey: "settings.autoUpdatePlugins", descriptionKey: "settings.autoUpdatePluginsDescription", targetId: "updates" },
+ { id: "updates-channel", category: "updates", titleKey: "settings.updateChannel", descriptionKey: "settings.updateChannelDescription", targetId: "updates", keywordKeys: ["settings.updateChannelStable", "settings.updateChannelBeta"] },
{ id: "updates-source", category: "updates", titleKey: "settings.updateDownloadSource", descriptionKey: "settings.updateDownloadSourceDescription", targetId: "updates", keywordKeys: ["settings.updateDownloadSourceOfficial", "settings.updateDownloadSourceCnb"] },
// Fixed controls added alongside the full template coverage audit.
{ id: "editor-keep-explicit-transaction-in-auto-commit", category: "editor", titleKey: "settings.keepExplicitTransactionInAutoCommit", descriptionKey: "settings.keepExplicitTransactionInAutoCommitDescription", targetId: "editor" },
diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts
index 9b1484d5a2..60550fd999 100644
--- a/apps/desktop/src/lib/settings/settingsTransfer.ts
+++ b/apps/desktop/src/lib/settings/settingsTransfer.ts
@@ -291,7 +291,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record();
diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
index 5111e12e0b..0afadb3d65 100644
--- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
+++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts
@@ -2368,6 +2368,7 @@ describe("settingsStore editor settings persistence", () => {
it("rolls back a failed atomic update and allows retry", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "",
+ updateChannel: "stable",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
welcomePageModeDefaultVersion: WELCOME_PAGE_DEFAULT_VERSION,
@@ -2417,6 +2418,7 @@ describe("settingsStore editor settings persistence", () => {
let resolveFirstSave!: () => void;
const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "",
+ updateChannel: "stable",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
welcomePageModeDefaultVersion: WELCOME_PAGE_DEFAULT_VERSION,
@@ -2454,6 +2456,7 @@ describe("settingsStore editor settings persistence", () => {
let rejectFirstSave!: (error: Error) => void;
const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "",
+ updateChannel: "stable",
theme: "system",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index 6fb93bbe12..2fa92410c9 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -159,6 +159,7 @@ export type InterfaceLayout = "separated" | "classic";
export type WelcomePageMode = "intro" | "workspace";
export type UpdateDownloadSource = "official" | "cnb";
+export type UpdateChannel = "stable" | "beta";
export type SqlSemanticDiagnosticsMode = "auto" | "enabled" | "disabled";
export type OpenTabsRestoreMode = "all" | "pinned" | "none";
export type AppCloseUnsavedTabsMode = "prompt" | "keep-drafts";
@@ -1270,6 +1271,7 @@ export interface EditorSettings {
exportRowLimit: number;
queryExportKeysetOptimizationEnabled: boolean;
updateDownloadSource: UpdateDownloadSource;
+ updateChannel: UpdateChannel;
ignoredUpdateVersion: string;
pluginShortcuts: PluginShortcutSettings;
/** Plugin ids the user granted `script-src 'unsafe-eval'` in the workbench sandbox. */
@@ -1629,6 +1631,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
exportRowLimit: 100000,
queryExportKeysetOptimizationEnabled: true,
updateDownloadSource: "official",
+ updateChannel: "stable",
ignoredUpdateVersion: "",
pluginShortcuts: normalizePluginShortcutSettings(undefined),
pluginGraphicsEngineIds: [],
@@ -1818,6 +1821,10 @@ function normalizeUpdateDownloadSource(value: unknown): UpdateDownloadSource {
return value === "cnb" ? "cnb" : DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
}
+function normalizeUpdateChannel(value: unknown): UpdateChannel {
+ return value === "beta" ? "beta" : "stable";
+}
+
function normalizeSqlSemanticDiagnosticsMode(value: unknown, legacyEnabled?: unknown): SqlSemanticDiagnosticsMode {
if (value === "auto" || value === "enabled" || value === "disabled") return value;
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "enabled" : "disabled";
@@ -2398,6 +2405,7 @@ export function normalizeEditorSettings(settings: Partial, exist
exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit,
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
updateDownloadSource: normalizeUpdateDownloadSource(settings.updateDownloadSource),
+ updateChannel: normalizeUpdateChannel(settings.updateChannel),
ignoredUpdateVersion: typeof settings.ignoredUpdateVersion === "string" ? settings.ignoredUpdateVersion : DEFAULT_EDITOR_SETTINGS.ignoredUpdateVersion,
pluginShortcuts: normalizePluginShortcutSettings(settings.pluginShortcuts),
pluginGraphicsEngineIds: normalizePluginGraphicsEngineIds(settings.pluginGraphicsEngineIds),
@@ -2644,7 +2652,17 @@ export const useSettingsStore = defineStore("settings", () => {
const needsDataGridExtractorOptionsMigration =
(typeof savedSettings.dataGridExtractorOptionsMigrationVersion !== "number" || savedSettings.dataGridExtractorOptionsMigrationVersion < DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION) && (savedNullText === "NULL" || savedSqlIncludeDatabaseName === false);
const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource;
- if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration || needsWelcomePageDefaultMigration || needsCompactTabTitleDefaultMigration || needsTabNavigationShortcutMigration || needsSidebarBrowseObjectsMigration || needsDataGridExtractorOptionsMigration) {
+ const savedUpdateChannel = (saved as { updateChannel?: unknown }).updateChannel;
+ if (
+ savedUpdateDownloadSource === "atomgit" ||
+ savedUpdateChannel !== normalizeUpdateChannel(savedUpdateChannel) ||
+ needsExecuteModeDefaultMigration ||
+ needsWelcomePageDefaultMigration ||
+ needsCompactTabTitleDefaultMigration ||
+ needsTabNavigationShortcutMigration ||
+ needsSidebarBrowseObjectsMigration ||
+ needsDataGridExtractorOptionsMigration
+ ) {
// Persist one-time migrations so removed or unsafe defaults cannot reappear.
await enqueueEditorSettingsSave().catch(() => {});
}
@@ -3309,6 +3327,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit)));
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
if (partial.updateDownloadSource !== undefined) editorSettings.value.updateDownloadSource = normalizeUpdateDownloadSource(partial.updateDownloadSource);
+ if (partial.updateChannel !== undefined) editorSettings.value.updateChannel = normalizeUpdateChannel(partial.updateChannel);
if (partial.ignoredUpdateVersion !== undefined) editorSettings.value.ignoredUpdateVersion = typeof partial.ignoredUpdateVersion === "string" ? partial.ignoredUpdateVersion : "";
if (partial.pluginShortcuts !== undefined) editorSettings.value.pluginShortcuts = normalizePluginShortcutSettings(partial.pluginShortcuts);
if (partial.pluginGraphicsEngineIds !== undefined) editorSettings.value.pluginGraphicsEngineIds = normalizePluginGraphicsEngineIds(partial.pluginGraphicsEngineIds);
diff --git a/crates/dbx-core/src/host/update.rs b/crates/dbx-core/src/host/update.rs
index fe148e8c43..42a582da3e 100644
--- a/crates/dbx-core/src/host/update.rs
+++ b/crates/dbx-core/src/host/update.rs
@@ -4,15 +4,26 @@ use serde::{Deserialize, Serialize};
const LATEST_JSON_GITHUB_PATH: &str = "https://github.com/t8y2/dbx/releases/latest/download/latest.json";
const LATEST_JSON_R2_PATH: &str = "releases/latest/latest.json";
+const LATEST_JSON_STABLE_R2_PATH: &str = "releases/stable/latest.json";
+const LATEST_JSON_BETA_R2_PATH: &str = "releases/beta/latest.json";
const LATEST_JSON_CNB_PATH: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/latest/download/latest.json";
const LATEST_EN_NOTES_R2_PATH: &str = "changelog/latest-en.json";
const GITHUB_RELEASE_API_PREFIX: &str = "https://api.github.com/repos/t8y2/dbx/releases/tags/v";
-const RELEASE_URL_PREFIX: &str = "https://github.com/t8y2/dbx/releases/tag/v";
+
+fn default_promoted() -> bool {
+ true
+}
#[derive(Debug, Deserialize)]
pub struct TauriRelease {
pub version: String,
#[serde(default)]
+ pub channel: UpdateChannel,
+ #[serde(default)]
+ pub release_tag: Option,
+ #[serde(default = "default_promoted")]
+ pub is_promoted: bool,
+ #[serde(default)]
pub notes: Option,
#[serde(default)]
pub jdbc_plugin: Option,
@@ -48,27 +59,54 @@ pub struct UpdateInfo {
pub release_name: String,
pub release_url: String,
pub release_notes: String,
+ pub channel: UpdateChannel,
+ pub release_tag: String,
+ pub is_promoted: bool,
}
pub async fn fetch_latest_release(locale: &str, source: crate::DownloadSource) -> Result {
+ fetch_latest_release_for_channel(locale, source, UpdateChannel::Stable).await
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum UpdateChannel {
+ #[default]
+ Stable,
+ Beta,
+}
+
+pub async fn fetch_latest_release_for_channel(
+ locale: &str,
+ source: crate::DownloadSource,
+ channel: UpdateChannel,
+) -> Result {
let client = build_update_http_client()?;
- let candidates = update_check_candidates(source);
+ let candidates = update_check_candidates(source, channel);
let resp = fetch_first_available(&client, &candidates).await?;
let mut release = resp.json::().await.map_err(|e| format!("Failed to parse update response: {e}"))?;
- if let Ok(github) = fetch_github_release_metadata(&client, &release.version).await {
+ let identity_version = release_identity_version(&release).to_string();
+ if let Ok(github) = fetch_github_release_metadata(&client, &identity_version).await {
release.github = Some(github);
}
// 非中文界面用户额外拉取英文 release notes;失败/版本不匹配则保持 None,上层回退中文。
if !is_chinese_locale(locale) {
- if let Ok(notes_en) = fetch_latest_release_notes_en(&client, &release.version).await {
+ if let Ok(notes_en) = fetch_latest_release_notes_en(&client, &identity_version).await {
release.notes_en = Some(notes_en);
}
}
Ok(release)
}
+/// A promoted beta publishes the stable base version in `version` but keeps its
+/// beta tag in `release_tag`; GitHub metadata and R2 release notes are both keyed
+/// by that tag.
+fn release_identity_version(release: &TauriRelease) -> &str {
+ release.release_tag.as_deref().unwrap_or(&release.version)
+}
+
async fn fetch_first_available(client: &reqwest::Client, candidates: &[String]) -> Result {
let mut errors = Vec::with_capacity(candidates.len());
for url in candidates {
@@ -87,17 +125,36 @@ async fn fetch_first_available(client: &reqwest::Client, candidates: &[String])
Err(format!("Failed to check updates: {}", errors.join("; ")))
}
-fn update_check_candidates(source: crate::DownloadSource) -> Vec {
+fn update_check_candidates(source: crate::DownloadSource, channel: UpdateChannel) -> Vec {
+ let r2_path = match channel {
+ UpdateChannel::Stable => LATEST_JSON_STABLE_R2_PATH,
+ UpdateChannel::Beta => LATEST_JSON_BETA_R2_PATH,
+ };
match source {
crate::DownloadSource::Official => {
- vec![format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE), LATEST_JSON_GITHUB_PATH.to_string()]
+ if matches!(channel, UpdateChannel::Beta) {
+ vec![format!("{}{}", crate::R2_CDN_BASE, r2_path)]
+ } else {
+ vec![
+ format!("{}{}", crate::R2_CDN_BASE, r2_path),
+ format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE),
+ LATEST_JSON_GITHUB_PATH.to_string(),
+ ]
+ }
}
// CNB exposes a moving latest release, so checking CNB does not need an official-source version first.
- crate::DownloadSource::Cnb => vec![
- LATEST_JSON_CNB_PATH.to_string(),
- format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE),
- LATEST_JSON_GITHUB_PATH.to_string(),
- ],
+ crate::DownloadSource::Cnb => {
+ if matches!(channel, UpdateChannel::Beta) {
+ vec![format!("{}{}", crate::R2_CDN_BASE, r2_path)]
+ } else {
+ vec![
+ format!("{}{}", crate::R2_CDN_BASE, r2_path),
+ LATEST_JSON_CNB_PATH.to_string(),
+ format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE),
+ LATEST_JSON_GITHUB_PATH.to_string(),
+ ]
+ }
+ }
}
}
@@ -163,6 +220,7 @@ async fn fetch_github_release_metadata(
pub fn build_update_info(release: TauriRelease, current_version: &str) -> UpdateInfo {
let latest_version = normalize_version(&release.version);
+ let update_available = is_channel_update_available(release.channel, &latest_version, current_version);
let github = release.github.as_ref();
let release_notes = non_empty(release.notes_en.as_deref())
.map(ToOwned::to_owned)
@@ -173,13 +231,13 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update
.and_then(|metadata| non_empty(metadata.name.as_deref()))
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("DBX v{latest_version}"));
+ let release_tag = release.release_tag.unwrap_or_else(|| format!("v{latest_version}"));
let release_url = github
.and_then(|metadata| non_empty(metadata.html_url.as_deref()))
.map(ToOwned::to_owned)
- .unwrap_or_else(|| format!("{RELEASE_URL_PREFIX}{latest_version}"));
-
+ .unwrap_or_else(|| format!("https://github.com/t8y2/dbx/releases/tag/{release_tag}"));
UpdateInfo {
- update_available: is_newer_version(&latest_version, current_version),
+ update_available,
portable_mode: false,
manual_update_only: false,
current_version: current_version.to_string(),
@@ -187,9 +245,42 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update
release_url,
release_notes,
latest_version,
+ channel: release.channel,
+ release_tag,
+ is_promoted: release.is_promoted,
}
}
+pub fn is_channel_update_available(channel: UpdateChannel, latest_version: &str, current_version: &str) -> bool {
+ // A promoted beta keeps its prerelease version inside the installed binary,
+ // while the stable manifest intentionally exposes the base version. Treat
+ // that pair as the same release so switching back to stable never offers
+ // the already-installed artifact again.
+ if matches!(channel, UpdateChannel::Stable) {
+ let latest = normalize_version(latest_version);
+ let current = normalize_version(current_version);
+ let latest_base = latest.split(['-', '+']).next().unwrap_or_default();
+ let current_base = current.split(['-', '+']).next().unwrap_or_default();
+ if !latest.contains('-') && current.contains('-') && latest_base == current_base {
+ return false;
+ }
+ }
+ if is_newer_version(latest_version, current_version) {
+ return true;
+ }
+ if matches!(channel, UpdateChannel::Beta) {
+ // SemVer ranks `0.6.60-beta.N` below `0.6.60`. A user who explicitly
+ // opts into beta must still be able to move from the stable build of
+ // that base version to its beta builds.
+ let latest = normalize_version(latest_version);
+ let current = normalize_version(current_version);
+ let latest_base = latest.split(['-', '+']).next().unwrap_or_default();
+ let current_base = current.split(['-', '+']).next().unwrap_or_default();
+ return latest.contains('-') && latest_base == current_base && !current.contains('-');
+ }
+ false
+}
+
fn canonical_release_notes(notes: Option<&str>) -> Option {
let notes = non_empty(notes)?;
// Tauri's generated updater notes are a transport fallback, not the curated release notes.
@@ -212,7 +303,10 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
#[cfg(test)]
mod tests {
- use super::{build_update_info, GithubReleaseMetadata, TauriRelease};
+ use super::{
+ build_update_info, normalize_version, release_identity_version, GithubReleaseMetadata, TauriRelease,
+ UpdateChannel,
+ };
#[test]
fn parses_jdbc_plugin_metadata_from_latest_json() {
@@ -234,12 +328,17 @@ mod tests {
assert_eq!(jdbc.version, "0.1.3");
assert_eq!(jdbc.protocol_version, 1);
assert_eq!(jdbc.url, "https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip");
+ assert_eq!(release.channel, UpdateChannel::Stable);
+ assert!(release.is_promoted);
}
#[test]
fn update_info_prefers_github_release_metadata() {
let release = TauriRelease {
version: "0.5.3".to_string(),
+ channel: UpdateChannel::Stable,
+ release_tag: None,
+ is_promoted: false,
notes: Some("See the assets below to download and install.".to_string()),
jdbc_plugin: None,
github: Some(GithubReleaseMetadata {
@@ -263,6 +362,9 @@ mod tests {
// 非中文界面用户:notes_en 命中时优先于 GitHub 中文 body,应用内更新提示展示英文
let release = TauriRelease {
version: "0.5.3".to_string(),
+ channel: UpdateChannel::Stable,
+ release_tag: None,
+ is_promoted: false,
notes: Some("See the assets below to download and install.".to_string()),
jdbc_plugin: None,
github: Some(GithubReleaseMetadata {
@@ -282,6 +384,9 @@ mod tests {
fn update_info_ignores_generated_notes_when_curated_notes_are_unavailable() {
let release = TauriRelease {
version: "0.5.3".to_string(),
+ channel: UpdateChannel::Stable,
+ release_tag: None,
+ is_promoted: false,
notes: Some("## What's Changed\n* generated item".to_string()),
jdbc_plugin: None,
github: None,
@@ -293,22 +398,94 @@ mod tests {
assert_eq!(info.release_notes, "");
}
+ #[test]
+ fn beta_release_url_uses_the_prerelease_tag_without_github_metadata() {
+ let release = TauriRelease {
+ version: "0.6.60-beta.20261028.1".to_string(),
+ channel: UpdateChannel::Beta,
+ release_tag: Some("v0.6.60-beta.20261028.1".to_string()),
+ is_promoted: false,
+ notes: None,
+ jdbc_plugin: None,
+ github: None,
+ notes_en: None,
+ };
+
+ let info = build_update_info(release, "0.6.59");
+
+ assert_eq!(info.release_url, "https://github.com/t8y2/dbx/releases/tag/v0.6.60-beta.20261028.1");
+ }
+
+ #[test]
+ fn release_identity_follows_the_tag_of_a_promoted_beta() {
+ let promoted = TauriRelease {
+ version: "0.6.60".to_string(),
+ channel: UpdateChannel::Stable,
+ release_tag: Some("v0.6.60-beta.20261028.1".to_string()),
+ is_promoted: true,
+ notes: None,
+ jdbc_plugin: None,
+ github: None,
+ notes_en: None,
+ };
+ // latest-en.json is keyed by the tag, so a promoted beta must not be matched by its base version.
+ assert_eq!(normalize_version(release_identity_version(&promoted)), "0.6.60-beta.20261028.1");
+
+ let legacy = TauriRelease { release_tag: None, ..promoted };
+ assert_eq!(release_identity_version(&legacy), "0.6.60");
+ }
+
+ #[test]
+ fn stable_channel_does_not_reoffer_the_promoted_beta_build() {
+ let release = TauriRelease {
+ version: "0.6.60".to_string(),
+ channel: UpdateChannel::Stable,
+ release_tag: Some("v0.6.60-beta.20261028.1".to_string()),
+ is_promoted: true,
+ notes: None,
+ jdbc_plugin: None,
+ github: None,
+ notes_en: None,
+ };
+
+ let info = build_update_info(release, "0.6.60-beta.20261028.1");
+
+ assert!(!info.update_available);
+ }
+
+ #[test]
+ fn beta_channel_offers_same_base_prerelease_from_stable_build() {
+ assert!(super::is_channel_update_available(UpdateChannel::Beta, "0.6.60-beta.20261028.1", "0.6.60"));
+ assert!(super::is_channel_update_available(
+ UpdateChannel::Beta,
+ "0.6.60-beta.20261028.2",
+ "0.6.60-beta.20261028.1"
+ ));
+ assert!(!super::is_channel_update_available(UpdateChannel::Stable, "0.6.60", "0.6.60-beta.20261028.1"));
+ }
+
#[test]
fn update_check_candidates_follow_selected_source() {
assert_eq!(
- super::update_check_candidates(crate::DownloadSource::Official),
+ super::update_check_candidates(crate::DownloadSource::Official, UpdateChannel::Stable),
vec![
+ "https://dl.dbxio.com/releases/stable/latest.json",
"https://dl.dbxio.com/releases/latest/latest.json",
"https://github.com/t8y2/dbx/releases/latest/download/latest.json",
]
);
assert_eq!(
- super::update_check_candidates(crate::DownloadSource::Cnb),
+ super::update_check_candidates(crate::DownloadSource::Cnb, UpdateChannel::Stable),
vec![
+ "https://dl.dbxio.com/releases/stable/latest.json",
"https://cnb.cool/dbxio.com/dbx/-/releases/latest/download/latest.json",
"https://dl.dbxio.com/releases/latest/latest.json",
"https://github.com/t8y2/dbx/releases/latest/download/latest.json",
]
);
+ assert_eq!(
+ super::update_check_candidates(crate::DownloadSource::Official, UpdateChannel::Beta)[0],
+ "https://dl.dbxio.com/releases/beta/latest.json"
+ );
}
}
diff --git a/crates/dbx-core/tests/agent_service.rs b/crates/dbx-core/tests/agent_service.rs
index a070e7f456..9128212cd8 100644
--- a/crates/dbx-core/tests/agent_service.rs
+++ b/crates/dbx-core/tests/agent_service.rs
@@ -562,6 +562,12 @@ fn accepts_current_app_when_min_version_is_not_newer() {
assert!(!is_app_version_compatible("0.5.14", "0.5.13"));
}
+#[test]
+fn accepts_promoted_beta_for_same_base_minimum_version() {
+ assert!(is_app_version_compatible("0.6.60", "0.6.60-beta.20261029.1"));
+ assert!(!is_app_version_compatible("0.6.61", "0.6.60-beta.20261029.1"));
+}
+
#[test]
fn atomic_replace_moves_download_into_place() {
let dir = test_path("atomic");
diff --git a/crates/dbx-driver-agent/src/agent_service.rs b/crates/dbx-driver-agent/src/agent_service.rs
index e7bf0e7309..881bdf6e4b 100644
--- a/crates/dbx-driver-agent/src/agent_service.rs
+++ b/crates/dbx-driver-agent/src/agent_service.rs
@@ -2751,7 +2751,7 @@ pub fn ensure_driver_app_version(
}
pub fn is_app_version_compatible(min_app_version: &str, current_version: &str) -> bool {
- !dbx_platform::version::is_newer_version(min_app_version, current_version)
+ dbx_platform::version::is_version_compatible(min_app_version, current_version)
}
pub fn download_temp_path(dest: &std::path::Path) -> std::path::PathBuf {
diff --git a/crates/dbx-platform/Cargo.toml b/crates/dbx-platform/Cargo.toml
index b732191807..4e50cfeda0 100644
--- a/crates/dbx-platform/Cargo.toml
+++ b/crates/dbx-platform/Cargo.toml
@@ -12,6 +12,7 @@ test-support = ["host-prompts"]
downloads = ["dep:reqwest", "dep:futures", "dep:serde"]
[dependencies]
+semver = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"], optional = true }
futures = { version = "0.3", optional = true }
tokio = { version = "1", features = ["process", "time"] }
diff --git a/crates/dbx-platform/src/version.rs b/crates/dbx-platform/src/version.rs
index 8421f683ae..f7035d4a9e 100644
--- a/crates/dbx-platform/src/version.rs
+++ b/crates/dbx-platform/src/version.rs
@@ -6,7 +6,20 @@ pub fn parse_version(version: &str) -> Vec {
normalize_version(version).split(['.', '-', '+']).map(|part| part.parse::().unwrap_or(0)).collect()
}
+pub fn base_version(version: &str) -> String {
+ normalize_version(version).split(['-', '+']).next().unwrap_or_default().to_string()
+}
+
+pub fn is_version_compatible(min_version: &str, current_version: &str) -> bool {
+ !is_newer_version(&base_version(min_version), &base_version(current_version))
+}
+
pub fn is_newer_version(latest: &str, current: &str) -> bool {
+ if let (Ok(latest), Ok(current)) =
+ (semver::Version::parse(&normalize_version(latest)), semver::Version::parse(&normalize_version(current)))
+ {
+ return latest > current;
+ }
let latest_parts = parse_version(latest);
let current_parts = parse_version(current);
let max_len = latest_parts.len().max(current_parts.len());
@@ -41,5 +54,18 @@ mod tests {
assert!(is_newer_version("1.0.0", "0.9.9"));
assert!(!is_newer_version("0.2.0", "0.2.0"));
assert!(!is_newer_version("0.1.9", "0.2.0"));
+ assert!(is_newer_version("0.6.60-beta.20261028.1", "0.6.59"));
+ assert!(is_newer_version("0.6.60-beta.20261029.1", "0.6.60-beta.20261028.1"));
+ assert!(is_newer_version("0.6.60", "0.6.60-beta.20261028.1"));
+ assert!(!is_newer_version("0.6.59", "0.6.60-beta.20261028.1"));
+ }
+
+ #[test]
+ fn compares_application_compatibility_by_base_version() {
+ assert_eq!(base_version("v0.6.60-beta.20261029.1"), "0.6.60");
+ assert_eq!(base_version("0.6.60+build.1"), "0.6.60");
+ assert!(is_version_compatible("0.6.60", "0.6.60-beta.20261029.1"));
+ assert!(is_version_compatible("0.6.59", "0.6.60-beta.20261029.1"));
+ assert!(!is_version_compatible("0.6.61", "0.6.60-beta.20261029.1"));
}
}
diff --git a/crates/dbx-web/src/routes/update.rs b/crates/dbx-web/src/routes/update.rs
index d17bdac392..e056e7ace9 100644
--- a/crates/dbx-web/src/routes/update.rs
+++ b/crates/dbx-web/src/routes/update.rs
@@ -19,12 +19,19 @@ pub struct UpdateCheckParams {
pub locale: Option,
#[serde(default)]
pub source: Option,
+ #[serde(default)]
+ pub channel: Option,
}
pub async fn check_for_updates(Query(params): Query) -> Result, AppError> {
let locale = params.locale.unwrap_or_else(|| "zh-CN".to_string());
- let release =
- update::fetch_latest_release(&locale, params.source.unwrap_or_default()).await.map_err(AppError::from)?;
+ let release = update::fetch_latest_release_for_channel(
+ &locale,
+ params.source.unwrap_or_default(),
+ params.channel.unwrap_or_default(),
+ )
+ .await
+ .map_err(AppError::from)?;
let info = update::build_update_info(release, env!("CARGO_PKG_VERSION"));
Ok(Json(serde_json::to_value(info).map_err(|e| AppError::from(e.to_string()))?))
}
diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs
index 17cf4594bf..879574c14d 100644
--- a/scripts/release.test.mjs
+++ b/scripts/release.test.mjs
@@ -9,6 +9,8 @@ import { isDesktopVersionOnlyCargoLockChange } from "./release-lock.mjs";
const repoRoot = new URL("..", import.meta.url).pathname;
const releaseScript = join(repoRoot, "scripts/release.mjs");
const releaseWorkflow = join(repoRoot, ".github/workflows/release.yml");
+const appPublishWorkflow = join(repoRoot, ".github/workflows/publish-packages.yml");
+const dailyBetaWorkflow = join(repoRoot, ".github/workflows/daily-beta-release.yml");
const packagesWorkflow = join(repoRoot, ".github/workflows/mcp-release.yml");
const vsignConfigPath = join(repoRoot, "src-tauri/tauri.vsign.conf.json");
const appImageBundlerSetup = join(repoRoot, ".github/scripts/prepare-appimage-bundler.sh");
@@ -72,6 +74,56 @@ test("rollback dry-run prints all affected channels without invoking GitHub", ()
assert.match(result.stdout, /does not downgrade clients/);
});
+test("app release workflows preserve beta tags and stable package URLs", () => {
+ const release = readFileSync(releaseWorkflow, "utf8");
+ const publish = readFileSync(appPublishWorkflow, "utf8");
+ const daily = readFileSync(dailyBetaWorkflow, "utf8");
+
+ assert.ok(release.includes("$version = $tagVersion -replace '-beta\\..*$', ''"));
+ assert.match(release, /--draft=false --prerelease/);
+ assert.match(publish, /__TAG__/);
+ assert.match(publish, /needs: \[publish-stable-alias, validate-candidate\]/);
+ assert.match(publish, /releases\/latest\/download\/\$\{PRODUCT_NAME\}_\\\$version/);
+ assert.match(publish, /Publish stable manifest to GitHub Release/);
+ assert.match(publish, /prepare-channel-manifest\.mjs[\s\S]+stable \"\$TAG\" true/);
+ assert.match(publish, /gh release upload \"\$TAG\" \"\$manifest_dir\/latest\.json\"/);
+ assert.match(publish, /prune-r2-releases\.sh stable "\$\{\{ needs\.upload-r2\.outputs\.tag \}\}"/);
+ assert.match(release, /prune-r2-releases\.sh beta "\$\{\{ inputs\.release_tag \|\| github\.ref_name \}\}"/);
+ assert.match(release, /gh release edit "\$\{RELEASE_TAG\}" --repo "\$\{GITHUB_REPOSITORY\}" --draft=false --prerelease\n/);
+ assert.doesNotMatch(release, /--draft=false --prerelease=false --latest/);
+ assert.match(release, /if: \$\{\{ !contains\(env\.RELEASE_TAG, '-beta\.'\) \}\}\n[\s\S]+gh workflow run publish-packages\.yml[\s\S]+-f tag="\$\{RELEASE_TAG\}"/);
+ assert.match(release, /permissions:\n contents: write\n actions: write/);
+ assert.match(publish, /approve-promotion:[\s\S]+if: \$\{\{ contains\(inputs\.tag, '-beta\.'\) \}\}[\s\S]+environment: release-promotion/);
+ assert.match(publish, /needs\.approve-promotion\.result/);
+ assert.match(publish, /edit_args\+=\(--title "DBX v\$\{\{ needs\.validate-candidate\.outputs\.version \}\}"\)/);
+ assert.match(publish, /sync-source-version:[\s\S]+needs: \[validate-candidate, promote-release\][\s\S]+ref: main[\s\S]+MCP_RELEASE_TOKEN/);
+ assert.match(publish, /set-release-version\.mjs "v\$\{VERSION\}"/);
+ assert.match(publish, /promote-docker:[\s\S]+is_beta == 'true'[\s\S]+needs: \[validate-candidate, promote-release\]/);
+ assert.match(publish, /imagetools create -t "\$\{image\}:\$\{STABLE_VERSION\}" -t "\$\{image\}:latest" "\$\{image\}:\$\{source_version\}"/);
+ assert.doesNotMatch(publish, /Stable publication requires a non-prerelease/);
+ assert.doesNotMatch(publish, /no promotion needed/);
+ assert.match(release, /prepare-msi-config\.mjs "\$\{env:RELEASE_TAG\}" src-tauri\/tauri\.msi-version\.conf\.json/);
+ assert.match(release, /--config src-tauri\/tauri\.msi-version\.conf\.json/);
+ assert.doesNotMatch(release, /--bundles nsis' \|\| ''/);
+ assert.match(release, /if \(\$msiInstallers\.Count -eq 0\)/);
+ assert.match(publish, /x64-setup\.exe arm64-setup\.exe x64_en-US\.msi arm64_en-US\.msi/);
+ assert.match(daily, /schedule:/);
+ assert.match(daily, /gh release create "\$tag"/);
+ assert.match(daily, /-beta\.\$\{date_suffix\}\.\$\{sequence\}/);
+ assert.match(daily, /gh workflow run release\.yml/);
+ assert.match(daily, /MCP_RELEASE_TOKEN/);
+ assert.match(daily, /matches stable; publishing a daily beta build/);
+ assert.match(daily, /next_patch="\$\{BASH_REMATCH\[1\]\}\.\$\{BASH_REMATCH\[2\]\}\.\$\(\(BASH_REMATCH\[3\] \+ 1\)\)"/);
+ assert.match(daily, /sort -V \| tail -n 1/);
+ assert.match(daily, /force:[\s\S]+type: boolean/);
+ assert.match(daily, /compare\/\$\{built_tag\}\.\.\.\$\{GITHUB_SHA\}/);
+ assert.match(daily, /contains\("\[skip ci\]"\) \| not/);
+ assert.match(daily, /No new commits since \$\{built_tag\}; skipping the daily beta build/);
+ assert.match(release, /workflow_dispatch:/);
+ assert.match(release, /release_tag:/);
+ assert.match(release, /RELEASE_TAG:/);
+});
+
test("rollback dispatches each distribution workflow after validation", () => {
const mockBin = createMockGh();
const logPath = join(mockBin, "gh.log");
diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs
index f1bc5cc827..1c2ba5d6ec 100644
--- a/src-tauri/src/commands/update.rs
+++ b/src-tauri/src/commands/update.rs
@@ -10,6 +10,7 @@ use super::{
update_cache::{self, CacheRecord},
update_portable,
};
+pub use dbx_core::update::UpdateChannel;
pub use dbx_core::update::UpdateInfo;
use semver::Version;
use serde::{Deserialize, Serialize};
@@ -17,10 +18,14 @@ use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
const OFFICIAL_UPDATE_ENDPOINTS: [&str; 2] = [
- "https://dl.dbxio.com/releases/latest/latest.json",
+ "https://dl.dbxio.com/releases/stable/latest.json",
"https://github.com/t8y2/dbx/releases/latest/download/latest.json",
];
+const BETA_UPDATE_ENDPOINTS: [&str; 1] = ["https://dl.dbxio.com/releases/beta/latest.json"];
+#[cfg(test)]
const R2_LATEST_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/latest/";
+const R2_STABLE_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/stable/latest/";
+const R2_BETA_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/beta/latest/";
const CNB_RELEASE_DOWNLOAD_PREFIX: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/download/";
const GITHUB_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/t8y2/dbx/releases/download/";
const UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "update-download-progress";
@@ -35,6 +40,12 @@ fn dbx_updater_builder(app: &AppHandle) -> UpdaterBuilder {
app.updater_builder().on_before_exit(move || crate::cleanup_before_updater_forced_exit(&cleanup_app))
}
+fn with_update_channel(builder: UpdaterBuilder, channel: UpdateChannel) -> UpdaterBuilder {
+ builder.version_comparator(move |current, update| {
+ dbx_core::update::is_channel_update_available(channel, &update.version.to_string(), ¤t.to_string())
+ })
+}
+
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateDownloadSource {
@@ -238,15 +249,22 @@ impl UpdateDownloadSource {
}
}
- fn endpoints(&self, latest_version: Option<&str>) -> Result, String> {
+ fn endpoints(&self, latest_version: Option<&str>, channel: UpdateChannel) -> Result, String> {
+ let channel_endpoints: Vec = match channel {
+ UpdateChannel::Stable => OFFICIAL_UPDATE_ENDPOINTS.iter().map(|endpoint| (*endpoint).to_string()).collect(),
+ UpdateChannel::Beta => BETA_UPDATE_ENDPOINTS.iter().map(|endpoint| (*endpoint).to_string()).collect(),
+ };
match self {
- Self::Official => Ok(OFFICIAL_UPDATE_ENDPOINTS.iter().map(|endpoint| endpoint.to_string()).collect()),
+ Self::Official => Ok(channel_endpoints),
Self::Cnb => {
+ if matches!(channel, UpdateChannel::Beta) {
+ return Ok(channel_endpoints);
+ }
let version =
latest_version.ok_or_else(|| "Latest version is required for CNB updates.".to_string())?;
Ok(vec![
format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{}/latest.json", tag_version(version)),
- OFFICIAL_UPDATE_ENDPOINTS[0].to_string(),
+ channel_endpoints[0].clone(),
])
}
}
@@ -278,18 +296,28 @@ impl UpdateDownloadSource {
&self,
latest_version: &str,
arch: &str,
+ channel: UpdateChannel,
+ release_tag: Option<&str>,
) -> Result, String> {
let normalized_version = latest_version.trim().trim_start_matches('v');
let filename = update_portable::portable_asset_name(normalized_version, arch)?;
- let tag = tag_version(normalized_version);
+ let tag = release_tag
+ .map(str::trim)
+ .filter(|tag| !tag.is_empty())
+ .map(ToOwned::to_owned)
+ .unwrap_or_else(|| tag_version(normalized_version));
+ let r2_prefix = match channel {
+ UpdateChannel::Stable => R2_STABLE_RELEASE_DOWNLOAD_PREFIX,
+ UpdateChannel::Beta => R2_BETA_RELEASE_DOWNLOAD_PREFIX,
+ };
let archive_urls = match self {
- Self::Official => vec![
- format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"),
- format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"),
- ],
+ Self::Official => {
+ vec![format!("{r2_prefix}{filename}"), format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}")]
+ }
Self::Cnb => vec![
format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"),
- format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"),
+ format!("{r2_prefix}{filename}"),
+ format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"),
],
};
Ok(archive_urls
@@ -298,24 +326,41 @@ impl UpdateDownloadSource {
.collect())
}
- fn installer_asset_candidates(&self, download_url: &str, latest_version: Option<&str>) -> Vec {
- let filename = download_url.rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or("");
+ fn installer_asset_candidates(
+ &self,
+ download_url: &str,
+ latest_version: Option<&str>,
+ channel: UpdateChannel,
+ release_tag: Option<&str>,
+ ) -> Vec {
+ let download_path = download_url.split_once('?').map(|(url, _)| url).unwrap_or(download_url);
+ let download_path = download_path.split_once('#').map(|(url, _)| url).unwrap_or(download_path);
+ let filename = download_path.rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or("");
+ let r2_prefix = match channel {
+ UpdateChannel::Stable => R2_STABLE_RELEASE_DOWNLOAD_PREFIX,
+ UpdateChannel::Beta => R2_BETA_RELEASE_DOWNLOAD_PREFIX,
+ };
- let tag = latest_version.map(tag_version).unwrap_or_else(|| {
- if let Some(pos) = download_url.find("/releases/download/") {
- let rest = &download_url[pos + "/releases/download/".len()..];
- if let Some(tag_end) = rest.find('/') {
- return rest[..tag_end].to_string();
+ let tag = release_tag
+ .map(str::trim)
+ .filter(|tag| !tag.is_empty())
+ .map(ToOwned::to_owned)
+ .or_else(|| latest_version.map(tag_version))
+ .unwrap_or_else(|| {
+ if let Some(pos) = download_url.find("/releases/download/") {
+ let rest = &download_url[pos + "/releases/download/".len()..];
+ if let Some(tag_end) = rest.find('/') {
+ return rest[..tag_end].to_string();
+ }
}
- }
- "".to_string()
- });
+ "".to_string()
+ });
let raw_candidates = match self {
Self::Official => {
let mut urls = Vec::new();
if !filename.is_empty() {
- urls.push(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"));
+ urls.push(format!("{r2_prefix}{filename}"));
}
urls.push(download_url.to_string());
if !tag.is_empty() && !filename.is_empty() {
@@ -331,7 +376,7 @@ impl UpdateDownloadSource {
urls.push(format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"));
}
if !filename.is_empty() {
- urls.push(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"));
+ urls.push(format!("{r2_prefix}{filename}"));
}
urls.push(download_url.to_string());
urls
@@ -357,13 +402,26 @@ fn tag_version(version: &str) -> String {
}
}
+fn release_tag_for_version(version: &Version, candidate: Option) -> Result {
+ let tag = candidate.unwrap_or_else(|| tag_version(&version.to_string()));
+ let normalized = tag.strip_prefix('v').unwrap_or(&tag);
+ let parsed = Version::parse(normalized).map_err(|_| "Invalid update release tag.".to_string())?;
+ if (parsed.major, parsed.minor, parsed.patch) != (version.major, version.minor, version.patch) {
+ return Err("Update release tag does not match the downloaded version.".into());
+ }
+ Ok(format!("v{normalized}"))
+}
+
#[tauri::command]
pub async fn check_for_updates(
locale: Option,
source: Option,
+ channel: Option,
) -> Result {
let locale = locale.unwrap_or_else(|| "zh-CN".to_string());
- let release = dbx_core::update::fetch_latest_release(&locale, source.unwrap_or_default()).await?;
+ let channel = channel.unwrap_or_default();
+ let release =
+ dbx_core::update::fetch_latest_release_for_channel(&locale, source.unwrap_or_default(), channel).await?;
let current_version = env!("CARGO_PKG_VERSION");
let mut info = dbx_core::update::build_update_info(release, current_version);
info.portable_mode = crate::data_dir::is_portable_mode();
@@ -444,7 +502,7 @@ fn restore_cached(app: &AppHandle) -> Result