diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 69f2772..bc158ef 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -73,8 +73,27 @@ jobs: working-directory: desktop/src-tauri # Frontend already built above; skip the beforeBuildCommand rebuild. + # + # createUpdaterArtifacts is applied only when a signing key exists (M10.9): + # tauri FAILS the build if it's asked to produce updater artifacts with no key, + # so making it conditional is what lets the pipeline stay green before the + # operator has added the secret. With the key present, each bundle gets a + # `.sig` beside it — the file the updater actually verifies against. - name: Tauri build (deb + AppImage) - run: cargo tauri build --config '{"build":{"beforeBuildCommand":""}}' + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + updater='{}' + if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + echo "Signing key present — producing updater artifacts." + updater='{"bundle":{"createUpdaterArtifacts":true}}' + else + echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts." + fi + cargo tauri build \ + --config '{"build":{"beforeBuildCommand":""}}' \ + --config "$updater" working-directory: desktop/src-tauri # Tauri's AppImage bundles the build host's graphics/display libs @@ -135,6 +154,28 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: bash desktop/packaging/publish-release.sh + # The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so + # the updater has a permanent URL to read — Forgejo has no + # /releases/latest/download/ route, so "newest" can't be named in a URL. + # + # Gated on the signing key INSIDE the script rather than with an `if:`, because + # the secrets context isn't reliably available to step conditions. Publishing + # bundles the app would then refuse to verify is worse than publishing nothing: + # it looks like a working feed. + - name: Publish to the dev channel + if: github.ref == 'refs/heads/dev' + env: + GITHUB_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + RELEASE_TAG: dev + RELEASE_PRERELEASE: "true" + run: | + if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish." + exit 0 + fi + bash desktop/packaging/publish-release.sh + # Windows installer, CROSS-COMPILED from Linux — there is no Windows build host. # A Windows container can't run on a Linux host (containers share the host # kernel), so cross-compiling is the only route without Windows hardware: @@ -200,3 +241,69 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: bash desktop/packaging/publish-release.sh + + # The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so + # the updater has a permanent URL to read — Forgejo has no + # /releases/latest/download/ route, so "newest" can't be named in a URL. + # + # Gated on the signing key INSIDE the script rather than with an `if:`, because + # the secrets context isn't reliably available to step conditions. Publishing + # bundles the app would then refuse to verify is worse than publishing nothing: + # it looks like a working feed. + - name: Publish to the dev channel + if: github.ref == 'refs/heads/dev' + env: + GITHUB_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + RELEASE_TAG: dev + RELEASE_PRERELEASE: "true" + run: | + if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish." + exit 0 + fi + bash desktop/packaging/publish-release.sh + + # The updater manifest, written AFTER both bundle jobs — they run in separate + # workspaces and neither can see the other's output, but one latest.json has to + # describe both platforms. Building it inside either job would silently omit the + # other, and a missing platform reads to a user as "no update available" rather + # than as a broken feed. + # + # Reads what actually landed on the channel release, so it can never advertise a + # bundle that failed to upload. + manifest: + name: Update manifest + needs: [build, windows] + if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 + steps: + - uses: actions/checkout@v6 + + - name: Write and publish latest.json + env: + GITHUB_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + echo "No TAURI_SIGNING_PRIVATE_KEY — nothing was signed, so there is no" + echo "manifest to write. Add the secret to enable in-app updates." + exit 0 + fi + # The version the bundles carry, read from the crate rather than guessed. + version="$(grep -m1 '^version' desktop/src-tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + if [ "${GITHUB_REF_NAME}" = "dev" ]; then + export RELEASE_TAG=dev + export RELEASE_NOTES="Development build from ${GITHUB_SHA}" + APP_VERSION="$version" bash desktop/packaging/write-manifest.sh + else + export RELEASE_TAG="${GITHUB_REF_NAME}" + export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}" + # Twice: once onto the versioned release itself, and once onto the + # permanent `stable` pointer the app actually reads. Same manifest both + # times — its URLs point at the versioned assets either way. + APP_VERSION="$version" bash desktop/packaging/write-manifest.sh + APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh + fi diff --git a/desktop/packaging/publish-release.sh b/desktop/packaging/publish-release.sh index 0e79bae..a8fb643 100755 --- a/desktop/packaging/publish-release.sh +++ b/desktop/packaging/publish-release.sh @@ -31,8 +31,15 @@ set -euo pipefail : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}" : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required (the tag, e.g. v0.1.0)}" +# The release to publish to. Defaults to the pushed tag (the versioned, stable +# case). M10.9 also calls this with RELEASE_TAG=dev to maintain the rolling +# development channel — a release whose tag never moves, because Forgejo has no +# `/releases/latest/download/` route for an updater to point at. +RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}" +RELEASE_PRERELEASE="${RELEASE_PRERELEASE:-false}" + API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" -TAG="$GITHUB_REF_NAME" +TAG="$RELEASE_TAG" AUTH=(-H "Authorization: token $GITHUB_TOKEN") SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -47,11 +54,17 @@ shopt -s nullglob # Linux job and the Windows job each run this script against the SAME release and # upload only what they actually built — they run in separate workspaces, so neither # can see the other's bundles. The release is created once and reused (409 path). +# The `.sig` files are the updater's whole trust story — a bundle published without +# its signature is one the app will refuse, so they ship together or not at all. +# They only exist when the build ran with a signing key (M10.9); nullglob drops +# them silently otherwise, which is the correct behaviour for an unsigned build. ASSETS=( "$BUNDLE_ROOT"/appimage/*.AppImage + "$BUNDLE_ROOT"/appimage/*.AppImage.sig "$BUNDLE_ROOT"/deb/*.deb "$BUNDLE_ROOT"/arch/*.pkg.tar.* "$WIN_BUNDLE_ROOT"/nsis/*.exe + "$WIN_BUNDLE_ROOT"/nsis/*.exe.sig ) if [ ${#ASSETS[@]} -eq 0 ]; then echo "ERROR: no bundles under $BUNDLE_ROOT — did the tauri build run?" >&2 @@ -83,7 +96,7 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE # --- create (or reuse) the release for the tag ------------------------------ echo "==> Creating release for $TAG" BODY=$(cat <-": { "signature": "...", "url": "..." } } } +set -euo pipefail + +: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" +: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}" +: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}" +: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}" + +# Where the manifest is PUBLISHED, which need not be where the bundles live. +# +# That split is what makes the stable channel work at all. A versioned release +# (`v0.2.0`) holds the real assets, but the app can only read a URL that never +# changes — so the same manifest is also attached to a `stable` release whose tag is +# permanent and whose only content is this file. It points back at the versioned +# assets, so nothing is duplicated. +MANIFEST_TAG="${MANIFEST_TAG:-$RELEASE_TAG}" + +API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" +AUTH=(-H "Authorization: token $GITHUB_TOKEN") +NOTES="${RELEASE_NOTES:-}" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT INT TERM + +echo "==> Reading assets on release $RELEASE_TAG" +release="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$RELEASE_TAG")" +release_id="$(printf '%s' "$release" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')" +[ -n "$release_id" ] || { echo "ERROR: no release tagged $RELEASE_TAG" >&2; exit 1; } +assets="$(curl -sS "${AUTH[@]}" "$API/releases/$release_id/assets")" + +# Asset names, one per line. The API returns them in a single JSON blob; this is +# the only field needed, and grep beats adding a jq dependency to the CI image. +names="$(printf '%s' "$assets" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' | sed -E 's/.*"([^"]+)"$/\1/')" + +download_url() { printf '%s/%s/releases/download/%s/%s' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$1"; } + +# One platform entry, or nothing if that platform's bundle or signature is absent. +# Emitting a partial entry would be worse than emitting none: the app would try to +# install something it can't verify. +platform_entry() { + local target="$1" pattern="$2" bundle sig_name + bundle="$(printf '%s\n' "$names" | grep -E "$pattern" | head -1 || true)" + [ -n "$bundle" ] || { echo " no bundle matching $pattern — skipping $target" >&2; return; } + sig_name="$bundle.sig" + if ! printf '%s\n' "$names" | grep -qxF "$sig_name"; then + echo " $bundle has no $sig_name — skipping $target (was the build signed?)" >&2 + return + fi + curl -fsSL "${AUTH[@]}" -o "$work/sig" "$(download_url "$sig_name")" + # The signature is base64 on one line already; strip any stray newline so it + # can't break the JSON string it's about to become. + local signature + signature="$(tr -d '\r\n' < "$work/sig")" + printf ' "%s": { "signature": "%s", "url": "%s" }' "$target" "$signature" "$(download_url "$bundle")" +} + +echo "==> Building the manifest" +entries=() +# `.AppImage` only on Linux: the updater replaces the running bundle in place, which +# a package-manager install (deb/pacman) must never have done to it. +if entry="$(platform_entry "linux-x86_64" '\.AppImage$')" && [ -n "$entry" ]; then entries+=("$entry"); fi +if entry="$(platform_entry "windows-x86_64" '\.exe$')" && [ -n "$entry" ]; then entries+=("$entry"); fi + +if [ ${#entries[@]} -eq 0 ]; then + echo "ERROR: no signed bundle on $RELEASE_TAG — refusing to publish an empty manifest." >&2 + echo " (An empty manifest would tell every client it is up to date.)" >&2 + exit 1 +fi + +# No `date -u -Is` — busybox date in the CI image doesn't take it. +pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +{ + printf '{\n' + printf ' "version": "%s",\n' "$APP_VERSION" + printf ' "pub_date": "%s",\n' "$pub_date" + printf ' "notes": "%s",\n' "$NOTES" + printf ' "platforms": {\n' + for i in "${!entries[@]}"; do + [ "$i" -eq 0 ] || printf ',\n' + printf '%s' "${entries[$i]}" + done + printf '\n }\n' + printf '}\n' +} > "$work/latest.json" + +echo "==> Manifest:" +cat "$work/latest.json" + +# --- resolve the release the manifest is published TO ------------------------ +if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then + target_id="$release_id" + target_assets="$assets" +else + echo "==> Resolving the $MANIFEST_TAG channel release" + target="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$MANIFEST_TAG")" + target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)" + if [ -z "${target_id:-}" ]; then + # First publish to this channel. A pointer release: no bundles of its own, just + # a permanent tag for the manifest to live under. + echo " creating it (pointer release, manifest only)" + body="{\"tag_name\":\"$MANIFEST_TAG\",\"name\":\"ThoughtSync ($MANIFEST_TAG channel)\",\"draft\":false,\"prerelease\":false,\"body\":\"Update channel pointer. The installable builds live on the versioned releases; this holds only the updater manifest.\"}" + target="$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$API/releases")" + target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')" + fi + [ -n "${target_id:-}" ] || { echo "ERROR: could not resolve the $MANIFEST_TAG release" >&2; exit 1; } + target_assets="$(curl -sS "${AUTH[@]}" "$API/releases/$target_id/assets")" +fi + +# Replace rather than duplicate: Forgejo rejects a second asset with the same name, +# and this file is rewritten on every publish by design. +old_id="$(printf '%s' "$target_assets" \ + | grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \ + | head -1 | grep -oE '[0-9]+' | head -1 || true)" +if [ -n "${old_id:-}" ]; then + echo "==> Removing the previous latest.json (id $old_id)" + curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null +fi + +echo "==> Uploading latest.json to $MANIFEST_TAG" +curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \ + -F "attachment=@$work/latest.json" >/dev/null + +echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)." diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 588d640..9f2c8e4 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -28,6 +28,9 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } # issues are diagnosable from any environment. `log` is the facade the code uses. tauri-plugin-log = "2" log = "0.4" +# In-app updates (M10.9). Signature verification is minisign; the public half lives +# in tauri.conf.json and the private half only ever as a CI secret. +tauri-plugin-updater = "2" # HTTP for the opt-in server handshake (M10.6) and, next, the sync engine (M10.7). # # native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index db5927c..02de8c5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod local; // the engine that will consume them is M10.7b/c, and a private module's unreachable // items read as dead code. pub mod sync; +mod update; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -33,6 +34,11 @@ pub fn run() { ]) .build(), ) + // In-app updates (M10.9). Registering the plugin is inert on its own — it + // reads its config only when `update_check`/`update_install` ask it to, so a + // build without a signing key still starts normally and simply reports that + // updates aren't configured. + .plugin(tauri_plugin_updater::Builder::new().build()) // Attachment bytes are served to the webview from the local blob store // (M10.7f). Registered on the BUILDER because a scheme has to exist before // the webview is created; the directory it reads from arrives later, in @@ -125,6 +131,10 @@ pub fn run() { sync::commands::sync_status, sync::commands::sync_now, sync::commands::sync_has_pending, + update::update_channel_get, + update::update_channel_set, + update::update_check, + update::update_install, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); diff --git a/desktop/src-tauri/src/local/schema.rs b/desktop/src-tauri/src/local/schema.rs index e4ff284..7e2e9f3 100644 --- a/desktop/src-tauri/src/local/schema.rs +++ b/desktop/src-tauri/src/local/schema.rs @@ -147,6 +147,18 @@ UPDATE notes SET trashed_at = updated_at WHERE trashed = 1; ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER; "#; +// v5 (M10.9): small key/value app preferences. +// +// The first entry is the update channel, which is neither note data nor part of the +// server link — so it belongs in neither `notes` nor `sync_state`. Generic on +// purpose: the next device-local preference shouldn't need another migration. +const SCHEMA_V5: &str = r#" +CREATE TABLE prefs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +"#; + /// Bring the database up to the latest schema. Idempotent. pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; @@ -167,5 +179,9 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V4)?; conn.execute_batch("PRAGMA user_version = 4;")?; } + if version < 5 { + conn.execute_batch(SCHEMA_V5)?; + conn.execute_batch("PRAGMA user_version = 5;")?; + } Ok(()) } diff --git a/desktop/src-tauri/src/local/store.rs b/desktop/src-tauri/src/local/store.rs index 5053426..854ce78 100644 --- a/desktop/src-tauri/src/local/store.rs +++ b/desktop/src-tauri/src/local/store.rs @@ -677,6 +677,25 @@ pub fn record_pending_delete(conn: &Connection, entity: &str, id: &str) -> rusql Ok(()) } +// ---- device-local preferences (schema v5) ----------------------------------- + +/// A stored preference, or `None` if it was never set. Callers supply their own +/// default rather than one being invented here — the meaning of "unset" belongs +/// with the setting, not with the storage. +pub fn pref(conn: &Connection, key: &str) -> rusqlite::Result> { + conn.query_row("SELECT value FROM prefs WHERE key = ?1", [key], |r| r.get(0)) + .optional() +} + +pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO prefs (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) +} + pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result> { let mut stmt = conn .prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?; diff --git a/desktop/src-tauri/src/update.rs b/desktop/src-tauri/src/update.rs new file mode 100644 index 0000000..22c2da9 --- /dev/null +++ b/desktop/src-tauri/src/update.rs @@ -0,0 +1,274 @@ +//! In-app updates (M10.9). +//! +//! Two channels, because two audiences: `stable` follows tagged `v*` releases, +//! `dev` follows every green push. Each reads a `latest.json` published as an asset +//! on a release whose TAG NEVER CHANGES — verified necessary, because Forgejo has no +//! `/releases/latest/download/` route (it 404s), so "newest" cannot be named +//! in a URL. A fixed tag can. +//! +//! The feed lives on Fabled-Git rather than on a ThoughtSync 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. +//! +//! 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 +//! signing key that has passed through anyone else's hands is not a signing key. + +use serde::{Deserialize, Serialize}; +use tauri::State; +use tauri_plugin_updater::UpdaterExt; + +use crate::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 CHANNEL_PREF: &str = "update_channel"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Channel { + Stable, + Dev, +} + +impl Channel { + fn as_str(self) -> &'static str { + match self { + Channel::Stable => "stable", + Channel::Dev => "dev", + } + } + + /// Anything unrecognized reads as `stable`. A corrupted or hand-edited value + /// must not silently opt someone into pre-release builds. + fn parse(raw: &str) -> Self { + match raw.trim() { + "dev" => Channel::Dev, + _ => Channel::Stable, + } + } + + fn feed_url(self) -> String { + format!("{FEED_BASE}/{}/latest.json", self.as_str()) + } +} + +/// What the UI needs to describe the update situation without a second call. +#[derive(Debug, Serialize)] +pub struct UpdateStatus { + pub channel: Channel, + pub current_version: String, + /// The newer version on offer, or `None` when already up to date. + pub available: Option, + pub notes: Option, + /// False when this install can't apply an update to itself (see + /// `self_update_blocker`). The UI must not offer a button that cannot work. + pub can_install: bool, + /// Why not, in words meant for the person reading them. + pub blocked_reason: Option, +} + +/// Whether this install can replace itself, and if not, why. +/// +/// The updater rewrites the running bundle in place, which only works for formats +/// that ARE a single self-contained file. On Linux that means the AppImage and +/// nothing else: a `.deb` or pacman install is owned by the package manager, and +/// silently overwriting files it tracks would corrupt its database. Tauri detects +/// the AppImage case by the `APPIMAGE` env var the runtime sets. +fn self_update_blocker() -> Option { + if cfg!(target_os = "linux") && std::env::var_os("APPIMAGE").is_none() { + return Some( + "This copy was installed by your package manager, so it updates the same \ + way — `apt upgrade`, `pacman -Syu`, or re-running the install script. \ + In-app updates work on the AppImage build." + .to_string(), + ); + } + None +} + +fn read_channel(db: &State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + let raw = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?; + Ok(raw.as_deref().map(Channel::parse).unwrap_or(Channel::Stable)) +} + +#[tauri::command] +pub fn update_channel_get(db: State<'_, Db>) -> Result { + read_channel(&db) +} + +#[tauri::command] +pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?; + log::info!("update channel set to {}", channel.as_str()); + Ok(channel) +} + +/// Ask the feed whether there's something newer. Never installs anything. +#[tauri::command] +pub async fn update_check( + app: tauri::AppHandle, + db: State<'_, Db>, +) -> Result { + let channel = read_channel(&db)?; + let current_version = app.package_info().version.to_string(); + let blocked_reason = self_update_blocker(); + + let url = channel + .feed_url() + .parse() + .map_err(|e| format!("the update feed address is malformed: {e}"))?; + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| e.to_string())? + .build() + .map_err(|e| format!("updates aren't configured for this build: {e}"))?; + + // A missing manifest is the ordinary state of a channel nobody has published to + // yet — report it as "nothing available" rather than as a failure to act on. + // Matched on the message rather than an error variant so this doesn't break on a + // plugin minor that renames one. + let found = match updater.check().await { + Ok(found) => found, + Err(e) => { + let detail = e.to_string(); + if is_missing_manifest(&detail) { + None + } else { + return Err(describe_check_error(&detail)); + } + } + }; + + Ok(UpdateStatus { + channel, + current_version, + available: found.as_ref().map(|u| u.version.clone()), + notes: found.as_ref().and_then(|u| u.body.clone()), + can_install: found.is_some() && blocked_reason.is_none(), + blocked_reason, + }) +} + +/// Download, verify and apply the update, then relaunch. +/// +/// Refuses up front on an install that can't replace itself, rather than failing +/// halfway through with a permissions error nobody can interpret. +#[tauri::command] +pub async fn update_install(app: tauri::AppHandle, db: State<'_, Db>) -> Result<(), String> { + if let Some(reason) = self_update_blocker() { + return Err(reason); + } + let channel = read_channel(&db)?; + let url = channel + .feed_url() + .parse() + .map_err(|e| format!("the update feed address is malformed: {e}"))?; + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| e.to_string())? + .build() + .map_err(|e| format!("updates aren't configured for this build: {e}"))?; + + let found = updater + .check() + .await + .map_err(|e| describe_check_error(&e.to_string()))?; + let Some(update) = found else { + return Err("There's no update to install — this is already the newest build.".to_string()); + }; + + log::info!( + "installing update {} over {} ({} channel)", + update.version, + app.package_info().version, + channel.as_str() + ); + update + .download_and_install(|_chunk, _total| {}, || {}) + .await + .map_err(|e| format!("the update couldn't be installed: {e}"))?; + + // Only reached if the install succeeded. `restart` diverges, so it's the tail. + log::info!("update installed; restarting"); + app.restart() +} + +/// Whether a check failure just means "this channel has nothing published yet". +fn is_missing_manifest(detail: &str) -> bool { + let lower = detail.to_lowercase(); + lower.contains("404") || lower.contains("not found") +} + +/// Turn a check failure into something worth reading. The default rendering of a +/// transport error names the URL and nothing else, which tells a user nothing about +/// what they could do next. +fn describe_check_error(detail: &str) -> String { + let lower = detail.to_lowercase(); + if lower.contains("error sending request") || lower.contains("dns") || lower.contains("connect") + { + return "Couldn't reach the update server — check your connection.".to_string(); + } + format!("Couldn't check for updates: {detail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_channel_has_its_own_fixed_feed() { + assert!(Channel::Stable.feed_url().ends_with("/stable/latest.json")); + assert!(Channel::Dev.feed_url().ends_with("/dev/latest.json")); + assert_ne!(Channel::Stable.feed_url(), Channel::Dev.feed_url()); + } + + #[test] + fn the_feed_is_https() { + // The manifest names the URL and signature of a binary that is about to + // replace this one. The signature is what actually protects it, but there's + // no reason to hand an attacker the manifest to tamper with in the first place. + assert!(Channel::Stable.feed_url().starts_with("https://")); + assert!(Channel::Dev.feed_url().starts_with("https://")); + } + + #[test] + fn an_unknown_channel_falls_back_to_stable() { + // A corrupted or hand-edited pref must never silently opt someone into + // pre-release builds — the safe default is the conservative one. + assert_eq!(Channel::parse("dev"), Channel::Dev); + assert_eq!(Channel::parse("stable"), Channel::Stable); + assert_eq!(Channel::parse("nightly"), Channel::Stable); + assert_eq!(Channel::parse(""), Channel::Stable); + } + + #[test] + fn an_unpublished_channel_is_not_reported_as_a_failure() { + // Before the first publish, or on a channel nobody uses, the feed simply + // isn't there. That's "you're up to date", not something to alarm anyone with. + assert!(is_missing_manifest("http status: 404 Not Found")); + assert!(is_missing_manifest("Release Not Found")); + assert!(!is_missing_manifest("invalid signature")); + } + + #[test] + fn an_unreachable_server_reads_as_a_connection_problem() { + let msg = describe_check_error("error sending request for url (https://…)"); + assert!(msg.contains("connection"), "got {msg}"); + // Anything unrecognized still surfaces its detail rather than being swallowed. + assert!(describe_check_error("invalid signature").contains("invalid signature")); + } + + #[test] + fn the_channel_name_round_trips() { + for channel in [Channel::Stable, Channel::Dev] { + assert_eq!(Channel::parse(channel.as_str()), channel); + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index ed5c26b..6b7b7a6 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -27,6 +27,17 @@ "csp": null } }, + "plugins": { + "updater": { + "endpoints": [ + "https://git.fabledsword.com/bvandeusen/thoughtsync/releases/download/stable/latest.json" + ], + "pubkey": "", + "windows": { + "installMode": "passive" + } + } + }, "bundle": { "active": true, "targets": ["deb", "appimage"], diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index f2f3964..9164fad 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -147,3 +147,31 @@ export const sync = { now: () => invoke("sync_now"), hasPending: () => invoke("sync_has_pending"), }; + +// --- In-app updates (M10.9) -------------------------------------------------- + +/** `stable` follows tagged releases; `dev` follows every green build. */ +export type UpdateChannel = "stable" | "dev"; + +export interface UpdateStatus { + channel: UpdateChannel; + current_version: string; + /** The newer version on offer, or null when already up to date. */ + available: string | null; + notes: string | null; + /** False when this install can't replace itself — see `blocked_reason`. */ + can_install: boolean; + blocked_reason: string | null; +} + +export const updates = { + channel: () => invoke("update_channel_get"), + setChannel: (channel: UpdateChannel) => invoke("update_channel_set", { channel }), + /** Ask the feed what's out there. Never installs anything. */ + check: () => invoke("update_check"), + /** + * Download, verify, apply, relaunch. Resolves only on failure — a success + * restarts the app out from under the caller. + */ + install: () => invoke("update_install"), +}; diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index c40d374..6ba885e 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -6,9 +6,12 @@ import BaseInput from "../components/BaseInput.vue"; import Icon from "../components/Icon.vue"; import { sync as syncBridge, + updates as updateBridge, type Compatibility, type ProbeResult, type SyncStatus, + type UpdateChannel, + type UpdateStatus, } from "../desktop/bridge"; // Opt-in server sync for the desktop app. Being UNLINKED is the normal resting @@ -58,7 +61,67 @@ function describe(c: Compatibility): string { return c.reason; } +// --- App updates (M10.9). Independent of sync: an unlinked, server-less install +// still updates itself, which is why the feed is the release host and not a +// ThoughtSync server. --- +const channel = ref("stable"); +const update = ref(null); +const checking = ref(false); +const installing = ref(false); +const updateError = ref(""); +const checkedOnce = ref(false); + +const updateAvailable = computed(() => !!update.value?.available); + +async function checkUpdates() { + checking.value = true; + updateError.value = ""; + try { + update.value = await updateBridge.check(); + channel.value = update.value.channel; + } catch (e) { + updateError.value = String((e as Error)?.message ?? e); + } finally { + checking.value = false; + checkedOnce.value = true; + } +} + +async function switchChannel(next: UpdateChannel) { + if (next === channel.value) return; + updateError.value = ""; + try { + channel.value = await updateBridge.setChannel(next); + // The previous answer described the OTHER channel, so it's meaningless now. + update.value = null; + checkedOnce.value = false; + await checkUpdates(); + } catch (e) { + updateError.value = String((e as Error)?.message ?? e); + } +} + +async function installUpdate() { + installing.value = true; + updateError.value = ""; + try { + // On success the app restarts and this never returns; reaching the next line + // means it failed. + await updateBridge.install(); + } catch (e) { + updateError.value = String((e as Error)?.message ?? e); + } finally { + installing.value = false; + } +} + async function refresh() { + try { + channel.value = await updateBridge.channel(); + } catch { + // An older build without the update commands — leave the default showing + // rather than blocking the whole Sync screen on it. + } try { status.value = await syncBridge.status(); pending.value = await syncBridge.hasPending(); @@ -349,5 +412,89 @@ onMounted(refresh); + + +
+

App updates

+

+ This is version {{ update?.current_version ?? "—" }}. +

+ +
+ + Channel + +
+ + +
+
+ +

+ {{ update.blocked_reason }} +

+ +
+

Version {{ update?.available }} is available.

+

+ {{ update.notes }} +

+
+

+ You're on the newest {{ channel === "dev" ? "development" : "stable" }} build. +

+ +

{{ updateError }}

+ +
+ + Check for updates + + + Install and restart + +
+