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
+1 -1
View File
@@ -4,7 +4,7 @@ name: Android
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
# uniffi.
#
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
+9 -9
View File
@@ -167,9 +167,9 @@ jobs:
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export THOUGHTSYNC_DISPLAY_VERSION
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export INKWELL_DISPLAY_VERSION
echo "Baking display version $INKWELL_DISPLAY_VERSION"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -240,7 +240,7 @@ jobs:
- name: Upload bundles
uses: actions/upload-artifact@v7
with:
name: thoughtsync-linux
name: inkwell-linux
path: |
target/release/bundle/appimage/*.AppImage
target/release/bundle/deb/*.deb
@@ -362,9 +362,9 @@ jobs:
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export THOUGHTSYNC_DISPLAY_VERSION
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export INKWELL_DISPLAY_VERSION
echo "Baking display version $INKWELL_DISPLAY_VERSION"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -382,7 +382,7 @@ jobs:
- name: Upload installer
uses: actions/upload-artifact@v7
with:
name: thoughtsync-windows
name: inkwell-windows
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error
@@ -467,7 +467,7 @@ jobs:
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# The version a PERSON reads, published beside the manifest as
# `thoughtsync-desktop.json`. The image build reads it to describe the
# `inkwell-desktop.json`. The image build reads it to describe the
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
# anything from its own checkout — which would be a different commit
# whenever the desktop did not rebuild.
Generated
+49 -49
View File
@@ -1854,6 +1854,55 @@ dependencies = [
"cfb",
]
[[package]]
name = "inkwell-core"
version = "0.1.0"
dependencies = [
"chrono",
"log",
"openssl-sys",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"uuid",
]
[[package]]
name = "inkwell-desktop"
version = "0.2.0"
dependencies = [
"inkwell-core",
"log",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-global-shortcut",
"tauri-plugin-log",
"tauri-plugin-updater",
]
[[package]]
name = "inkwell-ffi"
version = "0.1.0"
dependencies = [
"inkwell-core",
"log",
"serde_json",
"thiserror 2.0.20",
"tokio",
"uniffi",
]
[[package]]
name = "inkwell-uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -4215,55 +4264,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "thoughtsync-core"
version = "0.1.0"
dependencies = [
"chrono",
"log",
"openssl-sys",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"uuid",
]
[[package]]
name = "thoughtsync-desktop"
version = "0.2.0"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-global-shortcut",
"tauri-plugin-log",
"tauri-plugin-updater",
"thoughtsync-core",
]
[[package]]
name = "thoughtsync-ffi"
version = "0.1.0"
dependencies = [
"log",
"serde_json",
"thiserror 2.0.20",
"thoughtsync-core",
"tokio",
"uniffi",
]
[[package]]
name = "thoughtsync-uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]]
name = "time"
version = "0.3.55"
+5 -5
View File
@@ -16,7 +16,7 @@ val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/**
* Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
* `.so` into jniLibs, where AGP packages it.
*
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
@@ -49,7 +49,7 @@ abstract class CargoNdkBuild : DefaultTask() {
args += "-t"
args += abi
}
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi")
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi")
// --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on.
args += "--locked"
@@ -94,7 +94,7 @@ abstract class UniffiBindgen : DefaultTask() {
"run",
"--locked",
"-p",
"thoughtsync-uniffi-bindgen",
"inkwell-uniffi-bindgen",
"--",
"generate",
"--library",
@@ -149,7 +149,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
val cargoNdk =
tasks.register<CargoNdkBuild>("cargoNdk") {
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
description = "Cross-compile inkwell-ffi for the Android ABIs."
rustSources.from(rustInputs)
abis.set(androidAbis)
cargoProfile.set(rustProfile)
@@ -163,7 +163,7 @@ val generateBindings =
dependsOn(cargoNdk)
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
// reading one is cheaper than reading four.
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
workspaceDir.set(workspaceRoot)
outputDir.set(bindingsOut)
}
+3 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "thoughtsync-uniffi-bindgen"
name = "inkwell-uniffi-bindgen"
version = "0.1.0"
description = "Generates the Kotlin bindings for thoughtsync-ffi"
description = "Generates the Kotlin bindings for inkwell-ffi"
authors = ["bvandeusen"]
edition = "2021"
# A crate whose ONLY dependency is uniffi itself.
#
# This started life as a `[[bin]]` inside thoughtsync-ffi, which failed: building
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
# it compiled that crate and therefore the core, reqwest, native-tls and
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
+2 -2
View File
@@ -3,8 +3,8 @@
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
//!
//! ```text
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
//! generate --library <path/to/libthoughtsync_ffi.so> \
//! cargo run --locked -p inkwell-uniffi-bindgen -- \
//! generate --library <path/to/libinkwell_ffi.so> \
//! --language kotlin --out-dir <build/generated/uniffi>
//! ```
//!
+4 -4
View File
@@ -1,7 +1,7 @@
[package]
name = "thoughtsync-ffi"
name = "inkwell-ffi"
version = "0.1.0"
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
description = "uniffi bindings exposing inkwell-core to the native Android client"
authors = ["bvandeusen"]
edition = "2021"
@@ -10,10 +10,10 @@ edition = "2021"
# bindgen binary below — and this crate's own tests — can use the crate normally;
# a cdylib-only crate is unusable from Rust.
crate-type = ["cdylib", "lib"]
name = "thoughtsync_ffi"
name = "inkwell_ffi"
[dependencies]
thoughtsync-core = { path = "../../core" }
inkwell-core = { path = "../../core" }
serde_json = { workspace = true }
log = { workspace = true }
+8 -8
View File
@@ -1,4 +1,4 @@
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
//! uniffi bindings: `inkwell-core` as seen from Kotlin.
//!
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
//! — a thin shim over the shared core, holding no logic of its own. If something here
@@ -38,9 +38,9 @@ pub mod models;
use std::path::PathBuf;
use std::sync::Arc;
use thoughtsync_core::local::{self, Db};
use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use inkwell_core::local::{self, Db};
use inkwell_core::sync::blobs::BlobStore;
use inkwell_core::sync::{client, compat, engine, push, state};
use models::{
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
@@ -120,14 +120,14 @@ impl ThoughtSync {
///
/// `data_dir` comes from Kotlin because only Android knows where its app-private
/// storage is; the core must not guess at a platform path. The layout inside is
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
/// the core's business and matches the desktop's exactly — `inkwell.db` and
/// `blobs/` — so a store is readable by any client that opens it.
#[uniffi::constructor]
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
let dir = PathBuf::from(data_dir);
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
let db = local::open(&dir.join("inkwell.db")).map_err(CoreError::store)?;
log::info!("local store ready — {}", local::summary(&db));
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
@@ -583,7 +583,7 @@ pub fn checklist_render(text: String, checked: bool) -> String {
///
/// Android has to say so because the core cannot: the same crate is compiled into
/// the desktop app, and it used to announce every phone in the field as
/// `thoughtsync-desktop` carrying the CORE crate's version — a number no build
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
/// stamps and nobody has seen. The honest value is the installed package's own
/// `versionName`, which is what Kotlin passes here.
///
@@ -680,7 +680,7 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"thoughtsync-ffi-{}-{}",
"inkwell-ffi-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
+17 -17
View File
@@ -1,6 +1,6 @@
//! The types that cross into Kotlin.
//!
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
//! shapes are serde structs whose field names and optionality are contracted with the
//! shared Vue frontend; hanging uniffi derives on them would couple two very
//! different consumers to one definition and put a `serde_json::Value` (which has no
@@ -13,13 +13,13 @@
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
//! style here; please keep it.
use thoughtsync_core::local::models as core_models;
use thoughtsync_core::sync::client as core_client;
use thoughtsync_core::sync::compat as core_compat;
use thoughtsync_core::sync::engine as core_engine;
use thoughtsync_core::sync::pull as core_pull;
use thoughtsync_core::sync::push as core_push;
use thoughtsync_core::sync::state as core_state;
use inkwell_core::local::models as core_models;
use inkwell_core::sync::client as core_client;
use inkwell_core::sync::compat as core_compat;
use inkwell_core::sync::engine as core_engine;
use inkwell_core::sync::pull as core_pull;
use inkwell_core::sync::push as core_push;
use inkwell_core::sync::state as core_state;
/// A note, with everything needed to render a card or open the editor.
///
@@ -60,9 +60,9 @@ pub struct BodyItem {
pub checked: bool,
}
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem {
impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
let inkwell_core::local::derive::DerivedItem {
text,
checked,
line,
@@ -88,9 +88,9 @@ pub struct BodyTag {
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
let inkwell_core::local::derive::DerivedTag {
line,
start,
end,
@@ -121,12 +121,12 @@ pub struct ClientUpdate {
pub size: i64,
}
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
// Destructured exhaustively, like every other conversion in this file: a
// field added upstream stops this compiling until Android is told what to
// do with it, which turns silent drift into a build error.
let thoughtsync_core::sync::client::ClientRelease {
let inkwell_core::sync::client::ClientRelease {
version,
version_code,
size,
+1 -1
View File
@@ -2,4 +2,4 @@
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
[bindings.kotlin]
package_name = "com.fabledsword.thoughtsync.core"
cdylib_name = "thoughtsync_ffi"
cdylib_name = "inkwell_ffi"
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "thoughtsync-core"
name = "inkwell-core"
version = "0.1.0"
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
authors = ["bvandeusen"]
edition = "2021"
+1 -1
View File
@@ -1,4 +1,4 @@
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
//! Inkwell's client core: the on-device SQLite store and the sync engine.
//!
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
//! the Android client binds it through uniffi. Neither owns it, and a change to
+2 -2
View File
@@ -427,8 +427,8 @@ mod tests {
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/thoughtsync"),
"http://192.168.1.10:8000/thoughtsync/api/config"
config_url("http://192.168.1.10:8000/inkwell"),
"http://192.168.1.10:8000/inkwell/api/config"
);
}
}
+11 -16
View File
@@ -176,7 +176,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
/// Who this client says it is, set once by the host application at startup.
///
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
/// was `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
/// the desktop app AND the Android app — so every phone in the field announced
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
/// version of the CORE crate, a number no build stamps and no user has ever seen,
@@ -188,7 +188,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
/// the process and a second caller should be ignored rather than race the first.
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
/// Name this client for the servers it talks to — `("thoughtsync-android", "2026.08.31.1204")`.
/// Name this client for the servers it talks to — `("inkwell-android", "2026.08.31.1204")`.
///
/// Call once at startup, before any sync. Calling twice is not an error and the
/// first name wins; not calling it at all is visible in the header rather than
@@ -207,13 +207,10 @@ pub fn client_headers() -> [(&'static str, String); 2] {
let agent = CLIENT_AGENT
.get()
.cloned()
.unwrap_or_else(|| "thoughtsync-unidentified/unknown".to_string());
.unwrap_or_else(|| "inkwell-unidentified/unknown".to_string());
[
("X-ThoughtSync-Client", agent),
(
"X-ThoughtSync-Protocol",
CLIENT_PROTOCOL_VERSION.to_string(),
),
("X-Inkwell-Client", agent),
("X-Inkwell-Protocol", CLIENT_PROTOCOL_VERSION.to_string()),
]
}
@@ -411,21 +408,19 @@ mod tests {
// this one for the OnceLock, and whichever lost would see the other's name.
// One test, both branches, in order.
assert!(
client_headers()[0]
.1
.starts_with("thoughtsync-unidentified/"),
client_headers()[0].1.starts_with("inkwell-unidentified/"),
"a host that never introduced itself must say so"
);
set_client_agent("thoughtsync-test", "2026.08.31.1204");
set_client_agent("inkwell-test", "2026.08.31.1204");
let headers = client_headers();
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
assert_eq!(headers[0].1, "thoughtsync-test/2026.08.31.1204");
assert_eq!(headers[0].0, "X-Inkwell-Client");
assert_eq!(headers[0].1, "inkwell-test/2026.08.31.1204");
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
// First name wins — a second host cannot rename a running process.
set_client_agent("thoughtsync-impostor", "0");
assert_eq!(client_headers()[0].1, "thoughtsync-test/2026.08.31.1204");
set_client_agent("inkwell-impostor", "0");
assert_eq!(client_headers()[0].1, "inkwell-test/2026.08.31.1204");
}
#[test]
+1 -1
View File
@@ -1,4 +1,4 @@
//! Talking to a ThoughtSync server — entirely opt-in.
//! Talking to an Inkwell server — entirely opt-in.
//!
//! The app is local-first: `local` is the source of truth and everything works
//! unlinked. Nothing in here runs until the user links a server.
@@ -3,10 +3,10 @@
# grouping. Tauri writes the same value into the .deb's generated entry.
[Desktop Entry]
Type=Application
Name=ThoughtSync
Name=Inkwell
Comment=Capture a fleeting thought in a second
Exec=thoughtsync %U
Icon=thoughtsync
Exec=inkwell %U
Icon=inkwell
Terminal=false
Categories=Utility;Office;
StartupWMClass=thoughtsync
StartupWMClass=inkwell
+20 -19
View File
@@ -29,19 +29,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SRC_TAURI="$REPO_ROOT/desktop/src-tauri"
BINARY="$REPO_ROOT/target/release/thoughtsync"
BINARY="$REPO_ROOT/target/release/inkwell"
OUT_DIR="${1:-$REPO_ROOT/target/release/bundle/arch}"
PKGNAME="thoughtsync"
# The name this package used to ship under. pacman needs both to retire it: without
# them a `pacman -U` of the renamed package installs ALONGSIDE the old one, and two
# packages both own /usr/bin/thoughtsync (issue 2075).
REPLACES=(thoughtsync-desktop)
PKGNAME="inkwell"
# The names this package used to ship under: `thoughtsync` until the app became
# Inkwell (Scribe note 5071), `thoughtsync-desktop` before that. pacman needs both
# conflict and replace to retire them: without them a `pacman -U` of the renamed
# package installs ALONGSIDE the old one (issue 2075).
REPLACES=(thoughtsync thoughtsync-desktop)
PKGREL=1
PKGDESC="ThoughtSync desktop — local-first Keep-style thought capture"
URL="https://git.fabledsword.com/bvandeusen/thoughtsync"
PKGDESC="Inkwell desktop — local-first Keep-style thought capture"
URL="https://git.fabledsword.com/bvandeusen/inkwell"
LICENSE="MIT"
PACKAGER="ThoughtSync CI <noreply@fabledsword.com>"
PACKAGER="Inkwell CI <noreply@fabledsword.com>"
# Runtime deps. Deliberately minimal and grounded in what the binary actually
# links: tauri is built with features=[] (no tray -> no libayatana-appindicator)
@@ -79,25 +80,25 @@ STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT INT TERM
# --- lay out the filesystem tree --------------------------------------------
# /usr/bin/thoughtsync — the same command name the .deb installs and the AppImage
# /usr/bin/inkwell — the same command name the .deb installs and the AppImage
# installer symlinks into ~/.local/bin, so it's identical whichever way the app
# arrived. The binary already carries this name (Cargo `[[bin]]`), which is also
# what the .desktop entry's StartupWMClass has to match.
install -Dm755 "$BINARY" "$STAGE/usr/bin/thoughtsync"
install -Dm644 "$SCRIPT_DIR/thoughtsync.desktop" \
"$STAGE/usr/share/applications/thoughtsync.desktop"
install -Dm755 "$BINARY" "$STAGE/usr/bin/inkwell"
install -Dm644 "$SCRIPT_DIR/inkwell.desktop" \
"$STAGE/usr/share/applications/inkwell.desktop"
# Themed icons, so the menu entry's `Icon=thoughtsync` resolves at every size the
# Themed icons, so the menu entry's `Icon=inkwell` resolves at every size the
# desktop asks for; pixmaps is the legacy fallback for older launchers.
install -Dm644 "$SRC_TAURI/icons/32x32.png" \
"$STAGE/usr/share/icons/hicolor/32x32/apps/thoughtsync.png"
"$STAGE/usr/share/icons/hicolor/32x32/apps/inkwell.png"
install -Dm644 "$SRC_TAURI/icons/128x128.png" \
"$STAGE/usr/share/icons/hicolor/128x128/apps/thoughtsync.png"
"$STAGE/usr/share/icons/hicolor/128x128/apps/inkwell.png"
install -Dm644 "$SRC_TAURI/icons/128x128@2x.png" \
"$STAGE/usr/share/icons/hicolor/256x256/apps/thoughtsync.png"
"$STAGE/usr/share/icons/hicolor/256x256/apps/inkwell.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" \
"$STAGE/usr/share/icons/hicolor/512x512/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" "$STAGE/usr/share/pixmaps/thoughtsync.png"
"$STAGE/usr/share/icons/hicolor/512x512/apps/inkwell.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" "$STAGE/usr/share/pixmaps/inkwell.png"
# Installed size in KiB, as pacman reports it.
INSTALLED_SIZE="$(du -sb "$STAGE" | cut -f1)"
+13 -8
View File
@@ -13,7 +13,7 @@
# Five checks, cheapest first:
# 1. Print the control file + contents — the generated metadata becomes ground
# truth in the build log instead of an assumption.
# 2. Naming: the binary is /usr/bin/thoughtsync and the generated .desktop
# 2. Naming: the binary is /usr/bin/inkwell and the generated .desktop
# entry's StartupWMClass matches it. The app used to identify itself three
# different ways depending on install channel (issue 2075); this is what
# keeps the .deb — the only channel whose entry Tauri generates for us —
@@ -72,12 +72,17 @@ echo " (binary: ${BIN#"$WORK/root"})"
# CANON is the one name the app answers to everywhere: the binary, the CLI
# command, the icon, and the WM_CLASS the window reports. Hardcoded here on
# purpose — this literal IS the contract the three install channels are held to.
# Deliberately NOT asserted: the control file's `Package:` field, which is
# `thought-sync`. tauri-bundler derives it as kebab-case(productName) with no
# config override, so "ThoughtSync" splits at the hump. Fixing it would mean
# unpacking and rewriting the control archive on every build — a fragile step for
# a cosmetic gain on one uninstall command. Left as a known wart (issue 2075).
CANON="thoughtsync"
#
# The control file's `Package:` field is held to it too. tauri-bundler derives it
# as kebab-case(productName) with no override, which split "ThoughtSync" into
# `thought-sync` (issue 2075). "Inkwell" has no hump to split, so the package and
# the command finally agree, and a productName that brought the split back would
# also break the `conflicts`/`replaces` retiring the old package.
CANON="inkwell"
package="$(dpkg-deb -f "$DEB" Package 2>/dev/null || true)"
[ "$package" = "$CANON" ] ||
note_fail "the package is named '${package:-<unset>}', expected '$CANON' (kebab-case of productName)."
installed_bin="${BIN#"$WORK/root"}"
[ "$installed_bin" = "/usr/bin/$CANON" ] ||
@@ -107,7 +112,7 @@ if command -v dpkg-shlibdeps >/dev/null 2>&1; then
# dpkg-shlibdeps insists on a debian/control in the working directory even with
# -O (write to stdout); a stub is enough to let it do the ELF analysis.
mkdir -p "$WORK/deb-stub/debian"
printf 'Source: thoughtsync\n\nPackage: thoughtsync\nArchitecture: amd64\n' \
printf 'Source: inkwell\n\nPackage: inkwell\nArchitecture: amd64\n' \
>"$WORK/deb-stub/debian/control"
# `|| true`: pipefail is on and dpkg-shlibdeps still exits non-zero on some
# symbol warnings even with --ignore-missing-info. An empty result degrades to
+27 -27
View File
@@ -1,8 +1,8 @@
#!/bin/sh
#
# ThoughtSync desktop — one-command Linux installer.
# Inkwell desktop — one-command Linux installer.
#
# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
# curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh
#
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
# stable (default) — the rolling build from every merge to `main`.
@@ -35,7 +35,7 @@
set -eu
INSTANCE="https://git.fabledsword.com"
REPO="bvandeusen/thoughtsync"
REPO="bvandeusen/inkwell"
API="$INSTANCE/api/v1/repos/$REPO"
say() { printf '==> %s\n' "$1"; }
@@ -44,7 +44,7 @@ have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<'USAGE'
ThoughtSync desktop installer.
Inkwell desktop installer.
install.sh [--channel stable|dev]
@@ -83,11 +83,11 @@ have curl || die "curl is required."
arch="$(uname -m)"
case "$arch" in
x86_64 | amd64) : ;;
*) die "ThoughtSync ships x86_64 Linux builds only right now (this machine: $arch)." ;;
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
esac
# --- resolve the release for this channel -----------------------------------
say "Finding the latest ThoughtSync build on the $channel channel…"
say "Finding the latest Inkwell build on the $channel channel…"
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
# are pruned to the current build, so the tag alone names the newest build on that
@@ -131,10 +131,10 @@ trap 'rm -rf "$tmp"' EXIT INT TERM
# The app reads it at startup (src-tauri/src/update.rs, INSTALL_MARKER) and only acts
# when the value CHANGED, so switching channel in the app isn't undone on next launch.
#
# The directory is Tauri's app-data dir for identifier com.fabledsword.thoughtsync;
# The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell;
# both sides hardcode it, so a change to the identifier has to change both.
record_channel() {
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.thoughtsync"
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell"
# Best-effort: a failure here costs the channel setting, not the install, and a
# native install run as root would only be writing into root's home anyway.
mkdir -p "$marker_dir" 2>/dev/null && printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true
@@ -176,7 +176,7 @@ if have pacman && [ -n "$pkg_url" ]; then
need_root
$sudo pacman -U --noconfirm "$pkg_file"
record_channel
say "Done. Launch ThoughtSync from your application menu, or run thoughtsync."
say "Done. Launch Inkwell from your application menu, or run inkwell."
native_update_note
exit 0
fi
@@ -184,33 +184,33 @@ fi
# --- native .deb path (Debian/Ubuntu) ---------------------------------------
if have dpkg && have apt-get && [ -n "$deb_url" ]; then
say "Debian-family system detected — installing the native .deb"
curl -fSL -o "$tmp/thoughtsync.deb" "$deb_url"
curl -fSL -o "$tmp/inkwell.deb" "$deb_url"
need_root
# apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the
# fallback if this apt is too old for local-file installs — it leaves the deps
# unconfigured, so `apt-get -f install` is what actually completes that path.
$sudo apt-get install -y "$tmp/thoughtsync.deb" ||
{ $sudo dpkg -i "$tmp/thoughtsync.deb" || true; $sudo apt-get -f install -y; }
$sudo apt-get install -y "$tmp/inkwell.deb" ||
{ $sudo dpkg -i "$tmp/inkwell.deb" || true; $sudo apt-get -f install -y; }
record_channel
say "Done. Launch ThoughtSync from your application menu."
say "Done. Launch Inkwell from your application menu."
native_update_note
exit 0
fi
# --- universal AppImage path (user-local, no sudo) --------------------------
# Install into ~/Applications/ThoughtSync.AppImage — the SAME location the app's
# Install into ~/Applications/Inkwell.AppImage — the SAME location the app's
# own self-integration uses (src/integration.rs) — so the running app sees
# itself already installed and never makes a second copy or menu entry.
say "Installing the de-bundled AppImage (user-local, no sudo)"
[ -n "$appimage_url" ] || die "the $channel release (${version:-unknown}) has no AppImage asset."
apps_dir="$HOME/Applications"
dest="$apps_dir/ThoughtSync.AppImage"
dest="$apps_dir/Inkwell.AppImage"
mkdir -p "$apps_dir"
say "Downloading $(basename "$appimage_url")…"
curl -fSL -o "$tmp/ThoughtSync.AppImage" "$appimage_url"
chmod +x "$tmp/ThoughtSync.AppImage"
mv -f "$tmp/ThoughtSync.AppImage" "$dest"
curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url"
chmod +x "$tmp/Inkwell.AppImage"
mv -f "$tmp/Inkwell.AppImage" "$dest"
# Menu entry — written to match integration.rs verbatim (same paths + fields),
# so the app reports is_integrated=true and won't duplicate it.
@@ -222,39 +222,39 @@ mkdir -p "$apps_menu" "$icons_dir"
# entry looks right immediately. Extraction is a non-GUI unsquash (no FUSE, no
# black-window risk); if it fails we fall back to the themed name and the app
# writes its embedded icon on first launch anyway.
icon_ref="thoughtsync"
icon_ref="inkwell"
if ( cd "$tmp" && "$dest" --appimage-extract .DirIcon >/dev/null 2>&1 ) \
&& cp -L "$tmp/squashfs-root/.DirIcon" "$icons_dir/thoughtsync.png" 2>/dev/null; then
icon_ref="$icons_dir/thoughtsync.png"
&& cp -L "$tmp/squashfs-root/.DirIcon" "$icons_dir/inkwell.png" 2>/dev/null; then
icon_ref="$icons_dir/inkwell.png"
fi
rm -rf "$tmp/squashfs-root" 2>/dev/null || true
# StartupWMClass is the BINARY name, not the product name and not the AppImage
# filename: the AppImage's AppRun execs usr/bin/thoughtsync, and GTK derives
# filename: the AppImage's AppRun execs usr/bin/inkwell, and GTK derives
# WM_CLASS from whatever it ends up running. Anything else here means the window
# never associates with this entry and the taskbar shows a second, generic icon.
cat > "$apps_menu/thoughtsync.desktop" <<EOF
cat > "$apps_menu/inkwell.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=ThoughtSync
Name=Inkwell
Comment=Capture a fleeting thought in a second
Exec=$dest %U
Icon=$icon_ref
Terminal=false
Categories=Utility;Office;
StartupWMClass=thoughtsync
StartupWMClass=inkwell
EOF
have update-desktop-database && update-desktop-database "$apps_menu" >/dev/null 2>&1 || true
# Convenience CLI launcher.
mkdir -p "$HOME/.local/bin"
ln -sf "$dest" "$HOME/.local/bin/thoughtsync"
ln -sf "$dest" "$HOME/.local/bin/inkwell"
record_channel
say "Installed to $dest"
printf ' Launch it from your application menu, or run \033[1mthoughtsync\033[0m'
printf ' Launch it from your application menu, or run \033[1minkwell\033[0m'
printf ' (if ~/.local/bin is on your PATH).\n'
# This is the one path where the app can update itself, so say what it will follow.
if [ "$channel" = "dev" ]; then
+3 -3
View File
@@ -148,8 +148,8 @@ fi
echo "==> Creating release for $TAG"
BODY=$(cat <<JSON
{"tag_name":"$TAG","name":"ThoughtSync $CHANNEL_LABEL","draft":false,"prerelease":$RELEASE_PRERELEASE,
"body":"ThoughtSync $CHANNEL_LABEL.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
{"tag_name":"$TAG","name":"Inkwell $CHANNEL_LABEL","draft":false,"prerelease":$RELEASE_PRERELEASE,
"body":"Inkwell $CHANNEL_LABEL.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/inkwell/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
JSON
)
@@ -165,7 +165,7 @@ JSON
# whether it has a JSON encoder; this script cannot assume python3 is on PATH in
# every image that sources it.
if [ -n "${RELEASE_BODY_JSON:-}" ]; then
BODY="{\"tag_name\":\"$TAG\",\"name\":\"ThoughtSync $CHANNEL_LABEL\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
BODY="{\"tag_name\":\"$TAG\",\"name\":\"Inkwell $CHANNEL_LABEL\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
fi
# 409 = a release for this tag already exists (re-run) — fall through to lookup.
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
+3 -3
View File
@@ -158,8 +158,8 @@ replace_asset "$work/latest.json" "latest.json"
# downloads a bundle measures the bytes it actually got, which is the only way to
# tell a truncated download from a whole one.
printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json"
replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json"
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/inkwell-desktop.json"
replace_asset "$work/inkwell-desktop.json" "inkwell-desktop.json"
# --- TEMPORARY: the one-shot bridge off a retired channel tag ------------------
#
@@ -229,7 +229,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
# every desktop push regardless. That is exactly what happened on run
# 4098, which swept the APK run 4092 had just published.
case "$asset_name" in
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
latest.json|inkwell-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
*"$APP_VERSION"*) continue ;;
esac
echo " removing $asset_name"
+11 -11
View File
@@ -1,5 +1,5 @@
[package]
name = "thoughtsync-desktop"
name = "inkwell-desktop"
# NOT THE SHIPPED VERSION, and bumping it has no effect on anything a user sees.
#
# Cargo requires a version here, and Tauri reads one from `tauri.conf.json` — both
@@ -13,11 +13,11 @@ name = "thoughtsync-desktop"
# what that looked like in the field. A scheme needing a human to remember something
# before each release has not removed the decision, only hidden it.
version = "0.2.0"
description = "ThoughtSync desktop — local-first Keep-style thought capture"
description = "Inkwell desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"]
edition = "2021"
# The executable is named below, not inferred from this package name. Off so the
# inferred `thoughtsync-desktop` target and the explicit one can't both claim
# inferred `inkwell-desktop` target and the explicit one can't both claim
# src/main.rs.
autobins = false
@@ -25,23 +25,23 @@ autobins = false
# main.rs is a thin shim.
#
# rlib only. The staticlib/cdylib types existed for Tauri mobile, which Android no
# longer uses — it is a native Kotlin client over `thoughtsync-core` instead
# longer uses — it is a native Kotlin client over `inkwell-core` instead
# (Scribe note 2730), and the .so it loads is built from that crate, not this one.
[lib]
name = "thoughtsync_desktop_lib"
name = "inkwell_desktop_lib"
crate-type = ["rlib"]
# The shipped command is `thoughtsync` on every install channel, and the binary's
# The shipped command is `inkwell` on every install channel, and the binary's
# own name is what the desktop actually keys on: GTK derives the window's WM_CLASS
# from it, and Tauri's generated .desktop file sets StartupWMClass to that same
# name. (Written without braces on purpose: `tauri android init` round-trips this
# file through a TOML serializer that reformats brace tokens inside comments.)
# So the canonical name has to be carried by the build target itself, not just by
# the path it gets installed to (issue 2075). The crate stays `thoughtsync-desktop`
# — only the executable is renamed. `thoughtsync` is also fixed under kebab-casing,
# which the AppImage bundler applies to the binary name on its way in.
# the path it gets installed to (issue 2075). The crate is `inkwell-desktop`, so
# the executable is named here rather than inferred. `inkwell` is also fixed under
# kebab-casing, which the AppImage bundler applies to the binary name on its way in.
[[bin]]
name = "thoughtsync"
name = "inkwell"
path = "src/main.rs"
[build-dependencies]
@@ -50,7 +50,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
# The framework-free client core: local SQLite store + sync engine. Shared with the
# Android client, which binds the same crate through uniffi (Scribe note 2730).
thoughtsync-core = { path = "../../core" }
inkwell-core = { path = "../../core" }
tauri = { version = "2", features = [] }
serde = { workspace = true }
+1 -1
View File
@@ -7,7 +7,7 @@ fn main() {
// why this is easy to forget the day one is added.
//
// See DISPLAY_VERSION in `src/commands/local.rs`.
println!("cargo::rerun-if-env-changed=THOUGHTSYNC_DISPLAY_VERSION");
println!("cargo::rerun-if-env-changed=INKWELL_DISPLAY_VERSION");
tauri_build::build()
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
"description": "Core capability for the Inkwell windows: the board and the quick-capture window.",
"windows": ["main", "capture"],
"permissions": ["core:default"]
}
+2 -2
View File
@@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use thoughtsync_core::local::{store, Db};
use inkwell_core::local::{store, Db};
const SHORTCUT_PREF: &str = "capture_shortcut";
@@ -43,7 +43,7 @@ pub const CAPTURE_WINDOW: &str = "capture";
/// The two windows hold separate copies of the frontend and therefore separate
/// Pinia stores; nothing in the capture window's store can reach the board's. The
/// note is already in SQLite by the time this fires — this only says "look again".
pub const CAPTURED_EVENT: &str = "thoughtsync://captured";
pub const CAPTURED_EVENT: &str = "inkwell://captured";
/// The stored shortcut and whether it is actually live.
///
+6 -6
View File
@@ -6,11 +6,11 @@
use serde_json::Value;
use tauri::State;
use thoughtsync_core::local::models::*;
use thoughtsync_core::local::retention;
use thoughtsync_core::local::store;
use thoughtsync_core::local::Db;
use thoughtsync_core::sync::state;
use inkwell_core::local::models::*;
use inkwell_core::local::retention;
use inkwell_core::local::store;
use inkwell_core::local::Db;
use inkwell_core::sync::state;
// A macro would hide the (very regular) locking; kept explicit so each command reads
// as an obvious lock -> delegate -> stringify.
@@ -44,7 +44,7 @@ pub fn auth_me() -> User {
// The single synthetic local user, so the auth-gated router resolves with no login.
User {
id: "local".to_string(),
email: "local@thoughtsync.app".to_string(),
email: "local@inkwell.app".to_string(),
display_name: "You".to_string(),
email_verified: true,
is_admin: false,
+1 -1
View File
@@ -1,4 +1,4 @@
//! The Tauri command surface — the desktop's adapter onto `thoughtsync-core`.
//! The Tauri command surface — the desktop's adapter onto `inkwell-core`.
//!
//! This is the whole of what couples the store and sync engine to Tauri, and keeping
//! it in one place is deliberate: the Android client writes its own adapter (uniffi)
+7 -7
View File
@@ -6,13 +6,13 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use thoughtsync_core::local::Db;
use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::client::{self, Identity, ProbeResult};
use thoughtsync_core::sync::compat::Compatibility;
use thoughtsync_core::sync::engine;
use thoughtsync_core::sync::push;
use thoughtsync_core::sync::state;
use inkwell_core::local::Db;
use inkwell_core::sync::blobs::BlobStore;
use inkwell_core::sync::client::{self, Identity, ProbeResult};
use inkwell_core::sync::compat::Compatibility;
use inkwell_core::sync::engine;
use inkwell_core::sync::push;
use inkwell_core::sync::state;
/// Ask a server who it is, without committing to anything. The UI calls this as the
/// user finishes typing an address, so they see what answered before handing over
+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");
}
}
+8 -8
View File
@@ -1,7 +1,7 @@
//! Linux AppImage desktop integration.
//!
//! A bare AppImage is a portable file with no menu entry — deleting it uninstalls
//! the app. These commands let ThoughtSync install itself into the application menu
//! the app. These commands let Inkwell install itself into the application menu
//! (a `.desktop` launcher + icon) and keep a stable copy in `~/Applications`, so it
//! behaves like an installed app, and remove that again. No external helper
//! (AppImageLauncher / Gear Lever) is required.
@@ -20,9 +20,9 @@ use serde::Serialize;
// internal layout. Written to the user icon dir on integrate.
const APP_ICON: &[u8] = include_bytes!("../app-icon.png");
const DESKTOP_ENTRY_NAME: &str = "thoughtsync.desktop";
const ICON_FILE_NAME: &str = "thoughtsync.png";
const INSTALLED_APPIMAGE_NAME: &str = "ThoughtSync.AppImage";
const DESKTOP_ENTRY_NAME: &str = "inkwell.desktop";
const ICON_FILE_NAME: &str = "inkwell.png";
const INSTALLED_APPIMAGE_NAME: &str = "Inkwell.AppImage";
#[derive(Serialize)]
pub struct IntegrationStatus {
@@ -89,7 +89,7 @@ pub fn integrate_desktop() -> Result<IntegrationStatus, String> {
// StartupWMClass is the exception: it must be the BINARY name, because GTK
// derives the window's WM_CLASS from the executable and the desktop matches the
// two to group the taskbar icon. Not the product name, and not this AppImage's
// filename — AppRun execs `usr/bin/thoughtsync` inside it (issue 2075).
// filename — AppRun execs `usr/bin/inkwell` inside it (issue 2075).
let entry = desktop_entry_path().ok_or("Cannot resolve the applications dir.")?;
if let Some(parent) = entry.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?;
@@ -97,13 +97,13 @@ pub fn integrate_desktop() -> Result<IntegrationStatus, String> {
let contents = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=ThoughtSync\n\
Name=Inkwell\n\
Comment=Capture a fleeting thought in a second\n\
Exec={exec} %U\n\
Icon={icon}\n\
Terminal=false\n\
Categories=Utility;Office;\n\
StartupWMClass=thoughtsync\n",
StartupWMClass=inkwell\n",
exec = installed.to_string_lossy(),
icon = icon.to_string_lossy(),
);
@@ -122,7 +122,7 @@ pub fn unintegrate_desktop() -> Result<IntegrationStatus, String> {
if let Some(icon) = icon_path() {
remove_if_exists(&icon)?;
}
// Intentionally keep ~/Applications/ThoughtSync.AppImage: it may be the running
// Intentionally keep ~/Applications/Inkwell.AppImage: it may be the running
// binary, and removing it would delete the app the user is using. Un-integrate
// only removes the menu entry.
refresh_desktop_database(&home);
+26 -8
View File
@@ -1,16 +1,17 @@
//! ThoughtSync desktop (Tauri v2).
//! Inkwell desktop (Tauri v2).
//!
//! The window loads the shared Vue 3 frontend (`../../frontend`), which reaches the
//! store and sync engine through the `frontend/src/adapters/` seam (M10.3) over Tauri
//! `invoke`.
//!
//! This crate is the DESKTOP WRAPPER, not the core. The on-device SQLite store and
//! the sync engine live in `thoughtsync-core`, shared with the Android client; what
//! the sync engine live in `inkwell-core`, shared with the Android client; what
//! remains here is the Tauri command surface (`commands`), desktop integration
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
mod capture;
mod commands;
mod crossover;
mod integration;
mod update;
@@ -37,7 +38,7 @@ mod update;
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
/// values, two audiences. `update.rs` deliberately still reads the key, through
/// `app.package_info().version`, because a comparator is exactly what it is.
const DISPLAY_VERSION: Option<&str> = option_env!("THOUGHTSYNC_DISPLAY_VERSION");
const DISPLAY_VERSION: Option<&str> = option_env!("INKWELL_DISPLAY_VERSION");
/// The baked build, or the honest "I cannot say". The only way in — the const is
/// private so no caller can reach past the fallback.
@@ -45,11 +46,11 @@ pub(crate) fn display_version() -> &'static str {
DISPLAY_VERSION.unwrap_or("unknown")
}
// The store and the sync engine live in the shared `thoughtsync-core` crate, which
// The store and the sync engine live in the shared `inkwell-core` crate, which
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
// names so every call site below reads exactly as it did when they were modules of
// this crate — the extraction changed where they live, not what they are.
use thoughtsync_core::{local, sync};
use inkwell_core::{local, sync};
pub fn run() {
use tauri_plugin_log::{Target, TargetKind};
@@ -58,7 +59,7 @@ pub fn run() {
// The core cannot work this out — it is compiled into the Android app too — so
// the header says "desktop" only because the desktop says so here, and carries
// the build a person can read rather than the core crate's own version.
sync::compat::set_client_agent("thoughtsync-desktop", display_version());
sync::compat::set_client_agent("inkwell-desktop", display_version());
#[cfg(target_os = "linux")]
harden_linux_webkit_rendering();
@@ -111,11 +112,17 @@ pub fn run() {
log_environment(app);
paint_window_before_the_webview_does(app);
// The on-device store lives in the platform app-data dir (e.g. Linux
// ~/.local/share/com.fabledsword.thoughtsync/thoughtsync.db), created on
// ~/.local/share/com.fabledsword.inkwell/inkwell.db), created on
// first launch. This is what makes the app work with no server or login.
let dir = app.path().app_data_dir()?;
// Before the store opens: an install from when this app was ThoughtSync
// has its notes in the old identifier's dir, beside this one.
if let Some(root) = dir.parent() {
let legacy = root.join(crossover::LEGACY_IDENTIFIER);
log_crossover(&legacy, crossover::adopt_legacy_data(&legacy, &dir));
}
std::fs::create_dir_all(&dir)?;
let db_path = dir.join("thoughtsync.db");
let db_path = dir.join(crossover::DB_FILE);
log::info!("opening local store: {}", db_path.display());
let db = local::open(&db_path)?;
log::info!("local store ready — {}", local::summary(&db));
@@ -261,6 +268,17 @@ fn log_event(level: String, message: String) {
}
}
/// The startup log line for the ThoughtSync → Inkwell data crossover (crossover.rs).
fn log_crossover(legacy: &std::path::Path, result: std::io::Result<crossover::Outcome>) {
match result {
Ok(crossover::Outcome::Moved(n)) => {
log::info!("moved {n} entries from {}", legacy.display())
}
Ok(outcome) => log::debug!("ThoughtSync data crossover: {outcome:?}"),
Err(e) => log::warn!("could not move data from {}: {e}", legacy.display()),
}
}
/// Log the app version and the environment that determines whether the window
/// renders — the first thing to check when a build works on one machine but not
/// another.
+1 -1
View File
@@ -3,5 +3,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
thoughtsync_desktop_lib::run()
inkwell_desktop_lib::run()
}
+4 -4
View File
@@ -12,7 +12,7 @@
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//!
//! The feed lives on Fabled-Git rather than on a ThoughtSync server, deliberately:
//! The feed lives on Fabled-Git rather than on an Inkwell server, deliberately:
//! this app is usable having never linked a server, and an install that can't reach
//! its own updates because it isn't paired with anything would contradict the whole
//! local-first premise.
@@ -27,10 +27,10 @@ use serde::{Deserialize, Serialize};
use tauri::State;
use tauri_plugin_updater::UpdaterExt;
use thoughtsync_core::local::{store, Db};
use inkwell_core::local::{store, Db};
/// Where the manifests live. Fixed tags, so these URLs are permanent.
const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/thoughtsync/releases/download";
const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/inkwell/releases/download";
const CHANNEL_PREF: &str = "update_channel";
@@ -330,7 +330,7 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
fn db() -> Db {
thoughtsync_core::local::open_in_memory().expect("in-memory db")
inkwell_core::local::open_in_memory().expect("in-memory db")
}
/// A scratch path nothing else will collide with. Process id keeps concurrent
+11 -5
View File
@@ -1,9 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ThoughtSync",
"mainBinaryName": "thoughtsync",
"productName": "Inkwell",
"mainBinaryName": "inkwell",
"version": "0.2.0",
"identifier": "com.fabledsword.thoughtsync",
"identifier": "com.fabledsword.inkwell",
"build": {
"frontendDist": "../../frontend/dist",
"devUrl": "http://localhost:5173",
@@ -31,7 +31,7 @@
"plugins": {
"updater": {
"endpoints": [
"https://git.fabledsword.com/bvandeusen/thoughtsync/releases/download/stable/latest.json"
"https://git.fabledsword.com/bvandeusen/inkwell/releases/download/stable/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwRTk2RkVBMkY2RDlCNkEKUldScW0yMHY2bS9wa0VBdWFpM3c1d2trQnlNVUJXUUtwZXBzQjduM3FRVzdGa3dXNGxObkZFV28K",
"windows": {
@@ -43,8 +43,14 @@
"active": true,
"targets": ["deb", "appimage"],
"category": "Utility",
"linux": {
"deb": {
"conflicts": ["thought-sync"],
"replaces": ["thought-sync"]
}
},
"shortDescription": "Local-first Keep-style thought capture.",
"longDescription": "ThoughtSync captures a fleeting thought in a second on a Keep-style board and grows into a lightweight second brain. Local-first: works fully offline on an on-device store, and optionally syncs to your self-hosted ThoughtSync server.",
"longDescription": "Inkwell captures a fleeting thought in a second on a Keep-style board and grows into a lightweight second brain. Local-first: works fully offline on an on-device store, and optionally syncs to your self-hosted Inkwell server.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+1 -1
View File
@@ -223,7 +223,7 @@ export interface CaptureShortcut {
export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N";
/** Fired at the main window after a capture is saved. */
const CAPTURED_EVENT = "thoughtsync://captured";
const CAPTURED_EVENT = "inkwell://captured";
export const capture = {
shortcut: () => invoke<CaptureShortcut>("capture_shortcut_get"),
+5 -5
View File
@@ -108,7 +108,7 @@ fi
# serves.
bake_desktop() {
desk="$dest/.desktop-release.json"
if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then
if ! fetch "$BASE/inkwell-desktop.json" "$desk"; then
echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients."
rm -f "$desk"
return 0
@@ -132,10 +132,10 @@ bake_desktop() {
#
# `<platform id>|<published name>|<name on disk>`
for row in \
"linux-deb|ThoughtSync_${key}_amd64.deb|inkwell.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|inkwell.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|inkwell.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|inkwell-setup.exe"
"linux-deb|Inkwell_${key}_amd64.deb|inkwell.deb" \
"linux-pacman|inkwell-${key}-1-x86_64.pkg.tar.zst|inkwell.pkg.tar.zst" \
"linux-appimage|Inkwell_${key}_amd64.AppImage|inkwell.AppImage" \
"windows|Inkwell_${key}_x64-setup.exe|inkwell-setup.exe"
do
id="${row%%|*}"; rest="${row#*|}"
remote="${rest%%|*}"; local_name="${rest#*|}"
+1 -1
View File
@@ -46,7 +46,7 @@ else
header="First release."
fi
printf 'ThoughtSync %s\n\n%s\n\n' "$tag" "$header"
printf 'Inkwell %s\n\n%s\n\n' "$tag" "$header"
# `--no-merges`: a merge commit's subject is "Merge branch ..." and says nothing
# about what shipped. The commits it brought in are listed individually, which is