Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

162 changes: 162 additions & 0 deletions changelog.d/7789-app-update-config.md
Original file line number Diff line number Diff line change
@@ -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.

<details>
<summary><b>Validation is a build failure, on purpose</b></summary>

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.
</details>

<details>
<summary><b>The four sources, and what each refuses to do</b></summary>

`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.
</details>

<details>
<summary><b>Lessons taken from the CLI's own review rather than rediscovered</b></summary>

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.
</details>

<details>
<summary><b>Two gaps closed on the way past</b></summary>

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.
</details>

<details>
<summary><b>Tests</b></summary>

**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.
</details>

### 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.
5 changes: 5 additions & 0 deletions crates/perry-api-manifest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
5 changes: 5 additions & 0 deletions crates/perry-api-manifest/src/entries/part_4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
44 changes: 44 additions & 0 deletions crates/perry-api-manifest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
}
}
31 changes: 31 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

impl Default for AppMetadata {
Expand All @@ -29,6 +36,7 @@ impl Default for AppMetadata {
build_number: 1,
bundle_id: "com.perry.app".to_string(),
app_group: None,
update_config: None,
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/runtime_decls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions crates/perry-dispatch/src/updater_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading