M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
11 changed files with 773 additions and 3 deletions
Showing only changes of commit d6734cf7a0 - Show all commits
+108 -1
View File
@@ -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/<asset> 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/<asset> 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
+15 -2
View File
@@ -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/<asset>` 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 <<JSON
{"tag_name":"$TAG","name":"ThoughtSync $TAG","draft":false,"prerelease":false,
{"tag_name":"$TAG","name":"ThoughtSync $TAG","draft":false,"prerelease":$RELEASE_PRERELEASE,
"body":"ThoughtSync desktop $TAG.\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 | sh\n\`\`\`"}
JSON
)
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
#
# Write the updater manifest (`latest.json`) for one channel and attach it to that
# channel's release.
#
# WHY A SEPARATE STEP: the Linux and Windows bundles are built by two jobs in two
# workspaces, and neither can see the other's output — but ONE manifest has to
# describe both platforms. So this runs after both, reads what actually landed on
# the release, and writes the manifest from that. Building it inside either job
# would produce a manifest that silently omits the other platform, and a missing
# platform reads to a user as "no update available" rather than as a broken feed.
#
# WHAT IT READS: the release's own asset list. The signature for each bundle is a
# `.sig` asset published beside it (see publish-release.sh); its CONTENT is what
# goes in the manifest, which is why each one is downloaded rather than linked.
#
# Tauri's expected shape:
# { "version": "0.1.0", "pub_date": "...", "notes": "...",
# "platforms": { "<target>-<arch>": { "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)."
+3
View File
@@ -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
+10
View File
@@ -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");
+16
View File
@@ -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(())
}
+19
View File
@@ -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<Option<String>> {
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<Vec<NoteRevision>> {
let mut stmt = conn
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
+274
View File
@@ -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/<asset>` 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<String>,
pub notes: Option<String>,
/// 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<String>,
}
/// 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<String> {
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<Channel, String> {
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<Channel, String> {
read_channel(&db)
}
#[tauri::command]
pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result<Channel, String> {
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<UpdateStatus, String> {
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);
}
}
}
+11
View File
@@ -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"],
+28
View File
@@ -147,3 +147,31 @@ export const sync = {
now: () => invoke<SyncOutcome>("sync_now"),
hasPending: () => invoke<boolean>("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<UpdateChannel>("update_channel_get"),
setChannel: (channel: UpdateChannel) => invoke<UpdateChannel>("update_channel_set", { channel }),
/** Ask the feed what's out there. Never installs anything. */
check: () => invoke<UpdateStatus>("update_check"),
/**
* Download, verify, apply, relaunch. Resolves only on failure — a success
* restarts the app out from under the caller.
*/
install: () => invoke<void>("update_install"),
};
+147
View File
@@ -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<UpdateChannel>("stable");
const update = ref<UpdateStatus | null>(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);
</template>
</form>
</template>
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
has never touched a server still updates itself. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
<h2 class="text-sm font-semibold">App updates</h2>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
This is version {{ update?.current_version ?? "—" }}.
</p>
<fieldset class="mt-4">
<legend class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Channel
</legend>
<div class="mt-2 flex flex-col gap-2">
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-channel"
value="stable"
:checked="channel === 'stable'"
@change="switchChannel('stable')"
/>
<span>
<span class="font-medium">Stable</span>
<span class="block text-neutral-500 dark:text-neutral-400">
Released versions only.
</span>
</span>
</label>
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-channel"
value="dev"
:checked="channel === 'dev'"
@change="switchChannel('dev')"
/>
<span>
<span class="font-medium">Development</span>
<span class="block text-neutral-500 dark:text-neutral-400">
Every build that passes CI. Newer, and less tested.
</span>
</span>
</label>
</div>
</fieldset>
<p
v-if="update?.blocked_reason"
class="mt-4 rounded-lg bg-black/5 px-3 py-2 text-sm text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
>
{{ update.blocked_reason }}
</p>
<div v-if="updateAvailable" class="mt-4 rounded-lg bg-brand/10 px-3 py-2.5">
<p class="text-sm font-medium">Version {{ update?.available }} is available.</p>
<p v-if="update?.notes" class="mt-1 whitespace-pre-line text-sm text-neutral-600 dark:text-neutral-300">
{{ update.notes }}
</p>
</div>
<p
v-else-if="checkedOnce && !updateError"
class="mt-4 text-sm text-neutral-500 dark:text-neutral-400"
>
You're on the newest {{ channel === "dev" ? "development" : "stable" }} build.
</p>
<p v-if="updateError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ updateError }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<BaseButton variant="ghost" :loading="checking" @click="checkUpdates">
Check for updates
</BaseButton>
<BaseButton
v-if="updateAvailable && update?.can_install"
:loading="installing"
@click="installUpdate"
>
Install and restart
</BaseButton>
</div>
</section>
</div>
</template>