AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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"),
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user