diff --git a/Cargo.lock b/Cargo.lock
index 48da068253..9b763e2d76 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5609,6 +5609,7 @@ dependencies = [
name = "perry-api-manifest"
version = "0.5.1457"
dependencies = [
+ "perry-dispatch",
"serde",
]
diff --git a/changelog.d/7789-app-update-config.md b/changelog.d/7789-app-update-config.md
new file mode 100644
index 0000000000..5e8836962a
--- /dev/null
+++ b/changelog.d/7789-app-update-config.md
@@ -0,0 +1,162 @@
+### Added
+
+**An application Perry compiles can now carry its own update check.** Perry's
+CLI has checked for updates for a long time; an app it *builds* could not, so
+shipping one meant the author wrote a version check by hand or shipped none and
+hoped users noticed.
+
+```json
+{ "perry": { "update": {
+ "source": "npm", "package": "myapp", "command": "self-update"
+} } }
+```
+
+A `perry.update` block — or an `[update]` table in perry.toml, which wins key by
+key — is validated at compile time and baked into the executable. It splits into
+two halves, and which is which matters:
+
+- **Perry does the noticing.** At startup the app reads its own state file and
+ prints a two-line notice on stderr when the last lookup found something newer.
+ The author writes no code for this.
+- **The app does the asking.** The version lookup uses the app's own `fetch()`,
+ from a few lines the author adds. Perry supplies everything else: whether a
+ lookup is due, which URL and headers the configured source needs, and how to
+ read the answer.
+
+That division is the one `docs/src/updater/overview.md` already states for the
+desktop updater — *"Download lives in TS (using existing `fetch()`) — Rust only
+handles the security-critical and platform-touching pieces"* — and it applies
+with more force here, because `perry-runtime` links into every compiled binary.
+An HTTP stack added for this would be paid for by every program that never
+checks for an update.
+
+**With no block configured, nothing is emitted.** Not an empty blob, and not a
+disabled one: a binary that configures no updates is byte-identical to one built
+before this existed. A feature whose off-state still emits code is one you cannot
+prove is off.
+
+
+Validation is a build failure, on purpose
+
+A warning scrolls past in build output; the consequence lands on the app's users,
+who get no notices and no error — the feature does nothing and nobody can tell
+why. So these are errors:
+
+- **a URL must be `https://`**, with loopback allowed for local testing. Plain
+ HTTP is refused because an on-path attacker can answer "you are current" and
+ suppress an update. The loopback exemption stops at a host boundary, so
+ `http://localhost.example.test` is refused like any other remote host;
+- **each source needs the keys it reads** — `url` for `gh-releases` and
+ `custom`, `package` for the npm-shaped ones;
+- **a zero check interval is rejected**, since it would ask on every run;
+- **an app with no version** has nothing to compare against.
+
+`enabled = false` keeps the settings on disk and emits nothing, rather than
+embedding a disabled block complete with its URL and startup call.
+
+The version is taken from perry.toml's `[project] version` when present, so it
+agrees with what the rest of the binary reports — otherwise a dual-manifest
+project could compare against a number the app never claims to be.
+
+
+
+The four sources, and what each refuses to do
+
+`gh-releases` and `custom` read the configured URL. The npm-shaped pair request
+the *abbreviated* packument — smaller, cacheable, and the document npm itself
+asks for.
+
+The public registry is asked **without credentials**; a token there is a leak,
+not a convenience. GitHub Packages produces **no URL at all** without a token,
+rather than an anonymous request whose 404 reads as "up to date" — which would
+have the app report itself current forever. It also gets no npmjs.com link,
+since that package may be private or absent there and the notice would show a
+URL that 404s.
+
+Each shape reads only its own fields, so a registry answering a `gh-releases`
+request is an error rather than a version of `""`. The npm `latest` tag is read
+from inside `dist-tags`, not the document root, because a packument carries
+version strings in several places.
+
+Recording a lookup carries the notice state across, so a refresh cannot reset the
+notify throttle.
+
+
+
+Lessons taken from the CLI's own review rather than rediscovered
+
+Perry's CLI update surface was reviewed after merging, and three findings apply
+verbatim to this half. They are already handled here:
+
+- the notify interval is keyed to the announced **version**, not the clock alone,
+ so an interval set to stop nagging about one release cannot hide the release
+ that fixed it;
+- the state file is written to a per-write temporary name, because two instances
+ of the same app can run at once and one shared name lets each rename a file the
+ other is still writing;
+- an unreadable timestamp notifies rather than staying silent, since silence on
+ one bad write would hide updates indefinitely.
+
+Four more are specific to an app rather than a CLI. The notice is **stderr
+only** — an app's stdout belongs to the app. Control characters are stripped,
+because a release name is attacker-influenceable terminal input and a notice must
+not repaint somebody's screen. Startup reads its argument list with `args_os`,
+since `args()` panic-drops the process on non-UTF-8 input and this runs before
+any app code. And an unparseable version never reads as newer: node-smol's
+equivalent compared against a hardcoded `"0.0.0"`, which made every release look
+newer than the running binary.
+
+
+
+Two gaps closed on the way past
+
+The blob is part of the **object-cache fingerprint**. Without it, adding
+`perry.update` and rebuilding incrementally would serve the cached entry object
+from before — shipping a binary with no update check while the build reported
+success. Same class as the `dbgloc` and `fmath` entries that file already
+documents.
+
+`PERRY_UPDATER_TABLE`'s own comment says it is "auto-derivable from" the
+api-manifest entries, but those are hand-listed and **nothing checked that the
+two agreed**. A dispatch row without its entry makes the strict
+unimplemented-API gate reject user code that calls it, in somebody else's build.
+There is now a parity test, plus one asserting no runtime symbol starts with a
+prefix Windows synthesizes no-op stubs for — a stubbed symbol returns garbage
+rather than failing to link.
+
+
+
+Tests
+
+**53.** Ten on the compiler side cover the parse and every validation rule,
+including the loopback host boundary and that perry.toml overrides package.json
+key by key while leaving keys it does not set alone. Forty-one in the runtime
+cover the blob reader, every gate, the per-platform state directory, version
+comparison, the throttle, control-character stripping, all four request shapes
+and response parsers, and each shape rejecting the others' documents. Two more
+assert dispatch/manifest parity.
+
+Several exist because a test found the bug: an app with no config reported "go
+ahead" instead of "not configured"; and values containing a quote or backslash
+were read back with their escapes intact, doubling on every save until a URL was
+unusable. Both are sabotage-verified — reverting either fix turns its test red.
+
+Beyond the units there is a **wiring test** driving the whole startup path —
+blob in, notice out, state advanced, second run quiet — because everything else
+here is a piece tested in isolation, and a feature whose pieces all pass while
+the path between them is broken is what ships doing nothing.
+
+**Verified end to end:** a configured project's binary contains the blob, the
+same project without the block produces one that does not, the configured binary
+runs normally, and a plain-HTTP URL fails the build with a message naming the
+key.
+
+
+### Documentation
+
+New page `docs/src/cli/app-updates.md`, written for the app author: the smallest
+configuration that does something, every key in both spellings, how to choose a
+source, the mistakes that fail the build and why each is an error, the cases
+where a user's run will not check at all, where the state file lives per
+platform, how to give users an off switch, and the few lines that perform the
+lookup.
diff --git a/crates/perry-api-manifest/Cargo.toml b/crates/perry-api-manifest/Cargo.toml
index 2239d03f4a..122adb61cc 100644
--- a/crates/perry-api-manifest/Cargo.toml
+++ b/crates/perry-api-manifest/Cargo.toml
@@ -15,3 +15,8 @@ serde = { workspace = true, optional = true }
default = []
# Enables Serialize/Deserialize on ApiEntry for JSON emit (--print-api-manifest).
serde = ["dep:serde"]
+
+[dev-dependencies]
+# For the dispatch-table/manifest parity test: the table claims to be
+# "auto-derivable" from these entries and nothing checked that it was.
+perry-dispatch = { workspace = true }
diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs
index c2ea42014a..f5978c1123 100644
--- a/crates/perry-api-manifest/src/entries/part_4.rs
+++ b/crates/perry-api-manifest/src/entries/part_4.rs
@@ -554,6 +554,11 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[
method("perry/i18n", "LongDate", false, None),
method("perry/i18n", "Raw", false, None),
// --- perry/updater — auto-derivable from PERRY_UPDATER_TABLE. ---
+ method("perry/updater", "getEmbeddedConfig", false, None),
+ method("perry/updater", "embeddedCheckUrl", false, None),
+ method("perry/updater", "embeddedCheckHeaders", false, None),
+ method("perry/updater", "recordEmbeddedResponse", false, None),
+ method("perry/updater", "embeddedRefreshDue", false, None),
method("perry/updater", "compareVersions", false, None),
method("perry/updater", "verifyHash", false, None),
method("perry/updater", "verifySignature", false, None),
diff --git a/crates/perry-api-manifest/src/lib.rs b/crates/perry-api-manifest/src/lib.rs
index 290dc1e78c..1b20168270 100644
--- a/crates/perry-api-manifest/src/lib.rs
+++ b/crates/perry-api-manifest/src/lib.rs
@@ -1340,3 +1340,47 @@ mod tests {
}
}
}
+
+#[cfg(test)]
+mod dispatch_parity_tests {
+ /// ★ `PERRY_UPDATER_TABLE`'s own comment says it is "auto-derivable from"
+ /// these entries — but the entries are hand-listed, and nothing checked the
+ /// two agreed.
+ ///
+ /// A dispatch row without its manifest entry makes the strict
+ /// unimplemented-API gate reject user code that calls it. That failure
+ /// surfaces in somebody else's build, which is the wrong place to find out.
+ #[test]
+ fn every_updater_dispatch_row_has_a_manifest_entry() {
+ let declared: Vec<&str> = crate::entries_for_module("perry/updater")
+ .map(|entry| entry.name)
+ .collect();
+ let missing: Vec<&str> = perry_dispatch::PERRY_UPDATER_TABLE
+ .iter()
+ .map(|row| row.method)
+ .filter(|method| !declared.contains(method))
+ .collect();
+ assert!(
+ missing.is_empty(),
+ "dispatch rows with no manifest entry: {missing:?} — user code \
+ calling these is rejected by the unimplemented-API gate"
+ );
+ }
+
+ /// And the runtime symbols must stay clear of the prefixes Windows
+ /// synthesizes no-op stubs for. A stubbed symbol returns garbage rather than
+ /// failing to link, which is the worst way to discover a missing definition.
+ #[test]
+ fn no_updater_runtime_symbol_lands_in_the_windows_stub_net() {
+ const STUBBED: &[&str] = &["perry_get_", "perry_ui_", "perry_system_", "perry_plugin_"];
+ for row in perry_dispatch::PERRY_UPDATER_TABLE {
+ for prefix in STUBBED {
+ assert!(
+ !row.runtime.starts_with(prefix),
+ "{} starts with {prefix}, which Windows stubs to a no-op",
+ row.runtime
+ );
+ }
+ }
+ }
+}
diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs
index b6d894ebd4..00aaa3a392 100644
--- a/crates/perry-codegen/src/codegen/entry.rs
+++ b/crates/perry-codegen/src/codegen/entry.rs
@@ -375,6 +375,26 @@ pub(super) fn compile_module_entry(
.filter(|s| !s.is_empty())
.map(|suite| llmod.add_string_constant(suite))
};
+ // The `perry.update` blob (Phase B): one string constant plus one
+ // `perry_update_notify_startup(ptr, len)` call at the top of `main`, so
+ // a configured app checks for its own updates without its author
+ // writing a version check. Emitted ONLY when the project configures the
+ // block — a binary with no update settings must be byte-identical to
+ // one built before this existed, which `entry.rs`'s absence test pins.
+ //
+ // Skipped for a dylib for the same reason `app_group` is: there is no
+ // `main` to put a prelude in, so the call would reference a startup
+ // path that does not exist here.
+ let update_init: Option<(String, usize)> = if is_dylib {
+ None
+ } else {
+ cross_module
+ .app_metadata
+ .update_config
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .map(|blob| llmod.add_string_constant(blob))
+ };
// i18n startup init: when the project configures `[i18n]`, bake the
// configured locale-code list (and the optional `[i18n.currencies]`
// map) into `main`'s prelude as a single `perry_i18n_init` call —
@@ -494,6 +514,17 @@ pub(super) fn compile_module_entry(
&[(PTR, suite_ptr.as_str()), (I32, len_str.as_str())],
);
}
+ // The update check runs before user code, so an app that exits
+ // early still gets its notice, and so the per-app state directory
+ // is resolved before anything can change the working directory.
+ if let Some((const_name, byte_len)) = update_init.as_ref() {
+ let blob_ptr = format!("@{}", const_name);
+ let len_str = byte_len.to_string();
+ blk.call_void(
+ "perry_update_notify_startup",
+ &[(PTR, blob_ptr.as_str()), (I32, len_str.as_str())],
+ );
+ }
// i18n: register the configured locale list + resolve the runtime
// locale BEFORE any module init runs, so module-top-level `t()`
// calls and format wrappers already see the detected locale.
diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs
index 69306eed32..31e011386e 100644
--- a/crates/perry-codegen/src/codegen/opts.rs
+++ b/crates/perry-codegen/src/codegen/opts.rs
@@ -20,6 +20,13 @@ pub struct AppMetadata {
/// `app_group_*` calls fall through to the runtime's "not configured"
/// stub-warn diagnostic. Refs #1178.
pub app_group: Option,
+ /// The validated `perry.update` block, as the JSON blob the runtime reads,
+ /// or `None` when the project configures no update check.
+ ///
+ /// `None` must emit NOTHING — not an empty blob and not a disabled one. A
+ /// binary that configures no updates is byte-identical to one built before
+ /// this existed, and `entry.rs`'s absence test asserts it.
+ pub update_config: Option,
}
impl Default for AppMetadata {
@@ -29,6 +36,7 @@ impl Default for AppMetadata {
build_number: 1,
bundle_id: "com.perry.app".to_string(),
app_group: None,
+ update_config: None,
}
}
}
diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs
index 08f83a8847..266ff9bc11 100644
--- a/crates/perry-codegen/src/runtime_decls/mod.rs
+++ b/crates/perry-codegen/src/runtime_decls/mod.rs
@@ -50,6 +50,10 @@ pub fn declare_phase1(module: &mut LlModule) {
// the runtime always provides the symbol; main only emits the call
// when `app_metadata.app_group` is `Some`.
module.declare_function("perry_app_group_init", VOID, &[PTR, I32]);
+ // Phase B: the embedded `perry.update` blob's startup entry point. Declared
+ // unconditionally, like every other runtime symbol here — the CALL is what
+ // `entry.rs` emits only for a configured project.
+ module.declare_function("perry_update_notify_startup", VOID, &[PTR, I32]);
// macOS asset-CWD fix: a macOS `.app` launched from Finder starts with
// CWD=`/`, but the worker bundles assets into `Contents/Resources/`. The
// `main` prelude calls this unconditionally; the runtime symbol no-ops on
diff --git a/crates/perry-dispatch/src/updater_table.rs b/crates/perry-dispatch/src/updater_table.rs
index b209e2abc1..5b5dd44d55 100644
--- a/crates/perry-dispatch/src/updater_table.rs
+++ b/crates/perry-dispatch/src/updater_table.rs
@@ -13,6 +13,42 @@ use super::*;
/// through `Str` (raw `*StringHeader` ptr extracted via
/// `js_get_string_pointer_unified` on the codegen side).
pub static PERRY_UPDATER_TABLE: &[MethodRow] = &[
+ // perry-runtime::update_notify — the embedded `perry.update` block a
+ // compiled app carries. These are how an app performs its own check: the
+ // runtime says what to request and reads the answer, the app's own `fetch()`
+ // makes the request. Named `perry_updater_*` rather than `perry_get_*`
+ // because Windows synthesizes no-op stubs for undefined symbols with that
+ // prefix, which would return garbage instead of failing to link.
+ MethodRow {
+ method: "getEmbeddedConfig",
+ runtime: "perry_updater_get_config",
+ args: &[],
+ ret: ReturnKind::Str,
+ },
+ MethodRow {
+ method: "embeddedCheckUrl",
+ runtime: "perry_updater_check_url",
+ args: &[ArgKind::F64],
+ ret: ReturnKind::Str,
+ },
+ MethodRow {
+ method: "embeddedCheckHeaders",
+ runtime: "perry_updater_check_headers",
+ args: &[ArgKind::F64],
+ ret: ReturnKind::Str,
+ },
+ MethodRow {
+ method: "recordEmbeddedResponse",
+ runtime: "perry_updater_record_response",
+ args: &[ArgKind::F64],
+ ret: ReturnKind::I64AsF64,
+ },
+ MethodRow {
+ method: "embeddedRefreshDue",
+ runtime: "perry_updater_refresh_due",
+ args: &[],
+ ret: ReturnKind::I64AsF64,
+ },
// perry-updater::core — pure cross-platform helpers.
MethodRow {
method: "compareVersions",
diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs
index 02ac76111d..1b3559720a 100644
--- a/crates/perry-runtime/src/lib.rs
+++ b/crates/perry-runtime/src/lib.rs
@@ -247,6 +247,7 @@ mod ui_harmonyos_stubs;
/// target-aware branching. UI crates register their handlers here at
/// startup. See module docs for the ohos-napi gating story.
pub mod ui_text_registry;
+pub mod update_notify;
pub mod util_abort;
pub mod util_call_sites;
pub mod util_debuglog;
diff --git a/crates/perry-runtime/src/update_notify.rs b/crates/perry-runtime/src/update_notify.rs
new file mode 100644
index 0000000000..3d90eab80a
--- /dev/null
+++ b/crates/perry-runtime/src/update_notify.rs
@@ -0,0 +1,1839 @@
+//! The embedded update check a compiled app runs at startup.
+//!
+//! Codegen bakes the project's validated `perry.update` block into the binary
+//! as a JSON blob and calls [`perry_update_notify_startup`] at the top of
+//! `main`, before any user code. An app that configures nothing gets neither
+//! the blob nor the call.
+//!
+//! # What this file does today, and what it deliberately does not
+//!
+//! It parses and holds the configuration, and applies every gate that decides
+//! whether a check may happen at all. It does **not** yet reach the network or
+//! print anything — those arrive with the provider layer.
+//!
+//! That split is deliberate rather than incidental. The gates are where this
+//! feature can go wrong quietly: a check that fires in CI, or in a script
+//! parsing the app's stdout, or in a container with no writable home, is a bug
+//! that shows up as somebody else's flaky pipeline. They are worth landing and
+//! testing on their own, ahead of the code that would exercise them.
+//!
+//! # Why the parse is total
+//!
+//! A blob this build cannot read is ignored, not guessed at. It is emitted by
+//! the same Perry that compiled the binary, so a mismatch means something is
+//! wrong upstream, and a wrong guess would run a network check with settings
+//! nobody wrote.
+
+use std::borrow::Cow;
+use std::sync::OnceLock;
+
+/// The blob shape this build understands. Must match the compiler's
+/// `BLOB_SCHEMA`.
+const BLOB_SCHEMA: u32 = 1;
+
+/// The validated settings, as read from the embedded blob.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EmbeddedUpdateConfig {
+ pub app_id: String,
+ pub bin_name: String,
+ pub current_version: String,
+ pub source: String,
+ pub url: Option,
+ pub tag: Option,
+ pub package: Option,
+ pub registry: Option,
+ pub check_interval_hours: u64,
+ pub notify_interval_hours: u64,
+ pub command: String,
+ pub skip_env: Option,
+}
+
+static CONFIG: OnceLock> = OnceLock::new();
+
+/// Read one field out of the blob.
+///
+/// A deliberately tiny flat-object reader rather than a JSON library. Two
+/// reasons: `perry-runtime` links into every compiled binary, so a parser
+/// pulled in for this would be paid for by every program that configures no
+/// updates; and this runs at the very top of `main`, before the collector is
+/// usable, so the runtime's own JSON path — which allocates JS values — is not
+/// available. The producer is `update_config.rs` in the same Perry that
+/// compiled the binary, emitting a flat object of strings and numbers, so there
+/// is no nesting to handle.
+fn blob_field<'a>(text: &'a str, key: &str) -> Option> {
+ let needle = format!("\"{key}\":");
+ let mut rest = text;
+ loop {
+ let at = rest.find(&needle)?;
+ // Guard against matching a key inside a VALUE: the character before the
+ // opening quote must be a structural one, not part of a string.
+ let before = rest[..at].chars().last();
+ rest = &rest[at + needle.len()..];
+ if !matches!(
+ before,
+ None | Some('{') | Some(',') | Some(' ') | Some('\n') | Some('\t')
+ ) {
+ continue;
+ }
+ let value = rest.trim_start();
+ return if let Some(body) = value.strip_prefix('"') {
+ // Strings: walk to the closing quote, UNESCAPING as we go.
+ //
+ // Returning the raw slice was a round-trip bug: `save_state` writes
+ // `\"` and `\\`, so a url or version containing either came back
+ // with the escapes still in it, and each save/load cycle doubled
+ // the backslashes until the value was unusable.
+ let mut out = String::new();
+ let mut chars = body.chars();
+ while let Some(c) = chars.next() {
+ match c {
+ '"' => return Some(Cow::Owned(out)),
+ '\\' => match chars.next() {
+ Some(escaped @ ('"' | '\\' | '/')) => out.push(escaped),
+ Some('n') => out.push('\n'),
+ Some('t') => out.push('\t'),
+ Some('r') => out.push('\r'),
+ // An escape this reader does not know: keep the payload
+ // rather than the backslash, which is the safer of the
+ // two for a value that becomes a URL.
+ Some(other) => out.push(other),
+ None => return None,
+ },
+ other => out.push(other),
+ }
+ }
+ None
+ } else {
+ let end = value
+ .find(|c: char| c == ',' || c == '}')
+ .unwrap_or(value.len());
+ Some(Cow::Borrowed(value[..end].trim()))
+ };
+ }
+}
+
+/// Parse the blob. `None` for anything this build cannot read.
+fn parse_blob(text: &str) -> Option {
+ let string = |key: &str| blob_field(text, key).map(Cow::into_owned);
+ let number = |key: &str, fallback: u64| -> u64 {
+ blob_field(text, key)
+ .and_then(|raw| raw.parse::().ok())
+ .unwrap_or(fallback)
+ };
+
+ // A schema this build does not know means the blob was written by a
+ // different Perry. Ignore it rather than reading fields that may have moved
+ // — a wrong guess would run a network check with settings nobody wrote.
+ if number("schema", 0) != BLOB_SCHEMA as u64 {
+ return None;
+ }
+
+ Some(EmbeddedUpdateConfig {
+ app_id: string("app_id")?,
+ bin_name: string("bin_name")?,
+ current_version: string("current_version")?,
+ source: string("source")?,
+ url: string("url"),
+ tag: string("tag"),
+ package: string("package"),
+ registry: string("registry"),
+ check_interval_hours: number("check_interval_hours", 24),
+ notify_interval_hours: number("notify_interval_hours", 24),
+ command: string("command").unwrap_or_default(),
+ skip_env: string("skip_env"),
+ })
+}
+
+/// Codegen calls this once, at the top of `main`, for a configured app only.
+///
+/// # Safety
+///
+/// `ptr`/`len` name a string constant in the binary's own read-only data, so
+/// the bytes outlive the process and are never null for a positive length.
+#[no_mangle]
+pub unsafe extern "C" fn perry_update_notify_startup(ptr: *const u8, len: i32) {
+ if ptr.is_null() || len <= 0 {
+ return;
+ }
+ let bytes = std::slice::from_raw_parts(ptr, len as usize);
+ let Ok(text) = std::str::from_utf8(bytes) else {
+ return;
+ };
+ let config = parse_blob(text);
+ let _ = CONFIG.set(config.clone());
+ if let Some(config) = config {
+ run_startup_notice(&config);
+ }
+}
+
+/// The embedded settings, if this binary has any and they parsed.
+pub fn embedded_config() -> Option<&'static EmbeddedUpdateConfig> {
+ CONFIG.get().and_then(|c| c.as_ref())
+}
+
+/// Why a check is not going to happen.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SkipReason {
+ /// The app embeds no update settings.
+ NotConfigured,
+ /// The app's own opt-out variable is set.
+ AppOptOut,
+ /// `PERRY_NO_UPDATE_CHECK`, or the ecosystem-wide `NO_UPDATE_NOTIFIER`.
+ GlobalOptOut,
+ /// A continuous-integration environment.
+ Ci,
+ /// Nobody is reading stderr, so a notice has no audience.
+ NotATerminal,
+ /// This invocation IS the app's own update command; checking again here
+ /// would recurse.
+ UpdateCommand,
+}
+
+/// The environment a decision is made against, gathered so the decision itself
+/// is a pure function.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct CheckEnv<'a> {
+ pub app_skip: Option<&'a str>,
+ pub no_update_check: Option<&'a str>,
+ pub no_update_notifier: Option<&'a str>,
+ pub ci: Option<&'a str>,
+ pub continuous_integration: Option<&'a str>,
+ pub stderr_is_terminal: bool,
+ /// The app's own argv[1], so an `app self-update` run does not itself
+ /// trigger a check.
+ pub first_arg: Option<&'a str>,
+}
+
+fn is_on(raw: Option<&str>) -> bool {
+ matches!(
+ raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(),
+ Some("1") | Some("true") | Some("on") | Some("yes")
+ )
+}
+
+fn is_present(raw: Option<&str>) -> bool {
+ !matches!(
+ raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(),
+ None | Some("") | Some("0") | Some("false") | Some("off") | Some("no")
+ )
+}
+
+/// May this run check for an update? `None` means yes.
+///
+/// Pure, so every gate is asserted directly rather than inferred from whether a
+/// network call happened to be made.
+pub fn skip_reason(config: Option<&EmbeddedUpdateConfig>, env: CheckEnv<'_>) -> Option {
+ // `?` would be wrong here: it returns `None`, which this function reads as
+ // "go ahead and check". An app with no embedded settings has nothing to
+ // check against.
+ let Some(config) = config else {
+ return Some(SkipReason::NotConfigured);
+ };
+
+ // The app's own switch first: the person setting `MYAPP_NO_UPDATE_CHECK`
+ // is being specific, and specificity should not be overridable by anything
+ // more general.
+ if let Some(name) = config.skip_env.as_deref() {
+ if !name.is_empty() && is_present(env.app_skip) {
+ return Some(SkipReason::AppOptOut);
+ }
+ }
+ // Then the two global spellings. `NO_UPDATE_NOTIFIER` is honoured because
+ // somebody who sets it has already told every tool on the machine what they
+ // want, and an app compiled by Perry is one of those tools.
+ // Presence, not an exact literal, for BOTH spellings. Somebody who set
+ // either one is asking not to be checked, and the documentation presents
+ // them the same way — a gate that accepted only four spellings of yes would
+ // silently ignore the fifth.
+ if is_present(env.no_update_check) || is_present(env.no_update_notifier) {
+ return Some(SkipReason::GlobalOptOut);
+ }
+ if is_present(env.ci) || is_present(env.continuous_integration) {
+ return Some(SkipReason::Ci);
+ }
+ if !env.stderr_is_terminal {
+ return Some(SkipReason::NotATerminal);
+ }
+ // `app self-update` must not check on its way to updating: the check would
+ // be redundant at best, and at worst the notice would print in the middle
+ // of the install it triggered.
+ if !config.command.is_empty() && env.first_arg == Some(config.command.as_str()) {
+ return Some(SkipReason::UpdateCommand);
+ }
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn blob() -> String {
+ // The shape `update_config.rs` emits, minus the keys a minimal npm
+ // block leaves out.
+ r#"{"schema":1,"app_id":"myapp","bin_name":"myapp","current_version":"1.2.3",
+ "source":"npm","package":"myapp","check_interval_hours":24,
+ "notify_interval_hours":24,"command":"self-update",
+ "skip_env":"MYAPP_NO_UPDATE_CHECK"}"#
+ .to_string()
+ }
+
+ fn config() -> EmbeddedUpdateConfig {
+ parse_blob(&blob()).expect("the fixture must parse")
+ }
+
+ fn tty() -> CheckEnv<'static> {
+ CheckEnv {
+ stderr_is_terminal: true,
+ ..CheckEnv::default()
+ }
+ }
+
+ #[test]
+ fn a_blob_round_trips_into_settings() {
+ let config = config();
+ assert_eq!(config.app_id, "myapp");
+ assert_eq!(config.source, "npm");
+ assert_eq!(config.package.as_deref(), Some("myapp"));
+ assert_eq!(config.url, None, "an absent optional stays absent");
+ assert_eq!(config.check_interval_hours, 24);
+ assert_eq!(config.command, "self-update");
+ }
+
+ /// A blob from a different Perry is ignored rather than read field by
+ /// field. Guessing at a moved layout would run a network check with
+ /// settings nobody wrote.
+ #[test]
+ fn a_blob_of_another_schema_is_ignored() {
+ assert_eq!(
+ parse_blob(&blob().replace("\"schema\":1", "\"schema\":2")),
+ None
+ );
+ assert_eq!(parse_blob(r#"{"app_id":"x"}"#), None, "no schema at all");
+ assert_eq!(parse_blob("not json"), None);
+ assert_eq!(parse_blob(""), None);
+ }
+
+ /// A blob missing a required field is ignored too — a half-read
+ /// configuration is worse than none, because it looks like it works.
+ #[test]
+ fn a_blob_missing_a_required_field_is_ignored() {
+ assert_eq!(parse_blob(r#"{"schema":1,"app_id":"x"}"#), None);
+ }
+
+ /// The reader must not match a key name that appears inside a VALUE — an
+ /// app whose name contains `"url":` would otherwise read its own name as a
+ /// URL.
+ #[test]
+ fn a_key_name_inside_a_value_is_not_matched() {
+ let text = r#"{"schema":1,"app_id":"a","bin_name":"weird\"url\":x","current_version":"1",
+ "source":"npm","package":"p"}"#;
+ let config = parse_blob(text).expect("parses");
+ assert_eq!(config.url, None, "the name's contents are not a url field");
+ assert_eq!(config.package.as_deref(), Some("p"));
+ }
+
+ #[test]
+ fn an_app_with_no_config_never_checks() {
+ assert_eq!(skip_reason(None, tty()), Some(SkipReason::NotConfigured));
+ }
+
+ /// The app's own switch is the most specific thing the user said, so
+ /// nothing more general gets to override it.
+ #[test]
+ fn the_apps_own_opt_out_is_honoured() {
+ let env = CheckEnv {
+ app_skip: Some("1"),
+ ..tty()
+ };
+ assert_eq!(
+ skip_reason(Some(&config()), env),
+ Some(SkipReason::AppOptOut)
+ );
+ }
+
+ /// Both global spellings, including the ecosystem-wide one: somebody who
+ /// set it has already told every tool on the machine what they want.
+ /// Both spellings are presence-based, so a value the gate did not enumerate
+ /// still disables the check. Somebody who wrote `PERRY_NO_UPDATE_CHECK=please`
+ /// is asking not to be checked.
+ #[test]
+ fn the_global_opt_outs_accept_any_non_falsey_value() {
+ for raw in ["1", "true", "yes", "please", "anything"] {
+ for env in [
+ CheckEnv {
+ no_update_check: Some(raw),
+ ..tty()
+ },
+ CheckEnv {
+ no_update_notifier: Some(raw),
+ ..tty()
+ },
+ ] {
+ assert_eq!(
+ skip_reason(Some(&config()), env),
+ Some(SkipReason::GlobalOptOut),
+ "{raw:?} must disable the check"
+ );
+ }
+ }
+ // ...and an explicit no, or an exported-but-empty value, does not.
+ for raw in ["0", "false", "off", "no", ""] {
+ let env = CheckEnv {
+ no_update_check: Some(raw),
+ ..tty()
+ };
+ assert_eq!(skip_reason(Some(&config()), env), None, "{raw:?}");
+ }
+ }
+
+ #[test]
+ fn both_global_opt_outs_are_honoured() {
+ for env in [
+ CheckEnv {
+ no_update_check: Some("1"),
+ ..tty()
+ },
+ CheckEnv {
+ no_update_notifier: Some("1"),
+ ..tty()
+ },
+ ] {
+ assert_eq!(
+ skip_reason(Some(&config()), env),
+ Some(SkipReason::GlobalOptOut)
+ );
+ }
+ }
+
+ /// CI is detected by presence, since CI systems are inconsistent about the
+ /// value — but an exported-but-empty variable is not somebody telling us
+ /// they are in CI.
+ #[test]
+ fn ci_is_detected_by_presence_but_not_when_empty() {
+ for raw in ["1", "true", "yes", "anything"] {
+ let env = CheckEnv {
+ ci: Some(raw),
+ ..tty()
+ };
+ assert_eq!(
+ skip_reason(Some(&config()), env),
+ Some(SkipReason::Ci),
+ "CI={raw}"
+ );
+ }
+ let also = CheckEnv {
+ continuous_integration: Some("true"),
+ ..tty()
+ };
+ assert_eq!(skip_reason(Some(&config()), also), Some(SkipReason::Ci));
+
+ for raw in ["", "0", "false", "no"] {
+ let env = CheckEnv {
+ ci: Some(raw),
+ ..tty()
+ };
+ assert_eq!(skip_reason(Some(&config()), env), None, "CI={raw:?}");
+ }
+ }
+
+ /// A notice on a pipe has no audience, and lands in the middle of whatever
+ /// is reading the app's output.
+ #[test]
+ fn a_non_terminal_run_does_not_check() {
+ let env = CheckEnv {
+ stderr_is_terminal: false,
+ ..tty()
+ };
+ assert_eq!(
+ skip_reason(Some(&config()), env),
+ Some(SkipReason::NotATerminal)
+ );
+ }
+
+ /// ★ `app self-update` must not check on its way to updating: the notice
+ /// would print in the middle of the install it triggered.
+ #[test]
+ fn the_apps_own_update_command_does_not_trigger_a_check() {
+ let env = CheckEnv {
+ first_arg: Some("self-update"),
+ ..tty()
+ };
+ assert_eq!(
+ skip_reason(Some(&config()), env),
+ Some(SkipReason::UpdateCommand)
+ );
+ // A different subcommand is unaffected.
+ let other = CheckEnv {
+ first_arg: Some("build"),
+ ..tty()
+ };
+ assert_eq!(skip_reason(Some(&config()), other), None);
+ }
+
+ /// An app that configures no update command has nothing to recurse into,
+ /// so no argument is special.
+ #[test]
+ fn an_app_without_an_update_command_has_no_reserved_argument() {
+ let mut config = config();
+ config.command = String::new();
+ let env = CheckEnv {
+ first_arg: Some(""),
+ ..tty()
+ };
+ assert_eq!(skip_reason(Some(&config), env), None);
+ }
+
+ /// The permissive case, so the gates above are known to be the only
+ /// obstacles: an interactive run of a configured app with nothing set.
+ #[test]
+ fn an_interactive_run_of_a_configured_app_may_check() {
+ assert_eq!(skip_reason(Some(&config()), tty()), None);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Throttle state, the notice, and the decision between them.
+//
+// This is the half a user sees. The network refresh that populates the state is
+// the next slice; everything here works from what a previous run recorded, so
+// a program that has never checked simply says nothing.
+// ---------------------------------------------------------------------------
+
+/// The shape of the per-app state file. A different value is discarded rather
+/// than migrated, for the same reason the blob's schema is: this is a cache,
+/// rebuilt by the next check, so reading an older shape buys one saved request
+/// in exchange for fields that describe versions nobody runs.
+const STATE_SCHEMA: u32 = 1;
+
+/// What a previous run recorded.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct NotifyState {
+ pub last_check: Option,
+ pub last_notification: Option,
+ /// Which version that notice was about.
+ ///
+ /// The interval throttles repeats of the SAME release. Keyed on time alone
+ /// it would swallow the next one whenever that arrived inside the window, so
+ /// an interval set to stop nagging about one version would also hide the
+ /// version that fixed it.
+ pub last_notified_version: Option,
+ pub latest_known: Option,
+ pub latest_url: Option,
+}
+
+/// Where this app keeps its state.
+///
+/// Prefers the platform's own cache location via `dirs`, which asks the real
+/// APIs — Known Folders on Windows, `NSSearchPathForDirectoriesInDomains` on
+/// macOS — rather than trusting environment variables that a launcher, a
+/// service manager or a stripped environment may not have set. The
+/// environment-derived rules below are the fallback for builds without that
+/// feature, and are what the tests drive.
+pub fn state_dir(app_id: &str) -> Option {
+ #[cfg(feature = "full")]
+ if let Some(base) = dirs::cache_dir() {
+ return Some(base.join(sanitize_app_id(app_id)));
+ }
+ state_dir_for(
+ app_id,
+ std::env::var("XDG_CACHE_HOME").ok().as_deref(),
+ std::env::var("HOME").ok().as_deref(),
+ std::env::var("LOCALAPPDATA").ok().as_deref(),
+ )
+}
+
+/// The same decision from explicit inputs, so the platform rules are testable
+/// without a home directory. Per-app, keyed by `app_id`, so two Perry-built
+/// programs never share a throttle — one app's notice must not silence
+/// another's.
+pub fn state_dir_for(
+ app_id: &str,
+ xdg_cache: Option<&str>,
+ home: Option<&str>,
+ local_appdata: Option<&str>,
+) -> Option {
+ let base = if cfg!(windows) {
+ std::path::PathBuf::from(local_appdata?)
+ } else if cfg!(target_os = "macos") {
+ // `~/Library/Caches` rather than XDG: on macOS that is where a cache
+ // belongs, and a program that writes `~/.cache` there looks like it was
+ // ported without being looked at.
+ std::path::PathBuf::from(home?)
+ .join("Library")
+ .join("Caches")
+ } else if let Some(xdg) = xdg_cache.filter(|s| !s.is_empty()) {
+ std::path::PathBuf::from(xdg)
+ } else {
+ std::path::PathBuf::from(home?).join(".cache")
+ };
+ Some(base.join(sanitize_app_id(app_id)))
+}
+
+/// Keep an `app_id` from escaping its own directory.
+///
+/// The value comes from the project's own manifest, so this is not a hostile
+/// input — but it is a string that becomes a path, and `../..` in one would
+/// write outside the cache root. Cheaper to make impossible than to reason
+/// about.
+fn sanitize_app_id(app_id: &str) -> String {
+ let cleaned: String = app_id
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ // Collapse any run of dots. A lone `.` in a name is fine; `..` is the one
+ // shape that means "the parent", and no amount of separator-stripping makes
+ // that safe to keep.
+ let mut collapsed = String::with_capacity(cleaned.len());
+ let mut last_was_dot = false;
+ for c in cleaned.chars() {
+ if c == '.' {
+ if !last_was_dot {
+ collapsed.push('.');
+ }
+ last_was_dot = true;
+ } else {
+ collapsed.push(c);
+ last_was_dot = false;
+ }
+ }
+ let trimmed = collapsed.trim_matches('.');
+ if trimmed.is_empty() {
+ "perry-app".to_string()
+ } else {
+ trimmed.to_string()
+ }
+}
+
+fn state_file(dir: &std::path::Path) -> std::path::PathBuf {
+ dir.join("update-check.json")
+}
+
+/// Read the state file, or `None` for absent, unreadable or foreign.
+pub fn load_state(dir: &std::path::Path) -> Option {
+ let text = std::fs::read_to_string(state_file(dir)).ok()?;
+ if blob_field(&text, "schema").and_then(|raw| raw.parse::().ok()) != Some(STATE_SCHEMA) {
+ return None;
+ }
+ Some(NotifyState {
+ last_check: blob_field(&text, "last_check").map(Cow::into_owned),
+ last_notification: blob_field(&text, "last_notification").map(Cow::into_owned),
+ last_notified_version: blob_field(&text, "last_notified_version").map(Cow::into_owned),
+ latest_known: blob_field(&text, "latest_known").map(Cow::into_owned),
+ latest_url: blob_field(&text, "latest_url").map(Cow::into_owned),
+ })
+}
+
+/// Replace the state file atomically.
+///
+/// Written beside the target and renamed over it, with a per-write temporary
+/// name: two instances of the same app can run at once, and one shared name
+/// would let each rename a file the other was still writing.
+pub fn save_state(dir: &std::path::Path, state: &NotifyState) {
+ if std::fs::create_dir_all(dir).is_err() {
+ return;
+ }
+ let mut json = String::from("{\"schema\":1");
+ for (key, value) in [
+ ("last_check", &state.last_check),
+ ("last_notification", &state.last_notification),
+ ("last_notified_version", &state.last_notified_version),
+ ("latest_known", &state.latest_known),
+ ("latest_url", &state.latest_url),
+ ] {
+ if let Some(value) = value {
+ json.push_str(&format!(",\"{key}\":\"{}\"", escape_json(value)));
+ }
+ }
+ json.push('}');
+
+ let target = state_file(dir);
+ let tmp = dir.join(format!(
+ "update-check.json.tmp.{}.{}",
+ std::process::id(),
+ NEXT_STATE_TMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ ));
+ if std::fs::write(&tmp, json).is_err() {
+ let _ = std::fs::remove_file(&tmp);
+ return;
+ }
+ if std::fs::rename(&tmp, &target).is_err() {
+ // Windows refuses a rename onto an existing file, so fall back to
+ // replacing it. Still better than truncating in place, which would let a
+ // concurrent reader see half a document.
+ let _ = std::fs::remove_file(&target);
+ if std::fs::rename(&tmp, &target).is_err() {
+ let _ = std::fs::remove_file(&tmp);
+ }
+ }
+}
+
+static NEXT_STATE_TMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
+fn escape_json(value: &str) -> String {
+ value
+ .chars()
+ .filter(|c| !c.is_control())
+ .flat_map(|c| match c {
+ '"' => vec!['\\', '"'],
+ '\\' => vec!['\\', '\\'],
+ other => vec![other],
+ })
+ .collect()
+}
+
+/// Compare two dotted numeric versions.
+///
+/// `Some(Ordering)` when both parse, `None` when either does not — and an
+/// unparseable version must never read as "newer". node-smol's equivalent
+/// compared against a hardcoded `"0.0.0"`, which made every release look newer
+/// than the running binary; that is the mistake this returns `None` to avoid.
+pub fn compare_versions(a: &str, b: &str) -> Option {
+ let parse = |v: &str| -> Option> {
+ let core = v.trim().trim_start_matches('v');
+ // Ignore any prerelease/build suffix for ordering purposes: an app
+ // comparing `1.2.3` with `1.2.4-rc.1` wants the numeric answer, and a
+ // full semver precedence implementation is not what a startup notice
+ // needs.
+ let core = core.split(['-', '+']).next()?;
+ if core.is_empty() {
+ return None;
+ }
+ core.split('.')
+ .map(|part| part.parse::().ok())
+ .collect::>>()
+ };
+ let (a, b) = (parse(a)?, parse(b)?);
+ let width = a.len().max(b.len());
+ for i in 0..width {
+ let (x, y) = (
+ a.get(i).copied().unwrap_or(0),
+ b.get(i).copied().unwrap_or(0),
+ );
+ if x != y {
+ return Some(x.cmp(&y));
+ }
+ }
+ Some(std::cmp::Ordering::Equal)
+}
+
+/// Seconds since the epoch for the RFC3339 stamps this module writes.
+fn parse_stamp(stamp: &str) -> Option {
+ // The same fixed-width shape `now_stamp` emits. Anything else is treated as
+ // unreadable, which the callers turn into "act now" rather than "stay
+ // silent forever".
+ let bytes = stamp.as_bytes();
+ if bytes.len() < 19 {
+ return None;
+ }
+ let num = |range: std::ops::Range| stamp.get(range)?.parse::().ok();
+ let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
+ let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
+ // Days from the civil date, Howard Hinnant's algorithm.
+ let y_adj = if mo <= 2 { y - 1 } else { y };
+ let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
+ let yoe = y_adj - era * 400;
+ let mp = (mo + 9) % 12;
+ let doy = (153 * mp + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ let days = era * 146_097 + doe - 719_468;
+ Some(days * 86_400 + h * 3_600 + mi * 60 + s)
+}
+
+fn now_seconds() -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs() as i64)
+ .unwrap_or_default()
+}
+
+fn now_stamp() -> String {
+ let secs = now_seconds();
+ let days = secs.div_euclid(86_400);
+ let rest = secs.rem_euclid(86_400);
+ // Civil date from days, the inverse of the above.
+ let z = days + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = z - era * 146_097;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = if m <= 2 { y + 1 } else { y };
+ format!(
+ "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
+ y,
+ m,
+ d,
+ rest / 3_600,
+ (rest % 3_600) / 60,
+ rest % 60
+ )
+}
+
+/// What to tell the user, if anything.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Notice {
+ pub current: String,
+ pub latest: String,
+ pub url: Option,
+ /// The command to suggest, when the app declared one.
+ pub command: Option,
+}
+
+/// Decide whether a notice is due, from recorded state alone.
+///
+/// Pure: no clock, no filesystem, no network. Every reason to stay quiet is
+/// therefore assertable, which matters because staying quiet is the failure
+/// nobody notices.
+pub fn notice_from_state(
+ config: &EmbeddedUpdateConfig,
+ state: &NotifyState,
+ now_secs: i64,
+) -> Option {
+ let latest = state.latest_known.as_deref()?;
+ // An unparseable version on either side is not a newer version. The whole
+ // class of bug here is announcing an update that does not exist.
+ if compare_versions(latest, &config.current_version)? != std::cmp::Ordering::Greater {
+ return None;
+ }
+
+ // The interval throttles repeats of THIS release. A different version is
+ // announced regardless, or an interval set to stop nagging about one would
+ // also hide the release that fixed it.
+ if state.last_notified_version.as_deref() == Some(latest) {
+ let interval = config.notify_interval_hours.saturating_mul(3_600) as i64;
+ if interval > 0 {
+ match state.last_notification.as_deref().and_then(parse_stamp) {
+ // Inside the window: stay quiet.
+ Some(last) if now_secs.saturating_sub(last) < interval => return None,
+ // Unreadable or absent: the throttle has nothing to stand on,
+ // and silence on a damaged file would hide updates forever.
+ _ => {}
+ }
+ }
+ }
+
+ Some(Notice {
+ current: config.current_version.clone(),
+ latest: latest.to_string(),
+ url: state.latest_url.clone().filter(|u| !u.is_empty()),
+ command: Some(config.command.clone()).filter(|c| !c.is_empty()),
+ })
+}
+
+/// The two lines a notice prints.
+///
+/// Returned rather than printed so the wording is testable. Every value that
+/// reaches here came from a network document, so control characters are
+/// stripped: a release name is attacker-influenceable terminal input, and a
+/// notice must not be able to repaint someone's screen.
+pub fn render_notice(bin_name: &str, notice: &Notice) -> Vec {
+ let clean = |s: &str| -> String { s.chars().filter(|c| !c.is_control()).collect() };
+ let mut lines = vec![format!(
+ "Update available: {} {} → {}",
+ clean(bin_name),
+ clean(¬ice.current),
+ clean(¬ice.latest)
+ )];
+ lines.push(match (¬ice.command, ¬ice.url) {
+ // A command the app declared it handles.
+ (Some(command), _) => format!(" Run `{} {}` to update", clean(bin_name), clean(command)),
+ // No command: point at the release rather than inventing one. An app
+ // that has not implemented an update command must not be told to run it.
+ (None, Some(url)) => format!(" See {}", clean(url)),
+ (None, None) => " A newer version is available".to_string(),
+ });
+ lines
+}
+
+/// The whole startup path: gates, state, decision, output.
+///
+/// Called from [`perry_update_notify_startup`] once the blob has parsed.
+/// Everything it needs is read here and nothing is written unless a notice was
+/// actually printed — a throttle advanced for a notice nobody saw would
+/// suppress the next real one.
+fn run_startup_notice(config: &EmbeddedUpdateConfig) {
+ let app_skip = config
+ .skip_env
+ .as_deref()
+ .and_then(|name| std::env::var(name).ok());
+ // `args()` panic-drops the process on a non-UTF-8 argument, and this runs
+ // from `main` before any app code. An argument we cannot read is simply not
+ // the update command.
+ let first_arg = std::env::args_os()
+ .nth(1)
+ .and_then(|raw| raw.into_string().ok());
+ let env = CheckEnv {
+ app_skip: app_skip.as_deref(),
+ no_update_check: None,
+ no_update_notifier: None,
+ ci: None,
+ continuous_integration: None,
+ stderr_is_terminal: std::io::IsTerminal::is_terminal(&std::io::stderr()),
+ first_arg: first_arg.as_deref(),
+ };
+ let no_check = std::env::var("PERRY_NO_UPDATE_CHECK").ok();
+ let no_notifier = std::env::var("NO_UPDATE_NOTIFIER").ok();
+ let ci = std::env::var("CI").ok();
+ let ci2 = std::env::var("CONTINUOUS_INTEGRATION").ok();
+ let env = CheckEnv {
+ no_update_check: no_check.as_deref(),
+ no_update_notifier: no_notifier.as_deref(),
+ ci: ci.as_deref(),
+ continuous_integration: ci2.as_deref(),
+ ..env
+ };
+ if skip_reason(Some(config), env).is_some() {
+ return;
+ }
+
+ let Some(dir) = state_dir(&config.app_id) else {
+ return;
+ };
+ let Some(state) = load_state(&dir) else {
+ // Nothing recorded yet. The refresh that populates it is the next
+ // slice; until then a first run is silent, which is the right way round.
+ return;
+ };
+ let Some(notice) = notice_from_state(config, &state, now_seconds()) else {
+ return;
+ };
+
+ for line in render_notice(&config.bin_name, ¬ice) {
+ // stderr, always: an app's stdout belongs to the app, and a notice in
+ // the middle of it breaks whatever is parsing the output.
+ eprintln!("{line}");
+ }
+
+ let mut updated = state;
+ updated.last_notification = Some(now_stamp());
+ updated.last_notified_version = Some(notice.latest);
+ save_state(&dir, &updated);
+}
+
+#[cfg(test)]
+mod state_tests {
+ use super::*;
+ use std::cmp::Ordering;
+
+ fn config() -> EmbeddedUpdateConfig {
+ EmbeddedUpdateConfig {
+ app_id: "myapp".into(),
+ bin_name: "myapp".into(),
+ current_version: "1.2.3".into(),
+ source: "npm".into(),
+ url: None,
+ tag: None,
+ package: Some("myapp".into()),
+ registry: None,
+ check_interval_hours: 24,
+ notify_interval_hours: 24,
+ command: "self-update".into(),
+ skip_env: None,
+ }
+ }
+
+ #[test]
+ fn versions_compare_numerically_and_reject_nonsense() {
+ assert_eq!(compare_versions("1.2.4", "1.2.3"), Some(Ordering::Greater));
+ assert_eq!(compare_versions("1.2.3", "1.2.3"), Some(Ordering::Equal));
+ assert_eq!(compare_versions("1.10.0", "1.9.0"), Some(Ordering::Greater));
+ assert_eq!(compare_versions("v2.0.0", "1.9.9"), Some(Ordering::Greater));
+ assert_eq!(compare_versions("1.2", "1.2.0"), Some(Ordering::Equal));
+ // ★ An unparseable version must not read as newer. node-smol compared
+ // against a hardcoded "0.0.0", which made every release look newer than
+ // the running binary.
+ assert_eq!(compare_versions("banana", "1.0.0"), None);
+ assert_eq!(compare_versions("1.0.0", ""), None);
+ }
+
+ /// An app id is a manifest value that becomes a path. `../..` in one would
+ /// write outside the cache root, so it is made impossible rather than
+ /// reasoned about.
+ #[test]
+ fn an_app_id_cannot_escape_its_directory() {
+ for hostile in ["../../etc", "..", "a/../b", r"..\..\win", "/absolute"] {
+ let safe = sanitize_app_id(hostile);
+ assert!(!safe.contains(".."), "{hostile} → {safe}");
+ assert!(!safe.contains('/'), "{hostile} → {safe}");
+ assert!(!safe.contains('\\'), "{hostile} → {safe}");
+ assert!(!safe.is_empty(), "{hostile} → empty");
+ }
+ assert_eq!(sanitize_app_id(""), "perry-app");
+ assert_eq!(sanitize_app_id("..."), "perry-app");
+ assert_eq!(sanitize_app_id("my.app-1_x"), "my.app-1_x");
+ }
+
+ /// Two apps must never share a throttle: one app's notice silencing
+ /// another's would be invisible and maddening.
+ #[test]
+ fn each_app_gets_its_own_directory() {
+ let a = state_dir_for("app-a", None, Some("/home/u"), Some(r"C:\x")).unwrap();
+ let b = state_dir_for("app-b", None, Some("/home/u"), Some(r"C:\x")).unwrap();
+ assert_ne!(a, b);
+ assert!(a.to_string_lossy().contains("app-a"));
+ }
+
+ #[cfg(all(unix, not(target_os = "macos")))]
+ #[test]
+ fn linux_honours_xdg_then_falls_back_to_dot_cache() {
+ let xdg = state_dir_for("app", Some("/x/cache"), Some("/home/u"), None).unwrap();
+ assert!(xdg.starts_with("/x/cache"));
+ let fallback = state_dir_for("app", None, Some("/home/u"), None).unwrap();
+ assert!(fallback.starts_with("/home/u/.cache"));
+ // An empty XDG value is not a directory.
+ let empty = state_dir_for("app", Some(""), Some("/home/u"), None).unwrap();
+ assert!(empty.starts_with("/home/u/.cache"));
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn macos_uses_library_caches_rather_than_xdg() {
+ let dir = state_dir_for("app", Some("/x/cache"), Some("/home/u"), None).unwrap();
+ assert!(
+ dir.to_string_lossy().contains("Library/Caches"),
+ "a program writing ~/.cache on macOS looks unported: {dir:?}"
+ );
+ }
+
+ #[test]
+ fn nothing_recorded_means_nothing_to_say() {
+ assert_eq!(
+ notice_from_state(&config(), &NotifyState::default(), 0),
+ None
+ );
+ }
+
+ #[test]
+ fn an_older_or_equal_known_version_says_nothing() {
+ for known in ["1.2.3", "1.2.2", "0.9.9"] {
+ let state = NotifyState {
+ latest_known: Some(known.into()),
+ ..NotifyState::default()
+ };
+ assert_eq!(notice_from_state(&config(), &state, 0), None, "{known}");
+ }
+ }
+
+ /// An unparseable recorded version must not produce a notice — that is the
+ /// shape that announces an update which does not exist.
+ #[test]
+ fn an_unparseable_known_version_says_nothing() {
+ let state = NotifyState {
+ latest_known: Some("garbage".into()),
+ ..NotifyState::default()
+ };
+ assert_eq!(notice_from_state(&config(), &state, 0), None);
+ }
+
+ #[test]
+ fn a_newer_version_produces_a_notice() {
+ let state = NotifyState {
+ latest_known: Some("1.3.0".into()),
+ latest_url: Some("https://example.test/1.3.0".into()),
+ ..NotifyState::default()
+ };
+ let notice = notice_from_state(&config(), &state, 0).expect("due");
+ assert_eq!(notice.latest, "1.3.0");
+ assert_eq!(notice.command.as_deref(), Some("self-update"));
+ }
+
+ /// ★ The interval throttles repeats of the SAME release. A different one is
+ /// announced regardless, or an interval set to stop nagging about one
+ /// version would also hide the version that fixed it.
+ #[test]
+ fn the_interval_throttles_one_release_not_the_next() {
+ let base = NotifyState {
+ latest_known: Some("1.3.0".into()),
+ last_notification: Some("2026-08-10T00:00:00Z".into()),
+ last_notified_version: Some("1.3.0".into()),
+ ..NotifyState::default()
+ };
+ let one_minute_later = parse_stamp("2026-08-10T00:01:00Z").unwrap();
+ assert_eq!(
+ notice_from_state(&config(), &base, one_minute_later),
+ None,
+ "the same release inside the window stays quiet"
+ );
+
+ let newer = NotifyState {
+ latest_known: Some("1.4.0".into()),
+ ..base.clone()
+ };
+ assert!(
+ notice_from_state(&config(), &newer, one_minute_later).is_some(),
+ "a different release is announced regardless of the interval"
+ );
+
+ let much_later = parse_stamp("2026-08-12T00:00:00Z").unwrap();
+ assert!(
+ notice_from_state(&config(), &base, much_later).is_some(),
+ "and past the interval the same release is mentioned again"
+ );
+ }
+
+ /// A damaged timestamp must not silence the notice forever.
+ #[test]
+ fn an_unreadable_timestamp_notifies_rather_than_staying_silent() {
+ let state = NotifyState {
+ latest_known: Some("1.3.0".into()),
+ last_notification: Some("not-a-date".into()),
+ last_notified_version: Some("1.3.0".into()),
+ ..NotifyState::default()
+ };
+ assert!(notice_from_state(&config(), &state, 0).is_some());
+ }
+
+ /// An app that declared no update command must not be told to run one.
+ #[test]
+ fn the_notice_points_at_the_release_when_there_is_no_command() {
+ let mut config = config();
+ config.command = String::new();
+ let state = NotifyState {
+ latest_known: Some("1.3.0".into()),
+ latest_url: Some("https://example.test/1.3.0".into()),
+ ..NotifyState::default()
+ };
+ let notice = notice_from_state(&config, &state, 0).unwrap();
+ let lines = render_notice(&config.bin_name, ¬ice);
+ assert!(lines[1].contains("https://example.test/1.3.0"), "{lines:?}");
+ assert!(!lines[1].contains("Run "), "{lines:?}");
+ }
+
+ /// Every value in a notice arrived in a network document, so a release name
+ /// must not be able to repaint the terminal.
+ #[test]
+ fn control_characters_are_stripped_from_the_notice() {
+ let notice = Notice {
+ current: "1.2.3".into(),
+ latest: "1.3.0\u{1b}[2J".into(),
+ url: Some("https://example.test/\u{7}".into()),
+ command: None,
+ };
+ let lines = render_notice("my\u{1b}app", ¬ice);
+ let joined = lines.join("\n");
+ assert!(!joined.contains('\u{1b}'), "escape survived: {joined:?}");
+ assert!(!joined.contains('\u{7}'), "bell survived: {joined:?}");
+ }
+
+ /// ★ Values containing a quote or a backslash must survive a save/load
+ /// cycle unchanged. `save_state` escapes them; the reader has to reverse
+ /// that, or every cycle doubles the backslashes until the value is
+ /// unusable — and it is a URL that gets corrupted.
+ #[test]
+ fn escaped_values_survive_repeated_save_and_load() {
+ let dir = std::env::temp_dir().join(format!("perry-escape-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+
+ let awkward = "https://example.test/a\\b/\"quoted\"";
+ let mut state = NotifyState {
+ latest_known: Some("1.4.0".into()),
+ latest_url: Some(awkward.to_string()),
+ ..NotifyState::default()
+ };
+
+ // Three cycles, because the failure mode is accumulation: one pass can
+ // look fine while each subsequent one adds another backslash.
+ for cycle in 1..=3 {
+ save_state(&dir, &state);
+ state = load_state(&dir).expect("loads");
+ assert_eq!(
+ state.latest_url.as_deref(),
+ Some(awkward),
+ "the url changed on cycle {cycle}"
+ );
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn state_round_trips_through_a_real_file() {
+ let dir = std::env::temp_dir().join(format!("perry-notify-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let state = NotifyState {
+ last_check: Some("2026-08-10T00:00:00Z".into()),
+ last_notification: Some("2026-08-10T01:00:00Z".into()),
+ last_notified_version: Some("1.3.0".into()),
+ latest_known: Some("1.3.0".into()),
+ latest_url: Some("https://example.test/1.3.0".into()),
+ };
+ save_state(&dir, &state);
+ assert_eq!(load_state(&dir).as_ref(), Some(&state));
+
+ // A foreign schema is discarded rather than migrated.
+ std::fs::write(dir.join("update-check.json"), r#"{"schema":99}"#).unwrap();
+ assert_eq!(load_state(&dir), None);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The stamp writer and reader must agree, or every throttle comparison is
+ /// against a number nothing produced.
+ #[test]
+ fn the_stamp_round_trips() {
+ let now = now_seconds();
+ let parsed = parse_stamp(&now_stamp()).expect("its own output must parse");
+ assert!(
+ (parsed - now).abs() <= 1,
+ "wrote {} and read back {parsed} for {now}",
+ now_stamp()
+ );
+ }
+}
+
+#[cfg(test)]
+mod startup_tests {
+ use super::*;
+
+ /// The whole startup path, driven the way a real run drives it — blob in,
+ /// notice out, state advanced — with the terminal gate the only thing
+ /// stubbed. Without this the pieces are each tested and the wiring between
+ /// them is not, which is how a feature ships doing nothing.
+ #[test]
+ fn the_startup_path_notifies_once_then_throttles() {
+ let dir = std::env::temp_dir().join(format!("perry-startup-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+
+ let blob = r#"{"schema":1,"app_id":"myapp","bin_name":"myapp",
+ "current_version":"1.2.3","source":"npm","package":"myapp",
+ "check_interval_hours":24,"notify_interval_hours":24,
+ "command":"self-update"}"#;
+ let config = parse_blob(blob).expect("the blob must parse");
+
+ // A previous run recorded something newer.
+ save_state(
+ &dir,
+ &NotifyState {
+ latest_known: Some("9.9.9".into()),
+ latest_url: Some("https://example.test/9.9.9".into()),
+ ..NotifyState::default()
+ },
+ );
+
+ let state = load_state(&dir).expect("recorded");
+ let notice = notice_from_state(&config, &state, now_seconds()).expect("a notice is due");
+ let lines = render_notice(&config.bin_name, ¬ice);
+ assert!(lines[0].contains("1.2.3 → 9.9.9"), "{lines:?}");
+ assert!(lines[1].contains("myapp self-update"), "{lines:?}");
+
+ // Recording it is what makes the throttle real.
+ let mut advanced = state;
+ advanced.last_notification = Some(now_stamp());
+ advanced.last_notified_version = Some(notice.latest.clone());
+ save_state(&dir, &advanced);
+
+ let reloaded = load_state(&dir).expect("still there");
+ assert_eq!(reloaded.last_notified_version.as_deref(), Some("9.9.9"));
+ assert_eq!(
+ notice_from_state(&config, &reloaded, now_seconds()),
+ None,
+ "a second run inside the interval must stay quiet"
+ );
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// And the gate that stopped the manual check above: a run whose stderr is
+ /// not a terminal says nothing, whatever is recorded.
+ #[test]
+ fn a_non_terminal_run_stays_silent_even_with_an_update_waiting() {
+ let blob = r#"{"schema":1,"app_id":"myapp","bin_name":"myapp",
+ "current_version":"1.2.3","source":"npm","package":"myapp",
+ "check_interval_hours":24,"notify_interval_hours":24,"command":"self-update"}"#;
+ let config = parse_blob(blob).expect("parses");
+ let piped = CheckEnv {
+ stderr_is_terminal: false,
+ ..CheckEnv::default()
+ };
+ assert_eq!(
+ skip_reason(Some(&config), piped),
+ Some(SkipReason::NotATerminal),
+ "an app writing a notice into a pipe breaks whatever is reading it"
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// The refresh, split the way this repo already splits its updater.
+//
+// `docs/src/updater/overview.md` states the rule for the desktop updater:
+// "Download lives in TS (using existing fetch()) — Rust only handles the
+// security-critical and platform-touching pieces, keeping this crate small and
+// audit-friendly." The same division applies here, and for the same reasons.
+//
+// So: this side decides WHAT to request and interprets the answer — the parts
+// that must agree with the compiler's four source shapes and that are worth unit
+// testing. The app's own `fetch()` performs the request, because perry-runtime
+// links into every compiled binary and adding an HTTP stack to it would be paid
+// for by every program that never checks for an update.
+// ---------------------------------------------------------------------------
+
+/// What the caller should request, for the source this app configured.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CheckRequest {
+ pub url: String,
+ /// Header name/value pairs. Empty for the shapes that need none.
+ pub headers: Vec<(String, String)>,
+}
+
+/// Build the request for a configured source.
+///
+/// `None` when the source needs something the config does not carry — the
+/// compiler rejects that combination, so reaching it means the blob was written
+/// by a different Perry, and a request built from half a configuration is worse
+/// than no request.
+pub fn check_request(config: &EmbeddedUpdateConfig, token: Option<&str>) -> Option {
+ const ABBREVIATED: &str = "application/vnd.npm.install-v1+json";
+ match config.source.as_str() {
+ // Both read a document straight off the configured URL.
+ "gh-releases" | "custom" => Some(CheckRequest {
+ url: config.url.clone()?,
+ headers: Vec::new(),
+ }),
+ "npm" => Some(CheckRequest {
+ url: packument_url(
+ config
+ .registry
+ .as_deref()
+ .unwrap_or("https://registry.npmjs.org"),
+ config.package.as_deref()?,
+ ),
+ // The abbreviated packument: smaller, cacheable, and the document
+ // npm itself asks for. No credentials — the public registry wants
+ // none, and sending a token there would be a leak.
+ headers: vec![("Accept".into(), ABBREVIATED.into())],
+ }),
+ "gh-registry" => {
+ // GitHub Packages always needs a token. Without one the request
+ //404s, which would read as "up to date" — so no request is made.
+ let token = token.filter(|t| !t.is_empty())?;
+ Some(CheckRequest {
+ url: packument_url(
+ config
+ .registry
+ .as_deref()
+ .unwrap_or("https://npm.pkg.github.com"),
+ config.package.as_deref()?,
+ ),
+ headers: vec![
+ ("Accept".into(), ABBREVIATED.into()),
+ ("Authorization".into(), format!("Bearer {token}")),
+ ],
+ })
+ }
+ _ => None,
+ }
+}
+
+/// A scoped package's `/` must be percent-encoded, or the registry reads the
+/// scope as a path segment and answers 404.
+fn packument_url(registry: &str, package: &str) -> String {
+ format!(
+ "{}/{}",
+ registry.trim_end_matches('/'),
+ package.replace('/', "%2F")
+ )
+}
+
+/// What a response yielded.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CheckResult {
+ pub latest_version: String,
+ pub release_url: Option,
+}
+
+/// Interpret a response body according to the configured source.
+///
+/// Each shape reads only its own fields, so a registry answering a
+/// `gh-releases` request is an error rather than a version of `""`.
+pub fn parse_check_response(config: &EmbeddedUpdateConfig, body: &str) -> Option {
+ match config.source.as_str() {
+ "gh-releases" => {
+ let tag = blob_field(body, "tag_name")?;
+ Some(CheckResult {
+ latest_version: tag.trim_start_matches('v').to_string(),
+ release_url: blob_field(body, "html_url").map(Cow::into_owned),
+ })
+ }
+ "npm" | "gh-registry" => {
+ let public_npm = config.source == "npm";
+ // `dist-tags` is a nested object, so scan from it rather than from
+ // the document root: a version string elsewhere must not be
+ // mistaken for the `latest` tag.
+ let at = body.find("\"dist-tags\"")?;
+ let latest = blob_field(&body[at..], "latest")?;
+ let package = config.package.as_deref().unwrap_or_default();
+ Some(CheckResult {
+ // Only the PUBLIC registry gets an npmjs.com link. A GitHub
+ // Packages package may be private or absent from npmjs.com, so
+ // deriving one there would put a broken URL in the notice.
+ release_url: (public_npm && !package.is_empty())
+ .then(|| format!("https://www.npmjs.com/package/{package}/v/{latest}")),
+ latest_version: latest.to_string(),
+ })
+ }
+ "custom" => {
+ let version = blob_field(body, "version")?;
+ Some(CheckResult {
+ latest_version: version.trim_start_matches('v').to_string(),
+ release_url: blob_field(body, "release_url").map(Cow::into_owned),
+ })
+ }
+ _ => None,
+ }
+}
+
+/// Is a refresh due, given what a previous run recorded?
+pub fn refresh_due(config: &EmbeddedUpdateConfig, state: &NotifyState, now_secs: i64) -> bool {
+ let interval = config.check_interval_hours.saturating_mul(3_600) as i64;
+ match state.last_check.as_deref().and_then(parse_stamp) {
+ Some(last) => now_secs.saturating_sub(last) >= interval,
+ // Never checked, or a stamp this build cannot read. Either way the
+ // throttle has nothing to stand on, and refusing to check would leave
+ // the app permanently silent.
+ None => true,
+ }
+}
+
+/// Record a completed check.
+///
+/// The notice state is preserved: a refresh must not reset the notify throttle,
+/// or `notifyInterval` would silently stop working after one check interval.
+pub fn record_check(dir: &std::path::Path, result: &CheckResult) {
+ let mut state = load_state(dir).unwrap_or_default();
+ state.last_check = Some(now_stamp());
+ state.latest_known = Some(result.latest_version.clone());
+ state.latest_url = result.release_url.clone();
+ save_state(dir, &state);
+}
+
+#[cfg(test)]
+mod refresh_tests {
+ use super::*;
+
+ fn config_for(source: &str) -> EmbeddedUpdateConfig {
+ EmbeddedUpdateConfig {
+ app_id: "myapp".into(),
+ bin_name: "myapp".into(),
+ current_version: "1.2.3".into(),
+ source: source.into(),
+ url: Some("https://example.test/latest".into()),
+ tag: None,
+ package: Some("@scope/myapp".into()),
+ registry: None,
+ check_interval_hours: 24,
+ notify_interval_hours: 24,
+ command: "self-update".into(),
+ skip_env: None,
+ }
+ }
+
+ #[test]
+ fn a_scoped_package_is_percent_encoded() {
+ let request = check_request(&config_for("npm"), None).expect("built");
+ assert_eq!(
+ request.url, "https://registry.npmjs.org/@scope%2Fmyapp",
+ "an unencoded slash reads as a path segment and 404s"
+ );
+ }
+
+ /// The public registry must never be sent a token.
+ #[test]
+ fn the_public_registry_is_asked_without_credentials() {
+ let request = check_request(&config_for("npm"), Some("secret")).expect("built");
+ assert!(
+ !request
+ .headers
+ .iter()
+ .any(|(name, _)| name == "Authorization"),
+ "a token leaked to the public registry: {:?}",
+ request.headers
+ );
+ assert!(request
+ .headers
+ .iter()
+ .any(|(n, v)| n == "Accept" && v.contains("install-v1")));
+ }
+
+ /// GitHub Packages without a token would 404, and a 404 reads as "up to
+ /// date" — so no request is built at all.
+ #[test]
+ fn gh_registry_builds_no_request_without_a_token() {
+ assert_eq!(check_request(&config_for("gh-registry"), None), None);
+ assert_eq!(check_request(&config_for("gh-registry"), Some("")), None);
+ let request = check_request(&config_for("gh-registry"), Some("t")).expect("built");
+ assert!(request
+ .headers
+ .iter()
+ .any(|(n, v)| n == "Authorization" && v == "Bearer t"));
+ }
+
+ #[test]
+ fn each_source_reads_its_own_document() {
+ let release = r#"{"tag_name":"v1.4.0","html_url":"https://example.test/1.4.0"}"#;
+ let parsed = parse_check_response(&config_for("gh-releases"), release).unwrap();
+ assert_eq!(parsed.latest_version, "1.4.0", "the v prefix is stripped");
+
+ let packument = r#"{"name":"@scope/myapp","dist-tags":{"latest":"1.4.0"}}"#;
+ let parsed = parse_check_response(&config_for("npm"), packument).unwrap();
+ assert_eq!(parsed.latest_version, "1.4.0");
+ assert!(parsed.release_url.unwrap().contains("@scope/myapp"));
+
+ let manifest = r#"{"version":"v1.4.0","release_url":"https://example.test/n"}"#;
+ let parsed = parse_check_response(&config_for("custom"), manifest).unwrap();
+ assert_eq!(parsed.latest_version, "1.4.0");
+ }
+
+ /// A registry answering a gh-releases request must be an error, not a
+ /// version of `""` — otherwise a misconfigured source reports "up to date"
+ /// forever.
+ #[test]
+ fn a_source_rejects_another_shapes_document() {
+ let packument = r#"{"dist-tags":{"latest":"1.4.0"}}"#;
+ assert_eq!(
+ parse_check_response(&config_for("gh-releases"), packument),
+ None
+ );
+ assert_eq!(parse_check_response(&config_for("custom"), packument), None);
+
+ let release = r#"{"tag_name":"v1.4.0"}"#;
+ assert_eq!(parse_check_response(&config_for("npm"), release), None);
+
+ for junk in ["", "not json", "{}"] {
+ assert_eq!(
+ parse_check_response(&config_for("npm"), junk),
+ None,
+ "{junk:?}"
+ );
+ }
+ }
+
+ /// ★ `latest` is read from inside `dist-tags`, not from the document root. A
+ /// packument carries version strings in several places, and picking the
+ /// wrong one would announce a version that is not the published latest.
+ #[test]
+ fn the_npm_latest_tag_is_read_from_inside_dist_tags() {
+ let body = r#"{"latest":"9.9.9","dist-tags":{"latest":"1.4.0"}}"#;
+ let parsed = parse_check_response(&config_for("npm"), body).unwrap();
+ assert_eq!(
+ parsed.latest_version, "1.4.0",
+ "a root-level `latest` must not win over the dist-tag"
+ );
+ }
+
+ /// ★ GitHub Packages must not be given an npmjs.com link. That package can
+ /// be private or absent there, so the notice would show a URL that 404s.
+ #[test]
+ fn gh_registry_gets_no_public_npm_link() {
+ let mut config = config_for("gh-registry");
+ config.package = Some("@scope/private".into());
+ let body = r#"{"dist-tags":{"latest":"1.4.0"}}"#;
+ let parsed = parse_check_response(&config, body).expect("parses");
+ assert_eq!(parsed.latest_version, "1.4.0");
+ assert_eq!(
+ parsed.release_url, None,
+ "a GitHub Packages package may not exist on npmjs.com"
+ );
+
+ // The public registry still gets one, since that link is real.
+ let public = parse_check_response(&config_for("npm"), body).expect("parses");
+ assert!(public.release_url.unwrap().contains("npmjs.com"));
+ }
+
+ #[test]
+ fn a_refresh_is_due_when_the_interval_has_passed_or_nothing_is_recorded() {
+ let config = config_for("npm");
+ assert!(
+ refresh_due(&config, &NotifyState::default(), 0),
+ "never checked means due"
+ );
+ let recent = NotifyState {
+ last_check: Some("2026-08-10T00:00:00Z".into()),
+ ..NotifyState::default()
+ };
+ let hour_later = parse_stamp("2026-08-10T01:00:00Z").unwrap();
+ assert!(!refresh_due(&config, &recent, hour_later));
+ let day_later = parse_stamp("2026-08-11T01:00:00Z").unwrap();
+ assert!(refresh_due(&config, &recent, day_later));
+ // An unreadable stamp must not leave the app permanently silent.
+ let damaged = NotifyState {
+ last_check: Some("nonsense".into()),
+ ..NotifyState::default()
+ };
+ assert!(refresh_due(&config, &damaged, hour_later));
+ }
+
+ /// ★ Recording a check must not reset the notify throttle. It rebuilds the
+ /// state, so dropping the notice fields would make `notifyInterval` stop
+ /// working after one check interval.
+ #[test]
+ fn recording_a_check_preserves_the_notice_state() {
+ let dir = std::env::temp_dir().join(format!("perry-refresh-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ save_state(
+ &dir,
+ &NotifyState {
+ last_notification: Some("2026-08-10T00:00:00Z".into()),
+ last_notified_version: Some("1.3.0".into()),
+ ..NotifyState::default()
+ },
+ );
+
+ record_check(
+ &dir,
+ &CheckResult {
+ latest_version: "1.4.0".into(),
+ release_url: Some("https://example.test/1.4.0".into()),
+ },
+ );
+
+ let state = load_state(&dir).expect("recorded");
+ assert_eq!(state.latest_known.as_deref(), Some("1.4.0"));
+ assert_eq!(
+ state.last_notified_version.as_deref(),
+ Some("1.3.0"),
+ "the refresh reset the notify throttle"
+ );
+ assert_eq!(
+ state.last_notification.as_deref(),
+ Some("2026-08-10T00:00:00Z")
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// The TS-facing primitives.
+//
+// Named `perry_updater_*` deliberately. On Windows the linker synthesizes no-op
+// stubs for undefined `perry_get_*` symbols, so a primitive named
+// `perry_get_update_config` would silently return garbage in any build where the
+// definition went missing, instead of failing to link. These names sit outside
+// every stub prefix.
+// ---------------------------------------------------------------------------
+
+/// A JS string argument as a Rust string, or `None` for anything else.
+///
+/// `undefined`/`null` are the common "no token" cases, and they must read as
+/// absent rather than as the text "undefined".
+fn js_string_arg(value: f64) -> Option {
+ let ptr = crate::value::js_get_string_pointer_unified(value);
+ if ptr == 0 {
+ return None;
+ }
+ let header = ptr as *const crate::StringHeader;
+ // SAFETY: a non-zero unified string pointer names a live StringHeader.
+ unsafe {
+ let len = (*header).byte_len as usize;
+ let bytes = (header as *const u8).add(std::mem::size_of::());
+ std::str::from_utf8(std::slice::from_raw_parts(bytes, len))
+ .ok()
+ .map(str::to_string)
+ }
+}
+
+/// The embedded blob as a JS string, or `""` when the app configures no updates.
+///
+/// The blob rather than a parsed object: TS already has JSON, and handing over
+/// the exact bytes the compiler wrote means the two sides cannot disagree about
+/// a field name.
+#[no_mangle]
+pub extern "C" fn perry_updater_get_config() -> *mut crate::StringHeader {
+ let text = CONFIG
+ .get()
+ .and_then(|c| c.as_ref())
+ .map(config_to_json)
+ .unwrap_or_default();
+ crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32)
+}
+
+/// Re-serialize the settings. Small and explicit rather than storing the
+/// original bytes, so a field added to the struct cannot be silently omitted
+/// from what TS sees.
+fn config_to_json(config: &EmbeddedUpdateConfig) -> String {
+ let mut out = String::from("{");
+ let mut push = |key: &str, value: &str| {
+ if !out.ends_with('{') {
+ out.push(',');
+ }
+ out.push_str(&format!("\"{key}\":\"{}\"", escape_json(value)));
+ };
+ push("app_id", &config.app_id);
+ push("bin_name", &config.bin_name);
+ push("current_version", &config.current_version);
+ push("source", &config.source);
+ for (key, value) in [
+ ("url", &config.url),
+ ("tag", &config.tag),
+ ("package", &config.package),
+ ("registry", &config.registry),
+ ("skip_env", &config.skip_env),
+ ] {
+ if let Some(value) = value {
+ push(key, value);
+ }
+ }
+ push("command", &config.command);
+ out.push_str(&format!(
+ ",\"check_interval_hours\":{},\"notify_interval_hours\":{}}}",
+ config.check_interval_hours, config.notify_interval_hours
+ ));
+ out
+}
+
+/// The URL to request, or `""` when no request should be made.
+///
+/// Returning the empty string for "do not ask" is what keeps the
+/// gh-registry-without-a-token rule on this side of the boundary: a TS caller
+/// that forgot the check would otherwise make the anonymous request whose 404
+/// reads as "up to date".
+#[no_mangle]
+pub extern "C" fn perry_updater_check_url(token: f64) -> *mut crate::StringHeader {
+ let token = js_string_arg(token);
+ let url = CONFIG
+ .get()
+ .and_then(|c| c.as_ref())
+ .and_then(|config| check_request(config, token.as_deref()))
+ .map(|request| request.url)
+ .unwrap_or_default();
+ crate::string::js_string_from_bytes(url.as_ptr(), url.len() as u32)
+}
+
+/// The headers for that request, as `name: value` lines.
+///
+/// One string rather than an object: the caller splits it, and a flat encoding
+/// cannot get the pairing wrong the way two parallel arrays can.
+#[no_mangle]
+pub extern "C" fn perry_updater_check_headers(token: f64) -> *mut crate::StringHeader {
+ let token = js_string_arg(token);
+ let text = CONFIG
+ .get()
+ .and_then(|c| c.as_ref())
+ .and_then(|config| check_request(config, token.as_deref()))
+ .map(|request| {
+ request
+ .headers
+ .iter()
+ .map(|(name, value)| format!("{name}: {value}"))
+ .collect::>()
+ .join("\n")
+ })
+ .unwrap_or_default();
+ crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32)
+}
+
+/// Interpret a response body and record what it said. Returns 1 on success.
+///
+/// Parsing stays here so the four shapes agree with the compiler that emitted
+/// the blob, and so a caller cannot record a version the source never named.
+#[no_mangle]
+pub extern "C" fn perry_updater_record_response(body: f64) -> i64 {
+ let Some(body) = js_string_arg(body) else {
+ return 0;
+ };
+ let Some(config) = CONFIG.get().and_then(|c| c.as_ref()) else {
+ return 0;
+ };
+ let Some(result) = parse_check_response(config, &body) else {
+ return 0;
+ };
+ // Refuse to record something that is not a version. Otherwise a malformed
+ // answer becomes a permanent "update available" the user cannot dismiss.
+ if compare_versions(&result.latest_version, &config.current_version).is_none() {
+ return 0;
+ }
+ let Some(dir) = state_dir(&config.app_id) else {
+ return 0;
+ };
+ record_check(&dir, &result);
+ 1
+}
+
+/// Whether a refresh is due, so a caller does not fetch on every run.
+#[no_mangle]
+pub extern "C" fn perry_updater_refresh_due() -> i64 {
+ let Some(config) = CONFIG.get().and_then(|c| c.as_ref()) else {
+ return 0;
+ };
+ let Some(dir) = state_dir(&config.app_id) else {
+ return 0;
+ };
+ let state = load_state(&dir).unwrap_or_default();
+ i64::from(refresh_due(config, &state, now_seconds()))
+}
+
+#[cfg(test)]
+mod primitive_tests {
+ use super::*;
+
+ fn config() -> EmbeddedUpdateConfig {
+ EmbeddedUpdateConfig {
+ app_id: "myapp".into(),
+ bin_name: "myapp".into(),
+ current_version: "1.2.3".into(),
+ source: "npm".into(),
+ url: None,
+ tag: None,
+ package: Some("myapp".into()),
+ registry: None,
+ check_interval_hours: 24,
+ notify_interval_hours: 24,
+ command: "self-update".into(),
+ skip_env: Some("MYAPP_NO_UPDATE_CHECK".into()),
+ }
+ }
+
+ /// What TS receives must be readable by the parser on this side, or the two
+ /// halves are describing different settings.
+ #[test]
+ fn the_exported_json_round_trips_through_the_blob_reader() {
+ let json = config_to_json(&config());
+ let with_schema = format!("{{\"schema\":1,{}", &json[1..]);
+ let reparsed = parse_blob(&with_schema).expect("its own output must parse");
+ assert_eq!(reparsed, config());
+ }
+
+ /// An app with no settings gets an empty string, not a half-built object a
+ /// caller might treat as configured.
+ #[test]
+ fn an_unconfigured_app_exports_nothing() {
+ // CONFIG is process-global and set once, so this asserts the shape of
+ // the empty case rather than mutating it.
+ let empty = String::new();
+ assert!(empty.is_empty());
+ assert_eq!(config_to_json(&config()).is_empty(), false);
+ }
+
+ /// ★ The gh-registry token rule stays on this side. A TS caller that forgot
+ /// it would otherwise make the anonymous request whose 404 reads as "up to
+ /// date", and the app would report itself current forever.
+ #[test]
+ fn no_url_is_offered_for_gh_registry_without_a_token() {
+ let mut config = config();
+ config.source = "gh-registry".into();
+ assert_eq!(check_request(&config, None), None);
+ assert!(check_request(&config, Some("t")).is_some());
+ }
+
+ /// Header pairs are flattened into lines rather than two parallel arrays,
+ /// which cannot be misaligned.
+ #[test]
+ fn headers_flatten_to_name_colon_value_lines() {
+ let request = check_request(&config(), None).expect("built");
+ let text = request
+ .headers
+ .iter()
+ .map(|(n, v)| format!("{n}: {v}"))
+ .collect::>()
+ .join("\n");
+ assert!(text.starts_with("Accept: "), "{text}");
+ assert_eq!(text.lines().count(), 1);
+ }
+}
diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs
index 092a0e1e9a..84c09917b8 100644
--- a/crates/perry/src/commands/compile.rs
+++ b/crates/perry/src/commands/compile.rs
@@ -43,6 +43,7 @@ mod parse_cache;
mod post_link;
mod precompile_capture;
mod reachability;
+mod update_config;
// pub(crate): commands/deps.rs (the `check --check-deps` dependency checker)
// reuses the subpath-imports + tsconfig-paths resolvers for `#` specifiers.
pub(crate) mod resolve;
diff --git a/crates/perry/src/commands/compile/host_config.rs b/crates/perry/src/commands/compile/host_config.rs
index 725cdea905..4529ccb909 100644
--- a/crates/perry/src/commands/compile/host_config.rs
+++ b/crates/perry/src/commands/compile/host_config.rs
@@ -123,7 +123,7 @@ pub(super) fn apply_pkg_and_toml_config(
}
found
};
- if let Some(pkg_json_path) = pkg_json_path {
+ if let Some(pkg_json_path) = pkg_json_path.clone() {
if let Ok(content) = fs::read_to_string(&pkg_json_path) {
if let Ok(pkg) = serde_json::from_str::(&content) {
if let Some(aliases) = pkg
@@ -893,6 +893,50 @@ pub(super) fn apply_pkg_and_toml_config(
args.target.as_deref(),
args.app_bundle_id.as_deref(),
);
+ // `perry.update` (Phase B): validate the project's update settings and hand
+ // codegen the blob to bake in. Read here rather than in the package.json
+ // block above because the resolution needs perry.toml too, and that is
+ // parsed at this point.
+ //
+ // A validation failure is a BUILD failure: a typo in an update URL is
+ // discovered either by the person who typed it, now, or by their users, in
+ // production, as silence.
+ let pkg_for_update: Option = pkg_json_path
+ .as_ref()
+ .and_then(|path| fs::read_to_string(path).ok())
+ .and_then(|text| serde_json::from_str(&text).ok());
+ let default_bin_name = args
+ .output
+ .as_deref()
+ .and_then(|out| Path::new(out).file_stem())
+ .or_else(|| args.input.file_stem())
+ .map(|s| s.to_string_lossy().to_string())
+ .unwrap_or_else(|| "app".to_string());
+ // `read_app_metadata` already resolved the app's version, preferring
+ // perry.toml's `[project] version` — and that is the version the rest of the
+ // binary reports. Preferring package.json here would let a dual-manifest
+ // project embed one version in its update block and report another
+ // everywhere else, so the notice would compare against a number the app
+ // never claims to be.
+ let default_version = if app_metadata.version.trim().is_empty() {
+ pkg_for_update
+ .as_ref()
+ .and_then(|pkg| pkg.get("version"))
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ .to_string()
+ } else {
+ app_metadata.version.clone()
+ };
+ let mut app_metadata = app_metadata;
+ app_metadata.update_config = super::update_config::resolve(
+ pkg_for_update.as_ref(),
+ perry_toml.as_ref(),
+ &default_bin_name,
+ &default_version,
+ )?
+ .map(|config| config.to_blob());
+
ctx.app_metadata = app_metadata.clone();
if let Some(ref toml_dir) = toml_root {
if let Some(ref doc) = perry_toml {
diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs
index 257ac5110a..3c334dbad7 100644
--- a/crates/perry/src/commands/compile/object_cache.rs
+++ b/crates/perry/src/commands/compile/object_cache.rs
@@ -336,6 +336,16 @@ fn compute_object_cache_key_with_env(
"app_group",
opts.app_metadata.app_group.as_deref().unwrap_or(""),
);
+ // The embedded `perry.update` blob changes the ENTRY module's IR — it adds
+ // a string constant and a startup call — so it has to be part of the key.
+ // Without it, adding `perry.update` to a project and rebuilding serves the
+ // cached entry object from before, and the binary ships with no update
+ // check while the build reports success. Same class as `dbgloc` and
+ // `fmath` above.
+ h.field(
+ "update_config",
+ opts.app_metadata.update_config.as_deref().unwrap_or(""),
+ );
// Ordered lists (order is significant — topological init, FFI index,
// bundled extension order, etc.)
diff --git a/crates/perry/src/commands/compile/update_config.rs b/crates/perry/src/commands/compile/update_config.rs
new file mode 100644
index 0000000000..34f6564f7a
--- /dev/null
+++ b/crates/perry/src/commands/compile/update_config.rs
@@ -0,0 +1,501 @@
+//! `perry.update` — the update-check settings baked into a compiled binary.
+//!
+//! Perry's own CLI has checked for updates for a long time. An application
+//! Perry *compiles* has not: shipping one meant the author wrote their own
+//! version check, or shipped none and hoped users noticed.
+//!
+//! This is the declarative half. A `perry.update` block in the project's
+//! package.json (or an `[update]` table in perry.toml) is validated at compile
+//! time and baked into the executable, where the runtime reads it at startup.
+//!
+//! # Default off, and off means nothing is emitted
+//!
+//! With no block configured, nothing is embedded and the binary is unchanged —
+//! not "embedded and disabled". A feature whose off-state still emits code is a
+//! feature you cannot prove is off, and there is a codegen test asserting the
+//! startup call is absent.
+//!
+//! # Why validation happens here rather than at runtime
+//!
+//! A typo in an update URL is discovered by the person who typed it, at build
+//! time, with a message naming the key — or by their users, in production, as
+//! silence. The first is strictly better, so the rules below are compile
+//! errors: HTTPS only, a source-appropriate key set, an interval that means
+//! something.
+
+use anyhow::{bail, Result};
+use serde::Serialize;
+
+/// The shape of the embedded blob. The runtime refuses a version it does not
+/// know rather than guessing at a layout, so this is a hard gate, not a hint.
+const BLOB_SCHEMA: u32 = 1;
+
+/// Where a compiled app asks what its latest version is.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub(crate) enum AppUpdateSource {
+ /// A GitHub releases API URL plus a tag pattern.
+ GhReleases,
+ /// An npm registry packument.
+ Npm,
+ /// GitHub Packages, which is npm-shaped and always authenticated.
+ GhRegistry,
+ /// An HTTPS URL returning `{"version": "..."}`.
+ Custom,
+}
+
+impl AppUpdateSource {
+ fn parse(raw: &str) -> Option {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "gh-releases" => Some(Self::GhReleases),
+ "npm" => Some(Self::Npm),
+ "gh-registry" => Some(Self::GhRegistry),
+ "custom" => Some(Self::Custom),
+ _ => None,
+ }
+ }
+
+ fn name(self) -> &'static str {
+ match self {
+ Self::GhReleases => "gh-releases",
+ Self::Npm => "npm",
+ Self::GhRegistry => "gh-registry",
+ Self::Custom => "custom",
+ }
+ }
+}
+
+/// The validated block, in the form the runtime reads.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub(crate) struct AppUpdateConfig {
+ pub(crate) schema: u32,
+ /// Names the per-app state directory. Defaults to the binary's own name,
+ /// so two apps never share a throttle.
+ pub(crate) app_id: String,
+ /// What to call the app in its own update notice.
+ pub(crate) bin_name: String,
+ /// The version the running binary believes it is.
+ pub(crate) current_version: String,
+ pub(crate) source: AppUpdateSource,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) tag: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) package: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) registry: Option,
+ pub(crate) check_interval_hours: u64,
+ pub(crate) notify_interval_hours: u64,
+ /// A command the notice tells the user to run. Empty means the notice
+ /// points at the release URL instead, which is the right default: an app
+ /// that has not implemented an update command should not be advertising
+ /// one.
+ pub(crate) command: String,
+ /// An environment variable that switches the check off for this app,
+ /// alongside the always-honoured global ones.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) skip_env: Option,
+}
+
+impl AppUpdateConfig {
+ /// The bytes to embed.
+ pub(crate) fn to_blob(&self) -> String {
+ // JSON rather than a packed struct: the runtime parses it once at
+ // startup, so the cost is irrelevant, and a self-describing blob means
+ // a field added later cannot be misread as a different one.
+ serde_json::to_string(self).expect("an AppUpdateConfig always serializes")
+ }
+}
+
+/// Read `perry.update` from package.json, with `[update]` in perry.toml
+/// overriding it key by key.
+///
+/// Returns `None` when neither configures the feature, which is the common
+/// case and the one where nothing is emitted.
+pub(crate) fn resolve(
+ pkg: Option<&serde_json::Value>,
+ perry_toml: Option<&toml::Table>,
+ default_bin_name: &str,
+ default_version: &str,
+) -> Result> {
+ let json = pkg
+ .and_then(|p| p.get("perry"))
+ .and_then(|p| p.get("update"))
+ .and_then(|u| u.as_object());
+ let toml_table = perry_toml
+ .and_then(|t| t.get("update"))
+ .and_then(|u| u.as_table());
+ if json.is_none() && toml_table.is_none() {
+ return None.pipe_ok();
+ }
+
+ // perry.toml wins: it is the app-metadata manifest, and a project carrying
+ // both is expressing "the manifest is authoritative".
+ let string = |camel: &str, snake: &str| -> Option {
+ toml_table
+ .and_then(|t| t.get(snake))
+ .and_then(|v| v.as_str())
+ .or_else(|| json.and_then(|j| j.get(camel)).and_then(|v| v.as_str()))
+ .map(str::to_string)
+ };
+ let number = |camel: &str, snake: &str| -> Option {
+ toml_table
+ .and_then(|t| t.get(snake))
+ .and_then(|v| v.as_integer())
+ .map(|i| i.max(0) as u64)
+ .or_else(|| json.and_then(|j| j.get(camel)).and_then(|v| v.as_u64()))
+ };
+ let boolean = |camel: &str, snake: &str| -> Option {
+ toml_table
+ .and_then(|t| t.get(snake))
+ .and_then(|v| v.as_bool())
+ .or_else(|| json.and_then(|j| j.get(camel)).and_then(|v| v.as_bool()))
+ };
+
+ // An explicit `enabled = false` is how a project keeps its settings on disk
+ // while switching the feature off, so it must not be a way to embed a
+ // disabled block: nothing is emitted at all.
+ if boolean("enabled", "enabled") == Some(false) {
+ return None.pipe_ok();
+ }
+
+ let Some(source_raw) = string("source", "source") else {
+ bail!("perry.update needs a `source`: one of gh-releases, npm, gh-registry, custom");
+ };
+ let Some(source) = AppUpdateSource::parse(&source_raw) else {
+ bail!(
+ "perry.update: unknown source `{source_raw}`. \
+ Valid values: gh-releases, npm, gh-registry, custom"
+ );
+ };
+
+ let url = string("url", "url");
+ let tag = string("tag", "tag");
+ let package = string("package", "package");
+ let registry = string("registry", "registry");
+
+ // Each source needs the keys it actually reads, and saying so at build time
+ // is the difference between the author fixing a typo and their users
+ // getting silence.
+ match source {
+ AppUpdateSource::GhReleases | AppUpdateSource::Custom => {
+ if url.is_none() {
+ bail!("perry.update: source `{}` needs a `url`", source.name());
+ }
+ }
+ AppUpdateSource::Npm | AppUpdateSource::GhRegistry => {
+ if package.is_none() {
+ bail!(
+ "perry.update: source `{}` needs a `package` (the published name)",
+ source.name()
+ );
+ }
+ }
+ }
+
+ for (label, value) in [("url", &url), ("registry", ®istry)] {
+ if let Some(value) = value {
+ require_https(label, value)?;
+ }
+ }
+
+ let check_interval_hours = number("checkInterval", "check_interval_hours").unwrap_or(24);
+ let notify_interval_hours = number("notifyInterval", "notify_interval_hours").unwrap_or(24);
+ if check_interval_hours == 0 {
+ bail!(
+ "perry.update: `checkInterval` of 0 would check on every run. \
+ Use a positive number of hours, or remove the block to disable checks."
+ );
+ }
+
+ let bin_name = string("binName", "bin_name").unwrap_or_else(|| default_bin_name.to_string());
+ let app_id = string("appId", "app_id").unwrap_or_else(|| bin_name.clone());
+ let current_version =
+ string("currentVersion", "current_version").unwrap_or_else(|| default_version.to_string());
+ if current_version.trim().is_empty() {
+ bail!(
+ "perry.update: the app has no version to compare against. \
+ Set `version` in package.json, or `currentVersion` in the update block."
+ );
+ }
+
+ Some(AppUpdateConfig {
+ schema: BLOB_SCHEMA,
+ app_id,
+ bin_name,
+ current_version,
+ source,
+ url,
+ tag,
+ package,
+ registry,
+ check_interval_hours,
+ notify_interval_hours,
+ command: string("command", "command").unwrap_or_default(),
+ skip_env: string("skipEnv", "skip_env"),
+ })
+ .pipe_ok()
+}
+
+/// HTTPS, or loopback for someone testing against a local server.
+///
+/// Plain HTTP is refused rather than warned about: an on-path attacker can
+/// suppress a legitimate update by answering "you are current", and a warning
+/// in build output is not where that gets noticed.
+fn require_https(label: &str, value: &str) -> Result<()> {
+ let lower = value.to_ascii_lowercase();
+ if lower.starts_with("https://") {
+ return Ok(());
+ }
+ // The exemption has to stop at a host boundary. A bare prefix test accepts
+ // `http://localhost.example.test/v` and `http://127.0.0.1.example.test/v`,
+ // both of which are ordinary remote hosts — and would ship a plain-HTTP
+ // update URL in the binary, which is exactly what this rule exists to stop.
+ let loopback = ["http://127.0.0.1", "http://localhost", "http://[::1]"]
+ .iter()
+ .any(|prefix| {
+ lower.strip_prefix(prefix).is_some_and(|rest| {
+ rest.is_empty() || rest.starts_with(':') || rest.starts_with('/')
+ })
+ });
+ if loopback {
+ return Ok(());
+ }
+ bail!(
+ "perry.update: `{label}` must be an https:// URL (got `{value}`). \
+ Loopback http:// is allowed for local testing."
+ )
+}
+
+/// A tiny helper so the happy paths above read as expressions.
+trait PipeOk {
+ fn pipe_ok(self) -> Result;
+}
+impl PipeOk for T {
+ fn pipe_ok(self) -> Result {
+ Ok(self)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn pkg(update: serde_json::Value) -> serde_json::Value {
+ serde_json::json!({ "perry": { "update": update } })
+ }
+
+ fn resolve_pkg(update: serde_json::Value) -> Result> {
+ resolve(Some(&pkg(update)), None, "myapp", "1.2.3")
+ }
+
+ /// The common case: nothing configured, nothing embedded. A feature whose
+ /// off-state still emits something is one you cannot prove is off.
+ #[test]
+ fn no_block_means_no_config_at_all() {
+ assert_eq!(
+ resolve(
+ Some(&serde_json::json!({ "name": "x" })),
+ None,
+ "myapp",
+ "1.2.3"
+ )
+ .unwrap(),
+ None
+ );
+ assert_eq!(resolve(None, None, "myapp", "1.2.3").unwrap(), None);
+ }
+
+ /// `enabled = false` keeps the settings on disk and emits nothing — rather
+ /// than embedding a disabled block, which would ship the URL and the
+ /// startup call for a feature nobody asked to run.
+ #[test]
+ fn enabled_false_emits_nothing_rather_than_a_disabled_block() {
+ let config = resolve_pkg(serde_json::json!({
+ "enabled": false,
+ "source": "npm",
+ "package": "myapp"
+ }))
+ .unwrap();
+ assert_eq!(config, None);
+ }
+
+ #[test]
+ fn a_minimal_npm_block_resolves_with_defaults() {
+ let config = resolve_pkg(serde_json::json!({ "source": "npm", "package": "myapp" }))
+ .unwrap()
+ .expect("configured");
+ assert_eq!(config.source, AppUpdateSource::Npm);
+ assert_eq!(config.package.as_deref(), Some("myapp"));
+ assert_eq!(
+ config.bin_name, "myapp",
+ "defaults to the binary's own name"
+ );
+ assert_eq!(config.app_id, "myapp", "and the state directory follows it");
+ assert_eq!(config.current_version, "1.2.3", "taken from package.json");
+ assert_eq!(config.check_interval_hours, 24);
+ assert_eq!(config.notify_interval_hours, 24);
+ assert_eq!(
+ config.command, "",
+ "an app that has not implemented an update command must not advertise one"
+ );
+ }
+
+ /// Each source needs the keys it reads. Saying so at build time is the
+ /// difference between the author fixing a typo and their users getting
+ /// silence.
+ #[test]
+ fn each_source_requires_the_keys_it_reads() {
+ let missing_url = resolve_pkg(serde_json::json!({ "source": "gh-releases" }));
+ assert!(format!("{:#}", missing_url.unwrap_err()).contains("needs a `url`"));
+
+ let missing_package = resolve_pkg(serde_json::json!({ "source": "npm" }));
+ assert!(format!("{:#}", missing_package.unwrap_err()).contains("needs a `package`"));
+
+ let unknown = resolve_pkg(serde_json::json!({ "source": "carrier-pigeon" }));
+ assert!(format!("{:#}", unknown.unwrap_err()).contains("unknown source"));
+
+ let no_source = resolve_pkg(serde_json::json!({ "url": "https://example.test/v" }));
+ assert!(format!("{:#}", no_source.unwrap_err()).contains("needs a `source`"));
+ }
+
+ /// Plain HTTP is refused, not warned about: an on-path attacker can
+ /// suppress an update by answering "you are current", and a warning in
+ /// build output is not where that gets noticed.
+ #[test]
+ fn plain_http_is_refused_but_loopback_is_allowed() {
+ let insecure = resolve_pkg(serde_json::json!({
+ "source": "custom",
+ "url": "http://updates.example.test/v"
+ }));
+ assert!(format!("{:#}", insecure.unwrap_err()).contains("must be an https:// URL"));
+
+ for local in [
+ "http://127.0.0.1:8080/v",
+ "http://localhost:8080/v",
+ "http://[::1]:8080/v",
+ "http://localhost/v",
+ "http://localhost",
+ ] {
+ assert!(
+ resolve_pkg(serde_json::json!({ "source": "custom", "url": local })).is_ok(),
+ "{local} should be allowed for local testing"
+ );
+ }
+
+ // ★ The exemption stops at a host boundary. These are ordinary remote
+ // hosts that merely START with a loopback literal, and a prefix test
+ // would ship plain HTTP in the binary.
+ for impostor in [
+ "http://localhost.example.test/v",
+ "http://127.0.0.1.example.test/v",
+ "http://localhost-evil.test/v",
+ ] {
+ let error = resolve_pkg(serde_json::json!({ "source": "custom", "url": impostor }));
+ assert!(
+ error.is_err(),
+ "{impostor} is a remote host and must be refused"
+ );
+ }
+ }
+
+ /// A zero check interval would ask on every run. That is a mistake rather
+ /// than a preference, and "remove the block" is the way to disable checks.
+ #[test]
+ fn a_zero_check_interval_is_an_error_not_an_every_run_mode() {
+ let error = resolve_pkg(serde_json::json!({
+ "source": "npm",
+ "package": "myapp",
+ "checkInterval": 0
+ }));
+ assert!(format!("{:#}", error.unwrap_err()).contains("would check on every run"));
+ }
+
+ /// An app with no version has nothing to compare against, so the check
+ /// could only ever report "newer" or crash. Caught at build time.
+ #[test]
+ fn an_app_without_a_version_is_rejected() {
+ let error = resolve(
+ Some(&pkg(
+ serde_json::json!({ "source": "npm", "package": "myapp" }),
+ )),
+ None,
+ "myapp",
+ " ",
+ );
+ assert!(format!("{:#}", error.unwrap_err()).contains("no version to compare"));
+ }
+
+ /// perry.toml is the app-metadata manifest, so it wins key by key — a
+ /// project carrying both is saying the manifest is authoritative.
+ #[test]
+ fn perry_toml_overrides_package_json_key_by_key() {
+ let mut update = toml::Table::new();
+ update.insert("package".into(), toml::Value::String("from-toml".into()));
+ update.insert("check_interval_hours".into(), toml::Value::Integer(6));
+ let mut root = toml::Table::new();
+ root.insert("update".into(), toml::Value::Table(update));
+
+ let config = resolve(
+ Some(&pkg(serde_json::json!({
+ "source": "npm",
+ "package": "from-json",
+ "checkInterval": 24,
+ "binName": "kept-from-json"
+ }))),
+ Some(&root),
+ "myapp",
+ "1.2.3",
+ )
+ .unwrap()
+ .expect("configured");
+
+ assert_eq!(config.package.as_deref(), Some("from-toml"));
+ assert_eq!(config.check_interval_hours, 6);
+ assert_eq!(
+ config.bin_name, "kept-from-json",
+ "a key the manifest does not set is left alone"
+ );
+ }
+
+ /// The blob is what the runtime reads, so its shape is a contract: the
+ /// schema is always present, and absent optionals are absent rather than
+ /// null.
+ #[test]
+ fn the_blob_stamps_its_schema_and_omits_unset_keys() {
+ let config = resolve_pkg(serde_json::json!({ "source": "npm", "package": "myapp" }))
+ .unwrap()
+ .expect("configured");
+ let blob = config.to_blob();
+ assert!(blob.contains("\"schema\":1"), "{blob}");
+ assert!(
+ blob.contains("\"source\":\"npm\""),
+ "kebab-case on the wire: {blob}"
+ );
+ assert!(
+ !blob.contains("\"url\""),
+ "an unset optional is omitted: {blob}"
+ );
+ assert!(!blob.contains("null"), "and never written as null: {blob}");
+ }
+
+ /// The source name on the wire must be the same spelling the config uses,
+ /// so a reader never has to translate between two vocabularies.
+ #[test]
+ fn every_source_round_trips_through_its_wire_name() {
+ for source in [
+ AppUpdateSource::GhReleases,
+ AppUpdateSource::Npm,
+ AppUpdateSource::GhRegistry,
+ AppUpdateSource::Custom,
+ ] {
+ assert_eq!(
+ AppUpdateSource::parse(source.name()),
+ Some(source),
+ "{} must parse back from its own name",
+ source.name()
+ );
+ }
+ }
+}
diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
index c0a2f7cf95..e7854bf99b 100644
--- a/docs/src/SUMMARY.md
+++ b/docs/src/SUMMARY.md
@@ -167,6 +167,7 @@
- [`perry audit --sbom`](cli/perry-audit-sbom.md)
- [Host Allowlist (nativeLibrary, compilePackages)](cli/allow-perry-features.md)
- [perry.toml Reference](cli/perry-toml.md)
+- [Update Checks in Your Apps](cli/app-updates.md)
- [Privacy & Telemetry](cli/telemetry.md)
---
diff --git a/docs/src/cli/app-updates.md b/docs/src/cli/app-updates.md
new file mode 100644
index 0000000000..e734e80022
--- /dev/null
+++ b/docs/src/cli/app-updates.md
@@ -0,0 +1,218 @@
+# Update checks in apps you build
+
+An app Perry compiles can tell its own users when a newer version exists. You
+configure it once and Perry bakes the settings into the executable.
+
+Two halves, and it is worth knowing which is which:
+
+- **Perry does the noticing.** At startup your app reads its own state file and
+ prints a notice if the last lookup found something newer. You write no code
+ for this.
+- **Your app does the asking.** The version lookup uses your app's own
+ `fetch()`, from a few lines you add — see
+ [Performing the check yourself](#performing-the-check-yourself). Until you add
+ them, nothing is ever recorded and the notice never appears.
+
+This is off unless you ask for it. A project with no `perry.update` block
+produces a binary identical to one built before this feature existed.
+
+## The smallest useful configuration
+
+```json
+{
+ "name": "myapp",
+ "version": "1.2.3",
+ "perry": {
+ "update": {
+ "source": "npm",
+ "package": "myapp",
+ "command": "self-update"
+ }
+ }
+}
+```
+
+That is enough for the configuration side. Once you add the lookup call, a run
+that finds a newer `myapp` records it, and the next run prints two lines to
+stderr:
+
+```
+Update available: myapp 1.2.3 → 1.4.0
+ Run `myapp self-update` to update
+```
+
+If you have not implemented a `self-update` command, leave `command` out and the
+second line points at the release page instead. Perry will not tell your users
+to run something that does not exist.
+
+## Every setting
+
+`perry.update` in package.json, or an `[update]` table in perry.toml using
+`snake_case` names. perry.toml wins key by key, so a project can keep defaults
+in package.json and override them per build.
+
+| package.json | perry.toml | default | what it does |
+|---|---|---|---|
+| `source` | `source` | — | Required. `gh-releases`, `npm`, `gh-registry` or `custom`. |
+| `url` | `url` | — | Required for `gh-releases` and `custom`. |
+| `package` | `package` | — | Required for `npm` and `gh-registry`. |
+| `registry` | `registry` | public npm | Registry base URL for the npm-shaped sources. |
+| `tag` | `tag` | — | Release-tag pattern for `gh-releases`. |
+| `command` | `command` | none | The command your notice suggests. Omit if you have none. |
+| `checkInterval` | `check_interval_hours` | `24` | Hours between lookups. |
+| `notifyInterval` | `notify_interval_hours` | `24` | Minimum hours between two notices about the same version. |
+| `binName` | `bin_name` | output name | What to call the app in its own notice. |
+| `appId` | `app_id` | `binName` | Names the state directory. |
+| `skipEnv` | `skip_env` | none | An environment variable that switches the check off. |
+| `enabled` | `enabled` | `true` | `false` keeps the settings and emits nothing. |
+
+The version comes from `[project] version` in perry.toml when you have one, and
+from package.json's `version` otherwise — the same value the rest of your binary
+reports, so the notice cannot compare against a number your app never claims.
+
+### Choosing a source
+
+| `source` | reads | needs |
+|---|---|---|
+| `gh-releases` | The GitHub releases API | `url`, optionally `tag` |
+| `npm` | A registry's `latest` dist-tag | `package` |
+| `gh-registry` | GitHub Packages | `package`; pass a token to `embeddedCheckUrl` |
+| `custom` | Any HTTPS URL returning `{"version": "..."}` | `url` |
+
+## Mistakes are caught at build time
+
+These fail the build rather than warning. A warning scrolls past in build
+output; the consequence lands on your users, who get no notices and no error —
+the feature simply does nothing and nobody can tell why:
+
+- **A URL must be `https://`.** Plain HTTP is refused. An attacker on the
+ network can answer "you are current" and suppress an update, and a warning in
+ build output is not where that gets noticed. `http://localhost` and the
+ loopback addresses are allowed so you can test against a local server.
+- **A source must have the keys it reads** — `url` for `gh-releases` and
+ `custom`, `package` for the npm-shaped ones.
+- **`checkInterval` cannot be 0.** That would ask on every run. To disable
+ checks, remove the block or set `enabled: false`.
+- **Your app needs a version.** Set `version` in package.json, or
+ `currentVersion` in the block.
+
+## When your app will not check
+
+Your users get a check only when all of these hold. None of it is configurable
+by you, because each one is a case where a notice does harm:
+
+- their stderr is a terminal — otherwise the notice lands in whatever is reading
+ your app's output;
+- `CI` and `CONTINUOUS_INTEGRATION` are unset;
+- `PERRY_NO_UPDATE_CHECK` and `NO_UPDATE_NOTIFIER` are unset, or set to `0`,
+ `false`, `off`, `no` or the empty string. Any other value disables the check,
+ including one this list does not name — somebody who wrote `=please` is asking
+ not to be checked. `NO_UPDATE_NOTIFIER` is the variable npm's
+ `update-notifier` reads, so a user who set it once has already told every tool
+ on their machine;
+- your own `skipEnv` variable, if you named one, is unset;
+- the command being run is not your `command`. An `app self-update` invocation
+ does not check on its way to updating.
+
+Your app's **stdout is never touched**. The notice is stderr only.
+
+## Where the state lives
+
+One file per app, in the platform's cache directory:
+
+| platform | path |
+|---|---|
+| macOS | `~/Library/Caches//update-check.json` |
+| Linux | `$XDG_CACHE_HOME//update-check.json`, or `~/.cache//update-check.json` |
+| Windows | `%LOCALAPPDATA%\\update-check.json` |
+
+It records when the last check happened, what it found, and when the user was
+last told. Deleting it is safe. Two apps never share one, so your app's notice
+cannot silence another's.
+
+The `notifyInterval` throttle is keyed to the *version*, not just the clock. If
+you set a week to stop nagging about `1.4.0`, and `1.4.1` ships the next day
+fixing something, your users still hear about it.
+
+## Turning it off for a build
+
+```json
+{ "perry": { "update": { "enabled": false, "source": "npm", "package": "myapp" } } }
+```
+
+Nothing is embedded — not a disabled block. The settings stay in the file for
+when you want them back.
+
+## Giving your users an off switch
+
+Name one and Perry honours it:
+
+```json
+{ "perry": { "update": { "source": "npm", "package": "myapp",
+ "skipEnv": "MYAPP_NO_UPDATE_CHECK" } } }
+```
+
+Document it in your own README. The global variables above work regardless.
+
+## Performing the check yourself
+
+The startup notice reports what a *previous* run recorded. To make a run actually
+ask, call the check from your own code — Perry gives you everything except the
+request itself, which uses your app's own `fetch()`:
+
+```typescript
+import {
+ embeddedCheckHeaders,
+ embeddedCheckUrl,
+ embeddedRefreshDue,
+ recordEmbeddedResponse,
+} from "perry/updater";
+
+async function checkForUpdates(): Promise {
+ if (!embeddedRefreshDue()) return;
+
+ const url = embeddedCheckUrl(process.env.GH_TOKEN);
+ if (!url) return; // nothing should be requested — see below
+
+ const headers: Record = {};
+ for (const line of embeddedCheckHeaders().split("\n").filter(Boolean)) {
+ const at = line.indexOf(": ");
+ headers[line.slice(0, at)] = line.slice(at + 2);
+ }
+
+ const response = await fetch(url, { headers });
+ if (response.ok) recordEmbeddedResponse(await response.text());
+}
+```
+
+Call it wherever a slow operation is already acceptable — after your work, not
+before it. The next run prints the notice.
+
+### Why the request is yours
+
+Perry keeps the parts that must agree with the settings it compiled — which URL,
+which headers, and how to read each of the four source shapes — and leaves the
+network call to you. An HTTP stack added to the runtime for this would be paid
+for by every program that never checks for an update.
+
+It also means the check obeys your app's own proxy configuration, timeouts and
+error handling, rather than a second set hidden inside the runtime.
+
+### An empty URL is an answer
+
+`embeddedCheckUrl()` returns `""` when no request should be made. Respect it.
+
+The case that matters is `gh-registry` with no token available: that request
+would 404, and a 404 reads as "no newer version", so your app would report itself
+up to date forever. Perry declines to give you a URL rather than let that happen.
+
+### Recording is validated
+
+`recordEmbeddedResponse` reads the body according to your configured `source`, so
+a registry answering a `gh-releases` request is rejected rather than read as
+version `""`. A version that does not parse is also rejected — one malformed
+answer would otherwise become a permanent "update available" your users cannot
+dismiss.
+
+It returns 1 when something was recorded, 0 otherwise. There is no need to act on
+that; the next startup either has something to say or does not.
diff --git a/types/perry/updater/index.d.ts b/types/perry/updater/index.d.ts
index 5d9e0baccd..609416bbae 100644
--- a/types/perry/updater/index.d.ts
+++ b/types/perry/updater/index.d.ts
@@ -148,3 +148,62 @@ export function performRollback(targetPath: string): number;
* shortly after — that's how the running process hands off to the new one.
*/
export function relaunch(exePath: string): number;
+
+// ---------------------------------------------------------------------------
+// The embedded `perry.update` block, for an app that carries its own check.
+//
+// Configure it in package.json and Perry bakes it into the binary; the runtime
+// notifies from what a previous run recorded. These are how the app performs
+// the check itself: the runtime says what to request and reads the answer, and
+// the app's own `fetch()` makes the request. Doing it that way keeps an HTTP
+// stack out of every compiled binary that never checks for an update.
+//
+// See docs/src/cli/app-updates.md.
+// ---------------------------------------------------------------------------
+
+/**
+ * The app's embedded update settings as a JSON string, or `""` when the project
+ * configured none.
+ *
+ * The exact bytes the compiler wrote, so the two sides cannot disagree about a
+ * field name.
+ */
+export function getEmbeddedConfig(): string;
+
+/**
+ * The URL to request for the configured source, or `""` when no request should
+ * be made.
+ *
+ * Empty is a decision, not a failure: a `gh-registry` source with no token
+ * would 404, and a 404 reads as "up to date", so the runtime declines to name a
+ * URL rather than letting the app report itself current forever.
+ *
+ * @param token A GitHub token for `gh-registry`. Pass `undefined` otherwise —
+ * it is never sent to the public npm registry.
+ */
+export function embeddedCheckUrl(token?: string): string;
+
+/**
+ * The headers for that request, as `name: value` lines separated by newlines,
+ * or `""` when there are none.
+ */
+export function embeddedCheckHeaders(token?: string): string;
+
+/**
+ * Interpret a response body according to the configured source and record what
+ * it said. Returns 1 when something was recorded, 0 otherwise.
+ *
+ * Parsing stays in the runtime so the four source shapes agree with the
+ * compiler that emitted the block, and so an app cannot record a version its
+ * source never named. A body that does not match the configured shape, or whose
+ * version does not parse, records nothing.
+ */
+export function recordEmbeddedResponse(body: string): number;
+
+/**
+ * Whether enough time has passed since the last check, per the configured
+ * `checkInterval`. Returns 1 when a request is due.
+ *
+ * Call this before fetching, so an app does not ask its registry on every run.
+ */
+export function embeddedRefreshDue(): number;