diff --git a/Cargo.lock b/Cargo.lock index 4555d61..2d9deab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1631,7 +1631,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.11.0" +version = "1.12.0" dependencies = [ "blake3", "serde", diff --git a/crates/app-contract/CHANGELOG.md b/crates/app-contract/CHANGELOG.md index dafe559..2a0583f 100644 --- a/crates/app-contract/CHANGELOG.md +++ b/crates/app-contract/CHANGELOG.md @@ -10,6 +10,26 @@ versions' changes, and 1.10.0 includes 1.9.0's changes. [README.md](README.md#versions-on-cratesio) shows how to update a lock file that holds 1.2.0. +## 1.12.0 — unreleased + +- Add the optional `window` manifest field (`WindowHint { width, height }`, + with `WindowHint::new`): the size, in points, at which the app prefers its + desktop window to open. It is a hint, not a grant: hosts clamp it to the + screen, phones and other full-screen hosts ignore it, and the person can + still resize the window. `AppPolicy` is unchanged by it. +- `SCHEMA_MINOR` is now 1, the first bump. A manifest that sets `window` + must declare `"schema_minor": 1` or later; `parse` and `resolve` refuse it + otherwise. A host on an earlier contract then reads such a manifest + leniently: it ignores `window`, lists it in `ignored_fields()`, and + computes the same signing bytes, instead of refusing an unknown field. +- `parse` refuses a width outside 320 to 4096, a height outside 240 to + 4096, and a `window` that is not an object of those two integers. The + field is skipped when absent, so existing manifests and their signing + bytes do not change. +- A manifest with `"schema_minor": 1` is now read strictly, like any + manifest at this build's minor: an unknown field in it is refused rather + than ignored. Manifests for later minors are still read leniently. + ## 1.11.0 — 2026-10-10 Published through the [release workflow](https://github.com/OctoSense-org/OctoSense-App-Hub/actions/runs/38026012863) diff --git a/crates/app-contract/Cargo.toml b/crates/app-contract/Cargo.toml index 4cbbbad..0deb45b 100644 --- a/crates/app-contract/Cargo.toml +++ b/crates/app-contract/Cargo.toml @@ -7,7 +7,7 @@ # and no path dependencies. Within 1.x it only grows (see README.md). [package] name = "octosense-app-contract" -version = "1.11.0" +version = "1.12.0" edition = "2021" license = "Apache-2.0" description = "The OctoSense app contract: app manifests, the policy an app gets, package integrity and running a package" diff --git a/crates/app-contract/README.md b/crates/app-contract/README.md index c72215e..0fb2258 100644 --- a/crates/app-contract/README.md +++ b/crates/app-contract/README.md @@ -152,9 +152,9 @@ Within `1.x` the contract only grows (ADR 0005 section 2): 2. Every `requires` entry must be in `KNOWN_FEATURES`, at every `schema_minor`. Otherwise: `app needs a newer host: `. `1.0.0` knows no features. - 3. If `schema_minor` (default 0) is at most `SCHEMA_MINOR` (0 in every - release so far), `parse` reads the manifest strictly and refuses it if - any level has an unknown field. + 3. If `schema_minor` (default 0) is at most `SCHEMA_MINOR` (0 before + 1.12.0; 1 from 1.12.0, which adds the optional `window`), `parse` reads + the manifest strictly and refuses it if any level has an unknown field. 4. If `schema_minor` is above `SCHEMA_MINOR`, the manifest was written for a newer `1.x`. Rule 2 has passed, so every unknown field, at any level, is optional: `parse` ignores it, and `AppManifest::ignored_fields()` @@ -167,6 +167,10 @@ Within `1.x` the contract only grows (ADR 0005 section 2): An older host therefore never runs an app under weaker rules than its author wrote, and a newer optional field never breaks an older host. + That holds only when the manifest declares the minor that added the + field. `window`, the first field added at a new minor, needs + `"schema_minor": 1`, and `parse` refuses it in a manifest that declares + less. Ignored fields stay in the manifest's signing bytes, so a newer signed manifest still verifies. A field added in `1.x` must be omitted from serialization when it holds its default, and must serialize exactly as diff --git a/crates/app-contract/src/lib.rs b/crates/app-contract/src/lib.rs index 3d0e487..9b849fc 100644 --- a/crates/app-contract/src/lib.rs +++ b/crates/app-contract/src/lib.rs @@ -68,9 +68,9 @@ //! `schema_minor`; otherwise the app is refused with //! `app needs a newer host: `. `1.0.0` knows none. //! 3. A manifest whose `schema_minor` (default 0) is at most -//! [`SCHEMA_MINOR`] (`0` in `1.0.0`) is read strictly: an unknown -//! field at any level refuses it, exactly as before `requires` -//! existed. +//! [`SCHEMA_MINOR`] (`0` in `1.0.0`, `1` since `1.12.0`, which added +//! `window`) is read strictly: an unknown field at any level refuses +//! it, exactly as before `requires` existed. //! 4. A manifest whose `schema_minor` is above [`SCHEMA_MINOR`] was //! written for a newer `1.x`. Its unknown fields, at any level //! (top level, `network`, `storage`, `agent`, `agent.model`, …), are @@ -80,7 +80,9 @@ //! the old toolbox shape is still refused. //! //! So an older host never runs an app under weaker rules than its author -//! wrote, and a newer optional field never breaks an older host. The +//! wrote, and a newer optional field never breaks an older host, as long +//! as the manifest declares the minor that added the field: `window`, the +//! first field added at a new minor, is refused below `schema_minor` 1. The //! ignored fields stay part of the manifest's signing bytes, so a newer //! manifest's signature still verifies; a field added in `1.x` must //! therefore be skipped when serialising its default and must serialise @@ -109,7 +111,7 @@ pub use entry::{script_source, ASSETS_PLACEHOLDER, SCRIPT_ENTRY}; pub use manifest::{ check_component_id, check_reserved_id, is_exact_version, parse, short_id, AgentSpec, AgentWorkspace, AppManifest, ComponentDependency, Compute, GithubPublisher, Integrity, ModelNeed, ModelSpec, ModelTier, Network, ProfileMode, Signature, Storage, TaskModel, Triggers, - KNOWN_CAPABILITIES, KNOWN_FEATURES, KNOWN_MODEL_NEEDS, MAX_COMPONENTS, RESERVED_HOST_FILE_IDS, RESERVED_NAMES, SCHEMA, SCHEMA_MINOR, + WindowHint, KNOWN_CAPABILITIES, KNOWN_FEATURES, KNOWN_MODEL_NEEDS, MAX_COMPONENTS, RESERVED_HOST_FILE_IDS, RESERVED_NAMES, SCHEMA, SCHEMA_MINOR, }; pub use policy::{resolve, AppPolicy, HostLimits, StorageGrant}; pub use research::ResearchScope; diff --git a/crates/app-contract/src/manifest.rs b/crates/app-contract/src/manifest.rs index 5a58f37..9118296 100644 --- a/crates/app-contract/src/manifest.rs +++ b/crates/app-contract/src/manifest.rs @@ -18,11 +18,19 @@ use std::collections::BTreeMap; pub const SCHEMA: u32 = 1; /// The newest `schema_minor` this build knows: which additions to schema 1 -/// it understands. `0` is the grammar of `1.0.0`. A manifest may carry a +/// it understands. `0` is the grammar of `1.0.0`; `1` adds +/// [`AppManifest::window`] (contract `1.12.0`). A manifest may carry a /// higher `schema_minor` (it records what the manifest uses, and asks for /// nothing by itself); what a host must understand is listed in /// [`AppManifest::requires`]. -pub const SCHEMA_MINOR: u32 = 0; +pub const SCHEMA_MINOR: u32 = 1; + +/// The first `schema_minor` that knows [`AppManifest::window`]. +const WINDOW_SCHEMA_MINOR: u32 = 1; + +/// The widths and heights, in points, a [`WindowHint`] may ask for. +const WINDOW_WIDTHS: std::ops::RangeInclusive = 320..=4096; +const WINDOW_HEIGHTS: std::ops::RangeInclusive = 240..=4096; /// The features a manifest may list in [`AppManifest::requires`] and this /// build honours. Empty in `1.0.0`: every feature added in `1.x` that @@ -332,6 +340,16 @@ pub struct AppManifest { /// exactly as it did. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub components: Vec, + /// The size the app prefers its desktop window to open at, in points + /// ([`WindowHint`]). A hint, not a grant: hosts clamp it to the screen, + /// phones and other full-screen hosts ignore it, and the person can + /// still resize the window. An optional field in the growth rule's + /// sense, so it needs `schema_minor` 1 or later: a host that predates + /// it then reads the manifest leniently and ignores the hint instead of + /// refusing an unknown field. Skipped when absent, so a manifest written + /// before it existed signs exactly as it did. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, /// Features added in `1.x` that this manifest needs a host to honour, /// because they restrict or change what the app gets. A host that does /// not know one ([`KNOWN_FEATURES`]) refuses the app rather than run it @@ -562,6 +580,70 @@ pub fn is_exact_version(version: &str) -> bool { parts.len() == 3 && parts.iter().all(|p| numeric(p)) && pre_ok && build_ok } +/// The size an app prefers its desktop window to open at, in points +/// ([`AppManifest::window`], `schema_minor` 1): `{"width": 1536, "height": +/// 1024}`. +/// +/// A hint, not a grant. It asks for no capability, storage or budget, and +/// a host owes the app nothing for it: hosts clamp it to the screen, phones +/// and other full-screen hosts ignore it, and the person can still resize +/// the window. [`parse`] refuses a width outside 320 to 4096 or a height +/// outside 240 to 4096. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct WindowHint { + /// Points, 320 to 4096. + pub width: u32, + /// Points, 240 to 4096. + pub height: u32, +} + +impl WindowHint { + /// A hint of `width` by `height` points. [`parse`] checks the range; this + /// does not. + pub fn new(width: u32, height: u32) -> Self { + WindowHint { width, height } + } +} + +/// What a derived, `deny_unknown_fields` `Deserialize` does, except that only +/// an object is a window: serde would also read `[1536, 1024]` as one, which +/// then serialises, and signs, as an object rather than as written. +impl<'de> Deserialize<'de> for WindowHint { + fn deserialize>(deserializer: D) -> Result { + use serde::de::{Error, MapAccess, Visitor}; + const FIELDS: &[&str] = &["width", "height"]; + struct Object; + impl<'de> Visitor<'de> for Object { + type Value = WindowHint; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(r#"an object {"width": , "height": }"#) + } + fn visit_map>(self, mut map: A) -> Result { + let (mut width, mut height) = (None, None); + while let Some(key) = map.next_key::()? { + let (slot, name) = match key.as_str() { + "width" => (&mut width, "width"), + "height" => (&mut height, "height"), + other => return Err(A::Error::unknown_field(other, FIELDS)), + }; + if slot.is_some() { + return Err(A::Error::duplicate_field(name)); + } + *slot = Some(map.next_value::()?); + } + Ok(WindowHint { + width: width.ok_or_else(|| A::Error::missing_field("width"))?, + height: height.ok_or_else(|| A::Error::missing_field("height"))?, + }) + } + } + // Named as a struct, so a manifest for a newer 1.x has a later + // field inside `window` ignored and listed like any other. + deserializer.deserialize_struct("WindowHint", FIELDS, Object) + } +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] #[non_exhaustive] @@ -838,6 +920,11 @@ impl AppManifest { /// level: by the growth rule they are optional, because a field that /// restricts or changes what the app gets must be named in `requires`. /// The ignored fields are reported by [`AppManifest::ignored_fields`]. + /// - Known fields are checked as always, at every `schema_minor` + /// ([`AppManifest::check_requires`]). A manifest that sets `window`, + /// which `schema_minor` 1 added, is refused at `schema_minor` 0: a + /// host that predates `window` must read it leniently and ignore the + /// hint, never refuse it as an unknown field. pub fn parse(json: &str) -> Result { let manifest = match serde_json::from_str::(json) { Ok(manifest) => manifest, @@ -904,7 +991,9 @@ impl AppManifest { /// Refuse a manifest that requires a feature this build does not know: /// running it would give the app less containment, or something other, - /// than its author declared. + /// than its author declared. Also refuse a known field its rules refuse: + /// `integrity.github`, `host_api`, `backend` and `components` without + /// their feature, or `window` below `schema_minor` 1 or out of range. pub fn check_requires(&self) -> Result<(), String> { let github_required = self.requires.iter().any(|f| f == "publisher-github-v1"); if github_required != self.integrity.github.is_some() { @@ -930,6 +1019,7 @@ impl AppManifest { backend.validate()?; } self.check_components()?; + self.check_window()?; let unknown: Vec<&str> = self.requires.iter().map(String::as_str).filter(|feature| !KNOWN_FEATURES.contains(feature)).collect(); if unknown.is_empty() { @@ -961,6 +1051,36 @@ impl AppManifest { Ok(()) } + /// The `window` rules: the field arrived with `schema_minor` 1, so a + /// manifest that sets it must say so (an older host then ignores the + /// hint instead of refusing an unknown field), and each side must be in + /// range. The hint grants nothing, so nothing else is checked. + fn check_window(&self) -> Result<(), String> { + let Some(window) = &self.window else { return Ok(()) }; + if self.schema_minor < WINDOW_SCHEMA_MINOR { + return Err(format!( + "window requires \"schema_minor\": {WINDOW_SCHEMA_MINOR} or later, so that hosts which predate window ignore it instead of refusing the app" + )); + } + if !WINDOW_WIDTHS.contains(&window.width) { + return Err(format!( + "window width must be {} to {} points, not {}", + WINDOW_WIDTHS.start(), + WINDOW_WIDTHS.end(), + window.width + )); + } + if !WINDOW_HEIGHTS.contains(&window.height) { + return Err(format!( + "window height must be {} to {} points, not {}", + WINDOW_HEIGHTS.start(), + WINDOW_HEIGHTS.end(), + window.height + )); + } + Ok(()) + } + /// Device-side compatibility. Admission validates declarations separately; /// installers and runners must supply their actual implemented API versions. pub fn check_host_apis(&self, available: &BTreeMap) -> Result<(), String> { diff --git a/crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json b/crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json new file mode 100644 index 0000000..2d34f6d --- /dev/null +++ b/crates/app-contract/tests/fixtures/refuse-window-without-minor/expected.json @@ -0,0 +1,6 @@ +{ + "digest": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26", + "limits": "unsigned", + "refused": "window requires \"schema_minor\": 1 or later, so that hosts which predate window ignore it instead of refusing the app", + "source": "Contract 1.12.0: window in a manifest that does not declare schema_minor 1, which a host that predates window would refuse as an unknown field" +} diff --git a/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json new file mode 100644 index 0000000..0eef033 --- /dev/null +++ b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/manifest.json @@ -0,0 +1,9 @@ +{ + "schema": 1, + "id": "forecast", + "version": "1.2.0", + "name": "Forecast", + "integrity": { "bundle_blake3": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26" }, + "capabilities": ["storage"], + "window": { "width": 1536, "height": 1024 } +} diff --git a/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card new file mode 100644 index 0000000..03513c7 --- /dev/null +++ b/crates/app-contract/tests/fixtures/refuse-window-without-minor/package/page.card @@ -0,0 +1 @@ +a card bundle \ No newline at end of file diff --git a/crates/app-contract/tests/fixtures/test-window-hint/expected.json b/crates/app-contract/tests/fixtures/test-window-hint/expected.json new file mode 100644 index 0000000..a6c3ecb --- /dev/null +++ b/crates/app-contract/tests/fixtures/test-window-hint/expected.json @@ -0,0 +1,28 @@ +{ + "digest": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26", + "entry": "card", + "ignored_fields": [], + "limits": "unsigned", + "policy": { + "app_id": "forecast", + "capabilities": [ + "storage" + ], + "display_name": "Forecast", + "hosts": [], + "instruction_budget": 20000000, + "may_prompt": false, + "memory_bytes": 67108864, + "research": null, + "storage": { + "accounts": false, + "agent_workspace": "account", + "cache_max_bytes": null, + "max_bytes": 16777216 + }, + "storage_bytes": 16777216, + "version": "1.2.0" + }, + "signing_blake3": "33cd9cadafd87de362f31926030bc01c3f4686538d1835d3e1c22524ccb1595a", + "source": "Contract 1.12.0 (schema_minor 1): the optional window hint, read strictly at this build's minor; it grants nothing" +} diff --git a/crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json b/crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json new file mode 100644 index 0000000..33d6aae --- /dev/null +++ b/crates/app-contract/tests/fixtures/test-window-hint/package/manifest.json @@ -0,0 +1,10 @@ +{ + "schema": 1, + "schema_minor": 1, + "id": "forecast", + "version": "1.2.0", + "name": "Forecast", + "integrity": { "bundle_blake3": "729bc3dfe1ef9ae2e49cb86f82db670adc09282c4aaadc65d58ed804d6255c26" }, + "capabilities": ["storage"], + "window": { "width": 1536, "height": 1024 } +} diff --git a/crates/app-contract/tests/fixtures/test-window-hint/package/page.card b/crates/app-contract/tests/fixtures/test-window-hint/package/page.card new file mode 100644 index 0000000..03513c7 --- /dev/null +++ b/crates/app-contract/tests/fixtures/test-window-hint/package/page.card @@ -0,0 +1 @@ +a card bundle \ No newline at end of file diff --git a/crates/app-contract/tests/growth.rs b/crates/app-contract/tests/growth.rs index a715c9b..61dad6c 100644 --- a/crates/app-contract/tests/growth.rs +++ b/crates/app-contract/tests/growth.rs @@ -12,10 +12,19 @@ fn manifest_with(body: &str) -> String { ) } +/// A `schema_minor` newer than this build's: what a manifest written for a +/// later 1.x says. Relative, so the lenient-path tests keep testing the +/// lenient path when this build's minor moves on. +fn newer_minor() -> u32 { + SCHEMA_MINOR + 1 +} + #[test] fn palpo_extension_preserves_schema_and_declares_its_host_feature() { assert_eq!(SCHEMA, 1); - assert_eq!(SCHEMA_MINOR, 0); + // 1 since 1.12.0, which added the optional `window` hint; Palpo's + // operations (1.2.0) left it at 0. + assert_eq!(SCHEMA_MINOR, 1); assert_eq!( KNOWN_FEATURES, &[ @@ -318,6 +327,11 @@ fn unknown_fields_are_still_refused() { assert!(err.contains("unknown field"), "{err}"); let err = parse(&manifest_with(r#""schema_minor":0,"network":{"hosts":[],"proxy":"x"}"#)).unwrap_err(); assert!(err.contains("unknown field `proxy`"), "{err}"); + // Minor 1 is this build's own minor now: no longer read leniently. + let err = parse(&manifest_with(r#""schema_minor":1,"sandbox":"off""#)).unwrap_err(); + assert!(err.contains("unknown field `sandbox`"), "{err}"); + let err = parse(&manifest_with(r#""schema_minor":1,"window":{"width":800,"height":600,"x":0}"#)).unwrap_err(); + assert!(err.contains("unknown field `x`"), "{err}"); } #[test] @@ -333,7 +347,8 @@ fn the_new_fields_do_not_change_an_older_manifests_signing_bytes() { #[test] fn a_newer_minor_ignores_an_unknown_optional_field_and_reports_it() { - let manifest = parse(&manifest_with(r#""schema_minor":1,"capabilities":["storage"],"theme_color":"slate""#)).unwrap(); + let minor = newer_minor(); + let manifest = parse(&manifest_with(&format!(r#""schema_minor":{minor},"capabilities":["storage"],"theme_color":"slate""#))).unwrap(); assert_eq!(manifest.ignored_fields(), ["theme_color"]); assert_eq!(manifest.capabilities, ["storage"]); let limits = HostLimits::default().with_require_signature(false); @@ -391,22 +406,137 @@ fn a_newer_minor_ignores_unknown_fields_at_every_level() { fn a_newer_minor_still_refuses_what_it_knows_to_be_wrong() { // Ignoring is for unknown fields only: a known field with a bad value, // a foreign schema and the old research shape are refused as before. - let err = parse(&manifest_with(r#""schema_minor":1,"storage":{"agent_workspace":"everything"}"#)).unwrap_err(); + let minor = newer_minor(); + let err = parse(&manifest_with(&format!(r#""schema_minor":{minor},"storage":{{"agent_workspace":"everything"}}"#))).unwrap_err(); assert!(err.contains("manifest is not valid"), "{err}"); - let err = parse(&manifest_with(r#""schema_minor":1,"x":1"#).replace(r#""schema":1"#, r#""schema":2"#)).unwrap_err(); + let err = parse(&manifest_with(&format!(r#""schema_minor":{minor},"x":1"#)).replace(r#""schema":1"#, r#""schema":2"#)).unwrap_err(); assert!(err.contains("manifest is not valid"), "{err}"); - let err = parse(&manifest_with(r#""schema_minor":1,"capabilities":["research"],"research":{"languages":["en"]}"#)).unwrap_err(); + let err = parse(&manifest_with(&format!( + r#""schema_minor":{minor},"capabilities":["research"],"research":{{"languages":["en"]}}"# + ))) + .unwrap_err(); assert!(err.contains("old toolbox shape"), "{err}"); + // So is a window out of range. + let err = parse(&manifest_with(&format!(r#""schema_minor":{minor},"window":{{"width":100,"height":600}}"#))).unwrap_err(); + assert_eq!(err, "window width must be 320 to 4096 points, not 100"); } #[test] fn a_newer_manifest_signs_with_the_fields_it_was_signed_with() { - let manifest = parse(&manifest_with(r#""schema_minor":1,"theme_color":"blue","network":{"hosts":[],"retry":2}"#)).unwrap(); + let minor = newer_minor(); + let manifest = + parse(&manifest_with(&format!(r#""schema_minor":{minor},"theme_color":"blue","network":{{"hosts":[],"retry":2}}"#))).unwrap(); let bytes = String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(); assert!(bytes.contains(r#""theme_color":"blue""#), "{bytes}"); assert!(bytes.contains(r#""network":{"hosts":[],"retry":2}"#), "{bytes}"); } +// ---- the window hint (schema_minor 1, contract 1.12.0) ------------------- + +/// The signing bytes of `manifest_with(r#""capabilities":["storage"]"#)`, as +/// every contract before 1.12.0 computed them. +const PLAIN_SIGNING_BYTES: &str = r#"{"agent":null,"capabilities":["storage"],"compute":{"instruction_budget":null,"memory_bytes":null},"id":"forecast","integrity":{"bundle_blake3":"00","signature":null},"name":"Forecast","network":{"hosts":[]},"schema":1,"storage":{"max_bytes":null},"version":"1.0.0"}"#; + +#[test] +fn a_window_hint_parses_at_schema_minor_1_and_grants_nothing() { + let manifest = + parse(&manifest_with(r#""schema_minor":1,"capabilities":["storage"],"window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(manifest.window, Some(WindowHint::new(1536, 1024))); + assert!(manifest.ignored_fields().is_empty()); + // It serialises exactly as written, and its signing bytes carry it. + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["window"], serde_json::json!({"width": 1536, "height": 1024})); + let bytes = String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(); + assert!(bytes.contains(r#""schema_minor":1"#) && bytes.contains(r#""window":{"height":1024,"width":1536}"#), "{bytes}"); + // A hint, not a grant: the policy is the one the same manifest without + // it resolves to. + let limits = HostLimits::default().with_require_signature(false); + let plain = parse(&manifest_with(r#""capabilities":["storage"]"#)).unwrap(); + assert_eq!(resolve(&manifest, &limits).unwrap(), resolve(&plain, &limits).unwrap()); + let system = HostLimits::system(); + assert_eq!(resolve(&manifest, &system).unwrap(), resolve(&plain, &system).unwrap()); +} + +#[test] +fn a_manifest_without_a_window_parses_and_signs_as_before() { + let manifest = parse(&manifest_with(r#""capabilities":["storage"]"#)).unwrap(); + assert_eq!(manifest.window, None); + let json = serde_json::to_string(&manifest).unwrap(); + assert!(!json.contains("window"), "{json}"); + assert_eq!(String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(), PLAIN_SIGNING_BYTES); + // Declaring this build's minor without using window changes only the + // schema_minor key. + let minor = parse(&manifest_with(r#""schema_minor":1,"capabilities":["storage"]"#)).unwrap(); + assert_eq!(minor.window, None); + let bytes = String::from_utf8(minor.signing_bytes().unwrap()).unwrap(); + assert_eq!(bytes.replace(r#","schema_minor":1"#, ""), PLAIN_SIGNING_BYTES); +} + +#[test] +fn a_window_hint_needs_schema_minor_1() { + let expected = r#"window requires "schema_minor": 1 or later, so that hosts which predate window ignore it instead of refusing the app"#; + for minor in ["", r#""schema_minor":0,"#] { + let err = parse(&manifest_with(&format!(r#"{minor}"window":{{"width":1536,"height":1024}}"#))).unwrap_err(); + assert_eq!(err, expected, "{minor:?}"); + } + // resolve checks it too, for a manifest that did not come through parse. + let unparsed: AppManifest = serde_json::from_str(&manifest_with(r#""window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(resolve(&unparsed, &HostLimits::system()).unwrap_err(), expected); +} + +#[test] +fn a_window_hint_is_held_to_its_range() { + let at = |width: i64, height: i64| { + parse(&manifest_with(&format!(r#""schema_minor":1,"window":{{"width":{width},"height":{height}}}"#))) + }; + for (width, height) in [(320, 240), (4096, 4096), (1536, 1024), (320, 4096), (4096, 240)] { + assert_eq!(at(width, height).unwrap().window, Some(WindowHint::new(width as u32, height as u32))); + } + for (width, height, expected) in [ + (319, 600, "window width must be 320 to 4096 points, not 319"), + (4097, 600, "window width must be 320 to 4096 points, not 4097"), + (0, 600, "window width must be 320 to 4096 points, not 0"), + (800, 239, "window height must be 240 to 4096 points, not 239"), + (800, 4097, "window height must be 240 to 4096 points, not 4097"), + ] { + assert_eq!(at(width, height).unwrap_err(), expected, "{width}x{height}"); + } + // Not a size at all: refused by the parser like any malformed field. + for (window, why) in [ + (r#"{"width":800}"#, "missing field `height`"), + (r#"{"height":600}"#, "missing field `width`"), + (r#"{"width":800,"width":900,"height":600}"#, "duplicate field `width`"), + (r#"{"width":-1,"height":600}"#, "invalid value"), + (r#"{"width":800.5,"height":600}"#, "invalid type: floating point"), + (r#"{"width":1536.0,"height":1024}"#, "invalid type: floating point"), + (r#"{"width":4294967296,"height":600}"#, "invalid value"), + (r#"{"width":"800","height":600}"#, "invalid type: string"), + // Only an object: an array would sign as an object, not as written. + (r#"[800,600]"#, "invalid type: sequence"), + (r#""800x600""#, "invalid type: string"), + ] { + let err = parse(&manifest_with(&format!(r#""schema_minor":1,"window":{window}"#))).unwrap_err(); + assert!(err.starts_with("manifest is not valid") && err.contains(why), "{window}: {err}"); + } +} + +#[test] +fn a_newer_minor_reads_the_window_and_still_ignores_what_it_does_not_know() { + // A manifest written for a later 1.x: window is known and checked, the + // rest is ignored, listed, and kept in the signing bytes. + let minor = newer_minor(); + let manifest = parse(&manifest_with(&format!( + r#""schema_minor":{minor},"capabilities":["storage"],"theme_color":"slate", + "window":{{"width":1536,"height":1024,"maximized":true}},"storage":{{"backup":"never"}}"# + ))) + .unwrap(); + assert_eq!(manifest.window, Some(WindowHint::new(1536, 1024))); + assert_eq!(manifest.ignored_fields(), ["storage.backup", "theme_color", "window.maximized"]); + let bytes = String::from_utf8(manifest.signing_bytes().unwrap()).unwrap(); + assert!(bytes.contains(r#""window":{"height":1024,"maximized":true,"width":1536}"#), "{bytes}"); + assert!(bytes.contains(r#""theme_color":"slate""#), "{bytes}"); +} + #[test] fn hostlimits_builders_set_each_ceiling() { let limits = HostLimits::system() diff --git a/crates/card-host/src/args.rs b/crates/card-host/src/args.rs index 3e5bfb8..93d2224 100644 --- a/crates/card-host/src/args.rs +++ b/crates/card-host/src/args.rs @@ -32,8 +32,9 @@ Options: --static = Serve 's files at /... from memory. Repeatable. --size x The window's inner size in layout points, e.g. - 390x844. Default: 412x892. card-studio renders a - card at several sizes with it. + 390x844. Default: the manifest's window size, + else 412x892. card-studio renders a card at + several sizes with it. -h, --help Print this and exit. Every L0 card logs one `card-host: realize {json}` line: the lint result diff --git a/crates/card-host/src/host.rs b/crates/card-host/src/host.rs index e4dd568..f99bbee 100644 --- a/crates/card-host/src/host.rs +++ b/crates/card-host/src/host.rs @@ -77,6 +77,17 @@ fn load_statics(mounts: &[(String, PathBuf)]) -> octosense_app_policy::StaticAss Box::leak(out.into_boxed_slice()) } +/// The size the bundle's manifest asks its window to open at (`window`, in +/// points), which a run without `--size` uses. card-host is a render target, +/// so it does not clamp the hint to the screen, just as it does not clamp +/// `--size`. A manifest that does not parse sizes nothing: it is refused, +/// with its reason, in the default window. +fn manifest_window(bundle: &Path) -> Option<(f64, f64)> { + let text = std::fs::read_to_string(bundle.join(octosense_app_policy::MANIFEST_FILE)).ok()?; + let window = octosense_app_policy::AppManifest::parse(&text).ok()?.window?; + Some((f64::from(window.width), f64::from(window.height))) +} + /// Admit the bundle and resolve what it gets. Refusals are fatal: a card that /// cannot be admitted must not be drawn, not even partially. fn policy_for(args: &Args) -> Result { @@ -323,15 +334,20 @@ impl App { error!("card-host: started without its arguments"); return; }; - if let Some((w, h)) = args.size { - // A sized window is a render target (card-studio): the card gets - // the whole inner size, so the caption bar goes. + let manifest_size = if args.size.is_none() { manifest_window(&args.bundle) } else { None }; + if let Some((w, h)) = args.size.or(manifest_size) { + // A sized window is a render target (card-studio, App Flow's + // captures at a design's size): the card gets the whole inner + // size, so the caption bar goes. let mut window = self.ui.widget(cx, ids!(main_window)); script_apply_eval!(cx, window, { show_caption_bar: false }); let size = dvec2(w, h); self.ui.window(cx, ids!(main_window)).configure_window(cx, size, dvec2(40.0, 40.0), false, "Card host".into()); self.ui.window(cx, ids!(main_window)).resize(cx, size); log!("card-host: window sized {w}x{h}"); + if manifest_size.is_some() { + log!("card-host: the size is the manifest's window; --size overrides it"); + } } let policy = match policy_for(args) { Ok(policy) => policy, @@ -495,6 +511,24 @@ mod tests { } } + #[test] + fn without_size_the_window_opens_at_the_manifests_hint() { + let dir = std::env::temp_dir().join(format!("card-host-window-hint-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("manifest.json"); + let manifest = |extra: &str| { + format!(r#"{{"schema":1,"id":"example.window","version":"1.0.0","name":"Fixture","integrity":{{"bundle_blake3":""}}{extra}}}"#) + }; + std::fs::write(&path, manifest(r#","schema_minor":1,"window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(manifest_window(&dir), Some((1536.0, 1024.0))); + // No hint, or a manifest the contract refuses: the default window. + std::fs::write(&path, manifest("")).unwrap(); + assert_eq!(manifest_window(&dir), None); + std::fs::write(&path, manifest(r#","window":{"width":1536,"height":1024}"#)).unwrap(); + assert_eq!(manifest_window(&dir), None); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn stamp_preserves_unsigned_null_signature_support() { let dir = std::env::temp_dir().join(format!("card-host-unsigned-null-{}", std::process::id())); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f178356..22580d2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -105,7 +105,7 @@ the problem and the usage, and exits 2. Neither opens a window. | `--stamp` | Rewrite the manifest's `integrity.bundle_blake3` to match the directory before admitting. Without it, `card-host` refuses a bundle whose bytes changed since the last `hub stamp`. | | `--system` | Admit the bundle as a system app: by digest only, under the system ceilings. An empty digest is filled in memory. Use it to develop a system app. | | `--static =` | Serve ``'s files at `/...` from memory, as a shell serves a system app's compiled-in artwork. Photos uses `--static photos=`. Repeatable. | -| `--size x` | The window's inner size in layout points, with no caption bar, so the card gets exactly that viewport. Default: `412x892`. `card-studio` starts one `card-host` per size with it. | +| `--size x` | The window's inner size in layout points, with no caption bar, so the card gets exactly that viewport. Default: the manifest's [`window`](PUBLISHING.md#the-window-size) size when it has one, sized the same way and not clamped to the screen; otherwise `412x892`. `card-studio` starts one `card-host` per size with it. | | `--remote []` | Start the localhost HTTP control surface ([below](#drive-it-over-http-makepad_remote)) on ``. Without a port, `card-host` picks a free one and logs it. `MAKEPAD_REMOTE=` is equivalent to `--remote `. | The log says what happened: @@ -113,6 +113,7 @@ The log says what happened: | Log line | Meaning | | --- | --- | | `card-host: admitted — capabilities {…}, hosts {…}, storage bytes, agent ` | The bundle was admitted; the log shows usage declarations and resolved resource limits, not user consent. | +| `card-host: window sized x` | The window was sized by `--size`, or by the manifest's `window`, which the next line then says. | | `card-host: refused: ` | The bundle was refused. The window shows "card-host refused this bundle" and the reason. None of the bundle's code runs. | | `card-host: realize {json}` | An L0 card's lint and realize report, logged before the card is drawn. A script app logs none. | | `card-host: the card did not lower: ` | The card was admitted but could not be lowered to widgets. | diff --git a/docs/DEVELOPMENT.zh-CN.md b/docs/DEVELOPMENT.zh-CN.md index de495b6..d39b269 100644 --- a/docs/DEVELOPMENT.zh-CN.md +++ b/docs/DEVELOPMENT.zh-CN.md @@ -75,7 +75,7 @@ card-host --help | `--stamp` | 在准入之前重写清单的 `integrity.bundle_blake3`,使其与目录一致。不加这个参数时,如果应用包的字节在上次 `hub stamp` 之后有变化,`card-host` 会拒绝它。 | | `--system` | 按系统应用的方式准入:只校验摘要,适用系统上限。空摘要会在内存中补齐。用于开发系统应用。 | | `--static =` | 把 `` 中的文件读入内存,在 `/...` 路径下提供,与 Shell 提供系统应用内置素材的方式相同。相册使用 `--static photos=`。可重复使用。 | -| `--size x` | 窗口的内部尺寸(布局点),不带标题栏,使卡片正好获得这个视口。默认:`412x892`。`card-studio` 用它为每个尺寸各启动一个 `card-host`。 | +| `--size x` | 窗口的内部尺寸(布局点),不带标题栏,使卡片正好获得这个视口。默认:清单设有 [`window`](PUBLISHING.zh-CN.md#窗口尺寸) 时取它的尺寸,处理方式相同,也不受屏幕大小限制;否则为 `412x892`。`card-studio` 用它为每个尺寸各启动一个 `card-host`。 | | `--remote []` | 在 `` 上启动 localhost HTTP 控制接口(见 [下文](#通过-http-驱动makepad_remote))。不指定端口时,`card-host` 会自选一个空闲端口并记录在日志中。`MAKEPAD_REMOTE=` 与 `--remote ` 等效。 | 从日志可以看出运行结果: @@ -83,6 +83,7 @@ card-host --help | 日志行 | 含义 | | --- | --- | | `card-host: admitted — capabilities {…}, hosts {…}, storage bytes, agent ` | 应用包已准入;日志列出用途声明和解析后的资源上限,不代表用户同意。 | +| `card-host: window sized x` | 窗口尺寸来自 `--size`,或来自清单的 `window`;后一种情况下,下一行会说明。 | | `card-host: refused: ` | `card-host` 拒绝了这个应用包。窗口显示“card-host refused this bundle”和原因,应用包中的任何代码都不会运行。 | | `card-host: realize {json}` | L0 卡片的 lint 和 realize 报告,在绘制卡片之前记录。脚本应用没有这一行。 | | `card-host: the card did not lower: ` | 卡片已准入,但无法转换为控件。 | diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index b6c1de1..54bb577 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -673,7 +673,8 @@ Validate its exact build, platform and released bytes separately. | `components` | The shared components the app's functions use, each pinned to one exact version and digest. | Optional; needs `wasm-shared-components-v1`; at most 8 ([Shared components](#shared-components)). | | `host_api` | The host API methods the app needs (`required`) or can use (`optional`), each with its ABI major version. | Optional; needs `host-api-v1` in `requires`. The store checks the `required` methods at install and at every launch ([Declare what the app needs](HOST-API.md#declare-what-the-app-needs)). | | `backend` | The app's own backend: its public sign-in endpoints and the operations the app may call. | Optional; needs `backend-api-v1` in `requires` and `storage.accounts: true`. Holds no credentials ([Sign in to your own backend](#sign-in-to-your-own-backend)). | -| `schema_minor` | Which additions to schema 1 the manifest uses. | Leave it out. | +| `window` | The size the app prefers its desktop window to open at, in points: `{"width": 1536, "height": 1024}`. | Optional; needs `"schema_minor": 1`; width 320 to 4096, height 240 to 4096. A hint that grants nothing ([The window size](#the-window-size)). | +| `schema_minor` | Which additions to schema 1 the manifest uses. | Leave it out, unless the manifest sets `window`: then `1`. | Any other field is refused. After `hub publisher-prepare`, the manifest also holds `null` for optional fields you left out, such as `"agent": null`. They @@ -881,6 +882,48 @@ The store shows the scope in plain words. For `research` with privacy summary ([The listing](#the-listing)) says "Searches news in English and Chinese, from the last 7 days". +### The window size + +`window` asks a desktop host to open the app's window at a size, in points: + +```json +{ + "schema_minor": 1, + "window": { "width": 1536, "height": 1024 } +} +``` + +| Field | Rule | +| --- | --- | +| `width` | 320 to 4096. | +| `height` | 240 to 4096. | + +The parser refuses a size out of range, and refuses `window` in a manifest +without `"schema_minor": 1`: + +```text +window requires "schema_minor": 1 or later, so that hosts which predate window ignore it instead of refusing the app +``` + +`window` is a hint, not a grant. It changes no capability, quota or review, +and the store does not show it. + +- A desktop host that reads it opens the app's window at that size, clamped + to its screen. The person can still resize the window. +- Phones and other full-screen hosts ignore it. +- `card-host` opens at that size when `--size` is not given. It is a render + target, so it does not clamp the size to the screen + ([Run a bundle locally](DEVELOPMENT.md#run-a-bundle-locally-card-host)). +- A host built on a contract before 1.12.0 knows only `schema_minor` 0. It + reads a manifest with `"schema_minor": 1` leniently: it ignores `window`, + lists it in `AppManifest::ignored_fields()` and opens the app at its own + default size. Without `"schema_minor": 1`, that host would refuse `window` + as an unknown field. + +Without `window`, a host opens the app at its own default size. + +Source: `WindowHint` in `crates/app-contract/src/manifest.rs`. + ## The app's agent and tools An app can ship an agent of its own: its tools in `tools.json`, its diff --git a/docs/PUBLISHING.zh-CN.md b/docs/PUBLISHING.zh-CN.md index 639b7e9..689152f 100644 --- a/docs/PUBLISHING.zh-CN.md +++ b/docs/PUBLISHING.zh-CN.md @@ -497,7 +497,8 @@ hub: the bundle was refused | `components` | 应用的函数所用的共享组件,每个都固定到一个确切版本和摘要。 | 可选;需要 `wasm-shared-components-v1`;最多 8 个([共享组件](#共享组件))。 | | `host_api` | 应用需要(`required`)或可以使用(`optional`)的宿主 API 方法,每个方法都写明 ABI 主版本。 | 可选;须在 `requires` 中声明 `host-api-v1`。商店在安装时和每次启动时检查 `required` 中的方法([声明应用需要什么](HOST-API.zh-CN.md#声明应用需要什么))。 | | `backend` | 应用自己的后端:公开的登录端点,以及应用可以调用的操作。 | 可选;须在 `requires` 中声明 `backend-api-v1`,并设置 `storage.accounts: true`。不含任何凭据([登录自己的后端](#登录自己的后端))。 | -| `schema_minor` | 清单用到了 schema 1 的哪些新增内容。 | 省略此字段。 | +| `window` | 应用希望桌面窗口打开时的尺寸,单位为点:`{"width": 1536, "height": 1024}`。 | 可选;需要 `"schema_minor": 1`;宽度 320 到 4096,高度 240 到 4096。只是提示,不授予任何权限([窗口尺寸](#窗口尺寸))。 | +| `schema_minor` | 清单用到了 schema 1 的哪些新增内容。 | 省略此字段;但清单设置了 `window` 时,须写 `1`。 | 其他字段一律拒绝。运行 `hub publisher-prepare` 之后,你省略的可选字段也会出现在清单中,值为 `null`,例如 `"agent": null`。这些字段不改变任何行为。 @@ -661,6 +662,39 @@ ID 请以你能控制的反向域名前缀开头,例如把自己的域名倒 商店会用通俗的话展示这个范围。例如,`research` 的范围为 `{"langs":["en","zh"],"categories":["news"],"max_age_days":7}` 时,商店的隐私概要([商店信息](#商店信息))显示“Searches news in English and Chinese, from the last 7 days”。 +### 窗口尺寸 + +`window` 请求桌面宿主以指定尺寸打开应用窗口,单位为点: + +```json +{ + "schema_minor": 1, + "window": { "width": 1536, "height": 1024 } +} +``` + +| 字段 | 规则 | +| --- | --- | +| `width` | 320 到 4096。 | +| `height` | 240 到 4096。 | + +尺寸超出范围时,解析器会拒绝清单;清单没有写 `"schema_minor": 1` 却设置了 `window` 时,也会被拒绝: + +```text +window requires "schema_minor": 1 or later, so that hosts which predate window ignore it instead of refusing the app +``` + +`window` 只是提示,不是授权。它不改变任何能力、配额或审核,商店也不显示它。 + +- 读取它的桌面宿主按这个尺寸打开应用窗口,并限制在屏幕范围以内。用户仍然可以调整窗口大小。 +- 手机和其他全屏宿主忽略它。 +- 不加 `--size` 时,`card-host` 按这个尺寸打开窗口。它是渲染目标,所以不会把尺寸限制在屏幕范围以内([在本地运行应用包](DEVELOPMENT.zh-CN.md#在本地运行应用包card-host))。 +- 基于 1.12.0 之前的契约构建的宿主只认识 `schema_minor` 0。它以宽松方式读取写有 `"schema_minor": 1` 的清单:忽略 `window`,把它列入 `AppManifest::ignored_fields()`,并按自己的默认尺寸打开应用。清单如果没有写 `"schema_minor": 1`,这样的宿主会把 `window` 当作未知字段拒绝。 + +没有 `window` 时,宿主按自己的默认尺寸打开应用。 + +源码:`crates/app-contract/src/manifest.rs` 中的 `WindowHint`。 + ## 应用的 Agent 与工具 应用可以自带 Agent,由清单中的 `agent` 字段声明:工具写在 `tools.json` 中,指令写在 `AGENT.md` 中,只含数据的技能放在 `skills/` 下。每个文件都在应用包摘要的覆盖范围内,因此运行的 Agent 就是审核过的那一个。商店把这个 Agent 称为应用的助手(assistant)。