rename: the desktop is Inkwell — crates, Tauri identity, data move, packaging

Step 3 of milestone 481 (Scribe note 5071: a full rename).

- crates thoughtsync-{core,desktop,ffi,uniffi-bindgen} → inkwell-*, the
  Cargo.lock entries moved to match (checked with `cargo metadata --locked`)
- Tauri: productName "Inkwell", identifier com.fabledsword.inkwell, binary
  `inkwell`, updater feed on bvandeusen/inkwell, store file inkwell.db
- client agent inkwell-desktop, headers X-Inkwell-Client/-Protocol (the server
  reads neither), capture event inkwell://captured, display-version env
- .deb: conflicts + replaces thought-sync, so the updater's install retires the
  old package instead of colliding on it. kebab-case("Inkwell") is `inkwell`, so
  the package name finally matches the command and verify.sh now asserts it
- pacman: inkwell, conflicting with and replacing thoughtsync and
  thoughtsync-desktop
- AppImage ~/Applications/Inkwell.AppImage, menu entry inkwell.desktop,
  installer, release titles, desktop asset names in fetch-clients.sh

The one shim, chosen by the operator because it is the only copy of a
local-first user's notes: crossover.rs moves the old
com.fabledsword.thoughtsync app-data dir's contents into the new one on startup,
before the store opens, renaming thoughtsync.db and its -wal/-shm with it. It
skips when the new dir already has a store, and anything already in the new dir
wins (the installer writes its channel marker there first). Tested.

Android's Kotlin side (package, applicationId, uniffi class) is step 4. Its
release asset names stay thoughtsync.* until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:27:26 -04:00
co-authored by Claude Opus 5.5
parent a706644455
commit fe6f0746b2
37 changed files with 453 additions and 247 deletions
+181
View File
@@ -0,0 +1,181 @@
//! One-time move of a ThoughtSync install's data into Inkwell's.
//!
//! The app was renamed ThoughtSync → Inkwell (Scribe note 5071), and the Tauri
//! identifier with it. The app-data dir is named after the identifier, so the
//! renamed app looks in `com.fabledsword.inkwell` and would start EMPTY beside the
//! old `com.fabledsword.thoughtsync`, which still holds every note. Local-first means
//! that directory is the only copy for anyone who never linked a server.
//!
//! So on startup, before the store opens, the old directory's contents move across.
//! The store file was renamed with the app (`thoughtsync.db` → `inkwell.db`), so it
//! is renamed on the way, together with its SQLite `-wal`/`-shm` sidecars. Those
//! hold committed writes not yet checkpointed into the main file, and a database
//! renamed without them silently loses them.
//!
//! A removable shim, deliberately (family rule 22): delete this module and its one
//! call in `lib.rs` once no install is still on the old identifier.
use std::fs;
use std::io;
use std::path::Path;
/// The identifier ThoughtSync shipped under. Its app-data dir is a sibling of ours.
pub const LEGACY_IDENTIFIER: &str = "com.fabledsword.thoughtsync";
const LEGACY_DB: &str = "thoughtsync.db";
pub const DB_FILE: &str = "inkwell.db";
/// What a crossover did, for the startup log.
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
/// No old install beside this one.
NothingToMove,
/// This install already has its own store. The old directory is left alone:
/// merging two stores is not something a rename should decide.
AlreadyHasAStore,
/// Moved this many entries across.
Moved(usize),
}
/// Move `old_dir`'s contents into `new_dir`, unless `new_dir` already has a store.
///
/// Entry by entry rather than renaming the directory, because `new_dir` can already
/// exist without a store: the installer writes its channel marker there before the
/// app first runs (update.rs, issue 2183), and the webview may create its own files
/// before setup. Anything already in `new_dir` wins; the old copy of it is left in
/// place, and so is `old_dir` itself unless it ends up empty.
pub fn adopt_legacy_data(old_dir: &Path, new_dir: &Path) -> io::Result<Outcome> {
if new_dir.join(DB_FILE).exists() {
return Ok(Outcome::AlreadyHasAStore);
}
if !old_dir.is_dir() {
return Ok(Outcome::NothingToMove);
}
fs::create_dir_all(new_dir)?;
let mut moved = 0;
for entry in fs::read_dir(old_dir)? {
let entry = entry?;
let name = entry.file_name();
let target = new_dir.join(renamed(&name.to_string_lossy()));
if target.exists() {
continue;
}
fs::rename(entry.path(), &target)?;
moved += 1;
}
// Only succeeds when empty, which is the point: whatever was skipped stays put.
let _ = fs::remove_dir(old_dir);
Ok(Outcome::Moved(moved))
}
/// `thoughtsync.db`, `thoughtsync.db-wal`, `thoughtsync.db-shm` → the `inkwell.db`
/// family. Everything else keeps its name.
fn renamed(name: &str) -> String {
match name.strip_prefix(LEGACY_DB) {
Some(suffix @ ("" | "-wal" | "-shm")) => format!("{DB_FILE}{suffix}"),
_ => name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
/// A unique scratch root holding `old/` and `new/`, the shape of a data root
/// with both identifiers' directories in it.
fn root() -> (PathBuf, PathBuf) {
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("iw-crossover-{}-{n}", std::process::id()));
(root.join("old"), root.join("new"))
}
fn write(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
fs::write(path, contents).expect("write");
}
fn read(path: &Path) -> String {
fs::read_to_string(path).expect("read")
}
#[test]
fn a_thoughtsync_store_moves_across_with_its_wal_and_blobs() {
let (old, new) = root();
write(&old.join("thoughtsync.db"), "main");
write(&old.join("thoughtsync.db-wal"), "uncheckpointed writes");
write(&old.join("thoughtsync.db-shm"), "index");
write(&old.join("blobs/ab/abcdef"), "image bytes");
assert_eq!(
adopt_legacy_data(&old, &new).expect("move"),
Outcome::Moved(4)
);
assert_eq!(read(&new.join("inkwell.db")), "main");
assert_eq!(read(&new.join("inkwell.db-wal")), "uncheckpointed writes");
assert_eq!(read(&new.join("inkwell.db-shm")), "index");
assert_eq!(read(&new.join("blobs/ab/abcdef")), "image bytes");
assert!(!old.exists(), "an emptied old dir is removed");
}
#[test]
fn the_installers_marker_already_in_the_new_dir_wins() {
// install.sh writes the channel marker into the NEW dir before first launch,
// so the new dir exists but holds no store. The crossover still has to run,
// and the fresh marker has to beat the old install's.
let (old, new) = root();
write(&old.join("thoughtsync.db"), "main");
write(&old.join("install-channel"), "stable");
write(&new.join("install-channel"), "dev");
assert_eq!(
adopt_legacy_data(&old, &new).expect("move"),
Outcome::Moved(1)
);
assert_eq!(read(&new.join("inkwell.db")), "main");
assert_eq!(read(&new.join("install-channel")), "dev");
assert_eq!(
read(&old.join("install-channel")),
"stable",
"the loser stays put"
);
}
#[test]
fn an_install_with_its_own_store_is_left_alone() {
let (old, new) = root();
write(&old.join("thoughtsync.db"), "old notes");
write(&new.join("inkwell.db"), "new notes");
assert_eq!(
adopt_legacy_data(&old, &new).expect("check"),
Outcome::AlreadyHasAStore
);
assert_eq!(read(&new.join("inkwell.db")), "new notes");
assert_eq!(read(&old.join("thoughtsync.db")), "old notes");
}
#[test]
fn no_old_install_means_nothing_happens() {
let (old, new) = root();
assert_eq!(
adopt_legacy_data(&old, &new).expect("check"),
Outcome::NothingToMove
);
assert!(!new.exists(), "nothing created for a fresh install");
}
#[test]
fn only_the_store_family_is_renamed() {
assert_eq!(renamed("thoughtsync.db"), "inkwell.db");
assert_eq!(renamed("thoughtsync.db-wal"), "inkwell.db-wal");
assert_eq!(renamed("thoughtsync.db-journal"), "thoughtsync.db-journal");
assert_eq!(renamed("blobs"), "blobs");
}
}