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

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
6 changed files with 511 additions and 50 deletions
Showing only changes of commit b03c9cf81a - Show all commits
+3
View File
@@ -133,6 +133,8 @@ pub fn run() {
// Before anything can ask what channel we're on: the installer left a note
// in this directory saying which one the user picked (issue 2183).
update::adopt_installer_channel(&db, &dir);
// And which server, when it installed from one (milestone 325 step 6).
update::adopt_installer_server(&db, &dir);
sweep_local_trash(&db);
// Before the store is handed to the app: `restore` needs to read the
// stored shortcut out of it, and after `manage` the Db has moved.
@@ -213,6 +215,7 @@ pub fn run() {
commands::sync::shares_unshare,
update::update_channel_get,
update::update_channel_set,
update::update_source_set,
update::update_check,
update::update_install,
capture::capture_shortcut_get,
+362 -47
View File
@@ -12,10 +12,16 @@
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//!
//! The feed lives on Fabled-Git rather than on an Inkwell server, deliberately:
//! this app is usable having never linked a server, and an install that can't reach
//! its own updates because it isn't paired with anything would contradict the whole
//! local-first premise.
//! The feed is Fabled-Git by default, deliberately: this app is usable having never
//! linked a server, and an install that can't reach its own updates because it isn't
//! paired with anything would contradict the whole local-first premise.
//!
//! An Inkwell server can be the feed instead (milestone 325 step 6) — the one a
//! self-hoster installed from, whose forge they may have no access to. That reads
//! alarming until you notice what does NOT move: the server hands out the same
//! signed AppImage, and the updater checks it against the public key baked into
//! this build before it replaces anything. A server is a mirror, not a signer. It
//! can pass on official builds; it cannot substitute its own.
//!
//! Updates are signed. The public half is baked into `tauri.conf.json`; the private
//! half exists only as a CI secret, and is generated by the operator — a release
@@ -28,6 +34,7 @@ use tauri::State;
use tauri_plugin_updater::UpdaterExt;
use inkwell_core::local::{store, Db};
use inkwell_core::sync::state;
/// Where the manifests live. Fixed tags, so these URLs are permanent.
const FEED_BASE: &str = "https://git.fabledsword.com/bvandeusen/inkwell/releases/download";
@@ -44,6 +51,20 @@ const INSTALL_MARKER: &str = "install-channel";
/// from a repeat. See `adopt_installer_channel`.
const CHANNEL_SEED_PREF: &str = "update_channel_seed";
/// The server updates come from. Absent or empty means Fabled-Git.
const SERVER_PREF: &str = "update_server";
/// The server the app was installed from, written by `install.sh` when it installed
/// from one. Same contract as `INSTALL_MARKER`, and the same reason for being a file.
const SERVER_MARKER: &str = "install-server";
/// The server marker value we last acted on — `CHANNEL_SEED_PREF`'s twin.
const SERVER_SEED_PREF: &str = "update_server_seed";
/// The one bundle a server publishes in-app updates for: the AppImage is the only one
/// it holds a signature for, and the only Linux bundle that can replace itself.
const SERVER_FEED_PLATFORM: &str = "linux-appimage";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
@@ -102,10 +123,100 @@ impl Channel {
}
}
/// Where updates come from.
///
/// `read_source` is THE ONE READER. Three things hold an opinion about this — the
/// installer's marker files, this app's prefs, and `plugins.updater.endpoints` in
/// `tauri.conf.json` — and issues 2181–2183 were each one decision with several
/// holders and only one of them set. So: the markers only ever feed the prefs
/// (`adopt_installer_*`), the prefs are what is read, and the config's endpoint is
/// never consulted, because every check below names its own.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Source {
/// Fabled-Git's fixed-tag release for a channel. The default.
Forge(Channel),
/// An Inkwell server's own copy of the AppImage, by its base address.
Server(String),
}
impl Source {
fn feed_url(&self) -> Result<String, String> {
match self {
Source::Forge(channel) => Ok(channel.feed_url()),
Source::Server(base) if cfg!(target_os = "linux") => {
Ok(format!("{base}/api/client/{SERVER_FEED_PLATFORM}/update.json"))
}
Source::Server(base) => Err(linux_only(base)),
}
}
fn server(&self) -> Option<&str> {
match self {
Source::Server(base) => Some(base),
Source::Forge(_) => None,
}
}
fn describe(&self) -> String {
match self {
Source::Forge(channel) => format!("the {} channel", channel.as_str()),
Source::Server(base) => base.clone(),
}
}
}
/// A server address in the one shape this app stores, or `None` if it isn't one.
///
/// The shape `install.sh` and the server's installer route accept, for the same
/// reason: this value is spliced into every update URL, and an address carrying a
/// query, a fragment or whitespace is a paste gone wrong rather than a place to
/// fetch from.
fn normalize_server(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_end_matches('/');
let rest = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))?;
let allowed = |c: char| c.is_ascii_alphanumeric() || ":/._~-".contains(c);
if rest.is_empty() || !rest.chars().all(allowed) {
return None;
}
Some(trimmed.to_string())
}
/// Said when this app is on Windows and pointed at a server.
fn linux_only(base: &str) -> String {
format!(
"{base} only publishes in-app updates for the Linux AppImage. Switch App \
updates back to Fabled-Git on this system."
)
}
const NOT_AN_ADDRESS: &str =
"That isn't a server address. It should look like https://notes.example.com.";
/// A server that answered with no AppImage. Never "up to date": the app would sit
/// on its build forever believing it current, which is #2183's shape again.
fn no_server_build(base: &str) -> String {
format!(
"{base} has no desktop build to update from. Ask whoever runs it, or switch \
App updates back to Fabled-Git."
)
}
/// Why a server's update can be seen and not installed.
fn needs_link(base: &str) -> String {
format!(
"Updates from {base} download with this app's link to it, the same as sync. \
Link this app to {base} in Sync to install them."
)
}
/// What the UI needs to describe the update situation without a second call.
#[derive(Debug, Serialize)]
pub struct UpdateStatus {
pub channel: Channel,
/// The server updates come from, or `None` for Fabled-Git.
pub source: Option<String>,
pub current_version: String,
/// The newer version on offer, or `None` when already up to date.
pub available: Option<String>,
@@ -167,7 +278,7 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
);
return;
};
match adopt(db, channel) {
match adopt(db, CHANNEL_PREF, CHANNEL_SEED_PREF, channel.as_str()) {
Ok(true) => log::info!(
"following the {} update channel, as recorded by the installer",
channel.as_str()
@@ -177,21 +288,97 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
}
}
/// Apply `channel` unless we already applied this same marker value. Returns whether
/// anything changed.
fn adopt(db: &Db, channel: Channel) -> Result<bool, String> {
/// Adopt the server the installer recorded, exactly as the channel above is adopted
/// and for the same reasons: once per new marker value, so a choice made in the app
/// afterwards sticks, and reinstalling from a different server is honoured.
pub fn adopt_installer_server(db: &Db, data_dir: &Path) {
let path = data_dir.join(SERVER_MARKER);
let Ok(raw) = std::fs::read_to_string(&path) else {
return;
};
let Some(server) = normalize_server(&raw) else {
log::warn!(
"ignoring an unreadable install server marker at {}",
path.display()
);
return;
};
match adopt(db, SERVER_PREF, SERVER_SEED_PREF, &server) {
Ok(true) => log::info!("taking updates from {server}, as the installer recorded"),
Ok(false) => {}
Err(e) => log::warn!("could not apply the installer's update server: {e}"),
}
}
/// Write `value` to `pref` unless this same marker value was already applied.
/// Returns whether anything changed.
fn adopt(db: &Db, pref: &str, seed_pref: &str, value: &str) -> Result<bool, String> {
let conn = db.conn()?;
let adopted = store::pref(&conn, CHANNEL_SEED_PREF).map_err(|e| e.to_string())?;
let adopted = store::pref(&conn, seed_pref).map_err(|e| e.to_string())?;
// Already acted on this marker — whatever the pref says now is the user's own
// choice, and re-applying would quietly undo it.
if adopted.as_deref() == Some(channel.as_str()) {
if adopted.as_deref() == Some(value) {
return Ok(false);
}
store::set_pref(&conn, CHANNEL_PREF, channel.as_str()).map_err(|e| e.to_string())?;
store::set_pref(&conn, CHANNEL_SEED_PREF, channel.as_str()).map_err(|e| e.to_string())?;
store::set_pref(&conn, pref, value).map_err(|e| e.to_string())?;
store::set_pref(&conn, seed_pref, value).map_err(|e| e.to_string())?;
Ok(true)
}
/// Where updates come from right now. See `Source`: this is the only place it is
/// decided.
fn read_source(db: &Db) -> Result<Source, String> {
let conn = db.conn()?;
let server = store::pref(&conn, SERVER_PREF).map_err(|e| e.to_string())?;
if let Some(base) = server.as_deref().and_then(normalize_server) {
return Ok(Source::Server(base));
}
let channel = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?;
let channel = channel.as_deref().map(Channel::parse);
Ok(Source::Forge(channel.unwrap_or(Channel::Stable)))
}
/// The device token to download from `base` with: the sync link's, when this app
/// is linked to that same server. The bytes are for the server's accounts only, and
/// the link is the one credential this app already holds for it.
fn token_for(db: &Db, base: &str) -> Result<Option<String>, String> {
let conn = db.conn()?;
let link = state::read(&conn).map_err(|e| e.to_string())?;
let linked = link.server_url.as_deref().and_then(normalize_server);
if linked.as_deref() == Some(base) {
Ok(link.device_token)
} else {
Ok(None)
}
}
/// An updater pointed at `source`, carrying `token` when there is one. The plugin
/// sends the same headers on the download as on the check, so the token reaches the
/// server's download route — and the server builds that URL on the host this check
/// asked, so it goes nowhere else.
fn updater_for(
app: &tauri::AppHandle,
source: &Source,
token: Option<&str>,
) -> Result<tauri_plugin_updater::Updater, String> {
let url = source
.feed_url()?
.parse()
.map_err(|e| format!("the update feed address is malformed: {e}"))?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| e.to_string())?;
if let Some(token) = token {
builder = builder
.header("Authorization", format!("Bearer {token}"))
.map_err(|e| e.to_string())?;
}
builder
.build()
.map_err(|e| format!("updates aren't configured for this build: {e}"))
}
fn read_channel(db: &State<'_, Db>) -> Result<Channel, String> {
let conn = db.conn()?;
let raw = store::pref(&conn, CHANNEL_PREF).map_err(|e| e.to_string())?;
@@ -214,6 +401,28 @@ pub fn update_channel_set(channel: Channel, db: State<'_, Db>) -> Result<Channel
Ok(channel)
}
/// Take updates from `server`, or from Fabled-Git when it is `None`. Returns the
/// address as stored, so the UI shows what will actually be asked.
#[tauri::command]
pub fn update_source_set(
server: Option<String>,
db: State<'_, Db>,
) -> Result<Option<String>, String> {
let value = match server.as_deref().map(normalize_server) {
None => String::new(),
Some(Some(base)) => base,
Some(None) => return Err(NOT_AN_ADDRESS.to_string()),
};
let conn = db.conn()?;
store::set_pref(&conn, SERVER_PREF, &value).map_err(|e| e.to_string())?;
if value.is_empty() {
log::info!("taking updates from Fabled-Git");
} else {
log::info!("taking updates from {value}");
}
Ok((!value.is_empty()).then_some(value))
}
/// Ask the feed whether there's something newer. Never installs anything.
#[tauri::command]
pub async fn update_check(
@@ -221,38 +430,39 @@ pub async fn update_check(
db: State<'_, Db>,
) -> Result<UpdateStatus, String> {
let channel = read_channel(&db)?;
let source = read_source(&db)?;
let current_version = app.package_info().version.to_string();
let blocked_reason = self_update_blocker();
let token = match source.server() {
Some(base) => token_for(&db, base)?,
None => None,
};
let updater = updater_for(&app, &source, token.as_deref())?;
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));
match source.server() {
Some(base) if is_missing_manifest(&detail) => return Err(no_server_build(base)),
Some(_) => return Err(describe_check_error(&detail)),
// A missing manifest on the forge is the ordinary state of a channel
// nobody has published to yet — "nothing available", not a failure.
None if is_missing_manifest(&detail) => None,
None => return Err(describe_check_error(&detail)),
}
}
};
let blocked_reason = self_update_blocker().or_else(|| match source.server() {
Some(base) if token.is_none() => Some(needs_link(base)),
_ => None,
});
Ok(UpdateStatus {
channel,
source: source.server().map(str::to_string),
current_version,
available: found.as_ref().map(|u| u.version.clone()),
notes: found.as_ref().and_then(|u| u.body.clone()),
@@ -270,31 +480,32 @@ pub async fn update_install(app: tauri::AppHandle, db: State<'_, Db>) -> Result<
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 source = read_source(&db)?;
let token = match source.server() {
Some(base) => Some(token_for(&db, base)?.ok_or_else(|| needs_link(base))?),
None => None,
};
let updater = updater_for(&app, &source, token.as_deref())?;
let found = updater
.check()
.await
.map_err(|e| describe_check_error(&e.to_string()))?;
let found = match updater.check().await {
Ok(found) => found,
Err(e) => {
let detail = e.to_string();
return Err(match source.server() {
Some(base) if is_missing_manifest(&detail) => no_server_build(base),
_ => describe_check_error(&detail),
});
}
};
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)",
"installing update {} over {} (from {})",
update.version,
app.package_info().version,
channel.as_str()
source.describe()
);
update
.download_and_install(|_chunk, _total| {}, || {})
@@ -486,6 +697,110 @@ mod tests {
assert!(describe_check_error("invalid signature").contains("invalid signature"));
}
fn data_dir_with_server_marker(contents: &str) -> std::path::PathBuf {
let dir = scratch_path("server-marker");
std::fs::create_dir_all(&dir).expect("scratch dir");
std::fs::write(dir.join(SERVER_MARKER), contents).expect("write marker");
dir
}
fn set_server(db: &Db, value: &str) {
let conn = db.0.lock().expect("lock");
store::set_pref(&conn, SERVER_PREF, value).expect("set pref");
}
#[test]
fn with_no_server_recorded_updates_come_from_the_forge() {
// The operator's own installs: nothing here may change for them.
let db = db();
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
set_channel(&db, Channel::Dev);
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Dev)));
}
#[test]
fn an_install_from_a_server_takes_its_updates_from_that_server() {
let db = db();
let dir = data_dir_with_server_marker("https://notes.example.com/\n");
adopt_installer_server(&db, &dir);
assert_eq!(
read_source(&db),
Ok(Source::Server("https://notes.example.com".to_string()))
);
}
#[test]
fn choosing_the_forge_in_the_app_survives_the_next_launch() {
let db = db();
let dir = data_dir_with_server_marker("https://notes.example.com");
adopt_installer_server(&db, &dir);
set_server(&db, "");
adopt_installer_server(&db, &dir);
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
}
#[test]
fn a_damaged_server_marker_changes_nothing() {
let db = db();
adopt_installer_server(&db, &data_dir_with_server_marker("notes.example.com"));
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
}
#[test]
fn only_an_address_is_a_server() {
assert_eq!(
normalize_server(" https://notes.example.com:8443/inkwell/ "),
Some("https://notes.example.com:8443/inkwell".to_string())
);
assert_eq!(
normalize_server("http://192.168.1.20:5000"),
Some("http://192.168.1.20:5000".to_string())
);
for bad in [
"notes.example.com",
"ftp://notes.example.com",
"https://",
"https://notes.example.com/?q=1",
"https://notes.example.com/#x",
"https://notes example.com",
] {
assert_eq!(normalize_server(bad), None, "{bad:?} was accepted");
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_server_feed_is_its_appimage_manifest() {
let source = Source::Server("https://notes.example.com".to_string());
assert_eq!(
source.feed_url(),
Ok("https://notes.example.com/api/client/linux-appimage/update.json".to_string())
);
}
#[test]
fn the_download_token_is_the_link_to_that_same_server() {
let db = db();
assert_eq!(token_for(&db, "https://notes.example.com"), Ok(None));
{
let conn = db.0.lock().expect("lock");
state::set_link(&conn, "https://notes.example.com/", "tok").expect("link");
}
assert_eq!(
token_for(&db, "https://notes.example.com"),
Ok(Some("tok".to_string()))
);
// Never sent to a server it wasn't issued by.
assert_eq!(token_for(&db, "https://other.example.com"), Ok(None));
}
#[test]
fn a_server_with_nothing_to_offer_says_so() {
// Not "up to date" — that answer is the one that would never be corrected.
let msg = no_server_build("https://notes.example.com");
assert!(msg.contains("no desktop build"), "got {msg}");
}
#[test]
fn the_channel_name_round_trips() {
for channel in [Channel::Stable, Channel::Dev] {
+4
View File
@@ -229,6 +229,8 @@ export type UpdateChannel = "stable" | "dev";
export interface UpdateStatus {
channel: UpdateChannel;
/** The server updates come from, or null for Fabled-Git (and its channel). */
source: string | null;
current_version: string;
/** The newer version on offer, or null when already up to date. */
available: string | null;
@@ -241,6 +243,8 @@ export interface UpdateStatus {
export const updates = {
channel: () => invoke<UpdateChannel>("update_channel_get"),
setChannel: (channel: UpdateChannel) => invoke<UpdateChannel>("update_channel_set", { channel }),
/** Take updates from `server`, or from Fabled-Git when null. Returns what was stored. */
setSource: (server: string | null) => invoke<string | null>("update_source_set", { server }),
/** Ask the feed what's out there. Never installs anything. */
check: () => invoke<UpdateStatus>("update_check"),
/**
+70 -2
View File
@@ -96,6 +96,16 @@ const checkedOnce = ref(false);
const updateAvailable = computed(() => !!update.value?.available);
// Where updates come from: null is Fabled-Git. Learned from the check, because the
// check is what reads it (update.rs, `read_source`), so the screen shows the source
// that was actually asked.
const source = ref<string | null>(null);
// The server worth offering as a source: the one already chosen, else the one this
// app syncs with. With neither there is nothing to choose, and no choice is shown.
const offeredServer = computed(
() => source.value ?? status.value?.server_url?.replace(/\/+$/, "") ?? null,
);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
@@ -127,6 +137,7 @@ async function checkUpdates() {
try {
update.value = await updateBridge.check();
channel.value = update.value.channel;
source.value = update.value.source;
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
} finally {
@@ -149,6 +160,20 @@ async function switchChannel(next: UpdateChannel) {
}
}
async function switchSource(next: string | null) {
if (next === source.value) return;
updateError.value = "";
try {
source.value = await updateBridge.setSource(next);
// Same reason as a channel switch: the last answer came from somewhere else.
update.value = null;
checkedOnce.value = false;
await checkUpdates();
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
}
}
async function installUpdate() {
installing.value = true;
updateError.value = "";
@@ -584,7 +609,47 @@ onBeforeUnmount(() => stopSyncListener?.());
This is version {{ update?.current_version ?? "—" }}.
</p>
<fieldset class="mt-4">
<fieldset v-if="offeredServer" class="mt-4">
<legend class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Updates from
</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-source"
:checked="source === null"
@change="switchSource(null)"
/>
<span>
<span class="font-medium">Fabled-Git</span>
<span class="block text-neutral-500 dark:text-neutral-400">
Where Inkwell is published, on the channel below.
</span>
</span>
</label>
<label class="flex items-start gap-2 text-sm">
<input
type="radio"
class="mt-1"
name="update-source"
:checked="source !== null"
@change="switchSource(offeredServer)"
/>
<span>
<span class="font-medium">{{ offeredServer }}</span>
<span class="block text-neutral-500 dark:text-neutral-400">
The build that server holds. It passes on official builds only: every
update is checked against the signature built into this app first.
</span>
</span>
</label>
</div>
</fieldset>
<!-- A server holds one build, so a channel only means something on the forge. -->
<fieldset v-if="source === null" class="mt-4">
<legend class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Channel
</legend>
@@ -641,7 +706,10 @@ onBeforeUnmount(() => stopSyncListener?.());
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.
<template v-if="source">You're on the newest build {{ source }} has.</template>
<template v-else>
You're on the newest {{ channel === "dev" ? "development" : "stable" }} build.
</template>
</p>
<p v-if="updateError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ updateError }}</p>
+43 -1
View File
@@ -76,10 +76,11 @@ import json
from dataclasses import dataclass
from pathlib import Path
from quart import Blueprint, jsonify, send_from_directory
from quart import Blueprint, jsonify, request, send_from_directory
from .auth import login_required
from .config import Config
from .proxy import is_https
from .responses import json_error
@@ -328,6 +329,47 @@ async def client_metadata(platform_id: str):
return jsonify(found)
@bp.get("/api/client/<platform_id>/update.json")
async def client_update_manifest(platform_id: str):
"""The same build, in the shape the desktop's in-app updater reads.
Tauri's updater takes a manifest, not this module's metadata: `version` is the
ordering key it compares (the desktop's `1.0.<minutes>`, i.e. `version_code`),
and `url` + `signature` at the top level are its single-build form. So this is a
translation of `release()`, never a second description of the build.
Only for a SIGNED platform. The updater verifies the signature against the key
baked into the app before it replaces anything, so a server mirrors builds here
and cannot sign them: the trust anchor does not move with the feed. A platform
with no signature has no manifest, and a server holding no AppImage 404s, which
the desktop reports as "this server has no update to offer", never as "up to
date".
`url` must be absolute for the updater, which is the one place this module
hands out more than a path. It is built from the address THIS request arrived
on, not from Settings, because the updater sends the same Authorization header
to it that it sent here: the token goes back to the host that was asked, and
nowhere else.
Public, like the metadata above. The bytes `url` names are not.
"""
platform = BY_ID.get(platform_id)
if platform is None or not platform.signed:
return json_error(f"this server publishes no in-app updates for {platform_id}", 404)
found = release(platform_id)
if found is None:
return json_error(f"this server has no {platform_id} client", 404)
origin = f"{'https' if is_https() else 'http'}://{request.host}"
return jsonify(
{
"version": found["version_code"],
"notes": f"Inkwell {found['version']}",
"url": origin + found["url"],
"signature": found["signature"],
}
)
@bp.get("/api/client/<platform_id>/download")
@login_required
async def client_download(platform_id: str):
+29
View File
@@ -393,3 +393,32 @@ async def test_the_bytes_need_authentication_even_though_the_version_does_not(ap
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}/download")
assert resp.status_code == 401
async def test_the_appimage_has_an_updater_manifest_built_from_the_same_build(app):
"""Tauri's single-build form: the ordering key it compares, the signature it
checks, and an absolute URL on the host that was asked."""
place("linux-appimage", signature="sig-bytes")
resp = await app.test_client().get(
"/api/client/linux-appimage/update.json", headers={"Host": "notes.example.com"}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["version"] == code_for("linux-appimage")
assert body["signature"] == "sig-bytes"
assert body["url"] == "http://notes.example.com/api/client/linux-appimage/download"
@pytest.mark.parametrize("platform_id", [p.id for p in PLATFORMS if not p.signed])
async def test_an_unsigned_platform_has_no_updater_manifest(app, platform_id):
"""No signature, nothing the updater could verify, so nothing to offer it."""
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}/update.json")
assert resp.status_code == 404
async def test_a_server_without_the_appimage_404s_its_updater_manifest(app):
"""The desktop turns this into "this server has no update to offer" rather than
"up to date", so the 404 has to be there to turn."""
resp = await app.test_client().get("/api/client/linux-appimage/update.json")
assert resp.status_code == 404