Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,11 @@ class KioskPresentationTest {

@Test fun remoteMenuOpensSettingsWithoutPuttingAnAddressFieldOnTheKiosk() {
launch().use { scenario ->
awaitUi(scenario, "Kiosk menu did not appear") { activity ->
descendants(activity.window.decorView).any { it.contentDescription == "Kiosk menu" && it.isShown }
// A resumed activity can draw before its window receives input focus.
// Android drops injected keys in that interval (seen on API 29 CI).
awaitUi(scenario, "Kiosk menu window did not become ready for remote input") { activity ->
activity.hasWindowFocus() &&
descendants(activity.window.decorView).any { it.contentDescription == "Kiosk menu" && it.isShown }
}
instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_MENU)
awaitAccessibility("Remote Menu did not expose Settings") { root -> root.findAccessibilityNodeInfosByText("Settings").isNotEmpty() }
Expand Down
2 changes: 2 additions & 0 deletions client/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ pub mod layout;
pub mod protocol;
pub mod state;
pub mod version;

pub mod update_policy;
149 changes: 149 additions & 0 deletions client/core/src/update_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! Durable, non-secret update preferences; independent of enrollment state.
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Window {
pub day: u8,
pub start: String,
pub end: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Schedule {
pub mode: String,
pub windows: Vec<Window>,
pub timezone: String,
}

impl Schedule {
pub fn allows(&self, day: u8, minute: u16) -> bool {
if self.mode == "always" {
return true;
}
if self.mode != "windows" || day > 6 || minute >= 1440 {
return false;
}
self.windows.iter().any(|window| {
let (Some(start), Some(end)) = (minutes(&window.start), minutes(&window.end)) else {
return false;
};
if window.day > 6 || start == end {
return false;
}
if start < end {
window.day == day && minute >= start && minute < end
} else {
(window.day == day && minute >= start)
|| ((window.day + 1) % 7 == day && minute < end)
}
})
}
}

fn minutes(value: &str) -> Option<u16> {
let bytes = value.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return None;
}
if ![bytes[0], bytes[1], bytes[3], bytes[4]]
.iter()
.all(u8::is_ascii_digit)
{
return None;
}
let hour = (bytes[0] - b'0') as u16 * 10 + (bytes[1] - b'0') as u16;
let minute = (bytes[3] - b'0') as u16 * 10 + (bytes[4] - b'0') as u16;
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Policy {
pub server: String,
pub schedule: Schedule,
pub firmware_channel: String,
pub firmware_target_version: Option<String>,
pub os_update_channel: String,
pub os_update_target_version: Option<String>,
}

impl Policy {
pub fn selection(&self, os: bool) -> Vec<(String, String)> {
let (channel, version) = if os {
(&self.os_update_channel, &self.os_update_target_version)
} else {
(&self.firmware_channel, &self.firmware_target_version)
};
let mut query = vec![("channel".into(), channel.clone())];
if let Some(version) = version {
query.push(("version".into(), version.clone()));
}
query
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recurring_windows_include_start_exclude_end_and_wrap_week() {
let schedule = Schedule {
mode: "windows".into(),
timezone: "UTC".into(),
windows: vec![
Window {
day: 6,
start: "23:00".into(),
end: "01:00".into(),
},
Window {
day: 2,
start: "02:00".into(),
end: "03:00".into(),
},
],
};
assert!(schedule.allows(6, 1380));
assert!(schedule.allows(0, 59));
assert!(!schedule.allows(0, 60));
assert!(!schedule.allows(6, 1379));
assert!(schedule.allows(2, 120));
assert!(!schedule.allows(2, 180));
assert!(!schedule.allows(3, 120));
}
#[test]
fn malformed_or_empty_windows_do_not_allow_updates() {
for start in ["25:00", "1:00", "aa:bb", "01:00"] {
let schedule = Schedule {
mode: "windows".into(),
timezone: "UTC".into(),
windows: vec![Window {
day: 0,
start: start.into(),
end: "01:00".into(),
}],
};
assert!(!schedule.allows(0, 30));
}
}
#[test]
fn preferences_and_pins_survive_restart() {
let policy: Policy = serde_json::from_value(serde_json::json!({
"server": "https://frame.example", "schedule": {"mode":"always", "windows":[], "timezone":"UTC"},
"firmware_channel":"beta", "firmware_target_version":"2.0.0",
"os_update_channel":"dev", "os_update_target_version":null
})).unwrap();
let reloaded: Policy =
serde_json::from_slice(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(
reloaded.selection(false),
vec![
("channel".into(), "beta".into()),
("version".into(), "2.0.0".into())
]
);
assert_eq!(
reloaded.selection(true),
vec![("channel".into(), "dev".into())]
);
}
}
6 changes: 6 additions & 0 deletions client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ mod ui;
#[path = "platform/linux/update_guard.rs"]
mod update_guard;
#[cfg(target_os = "linux")]
#[path = "platform/linux/update_download.rs"]
mod update_download;
#[cfg(target_os = "linux")]
#[path = "platform/linux/update_recovery.rs"]
mod update_recovery;
#[cfg(target_os = "linux")]
#[path = "platform/linux/ws_client.rs"]
mod ws_client;

Expand Down
33 changes: 15 additions & 18 deletions client/src/platform/linux/firmware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,23 @@ pub struct UpdateInfo {
/// Public pre-boot firmware check — no auth needed. Always checks stable
/// channel. Used before pairing to self-update to latest binary.
pub fn check_public(server: &str, current_version: &str) -> Option<UpdateInfo> {
check_public_with_selection(server, current_version, &[])
}

pub fn check_recovery(server: &str, current_version: &str) -> Option<UpdateInfo> {
let policy = crate::update_recovery::policy_for(server)?;
check_public_with_selection(server, current_version, &policy.selection(false))
}

fn check_public_with_selection(server: &str, current_version: &str, selection: &[(String, String)]) -> Option<UpdateInfo> {
let url = format!(
"{server}/api/firmware/public/check?target={target}&arch={arch}&current={cur}",
target = FIRMWARE_TARGET,
arch = ARCH,
cur = current_version,
);
let client = crate::network::blocking_client();
let resp = match client.get(&url).timeout(Duration::from_secs(10)).send() {
let resp = match client.get(&url).query(selection).timeout(Duration::from_secs(10)).send() {
Ok(r) => r,
Err(err) => {
warn!("preboot firmware check: {err}");
Expand All @@ -129,13 +138,7 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> {
"preboot firmware: applying {} ({} bytes)",
info.version, info.size_bytes
);
let download_url = format!("{server}{}", info.download_url);
let client = crate::network::blocking_client();
let resp = client
.get(&download_url)
.timeout(Duration::from_secs(300))
.send()
.map_err(|e| format!("download failed: {e}"))?;
let resp = crate::update_download::get(server, None, &info.download_url, 0)?;
if !resp.status().is_success() {
return Err(format!("download HTTP {}", resp.status()));
}
Expand Down Expand Up @@ -203,19 +206,19 @@ pub fn check(server: &str, key: &str, current_version: &str) -> Option<UpdateInf
Ok(r) => r,
Err(err) => {
warn!("firmware check: request failed: {err}");
return None;
return check_recovery(server, current_version);
}
};

if !resp.status().is_success() {
warn!("firmware check: HTTP {}", resp.status());
return None;
return check_recovery(server, current_version);
}
match resp.json::<CheckResponse>() {
Ok(c) => newer_update(c, current_version),
Err(err) => {
warn!("firmware check: parse failed: {err}");
None
check_recovery(server, current_version)
}
}
}
Expand All @@ -237,14 +240,8 @@ pub fn apply(
on_progress("Downloading", 0);

// 1. Download
let url = format!("{}{}", server, info.download_url);
let client = crate::network::blocking_client();
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {key}"))
.timeout(Duration::from_secs(300))
.send()
.map_err(|e| format!("download request: {e}"))?;
let resp = crate::update_download::get(server, Some(key), &info.download_url, 0)?;

if !resp.status().is_success() {
return Err(format!("download HTTP {}", resp.status()));
Expand Down
48 changes: 26 additions & 22 deletions client/src/platform/linux/os_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,20 @@ pub struct UpdateInfo {

/// Public stable-channel check used before the kiosk has paired.
pub fn check_public(server: &str) -> Option<UpdateInfo> {
check_at(server, None, "/api/os/public/check")
check_at(server, None, "/api/os/public/check", &[])
}

/// Authenticated check used after pairing.
pub fn check(server: &str, key: &str) -> Option<UpdateInfo> {
check_at(server, Some(key), "/api/kiosk/os/check")
check_at(server, Some(key), "/api/kiosk/os/check", &[])
}

fn check_at(server: &str, key: Option<&str>, path: &str) -> Option<UpdateInfo> {
pub fn check_recovery(server: &str) -> Option<UpdateInfo> {
let policy = crate::update_recovery::policy_for(server)?;
check_at(server, None, "/api/os/public/check", &policy.selection(true))
}

fn check_at(server: &str, key: Option<&str>, path: &str, selection: &[(String, String)]) -> Option<UpdateInfo> {
if !enabled() {
return None;
}
Expand All @@ -190,27 +195,27 @@ fn check_at(server: &str, key: Option<&str>, path: &str) -> Option<UpdateInfo> {
cur = urlencoding::encode(&cur),
);
let client = crate::network::blocking_client();
let mut request = client.get(&url);
let mut request = client.get(&url).query(selection);
if let Some(key) = key {
request = request.header("Authorization", format!("Bearer {key}"));
}
let resp = match request.timeout(Duration::from_secs(10)).send() {
Ok(r) => r,
Err(err) => {
warn!("os-update check: request failed: {err}");
return None;
return if key.is_some() { check_recovery(server) } else { None };
}
};

if !resp.status().is_success() {
warn!("os-update check: HTTP {}", resp.status());
return None;
return if key.is_some() { check_recovery(server) } else { None };
}
match resp.json::<CheckResponse>() {
Ok(c) => newer_update(c, &cur),
Err(err) => {
warn!("os-update check: parse failed: {err}");
None
if key.is_some() { check_recovery(server) } else { None }
}
}
}
Expand Down Expand Up @@ -288,6 +293,14 @@ fn apply_tracked(
crate::update_guard::record_attempt("os", &info.version)?;
}
let result = apply_inner(server, key, info, on_progress);
if result.as_ref().is_err_and(|error| crate::update_download::is_deferred(error)) {
// No installation was attempted. Refund only this reservation, retaining
// any previous genuine failures for the same version.
crate::update_guard::refund_attempt("os", &info.version)?;
// Keep the local journal retryable, but do not publish a failed-install
// status for a download that the server asked us to defer.
return result;
}
Comment thread
bcbetterninja marked this conversation as resolved.
if let Err(ref error) = result {
let _lock = JOURNAL_LOCK.lock().map_err(|_| "OS update record locked")?;
let path = std::path::Path::new(JOURNAL_PATH);
Expand Down Expand Up @@ -321,7 +334,6 @@ fn apply_inner(
// Streams directly to disk (no 1.2GB in RAM). On network failure,
// resumes from where it left off using Range header. Retries up to
// 5 times with 10s backoff between attempts.
let url = format!("{}{}", server, info.download_url);
on_progress("Preparing", 0);
let staging_dir = PathBuf::from("/var/lib/betterframe/tmp");
fs::create_dir_all(&staging_dir).map_err(|e| format!("mkdir staging: {e}"))?;
Expand All @@ -341,24 +353,16 @@ fn apply_inner(
info.size_bytes
);

let client = crate::network::blocking_client();
let mut req = client.get(&url);
if let Some(key) = key {
req = req.header("Authorization", format!("Bearer {key}"));
}
if existing_bytes > 0 {
req = req.header("Range", format!("bytes={existing_bytes}-"));
}

let resp = match req.timeout(Duration::from_secs(300)).send() {
Ok(r) => r,
Err(e) => {
warn!("os-update: download request failed (attempt {attempt}): {e}");
let resp = match crate::update_download::get(server, key, &info.download_url, existing_bytes) {
Ok(response) => response,
Err(error) => {
if crate::update_download::is_deferred(&error) { return Err(error); }
warn!("os-update: download request failed (attempt {attempt}): {error}");
if attempt < max_retries {
std::thread::sleep(Duration::from_secs(10));
continue;
}
return Err(format!("download failed after {max_retries} attempts: {e}"));
return Err(format!("download failed after {max_retries} attempts: {error}"));
}
};

Expand Down
Loading
Loading