mirror of
https://github.com/hoornet/vega.git
synced 2026-07-25 17:48:11 -07:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user