diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1462c5e09..fd0cdcd0c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -258,11 +258,17 @@ jobs:
$portableDir = Join-Path $portableRoot "DBX_${version}_${arch}"
$zipName = "DBX_${version}_${arch}-portable.zip"
$exePath = "target/${{ matrix.target }}/release/dbx.exe"
+ $cargoMetadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
+ $appVersion = ($cargoMetadata.packages | Where-Object { $_.name -eq "dbx" } | Select-Object -First 1).version
if (!(Test-Path $exePath)) {
Write-Error "Missing Windows executable: $exePath"
exit 1
}
+ if (!$appVersion -or $appVersion -ne $version) {
+ Write-Error "Release tag version $version does not match the built DBX package version $appVersion"
+ exit 1
+ }
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath (Join-Path $portableDir "DBX.exe") -Force
@@ -270,8 +276,26 @@ jobs:
Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force
Set-Content -Path (Join-Path $portableDir "portable.dbx") -Value "" -NoNewline
+ $portableExe = Join-Path $portableDir "DBX.exe"
+ $manifest = [ordered]@{
+ schema_version = 1
+ version = $appVersion
+ arch = $arch
+ executable = "DBX.exe"
+ executable_sha256 = (Get-FileHash -LiteralPath $portableExe -Algorithm SHA256).Hash.ToLowerInvariant()
+ } | ConvertTo-Json
+ Set-Content -Path (Join-Path $portableDir "portable-update.json") -Value $manifest -Encoding utf8NoBOM
+
Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath $zipName -Force
- gh release upload "${env:GITHUB_REF_NAME}" $zipName --repo "${env:GITHUB_REPOSITORY}" --clobber
+ pnpm tauri signer sign $zipName
+
+ $signatureName = "${zipName}.sig"
+ if (!(Test-Path $signatureName)) {
+ Write-Error "Missing portable update signature: $signatureName"
+ exit 1
+ }
+
+ gh release upload "${env:GITHUB_REF_NAME}" $zipName $signatureName --repo "${env:GITHUB_REPOSITORY}" --clobber
- name: Upload Windows WebView2 offline installer
if: matrix.arch == 'x64' || matrix.arch == 'arm64'
@@ -317,7 +341,7 @@ jobs:
gh release view "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--json assets \
- --jq '.assets[].name | select(endswith(".sig"))'
+ --jq '.assets[].name | select(endswith(".sig") and (endswith("-portable.zip.sig") | not))'
)
if [ "${#SIG_ASSETS[@]}" -eq 0 ]; then
diff --git a/Cargo.lock b/Cargo.lock
index f4c050630..8cad18cf4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1825,6 +1825,7 @@ dependencies = [
"futures",
"libloading 0.8.9",
"log",
+ "minisign-verify",
"mongodb",
"objc2",
"objc2-app-kit",
@@ -1836,8 +1837,10 @@ dependencies = [
"russh",
"rust_decimal",
"rustls 0.23.40",
+ "semver",
"serde",
"serde_json",
+ "sha2 0.10.9",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
diff --git a/apps/desktop/src/components/layout/UpdateDialog.spec.ts b/apps/desktop/src/components/layout/UpdateDialog.spec.ts
index 298c86d11..3a53913b8 100644
--- a/apps/desktop/src/components/layout/UpdateDialog.spec.ts
+++ b/apps/desktop/src/components/layout/UpdateDialog.spec.ts
@@ -13,6 +13,7 @@ const mountedApps: App[] = [];
interface DialogState {
open: boolean;
+ portableMode: boolean;
isDownloadingUpdate: boolean;
downloadProgress: number;
updateDownloaded: boolean;
@@ -28,6 +29,7 @@ async function flushDialog() {
async function mountDialog(activeTaskCount: number, initialState: Partial = {}, installDownloaded = vi.fn(async () => {})) {
const state = reactive({
open: true,
+ portableMode: false,
isDownloadingUpdate: false,
downloadProgress: 0,
updateDownloaded: false,
@@ -64,7 +66,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial
- {{ t("updates.portableManualUpdate") }}
+ {{ t("updates.portableAutomaticUpdate") }}
diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts
index 6ea017e6e..27b7daaa0 100644
--- a/apps/desktop/src/composables/useAppUpdater.ts
+++ b/apps/desktop/src/composables/useAppUpdater.ts
@@ -19,7 +19,7 @@ export function shouldOpenUpdateDialog(options: { silent?: boolean }) {
}
export function canDownloadAndInstallUpdate(info: api.UpdateInfo | null, isDesktop: boolean) {
- return isDesktop && info?.update_available === true && info.portable_mode !== true;
+ return isDesktop && info?.update_available === true;
}
export function normalizeUpdateDownloadSource(value: unknown): SettingsUpdateDownloadSource {
@@ -169,9 +169,11 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
async function installPendingUpdate() {
isInstallingUpdate.value = true;
try {
+ const portableMode = updateInfo.value?.portable_mode === true;
await api.installDownloadedUpdate();
updateDownloaded.value = false;
- updateReady.value = true;
+ // The portable helper exits and relaunches DBX after the invoke response is delivered.
+ updateReady.value = !portableMode;
} finally {
isInstallingUpdate.value = false;
}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 9c3f25cd1..a4662f712 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -70,7 +70,7 @@ export default {
openRelease: "Open Release",
downloadAndInstall: "Download & Install",
activeTasksBlockUpdate: "{count} task(s) are still running. Wait for them to finish before updating DBX.",
- portableManualUpdate: "Portable builds cannot use the in-app installer. Download the portable ZIP from the release page, then extract it over the current DBX folder to keep portable.dbx and data.",
+ portableAutomaticUpdate: "DBX will download the signed portable ZIP, replace only DBX.exe after exit, and restart automatically. portable.dbx and data will be kept.",
downloading: "Downloading {progress}%",
downloadFailed: "Update download failed: {error}",
installing: "Installing update...",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index bd7518002..4046c2cc1 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -72,7 +72,7 @@ export default withEnglishFallback({
openRelease: "Abrir lanzamiento",
downloadAndInstall: "Descargar e instalar",
activeTasksBlockUpdate: "Hay {count} tarea(s) en ejecución. Espera a que terminen antes de actualizar DBX.",
- portableManualUpdate: "Las versiones portables no pueden usar el instalador integrado. Descarga el ZIP portable desde la página de lanzamiento y extráelo sobre la carpeta actual de DBX para conservar portable.dbx y data.",
+ portableAutomaticUpdate: "DBX descargará el ZIP portable firmado, reemplazará solo DBX.exe después de salir y se reiniciará automáticamente. portable.dbx y data se conservarán.",
downloading: "Descargando {progress}%",
downloadFailed: "Error al descargar la actualización: {error}",
installing: "Instalando actualización...",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index c25874938..f34f2b77b 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -71,7 +71,7 @@ export default withEnglishFallback({
openRelease: "Apri Release",
downloadAndInstall: "Scarica e Installa",
activeTasksBlockUpdate: "Ci sono {count} attività in esecuzione. Attendi che terminino prima di aggiornare DBX.",
- portableManualUpdate: "Le build portatili non possono utilizzare l'installer in-app. Scarica lo ZIP portatile dalla pagina delle release, quindi estrailo nella cartella DBX corrente per mantenere portable.dbx e i dati.",
+ portableAutomaticUpdate: "DBX scaricherà lo ZIP portatile firmato, sostituirà solo DBX.exe dopo l'uscita e si riavvierà automaticamente. portable.dbx e i dati verranno mantenuti.",
downloading: "Download in corso {progress}%",
downloadFailed: "Download dell'aggiornamento non riuscito: {error}",
installing: "Installazione dell'aggiornamento...",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 56d31a488..98a4fe597 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -72,7 +72,7 @@ export default withEnglishFallback({
openRelease: "リリースページを開く",
downloadAndInstall: "ダウンロード & インストール",
activeTasksBlockUpdate: "{count} 件のタスクが実行中です。完了してから DBX を更新してください。",
- portableManualUpdate: "ポータブル版はアプリ内インストーラーを使用できません。リリースページからポータブルZIPをダウンロードし、現在のDBXフォルダに上書き展開してください。portable.dbxとデータは保持されます。",
+ portableAutomaticUpdate: "DBX は署名済みのポータブル ZIP をダウンロードし、終了後に DBX.exe のみを置き換えて自動的に再起動します。portable.dbx とデータは保持されます。",
downloading: "ダウンロード中 {progress}%",
downloadFailed: "アップデートのダウンロードに失敗しました: {error}",
installing: "アップデートをインストール中...",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 937d8c561..af5f318fe 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -72,7 +72,7 @@ export default withEnglishFallback({
openRelease: "Abrir Lançamento",
downloadAndInstall: "Baixar e Instalar",
activeTasksBlockUpdate: "Há {count} tarefa(s) em execução. Aguarde a conclusão antes de atualizar o DBX.",
- portableManualUpdate: "Versões portáteis não podem usar o instalador interno. Baixe o ZIP portátil da página de lançamento e extraia-o sobre a pasta atual do DBX para manter o portable.dbx e os dados.",
+ portableAutomaticUpdate: "O DBX baixará o ZIP portátil assinado, substituirá apenas o DBX.exe após sair e reiniciará automaticamente. O portable.dbx e os dados serão mantidos.",
downloading: "Baixando {progress}%",
downloadFailed: "Falha ao baixar a atualização: {error}",
installing: "Instalando atualização...",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 90df59988..00f017447 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -72,7 +72,7 @@ export default withEnglishFallback({
openRelease: "打开下载页",
downloadAndInstall: "下载并安装",
activeTasksBlockUpdate: "有 {count} 个任务正在执行,请等待任务完成后再更新 DBX。",
- portableManualUpdate: "便携版不能使用应用内安装器更新。请从 release 下载便携版 ZIP,并解压覆盖当前 DBX 目录,以保留 portable.dbx 和 data。",
+ portableAutomaticUpdate: "DBX 将下载已签名的便携版 ZIP,退出后仅替换 DBX.exe 并自动重启。portable.dbx 和 data 会保留。",
downloading: "下载中 {progress}%",
downloadFailed: "更新下载失败:{error}",
installing: "正在安装更新...",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 19794fc5e..e69a26658 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -72,7 +72,7 @@ export default withEnglishFallback({
openRelease: "開啟下載頁",
downloadAndInstall: "下載並安裝",
activeTasksBlockUpdate: "有 {count} 個任務正在執行,請等待任務完成後再更新 DBX。",
- portableManualUpdate: "可攜版無法使用應用程式內建安裝器更新。請從發行頁下載可攜版 ZIP,並解壓縮覆蓋目前的 DBX 目錄,以保留 portable.dbx 和 data。",
+ portableAutomaticUpdate: "DBX 將下載已簽署的可攜版 ZIP,結束後僅替換 DBX.exe 並自動重新啟動。portable.dbx 和 data 會保留。",
downloading: "下載中 {progress}%",
downloadFailed: "更新下載失敗:{error}",
installing: "正在安裝更新...",
diff --git a/packages/app-tests/appUpdater.test.ts b/packages/app-tests/appUpdater.test.ts
index 16f9b8bde..a12f881f4 100644
--- a/packages/app-tests/appUpdater.test.ts
+++ b/packages/app-tests/appUpdater.test.ts
@@ -24,8 +24,8 @@ test("allows in-app update installation for installed desktop builds", () => {
assert.equal(canDownloadAndInstallUpdate(updateInfo(), true), true);
});
-test("blocks in-app update installation for portable builds", () => {
- assert.equal(canDownloadAndInstallUpdate(updateInfo({ portable_mode: true }), true), false);
+test("allows portable builds to use the portable update installer", () => {
+ assert.equal(canDownloadAndInstallUpdate(updateInfo({ portable_mode: true }), true), true);
});
test("blocks in-app update installation outside desktop runtime or without an update", () => {
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index a36f000c2..cdba2f333 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -53,6 +53,9 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
futures = "0.3"
mongodb = "3.2.5"
percent-encoding = "2"
+minisign-verify = "0.2.5"
+semver = "1"
+sha2 = "0.10"
russh = "0.60"
csv = "1.4.0"
calamine = "0.30.1"
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 537599094..de493f245 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -47,6 +47,7 @@ pub mod text_export;
pub mod transfer;
pub mod tunnel_profiles;
pub mod update;
+mod update_portable;
pub mod window_controls;
pub mod xlsx_export;
pub mod zookeeper_cmd;
diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs
index 95aaf8702..8ffa3098a 100644
--- a/src-tauri/src/commands/update.rs
+++ b/src-tauri/src/commands/update.rs
@@ -2,10 +2,13 @@ use std::sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
};
+use std::time::Duration;
+use super::update_portable;
pub use dbx_core::update::UpdateInfo;
+use semver::Version;
use serde::{Deserialize, Serialize};
-use tauri::{AppHandle, Emitter};
+use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_updater::{Update, UpdaterExt};
const OFFICIAL_UPDATE_ENDPOINTS: [&str; 2] = [
@@ -16,6 +19,8 @@ const R2_LATEST_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/l
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";
+const MAX_PORTABLE_ARCHIVE_BYTES: usize = 512 * 1024 * 1024;
+const MAX_PORTABLE_SIGNATURE_BYTES: usize = 64 * 1024;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -32,7 +37,19 @@ pub struct UpdateDownloadProgress {
enum PendingUpdate {
Downloading,
- Ready { update: Box
, bytes: Vec },
+ Installing,
+ Ready(ReadyUpdate),
+}
+
+enum ReadyUpdate {
+ Installer { update: Box, bytes: Vec },
+ Portable { archive: Vec, version: Version },
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PortableAssetCandidate {
+ archive_url: String,
+ signature_url: String,
}
#[derive(Default)]
@@ -50,9 +67,9 @@ impl PendingUpdateState {
Ok(())
}
- fn finish_download(&self, update: Update, bytes: Vec) -> Result<(), String> {
+ fn finish_download(&self, update: ReadyUpdate) -> Result<(), String> {
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
- *pending = Some(PendingUpdate::Ready { update: Box::new(update), bytes });
+ *pending = Some(PendingUpdate::Ready(update));
Ok(())
}
@@ -63,6 +80,32 @@ impl PendingUpdateState {
}
}
}
+
+ fn take_ready(&self) -> Result {
+ let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
+ match pending.take() {
+ Some(PendingUpdate::Ready(update)) => {
+ *pending = Some(PendingUpdate::Installing);
+ Ok(update)
+ }
+ other => {
+ *pending = other;
+ Err("No downloaded update is ready to install.".to_string())
+ }
+ }
+ }
+
+ fn restore_ready(&self, update: ReadyUpdate) -> Result<(), String> {
+ let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
+ *pending = Some(PendingUpdate::Ready(update));
+ Ok(())
+ }
+
+ fn finish_install(&self) -> Result<(), String> {
+ let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
+ *pending = None;
+ Ok(())
+ }
}
impl UpdateDownloadSource {
@@ -120,6 +163,30 @@ impl UpdateDownloadSource {
.ok_or_else(|| format!("Unsupported update download URL for {} source: {url}", self.label()))?;
Ok(Some(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}")))
}
+
+ fn portable_asset_candidates(
+ &self,
+ latest_version: &str,
+ arch: &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 archive_urls = match self {
+ Self::Official => vec![
+ format!("{R2_LATEST_RELEASE_DOWNLOAD_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}"),
+ ],
+ };
+ Ok(archive_urls
+ .into_iter()
+ .map(|archive_url| PortableAssetCandidate { signature_url: format!("{archive_url}.sig"), archive_url })
+ .collect())
+ }
}
fn tag_version(version: &str) -> String {
@@ -162,14 +229,25 @@ pub async fn download_update(
source: UpdateDownloadSource,
latest_version: Option,
) -> Result<(), String> {
- if crate::data_dir::is_portable_mode() {
- return Err("Portable builds cannot use the in-app installer.".to_string());
- }
-
+ let portable_version = if crate::data_dir::is_portable_mode() {
+ let requested_version =
+ latest_version.as_deref().ok_or_else(|| "Latest version is required for portable updates.".to_string())?;
+ Some(update_portable::validate_requested_portable_version(requested_version, env!("CARGO_PKG_VERSION"))?)
+ } else {
+ None
+ };
state.begin_download()?;
- let result = download_update_inner(&app, &source, latest_version.as_deref()).await;
+ let result = if let Some(version) = portable_version {
+ download_portable_update_inner(&app, &source, &version)
+ .await
+ .map(|archive| ReadyUpdate::Portable { archive, version })
+ } else {
+ download_update_inner(&app, &source, latest_version.as_deref())
+ .await
+ .map(|(update, bytes)| ReadyUpdate::Installer { update: Box::new(update), bytes })
+ };
match result {
- Ok((update, bytes)) => state.finish_download(update, bytes),
+ Ok(update) => state.finish_download(update),
Err(error) => {
state.cancel_download();
Err(error)
@@ -234,17 +312,123 @@ async fn download_update_inner(
Ok((update, bytes))
}
+async fn download_portable_update_inner(
+ app: &AppHandle,
+ source: &UpdateDownloadSource,
+ latest_version: &Version,
+) -> Result, String> {
+ let latest_version_text = latest_version.to_string();
+ let candidates = source.portable_asset_candidates(&latest_version_text, std::env::consts::ARCH)?;
+ let client = portable_update_http_client()?;
+ let mut failures = Vec::new();
+
+ for candidate in candidates {
+ println!("[DBX updater] downloading portable update from {}", candidate.archive_url);
+ let result = async {
+ let signature =
+ download_bounded_bytes(&client, &candidate.signature_url, MAX_PORTABLE_SIGNATURE_BYTES, None).await?;
+ let signature = String::from_utf8(signature)
+ .map_err(|error| format!("Portable update signature is not valid UTF-8: {error}"))?;
+ let archive =
+ download_bounded_bytes(&client, &candidate.archive_url, MAX_PORTABLE_ARCHIVE_BYTES, Some(app)).await?;
+ update_portable::verify_portable_archive(&archive, &signature, latest_version, std::env::consts::ARCH)?;
+ Ok::, String>(archive)
+ }
+ .await;
+
+ match result {
+ Ok(archive) => return Ok(archive),
+ Err(error) => {
+ println!("[DBX updater] portable update candidate failed: {error}");
+ failures.push(format!("{}: {error}", candidate.archive_url));
+ }
+ }
+ }
+
+ Err(format!("Failed to download a verified portable update. {}", failures.join("; ")))
+}
+
+fn portable_update_http_client() -> Result {
+ let mut builder =
+ reqwest::Client::builder().connect_timeout(Duration::from_secs(15)).timeout(Duration::from_secs(15 * 60));
+ if let Some(proxy_url) = dbx_core::update::system_proxy_url() {
+ let proxy = reqwest::Proxy::all(&proxy_url).map_err(|error| format!("Invalid system proxy URL: {error}"))?;
+ builder = builder.proxy(proxy);
+ }
+ builder.build().map_err(|error| format!("Failed to create portable update client: {error}"))
+}
+
+async fn download_bounded_bytes(
+ client: &reqwest::Client,
+ url: &str,
+ max_bytes: usize,
+ progress_app: Option<&AppHandle>,
+) -> Result, String> {
+ let mut response = client
+ .get(url)
+ .send()
+ .await
+ .map_err(|error| format!("Failed to request {url}: {error}"))?
+ .error_for_status()
+ .map_err(|error| format!("Failed to download {url}: {error}"))?;
+ let total = response.content_length();
+ if total.is_some_and(|total| total > max_bytes as u64) {
+ return Err(format!("Update asset exceeds the {max_bytes} byte limit."));
+ }
+
+ if let Some(app) = progress_app {
+ let _ = app.emit(UPDATE_DOWNLOAD_PROGRESS_EVENT, UpdateDownloadProgress { downloaded: 0, total });
+ }
+ let mut bytes = Vec::with_capacity(total.unwrap_or(0).min(max_bytes as u64) as usize);
+ while let Some(chunk) =
+ response.chunk().await.map_err(|error| format!("Failed while downloading {url}: {error}"))?
+ {
+ if bytes.len().saturating_add(chunk.len()) > max_bytes {
+ return Err(format!("Update asset exceeds the {max_bytes} byte limit."));
+ }
+ bytes.extend_from_slice(&chunk);
+ if let Some(app) = progress_app {
+ let _ = app
+ .emit(UPDATE_DOWNLOAD_PROGRESS_EVENT, UpdateDownloadProgress { downloaded: bytes.len() as u64, total });
+ }
+ }
+ Ok(bytes)
+}
+
#[tauri::command]
-pub fn install_downloaded_update(state: tauri::State<'_, PendingUpdateState>) -> Result<(), String> {
- let mut pending = state.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
- let Some(PendingUpdate::Ready { update, bytes }) = pending.as_ref() else {
- return Err("No downloaded update is ready to install.".to_string());
+pub fn install_downloaded_update(app: AppHandle, state: tauri::State<'_, PendingUpdateState>) -> Result<(), String> {
+ let ready = state.take_ready()?;
+ let portable = matches!(&ready, ReadyUpdate::Portable { .. });
+ let install_result = match &ready {
+ ReadyUpdate::Installer { update, bytes } => {
+ update.install(bytes).map_err(|error| format!("Failed to install update: {error}"))
+ }
+ ReadyUpdate::Portable { archive, version } => {
+ update_portable::ensure_portable_version_is_newer(version, env!("CARGO_PKG_VERSION"))
+ .and_then(|_| update_portable::launch_portable_update_helper(archive, version))
+ }
};
- update.install(bytes).map_err(|e| format!("Failed to install update: {e}"))?;
- *pending = None;
+ if let Err(error) = install_result {
+ state.restore_ready(ready)?;
+ return Err(error);
+ }
+ state.finish_install()?;
+ if portable {
+ schedule_portable_update_exit(app);
+ }
Ok(())
}
+fn schedule_portable_update_exit(app: AppHandle) {
+ tauri::async_runtime::spawn(async move {
+ tokio::time::sleep(Duration::from_millis(150)).await;
+ if let Some(state) = app.try_state::() {
+ state.allow_next_exit();
+ }
+ app.exit(0);
+ });
+}
+
async fn update_url_is_available(url: &str) -> bool {
let client = match reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)).build() {
Ok(client) => client,
@@ -262,8 +446,8 @@ async fn update_url_is_available(url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
- tag_version, UpdateDownloadSource, CNB_RELEASE_DOWNLOAD_PREFIX, OFFICIAL_UPDATE_ENDPOINTS,
- R2_LATEST_RELEASE_DOWNLOAD_PREFIX,
+ tag_version, UpdateDownloadSource, CNB_RELEASE_DOWNLOAD_PREFIX, GITHUB_RELEASE_DOWNLOAD_PREFIX,
+ OFFICIAL_UPDATE_ENDPOINTS, R2_LATEST_RELEASE_DOWNLOAD_PREFIX,
};
#[test]
@@ -311,4 +495,32 @@ mod tests {
.unwrap();
assert_eq!(fallback, Some(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.44_x64.dmg")));
}
+
+ #[test]
+ fn builds_signed_official_portable_asset_candidates() {
+ let candidates = UpdateDownloadSource::Official.portable_asset_candidates("0.5.64", "x86_64").unwrap();
+ assert_eq!(candidates.len(), 2);
+ assert_eq!(
+ candidates[0].archive_url,
+ format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_x64-portable.zip")
+ );
+ assert_eq!(
+ candidates[1].archive_url,
+ format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}v0.5.64/DBX_0.5.64_x64-portable.zip")
+ );
+ assert!(candidates.iter().all(|candidate| candidate.signature_url == format!("{}.sig", candidate.archive_url)));
+ }
+
+ #[test]
+ fn builds_cnb_portable_asset_candidate_with_r2_fallback() {
+ let candidates = UpdateDownloadSource::Cnb.portable_asset_candidates("v0.5.64", "aarch64").unwrap();
+ assert_eq!(
+ candidates[0].archive_url,
+ format!("{CNB_RELEASE_DOWNLOAD_PREFIX}v0.5.64/DBX_0.5.64_arm64-portable.zip")
+ );
+ assert_eq!(
+ candidates[1].archive_url,
+ format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_arm64-portable.zip")
+ );
+ }
}
diff --git a/src-tauri/src/commands/update_portable.rs b/src-tauri/src/commands/update_portable.rs
new file mode 100644
index 000000000..f544869aa
--- /dev/null
+++ b/src-tauri/src/commands/update_portable.rs
@@ -0,0 +1,405 @@
+use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
+use minisign_verify::{PublicKey, Signature};
+use semver::Version;
+use serde::Deserialize;
+use sha2::{Digest, Sha256};
+use std::io::{Cursor, Read};
+
+const MAX_PORTABLE_EXECUTABLE_BYTES: usize = 256 * 1024 * 1024;
+const MAX_PORTABLE_MANIFEST_BYTES: usize = 16 * 1024;
+const EMBEDDED_TAURI_CONFIG: &str = include_str!("../../tauri.conf.json");
+const PORTABLE_EXECUTABLE_NAME: &str = "DBX.exe";
+const PORTABLE_UPDATE_MANIFEST_NAME: &str = "portable-update.json";
+const PORTABLE_UPDATE_MANIFEST_SCHEMA_VERSION: u32 = 1;
+
+#[derive(Debug, Deserialize)]
+struct PortableUpdateManifest {
+ schema_version: u32,
+ version: String,
+ arch: String,
+ executable: String,
+ executable_sha256: String,
+}
+
+pub(super) fn validate_requested_portable_version(
+ requested_version: &str,
+ current_version: &str,
+) -> Result {
+ let requested = parse_portable_version(requested_version, "requested")?;
+ ensure_portable_version_is_newer(&requested, current_version)?;
+ Ok(requested)
+}
+
+pub(super) fn ensure_portable_version_is_newer(
+ requested_version: &Version,
+ current_version: &str,
+) -> Result<(), String> {
+ let current = parse_portable_version(current_version, "current app")?;
+ if requested_version <= ¤t {
+ return Err(format!(
+ "Portable update version {requested_version} must be newer than the current version {current}."
+ ));
+ }
+ Ok(())
+}
+
+fn parse_portable_version(value: &str, label: &str) -> Result {
+ let value = value.trim();
+ let value = value.strip_prefix('v').unwrap_or(value);
+ Version::parse(value).map_err(|error| format!("Invalid {label} portable update version: {error}"))
+}
+
+fn portable_arch_label(arch: &str) -> Result<&'static str, String> {
+ match arch {
+ "x86_64" => Ok("x64"),
+ "aarch64" => Ok("arm64"),
+ other => Err(format!("Portable updates are not available for architecture {other}.")),
+ }
+}
+
+pub(super) fn portable_asset_name(version: &str, arch: &str) -> Result {
+ let version = parse_portable_version(version, "requested")?;
+ let arch = portable_arch_label(arch)?;
+ Ok(format!("DBX_{version}_{arch}-portable.zip"))
+}
+
+pub(super) fn verify_portable_archive(
+ archive: &[u8],
+ encoded_signature: &str,
+ expected_version: &Version,
+ expected_arch: &str,
+) -> Result<(), String> {
+ let config: serde_json::Value = serde_json::from_str(EMBEDDED_TAURI_CONFIG)
+ .map_err(|error| format!("Failed to read embedded updater configuration: {error}"))?;
+ let encoded_public_key = config
+ .pointer("/plugins/updater/pubkey")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| "Embedded updater public key is missing.".to_string())?;
+ let public_key_text = decode_tauri_text(encoded_public_key, "public key")?;
+ let signature_text = decode_tauri_text(encoded_signature.trim(), "signature")?;
+ let public_key = PublicKey::decode(&public_key_text)
+ .map_err(|error| format!("Failed to decode portable update public key: {error}"))?;
+ let signature = Signature::decode(&signature_text)
+ .map_err(|error| format!("Failed to decode portable update signature: {error}"))?;
+ public_key
+ .verify(archive, &signature, true)
+ .map_err(|error| format!("Portable update signature verification failed: {error}"))?;
+ // The manifest lives inside the signed ZIP and hashes DBX.exe, binding the
+ // requested version and architecture to the exact executable we install.
+ validated_portable_executable(archive, expected_version, expected_arch).map(|_| ())
+}
+
+fn decode_tauri_text(value: &str, label: &str) -> Result {
+ let decoded =
+ BASE64_STANDARD.decode(value).map_err(|error| format!("Invalid updater {label} encoding: {error}"))?;
+ String::from_utf8(decoded).map_err(|error| format!("Updater {label} is not valid UTF-8: {error}"))
+}
+
+fn validated_portable_executable(
+ archive: &[u8],
+ expected_version: &Version,
+ expected_arch: &str,
+) -> Result, String> {
+ let reader = Cursor::new(archive);
+ let mut archive = zip::ZipArchive::new(reader).map_err(|error| format!("Invalid portable update ZIP: {error}"))?;
+ let manifest = {
+ let mut manifest_file = archive
+ .by_name(PORTABLE_UPDATE_MANIFEST_NAME)
+ .map_err(|_| format!("Portable update ZIP does not contain {PORTABLE_UPDATE_MANIFEST_NAME}."))?;
+ if manifest_file.is_dir() || manifest_file.size() > MAX_PORTABLE_MANIFEST_BYTES as u64 {
+ return Err("Portable update manifest is unexpectedly large or invalid.".to_string());
+ }
+ let mut manifest_bytes = Vec::with_capacity(manifest_file.size() as usize);
+ manifest_file
+ .by_ref()
+ .take((MAX_PORTABLE_MANIFEST_BYTES + 1) as u64)
+ .read_to_end(&mut manifest_bytes)
+ .map_err(|error| format!("Failed to read portable update manifest: {error}"))?;
+ if manifest_bytes.len() > MAX_PORTABLE_MANIFEST_BYTES {
+ return Err("Portable update manifest is unexpectedly large.".to_string());
+ }
+ serde_json::from_slice::(&manifest_bytes)
+ .map_err(|error| format!("Invalid portable update manifest: {error}"))?
+ };
+
+ if manifest.schema_version != PORTABLE_UPDATE_MANIFEST_SCHEMA_VERSION {
+ return Err(format!("Unsupported portable update manifest schema version {}.", manifest.schema_version));
+ }
+ let manifest_version = parse_portable_version(&manifest.version, "manifest")?;
+ if &manifest_version != expected_version {
+ return Err(format!(
+ "Portable update manifest version {manifest_version} does not match requested version {expected_version}."
+ ));
+ }
+ let expected_arch = portable_arch_label(expected_arch)?;
+ if manifest.arch != expected_arch {
+ return Err(format!(
+ "Portable update manifest architecture {} does not match the current architecture {expected_arch}.",
+ manifest.arch
+ ));
+ }
+ if manifest.executable != PORTABLE_EXECUTABLE_NAME {
+ return Err(format!("Portable update manifest executable must be {PORTABLE_EXECUTABLE_NAME}."));
+ }
+
+ let mut file = archive
+ .by_name(PORTABLE_EXECUTABLE_NAME)
+ .map_err(|_| format!("Portable update ZIP does not contain {PORTABLE_EXECUTABLE_NAME}."))?;
+ if file.size() > MAX_PORTABLE_EXECUTABLE_BYTES as u64 {
+ return Err("Portable update executable is unexpectedly large.".to_string());
+ }
+ let mut executable = Vec::with_capacity(file.size() as usize);
+ file.by_ref()
+ .take((MAX_PORTABLE_EXECUTABLE_BYTES + 1) as u64)
+ .read_to_end(&mut executable)
+ .map_err(|error| format!("Failed to extract DBX.exe from update ZIP: {error}"))?;
+ if executable.len() > MAX_PORTABLE_EXECUTABLE_BYTES {
+ return Err("Portable update executable is unexpectedly large.".to_string());
+ }
+ if !executable.starts_with(b"MZ") {
+ return Err("Portable update executable is not a valid Windows executable.".to_string());
+ }
+ let executable_sha256 = sha256_hex(&executable);
+ if !executable_sha256.eq_ignore_ascii_case(manifest.executable_sha256.trim()) {
+ return Err("Portable update executable hash does not match the signed manifest.".to_string());
+ }
+ Ok(executable)
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+ Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
+}
+
+#[cfg(target_os = "windows")]
+pub(super) fn launch_portable_update_helper(archive: &[u8], version: &Version) -> Result<(), String> {
+ use std::fs::{self, OpenOptions};
+ use std::io::Write;
+ use std::os::windows::process::CommandExt;
+ use std::process::Command;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
+
+ let current_exe =
+ std::env::current_exe().map_err(|error| format!("Failed to locate the portable executable: {error}"))?;
+ let exe_dir = current_exe.parent().ok_or_else(|| "Portable executable directory is unavailable.".to_string())?;
+ if !exe_dir.join("portable.dbx").is_file() {
+ return Err("Portable update marker is missing beside DBX.exe.".to_string());
+ }
+
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|error| format!("System clock is unavailable: {error}"))?
+ .as_nanos();
+ let update_id = format!("{}-{timestamp}", std::process::id());
+ let write_probe = exe_dir.join(format!(".dbx-update-{update_id}.probe"));
+ OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&write_probe)
+ .map_err(|error| format!("The portable DBX directory is not writable: {error}"))?;
+ fs::remove_file(&write_probe)
+ .map_err(|error| format!("Failed to finish portable directory write check: {error}"))?;
+
+ let staging_dir = std::env::temp_dir().join(format!("dbx-portable-update-{update_id}"));
+ fs::create_dir(&staging_dir)
+ .map_err(|error| format!("Failed to create portable update staging directory: {error}"))?;
+ let staged_exe = staging_dir.join("DBX.exe.new");
+ let script_path = staging_dir.join("apply-update.ps1");
+ let backup_exe = exe_dir.join(format!(".DBX-{update_id}.old.exe"));
+
+ let prepare_result = (|| -> Result<(), String> {
+ let executable = validated_portable_executable(archive, version, std::env::consts::ARCH)?;
+ let mut staged_file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&staged_exe)
+ .map_err(|error| format!("Failed to stage portable DBX executable: {error}"))?;
+ staged_file
+ .write_all(&executable)
+ .and_then(|_| staged_file.sync_all())
+ .map_err(|error| format!("Failed to write portable DBX executable: {error}"))?;
+ fs::write(&script_path, PORTABLE_UPDATE_SCRIPT)
+ .map_err(|error| format!("Failed to create portable update helper: {error}"))?;
+
+ Command::new("powershell.exe")
+ .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File"])
+ .arg(&script_path)
+ .arg("-ParentProcessId")
+ .arg(std::process::id().to_string())
+ .arg("-SourceExe")
+ .arg(&staged_exe)
+ .arg("-TargetExe")
+ .arg(¤t_exe)
+ .arg("-BackupExe")
+ .arg(&backup_exe)
+ .arg("-StagingDir")
+ .arg(&staging_dir)
+ .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
+ .spawn()
+ .map_err(|error| format!("Failed to start portable update helper: {error}"))?;
+ Ok(())
+ })();
+
+ if prepare_result.is_err() {
+ let _ = fs::remove_dir_all(&staging_dir);
+ }
+ prepare_result
+}
+
+#[cfg(not(target_os = "windows"))]
+pub(super) fn launch_portable_update_helper(_archive: &[u8], _version: &Version) -> Result<(), String> {
+ Err("Portable updates are only supported on Windows.".to_string())
+}
+
+#[cfg(target_os = "windows")]
+const PORTABLE_UPDATE_SCRIPT: &str = r#"param(
+ [Parameter(Mandatory = $true)][int]$ParentProcessId,
+ [Parameter(Mandatory = $true)][string]$SourceExe,
+ [Parameter(Mandatory = $true)][string]$TargetExe,
+ [Parameter(Mandatory = $true)][string]$BackupExe,
+ [Parameter(Mandatory = $true)][string]$StagingDir
+)
+
+$ErrorActionPreference = 'Stop'
+Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
+try { Wait-Process -Id $ParentProcessId -Timeout 120 -ErrorAction SilentlyContinue } catch {}
+
+$installed = $false
+for ($attempt = 0; $attempt -lt 120; $attempt++) {
+ try {
+ if (Test-Path -LiteralPath $TargetExe) {
+ if (Test-Path -LiteralPath $BackupExe) {
+ Remove-Item -LiteralPath $BackupExe -Force
+ }
+ Move-Item -LiteralPath $TargetExe -Destination $BackupExe -Force
+ }
+
+ if (-not (Test-Path -LiteralPath $BackupExe)) {
+ throw 'The existing DBX executable could not be backed up.'
+ }
+
+ Move-Item -LiteralPath $SourceExe -Destination $TargetExe -Force
+ $installed = $true
+ break
+ } catch {
+ if (-not (Test-Path -LiteralPath $TargetExe) -and (Test-Path -LiteralPath $BackupExe)) {
+ try { Copy-Item -LiteralPath $BackupExe -Destination $TargetExe -Force } catch {}
+ }
+ Start-Sleep -Seconds 1
+ }
+}
+
+if (-not $installed) {
+ if (-not (Test-Path -LiteralPath $TargetExe) -and (Test-Path -LiteralPath $BackupExe)) {
+ try { Copy-Item -LiteralPath $BackupExe -Destination $TargetExe -Force } catch {}
+ }
+ exit 1
+}
+
+try {
+ Start-Process -FilePath $TargetExe -WorkingDirectory (Split-Path -Parent $TargetExe)
+} catch {
+ try {
+ if (Test-Path -LiteralPath $TargetExe) { Remove-Item -LiteralPath $TargetExe -Force }
+ if (Test-Path -LiteralPath $BackupExe) { Move-Item -LiteralPath $BackupExe -Destination $TargetExe -Force }
+ Start-Process -FilePath $TargetExe -WorkingDirectory (Split-Path -Parent $TargetExe)
+ } catch {}
+ exit 1
+}
+
+Remove-Item -LiteralPath $BackupExe -Force -ErrorAction SilentlyContinue
+Remove-Item -LiteralPath $StagingDir -Recurse -Force -ErrorAction SilentlyContinue
+exit 0
+"#;
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ decode_tauri_text, portable_asset_name, sha256_hex, validate_requested_portable_version,
+ validated_portable_executable, PORTABLE_EXECUTABLE_NAME, PORTABLE_UPDATE_MANIFEST_NAME,
+ PORTABLE_UPDATE_MANIFEST_SCHEMA_VERSION,
+ };
+ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
+ use semver::Version;
+ use std::io::{Cursor, Write};
+
+ fn portable_zip(version: &str, arch: &str, executable: &[u8], executable_sha256: Option<&str>) -> Vec {
+ let cursor = Cursor::new(Vec::new());
+ let mut zip = zip::ZipWriter::new(cursor);
+ let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
+ let manifest = serde_json::json!({
+ "schema_version": PORTABLE_UPDATE_MANIFEST_SCHEMA_VERSION,
+ "version": version,
+ "arch": arch,
+ "executable": PORTABLE_EXECUTABLE_NAME,
+ "executable_sha256": executable_sha256.map(ToOwned::to_owned).unwrap_or_else(|| sha256_hex(executable)),
+ });
+ zip.start_file(PORTABLE_UPDATE_MANIFEST_NAME, options).unwrap();
+ zip.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap();
+ zip.start_file(PORTABLE_EXECUTABLE_NAME, options).unwrap();
+ zip.write_all(executable).unwrap();
+ zip.finish().unwrap().into_inner()
+ }
+
+ #[test]
+ fn builds_portable_asset_names_for_windows_architectures() {
+ assert_eq!(portable_asset_name("0.5.64", "x86_64").unwrap(), "DBX_0.5.64_x64-portable.zip");
+ assert_eq!(portable_asset_name("v0.5.64-beta.1", "aarch64").unwrap(), "DBX_0.5.64-beta.1_arm64-portable.zip");
+ assert!(portable_asset_name("0.5.64", "x86").is_err());
+ assert!(portable_asset_name("../../0.5.64", "x86_64").is_err());
+ }
+
+ #[test]
+ fn accepts_newer_portable_update_requests() {
+ assert_eq!(validate_requested_portable_version("0.5.64", "0.5.63").unwrap(), Version::parse("0.5.64").unwrap());
+ }
+
+ #[test]
+ fn rejects_equal_version_requests() {
+ let error = validate_requested_portable_version("0.5.63", "0.5.63").unwrap_err();
+ assert!(error.contains("must be newer"));
+ }
+
+ #[test]
+ fn rejects_downgrade_requests() {
+ let error = validate_requested_portable_version("0.5.62", "0.5.63").unwrap_err();
+ assert!(error.contains("must be newer"));
+ }
+
+ #[test]
+ fn decodes_tauri_base64_text() {
+ let encoded = BASE64_STANDARD.encode("untrusted comment: test\nAAAA");
+ assert_eq!(decode_tauri_text(&encoded, "test").unwrap(), "untrusted comment: test\nAAAA");
+ }
+
+ #[test]
+ fn extracts_the_executable_bound_to_the_signed_manifest() {
+ let archive = portable_zip("0.5.64", "x64", b"MZportable executable", None);
+ assert_eq!(
+ validated_portable_executable(&archive, &Version::parse("0.5.64").unwrap(), "x86_64").unwrap(),
+ b"MZportable executable"
+ );
+ }
+
+ #[test]
+ fn rejects_archives_without_a_windows_executable() {
+ let archive = portable_zip("0.5.64", "x64", b"not a PE file", None);
+ assert!(validated_portable_executable(&archive, &Version::parse("0.5.64").unwrap(), "x86_64").is_err());
+ }
+
+ #[test]
+ fn rejects_an_archive_whose_manifest_version_differs_from_the_request() {
+ let archive = portable_zip("0.5.62", "x64", b"MZolder executable", None);
+ let error = validated_portable_executable(&archive, &Version::parse("0.5.64").unwrap(), "x86_64").unwrap_err();
+ assert!(error.contains("does not match requested version"));
+ }
+
+ #[test]
+ fn rejects_an_executable_not_matching_the_signed_manifest_hash() {
+ let archive = portable_zip("0.5.64", "x64", b"MZportable executable", Some(&"0".repeat(64)));
+ let error = validated_portable_executable(&archive, &Version::parse("0.5.64").unwrap(), "x86_64").unwrap_err();
+ assert!(error.contains("hash does not match"));
+ }
+}