diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index cccd76c..49c0f49 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -133,6 +133,8 @@ pub fn run() {
// Before anything can ask what channel we're on: the installer left a note
// in this directory saying which one the user picked (issue 2183).
update::adopt_installer_channel(&db, &dir);
+ // And which server, when it installed from one (milestone 325 step 6).
+ update::adopt_installer_server(&db, &dir);
sweep_local_trash(&db);
// Before the store is handed to the app: `restore` needs to read the
// stored shortcut out of it, and after `manage` the Db has moved.
@@ -213,6 +215,7 @@ pub fn run() {
commands::sync::shares_unshare,
update::update_channel_get,
update::update_channel_set,
+ update::update_source_set,
update::update_check,
update::update_install,
capture::capture_shortcut_get,
diff --git a/desktop/src-tauri/src/update.rs b/desktop/src-tauri/src/update.rs
index 61f8239..bd7bd4c 100644
--- a/desktop/src-tauri/src/update.rs
+++ b/desktop/src-tauri/src/update.rs
@@ -12,10 +12,16 @@
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `/latest.json`, and that is still where the manifest lands.
//!
-//! 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.
+//! The feed is Fabled-Git by default, 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.
+//!
+//! An Inkwell server can be the feed instead (milestone 325 step 6) — the one a
+//! self-hoster installed from, whose forge they may have no access to. That reads
+//! alarming until you notice what does NOT move: the server hands out the same
+//! signed AppImage, and the updater checks it against the public key baked into
+//! this build before it replaces anything. A server is a mirror, not a signer. It
+//! can pass on official builds; it cannot substitute its own.
//!
//! Updates are signed. The public half is baked into `tauri.conf.json`; the private
//! half exists only as a CI secret, and is generated by the operator — a release
@@ -28,6 +34,7 @@ use tauri::State;
use tauri_plugin_updater::UpdaterExt;
use inkwell_core::local::{store, Db};
+use inkwell_core::sync::state;
/// Where the manifests live. Fixed tags, so these URLs are permanent.
const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/inkwell/releases/download";
@@ -44,6 +51,20 @@ const INSTALL_MARKER: &str = "install-channel";
/// from a repeat. See `adopt_installer_channel`.
const CHANNEL_SEED_PREF: &str = "update_channel_seed";
+/// The server updates come from. Absent or empty means Fabled-Git.
+const SERVER_PREF: &str = "update_server";
+
+/// The server the app was installed from, written by `install.sh` when it installed
+/// from one. Same contract as `INSTALL_MARKER`, and the same reason for being a file.
+const SERVER_MARKER: &str = "install-server";
+
+/// The server marker value we last acted on — `CHANNEL_SEED_PREF`'s twin.
+const SERVER_SEED_PREF: &str = "update_server_seed";
+
+/// The one bundle a server publishes in-app updates for: the AppImage is the only one
+/// it holds a signature for, and the only Linux bundle that can replace itself.
+const SERVER_FEED_PLATFORM: &str = "linux-appimage";
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
@@ -102,10 +123,100 @@ impl Channel {
}
}
+/// Where updates come from.
+///
+/// `read_source` is THE ONE READER. Three things hold an opinion about this — the
+/// installer's marker files, this app's prefs, and `plugins.updater.endpoints` in
+/// `tauri.conf.json` — and issues 2181–2183 were each one decision with several
+/// holders and only one of them set. So: the markers only ever feed the prefs
+/// (`adopt_installer_*`), the prefs are what is read, and the config's endpoint is
+/// never consulted, because every check below names its own.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum Source {
+ /// Fabled-Git's fixed-tag release for a channel. The default.
+ Forge(Channel),
+ /// An Inkwell server's own copy of the AppImage, by its base address.
+ Server(String),
+}
+
+impl Source {
+ fn feed_url(&self) -> Result {
+ match self {
+ Source::Forge(channel) => Ok(channel.feed_url()),
+ Source::Server(base) if cfg!(target_os = "linux") => {
+ Ok(format!("{base}/api/client/{SERVER_FEED_PLATFORM}/update.json"))
+ }
+ Source::Server(base) => Err(linux_only(base)),
+ }
+ }
+
+ fn server(&self) -> Option<&str> {
+ match self {
+ Source::Server(base) => Some(base),
+ Source::Forge(_) => None,
+ }
+ }
+
+ fn describe(&self) -> String {
+ match self {
+ Source::Forge(channel) => format!("the {} channel", channel.as_str()),
+ Source::Server(base) => base.clone(),
+ }
+ }
+}
+
+/// A server address in the one shape this app stores, or `None` if it isn't one.
+///
+/// The shape `install.sh` and the server's installer route accept, for the same
+/// reason: this value is spliced into every update URL, and an address carrying a
+/// query, a fragment or whitespace is a paste gone wrong rather than a place to
+/// fetch from.
+fn normalize_server(raw: &str) -> Option {
+ let trimmed = raw.trim().trim_end_matches('/');
+ let rest = trimmed
+ .strip_prefix("https://")
+ .or_else(|| trimmed.strip_prefix("http://"))?;
+ let allowed = |c: char| c.is_ascii_alphanumeric() || ":/._~-".contains(c);
+ if rest.is_empty() || !rest.chars().all(allowed) {
+ return None;
+ }
+ Some(trimmed.to_string())
+}
+
+/// Said when this app is on Windows and pointed at a server.
+fn linux_only(base: &str) -> String {
+ format!(
+ "{base} only publishes in-app updates for the Linux AppImage. Switch App \
+ updates back to Fabled-Git on this system."
+ )
+}
+
+const NOT_AN_ADDRESS: &str =
+ "That isn't a server address. It should look like https://notes.example.com.";
+
+/// A server that answered with no AppImage. Never "up to date": the app would sit
+/// on its build forever believing it current, which is #2183's shape again.
+fn no_server_build(base: &str) -> String {
+ format!(
+ "{base} has no desktop build to update from. Ask whoever runs it, or switch \
+ App updates back to Fabled-Git."
+ )
+}
+
+/// Why a server's update can be seen and not installed.
+fn needs_link(base: &str) -> String {
+ format!(
+ "Updates from {base} download with this app's link to it, the same as sync. \
+ Link this app to {base} in Sync to install them."
+ )
+}
+
/// What the UI needs to describe the update situation without a second call.
#[derive(Debug, Serialize)]
pub struct UpdateStatus {
pub channel: Channel,
+ /// The server updates come from, or `None` for Fabled-Git.
+ pub source: Option,
pub current_version: String,
/// The newer version on offer, or `None` when already up to date.
pub available: Option,
@@ -167,7 +278,7 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
);
return;
};
- match adopt(db, channel) {
+ match adopt(db, CHANNEL_PREF, CHANNEL_SEED_PREF, channel.as_str()) {
Ok(true) => log::info!(
"following the {} update channel, as recorded by the installer",
channel.as_str()
@@ -177,21 +288,97 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
}
}
-/// Apply `channel` unless we already applied this same marker value. Returns whether
-/// anything changed.
-fn adopt(db: &Db, channel: Channel) -> Result {
+/// Adopt the server the installer recorded, exactly as the channel above is adopted
+/// and for the same reasons: once per new marker value, so a choice made in the app
+/// afterwards sticks, and reinstalling from a different server is honoured.
+pub fn adopt_installer_server(db: &Db, data_dir: &Path) {
+ let path = data_dir.join(SERVER_MARKER);
+ let Ok(raw) = std::fs::read_to_string(&path) else {
+ return;
+ };
+ let Some(server) = normalize_server(&raw) else {
+ log::warn!(
+ "ignoring an unreadable install server marker at {}",
+ path.display()
+ );
+ return;
+ };
+ match adopt(db, SERVER_PREF, SERVER_SEED_PREF, &server) {
+ Ok(true) => log::info!("taking updates from {server}, as the installer recorded"),
+ Ok(false) => {}
+ Err(e) => log::warn!("could not apply the installer's update server: {e}"),
+ }
+}
+
+/// Write `value` to `pref` unless this same marker value was already applied.
+/// Returns whether anything changed.
+fn adopt(db: &Db, pref: &str, seed_pref: &str, value: &str) -> Result {
let conn = db.conn()?;
- let adopted = store::pref(&conn, CHANNEL_SEED_PREF).map_err(|e| e.to_string())?;
+ let adopted = store::pref(&conn, seed_pref).map_err(|e| e.to_string())?;
// Already acted on this marker — whatever the pref says now is the user's own
// choice, and re-applying would quietly undo it.
- if adopted.as_deref() == Some(channel.as_str()) {
+ if adopted.as_deref() == Some(value) {
return Ok(false);
}
- store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?;
- store::set_pref(&conn, CHANNEL_SEED_PREF, channel.as_str()).map_err(|e| e.to_string())?;
+ store::set_pref(&conn, pref, value).map_err(|e| e.to_string())?;
+ store::set_pref(&conn, seed_pref, value).map_err(|e| e.to_string())?;
Ok(true)
}
+/// Where updates come from right now. See `Source`: this is the only place it is
+/// decided.
+fn read_source(db: &Db) -> Result {
+ let conn = db.conn()?;
+ let server = store::pref(&conn, SERVER_PREF).map_err(|e| e.to_string())?;
+ if let Some(base) = server.as_deref().and_then(normalize_server) {
+ return Ok(Source::Server(base));
+ }
+ let channel = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?;
+ let channel = channel.as_deref().map(Channel::parse);
+ Ok(Source::Forge(channel.unwrap_or(Channel::Stable)))
+}
+
+/// The device token to download from `base` with: the sync link's, when this app
+/// is linked to that same server. The bytes are for the server's accounts only, and
+/// the link is the one credential this app already holds for it.
+fn token_for(db: &Db, base: &str) -> Result