Share my setup, links: a waybetter://setup link opened from outside reaches the shell as text on the install-link event (shape and size checked natively, never decoded or applied there); the install controller hands it to the app's onSetupLink and stages nothing

This commit is contained in:
CRTkafa 2026-10-01 04:49:57 +07:00
parent 95e0443abd
commit d1a1918119
4 changed files with 73 additions and 8 deletions

View file

@ -20,8 +20,14 @@ pub const RECORDS_FILE: &str = "store-installed.json";
/// The last verified catalog and its signature (verified again when read back).
pub const CATALOG_CACHE: &str = "store-catalog.json";
pub const CATALOG_SIG_CACHE: &str = "store-catalog.json.sig";
/// Shell event: a validated install link (`{ link: { kind, url } }`) or `{ error }`.
/// Shell event: a validated install link (`{ link: { kind, url } }`), a "Share my setup" link
/// (`{ setup }`: its text, for the shell's preview) or `{ error }`.
pub const EVENT_LINK: &str = "install-link";
/// "Share my setup" links: `waybetter://setup?app=…&d=…`. Native code only checks the shape and
/// hands the text to the shell; it never decodes or applies a setup (the shell shows what would
/// change and asks first).
pub const SETUP_LINK: &str = "waybetter://setup";
const MAX_SETUP_LINK: usize = 24 * 1024;
/// Links received before the shell asked for them (startup, or while the shell reloads).
const MAX_PENDING: usize = 8;
const MAX_STAGED: usize = 8;
@ -38,6 +44,22 @@ pub struct LinkEvent {
pub link: Option<InstallLink>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// A "Share my setup" link, as text (see [`SETUP_LINK`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub setup: Option<String>,
}
/// `Some` when `raw` is a setup link: its text when it has the plain shape (the path, a query of
/// URL-safe characters, bounded), else an error. `None`: not a setup link.
pub fn setup_link(raw: &str) -> Option<Result<String, String>> {
let rest = raw.strip_prefix(SETUP_LINK)?;
let query = rest.strip_prefix("/?").or_else(|| rest.strip_prefix('?'));
let ok = query.is_some_and(|query| {
!query.is_empty()
&& raw.len() <= MAX_SETUP_LINK
&& query.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'=' | b'&' | b'%' | b'.'))
});
Some(if ok { Ok(raw.to_owned()) } else { Err("LINK: this setup link is not valid".to_owned()) })
}
#[derive(Clone, Debug, Serialize)]
@ -220,9 +242,13 @@ impl Store {
/// A link reached the app (OS deep link, second launch): validate it and queue it for the
/// shell. Returns the event to emit ([`EVENT_LINK`]). Nothing is downloaded or installed here.
pub fn receive_link(&self, raw: &str) -> LinkEvent {
let event = match dist::parse_install_link(self.config, raw) {
Ok(link) => LinkEvent { link: Some(link), error: None },
Err(error) => LinkEvent { link: None, error: Some(error) },
let event = match setup_link(raw) {
Some(Ok(text)) => LinkEvent { link: None, error: None, setup: Some(text) },
Some(Err(error)) => LinkEvent { link: None, error: Some(error), setup: None },
None => match dist::parse_install_link(self.config, raw) {
Ok(link) => LinkEvent { link: Some(link), error: None, setup: None },
Err(error) => LinkEvent { link: None, error: Some(error), setup: None },
},
};
let mut inner = self.lock();
if inner.pending.len() >= MAX_PENDING {
@ -598,6 +624,19 @@ mod tests {
assert!(ok.link.is_some() && ok.error.is_none());
let bad = store.receive_link("waybetter://install?kind=theme&url=https://evil.example/a.json");
assert!(bad.link.is_none() && bad.error.unwrap().starts_with("LINK:"));
// A "Share my setup" link is only handed over as text; its data is never read here.
let setup = store.receive_link("waybetter://setup?app=x&d=eyJmb3JtYXQiOiJ3YXliZXR0ZXItc2V0dXAifQ");
assert_eq!(setup.setup.as_deref(), Some("waybetter://setup?app=x&d=eyJmb3JtYXQiOiJ3YXliZXR0ZXItc2V0dXAifQ"));
assert!(setup.link.is_none() && setup.error.is_none());
for bad in ["waybetter://setup", "waybetter://setup?", "waybetter://setup?d=<script>", "waybetter://setup?d=a b", "waybetter://setupx?d=a"] {
let event = store.receive_link(bad);
assert!(event.setup.is_none(), "{bad}");
assert!(event.error.is_some(), "{bad}");
}
let long = format!("waybetter://setup?d={}", "a".repeat(MAX_SETUP_LINK));
assert!(store.receive_link(&long).setup.is_none(), "bounded");
assert_eq!(serde_json::to_string(&setup).unwrap(), r#"{"setup":"waybetter://setup?app=x&d=eyJmb3JtYXQiOiJ3YXliZXR0ZXItc2V0dXAifQ"}"#);
store.take_pending();
for _ in 0..20 {
store.receive_link("waybetter://install?kind=theme&url=https://waybetter.fyi/a.json");
}