M12 — the Android client, end to end #2
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)."
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user