AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
37 changed files with 453 additions and 247 deletions
Showing only changes of commit fe6f0746b2 - Show all commits
+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