Bump to v0.14.0 — app identifier moves to com.veganostr.Vega

The app identifier changes from `com.hoornet.vega` to `com.veganostr.Vega`.
A reverse-DNS app ID must sit on a domain the project controls; the old one
did not, which blocks publishing on Flathub and leaves Vega's identity
inconsistent across Flathub, winget and the native installers.

The identifier keys every per-app directory, so this migrates existing
installs on first launch: the SQLite cache (vega.db), the embedded relay's
database (relay.db), and webview localStorage (themes, drafts, podcast
subscriptions, article read-state).

Migration runs at the top of run(), before tauri::Builder — not inside
.setup(). Tauri builds the config-defined windows before invoking the setup
hook, and building a webview create_dir_all's <LocalData>/<identifier>, which
on Linux is the same directory that holds vega.db. Migrating from setup()
would therefore always find a non-empty destination, skip, and silently
strand every Linux user's data with no way for a later fix to recover it.

Entries are moved individually rather than renaming the whole tree, so a
destination the webview already created does not block the migration, and a
failed attempt retries on the next launch. An entry that already exists at
the destination is never overwritten — the live copy always wins.

All three per-platform roots are covered: WebView2 keeps localStorage under
%LOCALAPPDATA% on Windows (not %APPDATA%), and WKWebView keeps it under
~/Library/WebKit on macOS, neither of which is the app data dir.

Keys are unaffected — the keychain service name is independent of the
identifier, so users stay logged in.

Windows installs v0.14.0 alongside v0.13.2 rather than replacing it, since
the changed identifier reads as a new application. Documented in the README,
CHANGELOG and release notes.
This commit is contained in:
Jure
2026-07-14 12:20:32 +02:00
parent d37a414b82
commit 957557708e
9 changed files with 285 additions and 6 deletions
+244
View File
@@ -9,6 +9,110 @@ use tauri::{
mod relay;
// ── Data directory migration ────────────────────────────────────────────────
// The app identifier changed from `com.hoornet.vega` to `com.veganostr.Vega` in
// v0.14.0 so it is anchored to a domain we control (Flathub requires this). The
// identifier keys every per-app directory, so without this the SQLite cache, the
// embedded relay database and the webview's localStorage would all be stranded
// and existing users would open a factory-fresh app.
const LEGACY_IDENTIFIER: &str = "com.hoornet.vega";
const APP_IDENTIFIER: &str = "com.veganostr.Vega";
/// Move the contents of `<parent>/com.hoornet.vega` into `<parent>/com.veganostr.Vega`.
///
/// Entry-by-entry rather than a single directory rename, because the destination is
/// *not* reliably absent: Tauri creates `<LocalData>/<identifier>` while building the
/// window, and the webview populates it. A whole-directory rename would fail against
/// that, and treating "destination is non-empty" as "already migrated" would silently
/// strand the user's data (see `run()`).
///
/// An entry that already exists at the destination is never overwritten — real data
/// always wins over the legacy copy — so this is safe to retry on every launch.
fn migrate_dir(new_dir: &std::path::Path) {
let Some(parent) = new_dir.parent() else {
return;
};
let legacy_dir = parent.join(LEGACY_IDENTIFIER);
if legacy_dir == new_dir || !legacy_dir.is_dir() {
return;
}
// Fast path: destination not created yet, so move the whole tree in one atomic
// rename. Same parent means same filesystem, so this can't hit EXDEV, and the
// data dir can be hundreds of MB — a deep copy would stall startup.
if !new_dir.exists() && std::fs::rename(&legacy_dir, new_dir).is_ok() {
eprintln!(
"[migrate] moved {} -> {}",
legacy_dir.display(),
new_dir.display()
);
return;
}
std::fs::create_dir_all(new_dir).ok();
let Ok(entries) = std::fs::read_dir(&legacy_dir) else {
return;
};
let (mut moved, mut left) = (0usize, 0usize);
for entry in entries.flatten() {
let dest = new_dir.join(entry.file_name());
if dest.exists() {
left += 1; // Never clobber: whatever is already there is the live copy.
continue;
}
match std::fs::rename(entry.path(), &dest) {
Ok(()) => moved += 1,
Err(e) => {
left += 1;
eprintln!("[migrate] could not move {}: {e}", entry.path().display());
}
}
}
// Fully drained: drop the empty legacy dir so this stops running. If anything was
// left behind, keep it — a later launch retries, and nothing is ever lost.
if left == 0 {
std::fs::remove_dir(&legacy_dir).ok();
}
eprintln!(
"[migrate] {}: moved {moved} entries, left {left}",
legacy_dir.display()
);
}
/// Carry a pre-v0.14.0 install's data across the identifier change.
///
/// Must run before `tauri::Builder` — see the call site in `run()`.
///
/// Covers every root the identifier keys, because localStorage does not live in the
/// same place on each platform:
/// - Linux — WebKitGTK stores it under the data dir alongside SQLite.
/// - Windows — WebView2 keeps `EBWebView/` under the *local* data dir (`%LOCALAPPDATA%`),
/// a different root from `%APPDATA%`; migrating only one of them loses it.
/// - macOS — WKWebView stores website data in `~/Library/WebKit/<bundle-id>`, outside
/// the app data dir entirely.
///
/// These roots coincide on some platforms (on Linux data == local data; on Windows and
/// macOS data == config). The duplicate call is a no-op: the first one drains the legacy
/// directory, the second finds nothing to do.
fn migrate_legacy_data_dirs() {
// `dirs` is what Tauri's own path resolver uses, so these resolve identically to
// app_data_dir()/app_local_data_dir()/app_config_dir() — but without needing an App.
for base in [dirs::data_dir(), dirs::data_local_dir(), dirs::config_dir()]
.into_iter()
.flatten()
{
migrate_dir(&base.join(APP_IDENTIFIER));
}
#[cfg(target_os = "macos")]
if let Some(home) = dirs::home_dir() {
migrate_dir(&home.join("Library").join("WebKit").join(APP_IDENTIFIER));
}
}
// ── OS keychain ─────────────────────────────────────────────────────────────
// Keep legacy keyring service name so existing users don't lose their keys
@@ -449,6 +553,13 @@ fn install_info() -> serde_json::Value {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Before tauri::Builder, not inside .setup(). Tauri builds the config-defined
// windows *before* invoking the setup hook, and building a webview creates (and
// populates) <LocalData>/<identifier> — which on Linux is the very directory
// holding vega.db, relay.db and localStorage. Migrating from inside setup() would
// therefore always find a non-empty destination and skip, stranding user data.
migrate_legacy_data_dirs();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
@@ -575,3 +686,136 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::{migrate_dir, APP_IDENTIFIER, LEGACY_IDENTIFIER};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Isolated stand-in for a per-platform data root (e.g. ~/.local/share).
fn scratch_root() -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root =
std::env::temp_dir().join(format!("vega-migrate-test-{}-{}", std::process::id(), n));
fs::remove_dir_all(&root).ok();
fs::create_dir_all(&root).unwrap();
root
}
fn write(path: &Path, body: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
/// A pre-v0.14.0 install: SQLite cache, embedded relay db, and WebKit localStorage,
/// laid out the way they actually are on disk (relay.db sits directly in the data dir).
fn seed_legacy(root: &Path) -> PathBuf {
let legacy = root.join(LEGACY_IDENTIFIER);
write(&legacy.join("vega.db"), "cached-notes");
write(&legacy.join("relay.db"), "relay-events");
write(&legacy.join("localstorage").join("ls.db"), "themes+drafts");
legacy
}
#[test]
fn moves_legacy_data_into_a_fresh_install() {
let root = scratch_root();
let legacy = seed_legacy(&root);
let new = root.join(APP_IDENTIFIER);
migrate_dir(&new);
assert_eq!(read(&new.join("vega.db")), "cached-notes");
assert_eq!(read(&new.join("relay.db")), "relay-events");
assert_eq!(
read(&new.join("localstorage").join("ls.db")),
"themes+drafts",
"localStorage carries themes, drafts and podcast subs — it must survive"
);
assert!(!legacy.exists(), "drained legacy dir should be removed");
fs::remove_dir_all(&root).ok();
}
/// The regression that shipped-and-was-caught: Tauri creates and populates
/// <LocalData>/<identifier> while building the window, so on Linux the destination
/// already exists — and holds webview files — by the time we look at it. Treating
/// "non-empty destination" as "already migrated" stranded 100% of user data.
#[test]
fn migrates_even_when_the_webview_already_created_the_destination() {
let root = scratch_root();
let legacy = seed_legacy(&root);
let new = root.join(APP_IDENTIFIER);
// What WebKitGTK leaves behind on a first v0.14.0 launch:
write(&new.join("hsts-storage.sqlite"), "webview");
write(&new.join("WebKitCache").join("blob"), "webview");
migrate_dir(&new);
assert_eq!(
read(&new.join("vega.db")),
"cached-notes",
"user data must migrate despite the destination being non-empty"
);
assert_eq!(read(&new.join("relay.db")), "relay-events");
assert_eq!(read(&new.join("localstorage").join("ls.db")), "themes+drafts");
assert_eq!(read(&new.join("hsts-storage.sqlite")), "webview");
assert!(!legacy.exists());
fs::remove_dir_all(&root).ok();
}
#[test]
fn never_overwrites_data_already_at_the_destination() {
let root = scratch_root();
let legacy = seed_legacy(&root);
let new = root.join(APP_IDENTIFIER);
write(&new.join("vega.db"), "live-data");
migrate_dir(&new);
assert_eq!(
read(&new.join("vega.db")),
"live-data",
"the live copy always wins over the legacy one"
);
// Non-conflicting entries still come across.
assert_eq!(read(&new.join("relay.db")), "relay-events");
assert!(
legacy.join("vega.db").exists(),
"the un-moved legacy file is kept, never deleted"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn is_safe_to_run_again_after_a_successful_migration() {
let root = scratch_root();
seed_legacy(&root);
let new = root.join(APP_IDENTIFIER);
migrate_dir(&new);
migrate_dir(&new); // second launch
assert_eq!(read(&new.join("vega.db")), "cached-notes");
fs::remove_dir_all(&root).ok();
}
#[test]
fn does_nothing_for_a_brand_new_user() {
let root = scratch_root();
let new = root.join(APP_IDENTIFIER);
migrate_dir(&new);
assert!(!new.exists(), "must not fabricate a data dir for a new user");
fs::remove_dir_all(&root).ok();
}
}