From f02a1a585c9dc820a1551ee49ec85ebab23990ac Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 01:09:12 +0300 Subject: [PATCH 01/74] chore(release): pin Jellium in-page select click-outside fix Keep the request modal open when picking a native ` option. The in-page dropdown overlay was reaching the + modal's click-outside handler (browser-native popups do not). + ## 0.2.9 — 2026-08-14 ### Fixed diff --git a/docs/jellium-patch-manifest.md b/docs/jellium-patch-manifest.md index ae02efd..44c8f73 100644 --- a/docs/jellium-patch-manifest.md +++ b/docs/jellium-patch-manifest.md @@ -22,6 +22,9 @@ recorded here before `scripts/boundary-audit.sh` will pass. | `d04f440` | `d04f4404569e39ade88135f61b3a2f4324317f6f` | runtime fix | Preserve Jellyfin OSD pointer targets and hidden action-sheet placeholders during veiled playback | | `ffa6e22` | `ffa6e228e1f66e615f2a03e48b18c05bb27d6140` | runtime fix | Unmap hidden Windows DComp CEF visuals so playback restore remaps the last host-frontend frame | | `bf647ab` | `bf647ab54b45737c38025f734980719700f16909` | runtime fix | Open Windows `` clicks from dismissing page dialogs | ## Ownership rules diff --git a/jellium.rev b/jellium.rev index 7009ace..839d9f4 100644 --- a/jellium.rev +++ b/jellium.rev @@ -1 +1 @@ -bf647ab54b45737c38025f734980719700f16909 +cb4e9d0a73358dda95555fa7f6110d83ef418110 From 3383dfa2b9709846ef625213f8dfc00979e32bbe Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:13:09 +0300 Subject: [PATCH 02/74] feat: supervise bundled standalone Foreseerr --- README.md | 20 +- foreseerr.rev | 1 + scripts/check-release-pins.sh | 12 ++ scripts/stage-foreseerr.sh | 38 ++++ src/config.rs | 203 +++++++++++------- src/extension.rs | 54 ++++- src/lib.rs | 3 +- src/main.rs | 115 ++++++++-- src/supervisor.rs | 381 ++++++++++++++++++++++++++++++++++ 9 files changed, 725 insertions(+), 102 deletions(-) create mode 100644 foreseerr.rev create mode 100755 scripts/check-release-pins.sh create mode 100755 scripts/stage-foreseerr.sh create mode 100644 src/supervisor.rs diff --git a/README.md b/README.md index 3c0e09f..e8db185 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ See [LICENSE](LICENSE). ## How this fits the Foreseer product -Foreseer Desktop is an optional client for the hosted -[Foreseerr](https://github.com/selmant/foreseerr) application. It does not -replace or bundle the web app, run a separate request server, or own the user's -media-account configuration. +Foreseer Desktop defaults to standalone mode: it starts a bundled Foreseerr +server on an ephemeral `127.0.0.1` port and owns its local data. Remote mode +remains available for an existing Foreseerr deployment. | Component | Owns | | --- | --- | @@ -63,15 +62,17 @@ Foreseer Desktop persists its configuration in a standard OS config directory: ```json { - "server_url": "https://foreseer.example.com", - "allow_insecure_http": false + "schema_version": 2, + "mode": "standalone", + "remote": { "server_url": "https://foreseer.example.com", "allow_insecure_http": false }, + "standalone": { "cache_limit_bytes": 2147483648 } } ``` ### CLI Commands & Environment Variables ```sh -# Run with default or saved server URL: +# Run the saved standalone or remote mode: cargo run # Launch the graphical server setup GUI: @@ -80,8 +81,9 @@ cargo run -- --setup # View current configuration and file location: cargo run -- --show-config -# Set a new default server URL: -cargo run -- --set-url https://foreseer.example.com +# Switch modes: +cargo run -- --standalone +cargo run -- --remote https://foreseer.example.com # Allow HTTP (non-HTTPS) server URL: cargo run -- --set-url http://192.168.1.50:5055 --allow-http diff --git a/foreseerr.rev b/foreseerr.rev new file mode 100644 index 0000000..b616048 --- /dev/null +++ b/foreseerr.rev @@ -0,0 +1 @@ +0.6.2 diff --git a/scripts/check-release-pins.sh b/scripts/check-release-pins.sh new file mode 100755 index 0000000..460fada --- /dev/null +++ b/scripts/check-release-pins.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FORESEERR_DIR="${FORESEERR_DIR:-$ROOT/../SeerrSuggestArr}" +test -s "$ROOT/jellium.rev" +test -s "$ROOT/foreseerr.rev" +test -f "$FORESEERR_DIR/package.json" +PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" +VERSION="$(node -p "require('$FORESEERR_DIR/package.json').version")" +test "$PIN" = "$VERSION" +test -f "$FORESEERR_DIR/launcher.js" +echo "release pins: Foreseer Desktop $(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1), Foreseerr $PIN" diff --git a/scripts/stage-foreseerr.sh b/scripts/stage-foreseerr.sh new file mode 100755 index 0000000..6874669 --- /dev/null +++ b/scripts/stage-foreseerr.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Stage a target-native, production-only Foreseerr bundle for a desktop build. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE="${FORESEERR_DIR:-$ROOT/../SeerrSuggestArr}" +NODE_BIN="${FORESEERR_NODE_BIN:-$(command -v node)}" +DEST="${1:-$ROOT/resources}" +PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" + +if [[ ! -x "$NODE_BIN" ]]; then + echo "stage-foreseerr: provide FORESEERR_NODE_BIN or install Node 22" >&2 + exit 1 +fi +if [[ ! -f "$SOURCE/package.json" ]]; then + echo "stage-foreseerr: no Foreseerr checkout at $SOURCE" >&2 + exit 1 +fi +VERSION="$($NODE_BIN -p "require('$SOURCE/package.json').version")" +if [[ "$VERSION" != "$PIN" ]]; then + echo "stage-foreseerr: Foreseerr $VERSION does not match foreseerr.rev $PIN" >&2 + exit 1 +fi + +pnpm --dir "$SOURCE" build +rm -rf "$DEST/foreseerr" "$DEST/node" +mkdir -p "$DEST/foreseerr" "$DEST/node" +install -m 0755 "$NODE_BIN" "$DEST/node/node" +install -m 0644 "$SOURCE/launcher.js" "$DEST/foreseerr/launcher.js" +for item in dist .next public node_modules seerr-api.yml; do + [[ -e "$SOURCE/$item" ]] && cp -a "$SOURCE/$item" "$DEST/foreseerr/" +done +find "$DEST/foreseerr" -type d \( -name '.cache' -o -name 'cypress' -o -name 'test' -o -name 'tests' \) -prune -exec rm -rf {} + +find "$DEST/foreseerr" -type f \( -name '*.map' -o -name '*.ts' -o -name '*.tsx' \) -delete +test -x "$DEST/node/node" +test -f "$DEST/foreseerr/launcher.js" +test -d "$DEST/foreseerr/dist" +echo "stage-foreseerr: staged $PIN in $DEST" diff --git a/src/config.rs b/src/config.rs index c5e503d..0b5106f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,16 @@ -//! Product configuration and Foreseer URL validation. +//! Product configuration, migration, and Foreseer URL validation. use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::fs; use std::net::IpAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use url::Url; pub const DEFAULT_FRONTEND_URL: &str = "https://foreseer.example.com"; pub const MAX_FORESEER_URL_LEN: usize = 2048; +pub const CONFIG_SCHEMA_VERSION: u32 = 2; +pub const DEFAULT_CACHE_LIMIT_BYTES: u64 = 2 * 1024 * 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ForeseerUrlError { @@ -20,7 +22,6 @@ pub enum ForeseerUrlError { InsecureHttpNotAllowed, InsecureHttpNonLocalHost, } - impl ForeseerUrlError { pub const fn message(self) -> &'static str { match self { @@ -57,13 +58,12 @@ pub fn validate_foreseer_url( if !parsed.username().is_empty() || parsed.password().is_some() { return Err(ForeseerUrlError::CredentialsNotAllowed); } - if parsed.scheme() == "http" { - if !allow_insecure_http { - return Err(ForeseerUrlError::InsecureHttpNotAllowed); - } - if !is_local_http_host(host) { - return Err(ForeseerUrlError::InsecureHttpNonLocalHost); - } + if parsed.scheme() == "http" && (!allow_insecure_http || !is_local_http_host(host)) { + return Err(if allow_insecure_http { + ForeseerUrlError::InsecureHttpNonLocalHost + } else { + ForeseerUrlError::InsecureHttpNotAllowed + }); } Ok(parsed.origin().ascii_serialization()) } @@ -101,85 +101,169 @@ pub fn validate_bootstrap_server_url(input: &str) -> Result Self { Self { - server_url: DEFAULT_FRONTEND_URL.to_string(), - allow_insecure_http: false, + schema_version: CONFIG_SCHEMA_VERSION, + mode: AppMode::Standalone, + remote: RemoteConfig { + server_url: DEFAULT_FRONTEND_URL.into(), + allow_insecure_http: false, + }, + standalone: StandaloneConfig { + cache_limit_bytes: DEFAULT_CACHE_LIMIT_BYTES, + }, } } } - impl AppConfig { - pub fn config_file_path() -> Option { - if let Some(dir) = std::env::var_os("JELLIUM_DESKTOP_CONFIG_DIR") + pub fn config_directory() -> Option { + std::env::var_os("JELLIUM_DESKTOP_CONFIG_DIR") .or_else(|| std::env::var_os("FORESEER_CONFIG_DIR")) - { - return Some(PathBuf::from(dir).join("config.json")); - } - ProjectDirs::from("com", "selmantrabzon", "Foreseer") - .map(|dirs| dirs.config_dir().join("config.json")) + .map(PathBuf::from) + .or_else(|| { + ProjectDirs::from("com", "selmantrabzon", "Foreseer") + .map(|d| d.config_dir().to_path_buf()) + }) + } + pub fn cache_directory() -> Option { + std::env::var_os("JELLIUM_DESKTOP_CACHE_DIR") + .or_else(|| std::env::var_os("FORESEER_CACHE_DIR")) + .map(PathBuf::from) + .or_else(|| { + ProjectDirs::from("com", "selmantrabzon", "Foreseer") + .map(|d| d.cache_dir().to_path_buf()) + }) + } + pub fn config_file_path() -> Option { + Self::config_directory().map(|d| d.join("config.json")) + } + pub fn standalone_data_directory() -> Option { + Self::config_directory().map(|d| d.join("standalone")) + } + pub fn standalone_cache_directory() -> Option { + Self::cache_directory().map(|d| d.join("standalone")) + } + pub fn standalone_log_directory() -> Option { + Self::standalone_data_directory().map(|d| d.join("logs")) } - pub fn exists() -> bool { Self::config_file_path().is_some_and(|p| p.exists()) } - pub fn is_configured(&self) -> bool { - validate_foreseer_url(&self.server_url, self.allow_insecure_http).is_ok() + self.mode == AppMode::Standalone + || validate_foreseer_url(&self.remote.server_url, self.remote.allow_insecure_http) + .is_ok() + } + pub fn remote_url(&self) -> Result { + validate_foreseer_url(&self.remote.server_url, self.remote.allow_insecure_http) } - pub fn load() -> Self { - let path = Self::config_file_path(); - let mut config = if let Some(ref p) = path - && let Ok(content) = fs::read_to_string(p) - && let Ok(cfg) = serde_json::from_str::(&content) + let mut config = Self::config_file_path() + .as_ref() + .and_then(|p| fs::read_to_string(p).ok()) + .and_then(|text| { + serde_json::from_str::(&text).ok().or_else(|| { + serde_json::from_str::(&text) + .ok() + .map(|legacy| Self { + schema_version: CONFIG_SCHEMA_VERSION, + mode: AppMode::Remote, + remote: RemoteConfig { + server_url: legacy.server_url, + allow_insecure_http: legacy.allow_insecure_http, + }, + standalone: StandaloneConfig { + cache_limit_bytes: DEFAULT_CACHE_LIMIT_BYTES, + }, + }) + }) + }) + .unwrap_or_default(); + config.schema_version = CONFIG_SCHEMA_VERSION; + if let Ok(url) = std::env::var("FORESEER_URL") + && !url.trim().is_empty() { - cfg - } else { - Self::default() - }; - - if let Ok(env_url) = std::env::var("FORESEER_URL") - && !env_url.trim().is_empty() - { - config.server_url = env_url; + config.mode = AppMode::Remote; + config.remote.server_url = url; } if std::env::var("FORESEER_ALLOW_INSECURE_HTTP").as_deref() == Ok("1") { - config.allow_insecure_http = true; + config.remote.allow_insecure_http = true; } - config } - pub fn save(&self) -> Result<(), std::io::Error> { if let Some(path) = Self::config_file_path() { self.save_to(&path)?; } Ok(()) } - - pub fn save_to(&self, path: &std::path::Path) -> Result<(), std::io::Error> { + pub fn save_to(&self, path: &Path) -> Result<(), std::io::Error> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - fs::write(path, json)?; - Ok(()) + fs::write(path, json) } } #[cfg(test)] mod tests { use super::*; - + #[test] + fn legacy_config_is_migrated_to_remote_without_losing_values() { + let legacy = r#"{"server_url":"https://foreseer.example","allow_insecure_http":false}"#; + let value: LegacyConfig = serde_json::from_str(legacy).unwrap(); + let migrated = AppConfig { + schema_version: CONFIG_SCHEMA_VERSION, + mode: AppMode::Remote, + remote: RemoteConfig { + server_url: value.server_url, + allow_insecure_http: value.allow_insecure_http, + }, + standalone: StandaloneConfig { + cache_limit_bytes: DEFAULT_CACHE_LIMIT_BYTES, + }, + }; + assert_eq!(migrated.mode, AppMode::Remote); + assert_eq!(migrated.remote.server_url, "https://foreseer.example"); + } + #[test] + fn defaults_are_standalone_and_use_two_gib_cache_budget() { + let config = AppConfig::default(); + assert_eq!(config.mode, AppMode::Standalone); + assert_eq!(config.standalone.cache_limit_bytes, 2_147_483_648); + } #[test] fn insecure_foreseer_urls_require_local_override() { assert_eq!( @@ -187,39 +271,12 @@ mod tests { ForeseerUrlError::InsecureHttpNotAllowed ); assert!(validate_foreseer_url("http://127.0.0.1", true).is_ok()); - assert_eq!( - validate_foreseer_url("http://example.com", true).unwrap_err(), - ForeseerUrlError::InsecureHttpNonLocalHost - ); - } - - #[test] - fn rejects_credential_bearing_urls() { - assert_eq!( - validate_foreseer_url("https://user:pass@foreseer.example", false).unwrap_err(), - ForeseerUrlError::CredentialsNotAllowed - ); } - #[test] fn bootstrap_urls_are_always_https() { assert_eq!( validate_bootstrap_server_url("http://jellyfin.example").unwrap_err(), ForeseerUrlError::InsecureHttpNotAllowed ); - assert!(validate_bootstrap_server_url("https://jellyfin.example/").is_ok()); - } - - #[test] - fn app_config_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.json"); - let cfg = AppConfig { - server_url: "https://foreseer.example".into(), - allow_insecure_http: false, - }; - cfg.save_to(&path).unwrap(); - let loaded: AppConfig = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(loaded, cfg); } } diff --git a/src/extension.rs b/src/extension.rs index 9cab91a..a3e52fd 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -10,10 +10,11 @@ use jfn_rust::{ use serde_json::json; use crate::auth::{AuthErrorCode, redeem_ticket, redemption_url}; -use crate::config::{AppConfig, validate_foreseer_url}; +use crate::config::{AppConfig, AppMode, validate_foreseer_url}; use crate::controller::{AppState, Controller, ControllerEvent, Presentation, RuntimeOps}; use crate::protocol::{NativeCommandV1, NativeEventV1, parse_command, serialize_event}; use crate::session::SessionBootstrap; +use crate::supervisor::StandaloneSupervisor; struct HandleRuntime { handle: RuntimeHandle, @@ -83,6 +84,7 @@ pub struct ForeseerExtension { in_setup: bool, state: Mutex>, self_weak: Mutex>>, + standalone_supervisor: Option>>, } impl ForeseerExtension { @@ -91,6 +93,22 @@ impl ForeseerExtension { frontend_url: String, allow_insecure_http: bool, in_setup: bool, + ) -> Arc { + Self::new_with_supervisor( + descriptor, + frontend_url, + allow_insecure_http, + in_setup, + None, + ) + } + + pub fn new_with_supervisor( + descriptor: HostExtensionDescriptor, + frontend_url: String, + allow_insecure_http: bool, + in_setup: bool, + standalone_supervisor: Option>>, ) -> Arc { let extension = Arc::new(Self { descriptor, @@ -99,6 +117,7 @@ impl ForeseerExtension { in_setup, state: Mutex::new(None), self_weak: Mutex::new(None), + standalone_supervisor, }); if let Ok(mut slot) = extension.self_weak.lock() { *slot = Some(Arc::downgrade(&extension)); @@ -244,6 +263,34 @@ impl HostExtension for ForeseerExtension { if let RuntimeEvent::PrimaryWebLoaded { url } = &event { self.deliver_pending_bootstrap(url); } + match event { + RuntimeEvent::PlaybackStarted => { + if let Some(supervisor) = &self.standalone_supervisor + && let Ok(mut supervisor) = supervisor.lock() + { + supervisor.set_playback_active(true); + } + } + RuntimeEvent::PlaybackFinished + | RuntimeEvent::PlaybackCanceled + | RuntimeEvent::PlaybackError => { + if let Some(supervisor) = &self.standalone_supervisor + && let Ok(mut supervisor) = supervisor.lock() + { + supervisor.set_playback_active(false); + } + } + RuntimeEvent::ShutdownBeginning => { + if let Some(supervisor) = self.standalone_supervisor.clone() { + std::thread::spawn(move || { + if let Ok(mut supervisor) = supervisor.lock() { + supervisor.shutdown(); + } + }); + } + } + _ => {} + } let mapped = match event { RuntimeEvent::PlaybackStarted => Some(ControllerEvent::PlaybackStarted), RuntimeEvent::PlaybackFinished => Some(ControllerEvent::PlaybackFinished), @@ -441,8 +488,9 @@ impl ForeseerExtension { } }; let mut config = AppConfig::load(); - config.server_url = normalized.clone(); - config.allow_insecure_http = allow_http; + config.mode = AppMode::Remote; + config.remote.server_url = normalized.clone(); + config.remote.allow_insecure_http = allow_http; let _ = config.save(); if let Ok(mut url_guard) = self.frontend_url.lock() { *url_guard = normalized.clone(); diff --git a/src/lib.rs b/src/lib.rs index 79c4ea1..43b2f96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,9 +6,10 @@ pub mod controller; pub mod extension; pub mod protocol; pub mod session; +pub mod supervisor; pub use config::{ - AppConfig, ForeseerUrlError, validate_bootstrap_server_url, validate_foreseer_url, + AppConfig, AppMode, ForeseerUrlError, validate_bootstrap_server_url, validate_foreseer_url, }; pub use controller::{AppState, Controller, ControllerEvent, RuntimeOps}; pub use protocol::{ diff --git a/src/main.rs b/src/main.rs index 840638e..5218fcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,36 @@ use base64::Engine; use directories::ProjectDirs; -use foreseer_desktop::config::{AppConfig, validate_foreseer_url}; +use foreseer_desktop::config::{AppConfig, AppMode, validate_foreseer_url}; use foreseer_desktop::extension::ForeseerExtension; +use foreseer_desktop::supervisor::StandaloneSupervisor; use jfn_rust::{HostExtensionDescriptor, HostOptions}; use std::ffi::OsStr; use std::process::Command; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; mod setup; const SETUP_RELAUNCH_ENV: &str = "FORESEER_SETUP_RELAUNCHED"; fn main() { + // CEF helper processes share this binary. They must never create data, + // backups, or a managed Node child. + if std::env::args().any(|arg| arg.starts_with("--type=")) { + std::process::exit(jfn_rust::app::jfn_app_main_with(HostOptions::default())); + } relaunch_after_consuming_setup_flag(); configure_product_profile(); let cli_requested_setup = handle_cli_args(); - let config_exists = AppConfig::exists(); let config = AppConfig::load(); - let needs_setup = cli_requested_setup || !config_exists || !config.is_configured(); + let needs_setup = + cli_requested_setup || (config.mode == AppMode::Remote && !config.is_configured()); let frontend_script = include_str!("assets/foreseer-native.js") .replace("__HOST_VERSION__", env!("CARGO_PKG_VERSION")); let primary_web_script = include_str!("assets/jellyfin-session.js").to_string(); + let mut standalone: Option>> = None; let (descriptor, frontend_url, allow_insecure_http) = if needs_setup { let setup_html = setup::get_setup_html(""); let base64_html = base64::engine::general_purpose::STANDARD.encode(setup_html); @@ -36,9 +43,42 @@ fn main() { ) .expect("generated setup document"); (descriptor, url, true) + } else if config.mode == AppMode::Standalone { + let child = match StandaloneSupervisor::start(&config) { + Ok(child) => child, + Err(error) => { + let setup_html = setup::get_setup_html(&format!("

{error}

")); + let url = format!( + "data:text/html;base64,{}", + base64::engine::general_purpose::STANDARD.encode(setup_html) + ); + let descriptor = HostExtensionDescriptor::from_setup_document( + &url, + vec![frontend_script], + vec![primary_web_script], + false, + ) + .expect("generated recovery document"); + let extension: Arc = + ForeseerExtension::new(descriptor, url, true, true); + let options = HostOptions::with_extension(extension); + std::process::exit(jfn_rust::app::jfn_app_main_with(options)); + } + }; + let url = child.origin.clone(); + let descriptor = HostExtensionDescriptor::from_url( + &url, + vec![frontend_script], + vec![primary_web_script], + false, + ) + .expect("validated managed loopback URL"); + standalone = Some(Arc::new(Mutex::new(child))); + (descriptor, url, true) } else { - let url = validate_foreseer_url(&config.server_url, config.allow_insecure_http) - .expect("validated configured Foreseer URL"); + let url = + validate_foreseer_url(&config.remote.server_url, config.remote.allow_insecure_http) + .expect("validated configured Foreseer URL"); let descriptor = HostExtensionDescriptor::from_url( &url, vec![frontend_script], @@ -46,13 +86,30 @@ fn main() { false, ) .expect("validated configured Foreseer URL"); - (descriptor, url, config.allow_insecure_http) + (descriptor, url, config.remote.allow_insecure_http) }; - let extension: Arc = - ForeseerExtension::new(descriptor, frontend_url, allow_insecure_http, needs_setup); + let extension: Arc = ForeseerExtension::new_with_supervisor( + descriptor, + frontend_url, + allow_insecure_http, + needs_setup, + standalone.clone(), + ); let options = HostOptions::with_extension(extension); - std::process::exit(jfn_rust::app::jfn_app_main_with(options)); + let options = if config.mode == AppMode::Standalone { + options.with_cef_disk_cache_limit(config.standalone.cache_limit_bytes * 3 / 8) + } else { + options + }; + let code = jfn_rust::app::jfn_app_main_with(options); + if let Some(supervisor) = &standalone + && let Ok(mut supervisor) = supervisor.lock() + { + supervisor.shutdown(); + } + drop(standalone); + std::process::exit(code); } /// `--setup` belongs to Foreseer, while the embedded Jellium runtime parses @@ -93,7 +150,9 @@ fn handle_cli_args() -> bool { println!(); println!("OPTIONS:"); println!(" --setup Open the graphical server setup page"); - println!(" --set-url Set target Foreseer server URL in config file and exit"); + println!(" --remote Set remote Foreseerr URL and switch to remote mode"); + println!(" --set-url Compatibility alias for --remote"); + println!(" --standalone Switch to bundled standalone mode"); println!(" --allow-http Allow insecure HTTP when saving server URL"); println!(" --show-config Display current config file path and settings"); println!(" --help, -h Show this help message"); @@ -107,11 +166,24 @@ fn handle_cli_args() -> bool { path.map(|p| p.display().to_string()) .unwrap_or_else(|| "Unknown".into()) ); - println!("Server URL: {}", config.server_url); - println!("Allow HTTP: {}", config.allow_insecure_http); + println!("Schema version: {}", config.schema_version); + println!("Mode: {:?}", config.mode); + println!("Remote URL: {}", config.remote.server_url); + println!("Allow HTTP: {}", config.remote.allow_insecure_http); + println!( + "Standalone data: {}", + AppConfig::standalone_data_directory() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "Unknown".into()) + ); + println!("Cache budget: {}", config.standalone.cache_limit_bytes); + println!( + "Bundled Foreseerr version: {}", + include_str!("../foreseerr.rev").trim() + ); std::process::exit(0); } - "--set-url" => { + "--set-url" | "--remote" => { if args.len() < 3 { eprintln!( "Error: --set-url requires a URL argument (e.g. --set-url https://my-server.com)" @@ -129,8 +201,9 @@ fn handle_cli_args() -> bool { }; let mut config = AppConfig::load(); - config.server_url = url; - config.allow_insecure_http = allow_http; + config.mode = AppMode::Remote; + config.remote.server_url = url; + config.remote.allow_insecure_http = allow_http; if let Err(e) = config.save() { eprintln!("Error saving config: {}", e); std::process::exit(1); @@ -138,6 +211,16 @@ fn handle_cli_args() -> bool { println!("Successfully saved server URL to config."); std::process::exit(0); } + "--standalone" => { + let mut config = AppConfig::load(); + config.mode = AppMode::Standalone; + if let Err(e) = config.save() { + eprintln!("Error saving config: {e}"); + std::process::exit(1); + } + println!("Successfully enabled standalone mode."); + std::process::exit(0); + } _ => {} } false diff --git a/src/supervisor.rs b/src/supervisor.rs new file mode 100644 index 0000000..89d2b05 --- /dev/null +++ b/src/supervisor.rs @@ -0,0 +1,381 @@ +//! Managed loopback Foreseerr child lifecycle. +//! +//! This module deliberately has no knowledge of CEF. It owns only the bundled +//! Node process and exposes a validated, exact loopback origin to the caller. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use crate::config::AppConfig; + +pub const READY_PREFIX: &str = "FORESEERR_DESKTOP_READY "; +pub const READY_PROTOCOL_VERSION: u32 = 1; +const READY_TIMEOUT: Duration = Duration::from_secs(90); +const BUNDLED_FORESEERR_VERSION_FILE: &str = include_str!("../foreseerr.rev"); + +fn bundled_foreseerr_version() -> &'static str { + BUNDLED_FORESEERR_VERSION_FILE.trim() +} + +#[derive(Debug, Deserialize, Serialize)] +struct RuntimeVersion { + foreseerr_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadyRecord { + pub protocol_version: u32, + pub pid: u32, + pub origin: String, + pub foreseerr_version: String, + pub commit: String, + pub schema_version: u32, +} + +#[derive(Debug)] +pub enum SupervisorError { + ResourcesNotFound(PathBuf), + Spawn(std::io::Error), + Startup(String), + InvalidReady(String), +} +impl std::fmt::Display for SupervisorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ResourcesNotFound(path) => write!( + f, + "Bundled Foreseerr resources were not found at {}", + path.display() + ), + Self::Spawn(err) => write!(f, "Could not start bundled Foreseerr: {err}"), + Self::Startup(message) | Self::InvalidReady(message) => f.write_str(message), + } + } +} +impl std::error::Error for SupervisorError {} + +pub struct StandaloneSupervisor { + child: Child, + stdin: Option, + pub origin: String, + pub diagnostics: Vec, +} + +impl StandaloneSupervisor { + pub fn start(config: &AppConfig) -> Result { + let resource_root = resource_root()?; + let node = resource_root + .join("node") + .join(if cfg!(windows) { "node.exe" } else { "node" }); + let launcher = resource_root.join("foreseerr").join("launcher.js"); + if !node.is_file() || !launcher.is_file() { + return Err(SupervisorError::ResourcesNotFound(resource_root)); + } + let config_dir = AppConfig::standalone_data_directory().ok_or_else(|| { + SupervisorError::Startup("No platform configuration directory is available".into()) + })?; + let cache_dir = AppConfig::standalone_cache_directory().ok_or_else(|| { + SupervisorError::Startup("No platform cache directory is available".into()) + })?; + let log_dir = AppConfig::standalone_log_directory().ok_or_else(|| { + SupervisorError::Startup("No platform log directory is available".into()) + })?; + for directory in [ + &config_dir, + &cache_dir, + &log_dir, + &config_dir.join("state"), + &config_dir.join("backups"), + ] { + std::fs::create_dir_all(directory).map_err(SupervisorError::Spawn)?; + } + backup_before_upgrade(&config_dir)?; + let mut command = Command::new(node); + command + .arg(launcher) + .current_dir(resource_root.join("foreseerr")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for key in [ + "PORT", + "HOST", + "CONFIG_DIRECTORY", + "CACHE_DIRECTORY", + "LOG_DIRECTORY", + "NODE_OPTIONS", + "NODE_PATH", + ] { + command.env_remove(key); + } + command + .env("FORESEERR_RUNTIME", "desktop") + .env("CONFIG_DIRECTORY", &config_dir) + .env("CACHE_DIRECTORY", &cache_dir) + .env("LOG_DIRECTORY", &log_dir) + .env("HOST", "127.0.0.1") + .env("PORT", "0") + .env( + "FORESEER_CACHE_LIMIT_BYTES", + config.standalone.cache_limit_bytes.to_string(), + ); + let mut child = command.spawn().map_err(SupervisorError::Spawn)?; + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + let (tx, rx) = mpsc::channel(); + let tx_stdout = tx.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if tx_stdout.send((false, line.unwrap_or_default())).is_err() { + break; + } + } + }); + let tx_stderr = tx.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines() { + if tx_stderr.send((true, line.unwrap_or_default())).is_err() { + break; + } + } + }); + let deadline = Instant::now() + READY_TIMEOUT; + let mut diagnostics = Vec::new(); + loop { + if let Some(status) = child.try_wait().map_err(SupervisorError::Spawn)? { + return Err(SupervisorError::Startup(format!( + "Bundled Foreseerr exited before readiness ({status}); {}", + diagnostics.join("\n") + ))); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let _ = child.kill(); + return Err(SupervisorError::Startup(format!( + "Timed out waiting for bundled Foreseerr readiness; {}", + diagnostics.join("\n") + ))); + } + if let Ok((is_stderr, line)) = + rx.recv_timeout(remaining.min(Duration::from_millis(250))) + { + if diagnostics.len() == 200 { + diagnostics.remove(0); + } + diagnostics.push(redact(&line)); + if !is_stderr && line.starts_with(READY_PREFIX) { + let ready: ReadyRecord = serde_json::from_str(&line[READY_PREFIX.len()..]) + .map_err(|_| { + SupervisorError::InvalidReady( + "Bundled Foreseerr emitted malformed readiness data".into(), + ) + })?; + validate_ready(&ready)?; + if ready.foreseerr_version != bundled_foreseerr_version() { + let _ = child.kill(); + return Err(SupervisorError::InvalidReady(format!( + "Bundled Foreseerr version mismatch (expected {}, got {})", + bundled_foreseerr_version(), + ready.foreseerr_version + ))); + } + let supervisor = Self { + stdin: child.stdin.take(), + child, + origin: ready.origin, + diagnostics, + }; + if !supervisor.status_is_healthy() { + return Err(SupervisorError::Startup( + "Bundled Foreseerr did not pass its status health check".into(), + )); + } + write_runtime_version(&config_dir, &ready.foreseerr_version)?; + return Ok(supervisor); + } + } + } + } + pub fn set_playback_active(&mut self, active: bool) { + self.send_control(&format!( + r#"{{"type":"runtime-state","playbackActive":{active}}}"# + )); + } + pub fn shutdown(&mut self) { + self.send_control(r#"{"type":"shutdown","deadlineMs":10000}"#); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if self.child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } + fn send_control(&mut self, message: &str) { + if let Some(stdin) = self.stdin.as_mut() { + let _ = writeln!(stdin, "{message}"); + let _ = stdin.flush(); + } + } + + fn status_is_healthy(&self) -> bool { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(10))) + .http_status_as_error(false) + .build() + .into(); + agent + .get(&format!("{}/api/v1/status", self.origin)) + .call() + .ok() + .is_some_and(|response| response.status().is_success()) + } +} +impl Drop for StandaloneSupervisor { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn resource_root() -> Result { + let executable = std::env::current_exe().map_err(SupervisorError::Spawn)?; + let base = executable + .parent() + .unwrap_or(Path::new(".")) + .join("resources"); + if base.is_dir() { + Ok(base) + } else { + Err(SupervisorError::ResourcesNotFound(base)) + } +} +fn validate_ready(ready: &ReadyRecord) -> Result<(), SupervisorError> { + if ready.protocol_version != READY_PROTOCOL_VERSION { + return Err(SupervisorError::InvalidReady( + "Bundled Foreseerr uses an unsupported desktop protocol".into(), + )); + } + let url = url::Url::parse(&ready.origin).map_err(|_| { + SupervisorError::InvalidReady("Bundled Foreseerr supplied an invalid origin".into()) + })?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().unwrap_or(0) == 0 + || url.username() != "" + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(SupervisorError::InvalidReady( + "Bundled Foreseerr supplied a non-loopback origin".into(), + )); + } + Ok(()) +} +fn redact(line: &str) -> String { + let lower = line.to_ascii_lowercase(); + if ["authorization", "cookie", "token", "ticket"] + .iter() + .any(|secret| lower.contains(secret)) + { + "[redacted child diagnostic]".into() + } else { + line.into() + } +} + +fn backup_before_upgrade(config_dir: &Path) -> Result<(), SupervisorError> { + let state_file = config_dir.join("state/runtime-version.json"); + let previous = fs::read_to_string(&state_file) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()); + let Some(previous) = previous else { + return Ok(()); + }; + if previous.foreseerr_version == bundled_foreseerr_version() { + return Ok(()); + } + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let backup = config_dir + .join("backups") + .join(format!("{}-{timestamp}", previous.foreseerr_version)); + fs::create_dir_all(&backup).map_err(SupervisorError::Spawn)?; + for relative in [ + "settings.json", + "db/db.sqlite3", + "db/db.sqlite3-wal", + "db/db.sqlite3-shm", + ] { + let source = config_dir.join(relative); + if source.is_file() { + let target = backup.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(SupervisorError::Spawn)?; + } + fs::copy(source, target).map_err(SupervisorError::Spawn)?; + } + } + let metadata = serde_json::json!({ + "previousVersion": previous.foreseerr_version, + "newVersion": bundled_foreseerr_version(), + "createdAt": timestamp, + }); + fs::write( + backup.join("metadata.json"), + serde_json::to_vec_pretty(&metadata).unwrap(), + ) + .map_err(SupervisorError::Spawn)?; + let backups = config_dir.join("backups"); + let mut entries: Vec<_> = fs::read_dir(&backups) + .map_err(SupervisorError::Spawn)? + .flatten() + .filter(|entry| entry.path().is_dir()) + .collect(); + entries.sort_by_key(|entry| entry.file_name()); + while entries.len() > 3 { + let oldest = entries.remove(0); + fs::remove_dir_all(oldest.path()).map_err(SupervisorError::Spawn)?; + } + Ok(()) +} + +fn write_runtime_version(config_dir: &Path, version: &str) -> Result<(), SupervisorError> { + let state = config_dir.join("state/runtime-version.json"); + let payload = serde_json::to_vec_pretty(&RuntimeVersion { + foreseerr_version: version.into(), + }) + .map_err(|error| SupervisorError::Startup(error.to_string()))?; + fs::write(state, payload).map_err(SupervisorError::Spawn) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn readiness_requires_exact_loopback_origin() { + let ready = ReadyRecord { + protocol_version: 1, + pid: 1, + origin: "http://127.0.0.1:43127".into(), + foreseerr_version: "0.6.2".into(), + commit: "test".into(), + schema_version: 1, + }; + assert!(validate_ready(&ready).is_ok()); + let mut bad = ready; + bad.origin = "http://localhost:43127".into(); + assert!(validate_ready(&bad).is_err()); + } +} From 27f30b1f9450c2ea3dc9b36df483abc0d5987519 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:19:16 +0300 Subject: [PATCH 03/74] fix: atomically persist desktop mode configuration --- src/config.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 0b5106f..8f49a98 100644 --- a/src/config.rs +++ b/src/config.rs @@ -233,7 +233,11 @@ impl AppConfig { } let json = serde_json::to_string_pretty(self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - fs::write(path, json) + // Keep a power loss or interrupted mode switch from replacing the + // durable configuration with a partial JSON document. + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, json)?; + fs::rename(temporary, path) } } From 9645f9c816457b22c6511dc36092295ed8913a3b Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:19:57 +0300 Subject: [PATCH 04/74] feat: reap standalone child process groups on Unix --- Cargo.lock | 1 + Cargo.toml | 1 + src/supervisor.rs | 16 ++++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6d34ca3..0c65a28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,6 +1121,7 @@ dependencies = [ "directories", "getrandom 0.3.4", "jfn-rust", + "libc", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index cda7b73..00b9c8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ sha2 = "0.10" directories = "6" url = "2" tracing = "0.1" +libc = "0.2" [dev-dependencies] tempfile = "3" diff --git a/src/supervisor.rs b/src/supervisor.rs index 89d2b05..62221e3 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -11,6 +11,9 @@ use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; + use crate::config::AppConfig; pub const READY_PREFIX: &str = "FORESEERR_DESKTOP_READY "; @@ -103,6 +106,8 @@ impl StandaloneSupervisor { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + #[cfg(unix)] + command.process_group(0); for key in [ "PORT", "HOST", @@ -209,6 +214,12 @@ impl StandaloneSupervisor { } pub fn shutdown(&mut self) { self.send_control(r#"{"type":"shutdown","deadlineMs":10000}"#); + #[cfg(unix)] + if let Ok(pid) = i32::try_from(self.child.id()) { + // The child is the leader of a dedicated group, so this also + // reaches Node helpers spawned for native modules. + unsafe { libc::kill(-pid, libc::SIGTERM) }; + } let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { if self.child.try_wait().ok().flatten().is_some() { @@ -216,6 +227,11 @@ impl StandaloneSupervisor { } std::thread::sleep(Duration::from_millis(50)); } + #[cfg(unix)] + if let Ok(pid) = i32::try_from(self.child.id()) { + unsafe { libc::kill(-pid, libc::SIGKILL) }; + } + #[cfg(not(unix))] let _ = self.child.kill(); let _ = self.child.wait(); } From 440425f259a1cf9f39dc6dfdd679253180feeace Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:21:00 +0300 Subject: [PATCH 05/74] feat: offer standalone mode in native setup --- src/controller.rs | 4 ++++ src/extension.rs | 24 ++++++++++++++++++++++++ src/protocol.rs | 3 +++ src/setup.html | 14 ++++++++++++-- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/controller.rs b/src/controller.rs index 264e8d2..8f70946 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -146,6 +146,10 @@ impl Controller { url, allow_http, } => self.on_setup_save(id, url, allow_http), + NativeCommandV1::SetupStandalone { id } => { + self.emit_error(&id, AuthErrorCode::InvalidRequest); + true + } NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index a3e52fd..cbd07a9 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -364,6 +364,7 @@ impl ForeseerExtension { url, allow_http, } => self.save_setup(id, url, allow_http), + NativeCommandV1::SetupStandalone { id } => self.save_standalone_setup(id), NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -413,6 +414,29 @@ impl ForeseerExtension { } } + fn save_standalone_setup(&self, id: String) -> bool { + let mut config = AppConfig::load(); + config.mode = AppMode::Standalone; + if config.save().is_err() { + self.with_inner(|inner| { + inner.controller.runtime.post_frontend_event( + NativeEventV1::new(id, "error") + .with_error("config_save_failed") + .with_message("Could not save standalone mode"), + ); + }); + return true; + } + self.with_inner(|inner| { + inner + .controller + .runtime + .post_frontend_event(NativeEventV1::new(id, "save-config-success")); + inner.controller.runtime.request_shutdown(); + }); + true + } + fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { diff --git a/src/protocol.rs b/src/protocol.rs index b2d9b6f..f9491c9 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -65,6 +65,8 @@ pub enum NativeCommandV1 { #[serde(rename = "allowHttp")] allow_http: bool, }, + #[serde(rename = "setup.standalone")] + SetupStandalone { id: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -84,6 +86,7 @@ impl NativeCommandV1 { | Self::PlayItem { id, .. } | Self::SetupCheck { id, .. } | Self::SetupSave { id, .. } + | Self::SetupStandalone { id } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } diff --git a/src/setup.html b/src/setup.html index 2183b84..10b199a 100644 --- a/src/setup.html +++ b/src/setup.html @@ -194,7 +194,7 @@
@@ -212,6 +212,7 @@

FORESEER

+
@@ -248,7 +249,7 @@

FORESEER

} const ok = host.send({ id: reqId, - type: action === 'test' ? 'setup.check' : 'setup.save', + type: action === 'test' ? 'setup.check' : action === 'standalone' ? 'setup.standalone' : 'setup.save', url, allowHttp: !!allowHttp, }); @@ -262,6 +263,15 @@

FORESEER

}); } + async function useStandalone() { + try { + await nativeCall('standalone', '', false); + showStatus('Standalone mode will start after restart.', 'success'); + } catch (err) { + showStatus(err.message, 'error'); + } + } + async function testConnection() { const url = document.getElementById('serverUrl').value.trim(); const allowHttp = document.getElementById('allowHttp').checked; From 9204a1975027288cc38b084b1c60b4100dbb5c3c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:21:16 +0300 Subject: [PATCH 06/74] test: cover standalone setup action --- src/setup.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/setup.rs b/src/setup.rs index ca64465..29609a7 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -18,6 +18,8 @@ mod tests { assert!(html.contains("detail.status")); assert!(html.contains("detail.message")); assert!(html.contains("foreseerNative")); + assert!(html.contains("setup.standalone")); + assert!(html.contains("Use Standalone")); assert!(!html.contains("{{SETUP_EVENT_JS}}")); assert!(!html.contains("jelliumHost")); } From 591a89eec38823bfff9fec7b8a923cc1c1efef8d Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:22:10 +0300 Subject: [PATCH 07/74] build: pin bundled Node runtime --- node.rev | 1 + scripts/check-release-pins.sh | 5 ++++- scripts/stage-foreseerr.sh | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 node.rev diff --git a/node.rev b/node.rev new file mode 100644 index 0000000..2c6984e --- /dev/null +++ b/node.rev @@ -0,0 +1 @@ +v22.19.0 diff --git a/scripts/check-release-pins.sh b/scripts/check-release-pins.sh index 460fada..f1df59d 100755 --- a/scripts/check-release-pins.sh +++ b/scripts/check-release-pins.sh @@ -4,9 +4,12 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FORESEERR_DIR="${FORESEERR_DIR:-$ROOT/../SeerrSuggestArr}" test -s "$ROOT/jellium.rev" test -s "$ROOT/foreseerr.rev" +test -s "$ROOT/node.rev" test -f "$FORESEERR_DIR/package.json" PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" VERSION="$(node -p "require('$FORESEERR_DIR/package.json').version")" test "$PIN" = "$VERSION" test -f "$FORESEERR_DIR/launcher.js" -echo "release pins: Foreseer Desktop $(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1), Foreseerr $PIN" +NODE_PIN="$(tr -d '[:space:]' < "$ROOT/node.rev")" +test "$(node --version)" = "$NODE_PIN" +echo "release pins: Foreseer Desktop $(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1), Foreseerr $PIN, Node $NODE_PIN" diff --git a/scripts/stage-foreseerr.sh b/scripts/stage-foreseerr.sh index 6874669..e519bcd 100755 --- a/scripts/stage-foreseerr.sh +++ b/scripts/stage-foreseerr.sh @@ -7,11 +7,16 @@ SOURCE="${FORESEERR_DIR:-$ROOT/../SeerrSuggestArr}" NODE_BIN="${FORESEERR_NODE_BIN:-$(command -v node)}" DEST="${1:-$ROOT/resources}" PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" +NODE_PIN="$(tr -d '[:space:]' < "$ROOT/node.rev")" if [[ ! -x "$NODE_BIN" ]]; then echo "stage-foreseerr: provide FORESEERR_NODE_BIN or install Node 22" >&2 exit 1 fi +if [[ "$($NODE_BIN --version)" != "$NODE_PIN" ]]; then + echo "stage-foreseerr: Node $NODE_PIN is required" >&2 + exit 1 +fi if [[ ! -f "$SOURCE/package.json" ]]; then echo "stage-foreseerr: no Foreseerr checkout at $SOURCE" >&2 exit 1 From 774f77b782acc2bdf570934f76d94e9b06e088c1 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:29:09 +0300 Subject: [PATCH 08/74] fix: enforce safe standalone cache minimum --- src/config.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/config.rs b/src/config.rs index 8f49a98..9f6cca2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ pub const DEFAULT_FRONTEND_URL: &str = "https://foreseer.example.com"; pub const MAX_FORESEER_URL_LEN: usize = 2048; pub const CONFIG_SCHEMA_VERSION: u32 = 2; pub const DEFAULT_CACHE_LIMIT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +pub const MIN_CACHE_LIMIT_BYTES: u64 = 128 * 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ForeseerUrlError { @@ -210,6 +211,10 @@ impl AppConfig { }) .unwrap_or_default(); config.schema_version = CONFIG_SCHEMA_VERSION; + config.standalone.cache_limit_bytes = config + .standalone + .cache_limit_bytes + .max(MIN_CACHE_LIMIT_BYTES); if let Ok(url) = std::env::var("FORESEER_URL") && !url.trim().is_empty() { @@ -269,6 +274,10 @@ mod tests { assert_eq!(config.standalone.cache_limit_bytes, 2_147_483_648); } #[test] + fn standalone_cache_budget_has_a_safe_minimum() { + assert_eq!(MIN_CACHE_LIMIT_BYTES, 128 * 1024 * 1024); + } + #[test] fn insecure_foreseer_urls_require_local_override() { assert_eq!( validate_foreseer_url("http://example.com", false).unwrap_err(), From 1f62eaa8da7b3d7d83fc6ea937106dbea682306b Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:32:28 +0300 Subject: [PATCH 09/74] feat: bridge browser cache clearing to Foreseer --- src/controller.rs | 4 ++++ src/extension.rs | 15 +++++++++++++++ src/protocol.rs | 3 +++ src/setup-event.js | 1 + 4 files changed, 23 insertions(+) diff --git a/src/controller.rs b/src/controller.rs index 8f70946..51251ce 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -150,6 +150,10 @@ impl Controller { self.emit_error(&id, AuthErrorCode::InvalidRequest); true } + NativeCommandV1::CacheClearBrowser { id } => { + self.emit_error(&id, AuthErrorCode::InvalidRequest); + true + } NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index cbd07a9..daf59d1 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -365,6 +365,7 @@ impl ForeseerExtension { allow_http, } => self.save_setup(id, url, allow_http), NativeCommandV1::SetupStandalone { id } => self.save_standalone_setup(id), + NativeCommandV1::CacheClearBrowser { id } => self.clear_browser_cache(id), NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -437,6 +438,20 @@ impl ForeseerExtension { true } + fn clear_browser_cache(&self, id: String) -> bool { + self.with_inner(|inner| { + let event = if inner.runtime.clear_http_cache() { + NativeEventV1::new(id, "cache-clear-success") + } else { + NativeEventV1::new(id, "error") + .with_error("cache_clear_unavailable") + .with_message("Browser cache is not available yet") + }; + inner.controller.runtime.post_frontend_event(event); + }); + true + } + fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { diff --git a/src/protocol.rs b/src/protocol.rs index f9491c9..ec089ad 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -67,6 +67,8 @@ pub enum NativeCommandV1 { }, #[serde(rename = "setup.standalone")] SetupStandalone { id: String }, + #[serde(rename = "cache.clear-browser")] + CacheClearBrowser { id: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -87,6 +89,7 @@ impl NativeCommandV1 { | Self::SetupCheck { id, .. } | Self::SetupSave { id, .. } | Self::SetupStandalone { id } + | Self::CacheClearBrowser { id } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } diff --git a/src/setup-event.js b/src/setup-event.js index a5a44cb..cc7dca7 100644 --- a/src/setup-event.js +++ b/src/setup-event.js @@ -4,6 +4,7 @@ const eventTypes = Object.freeze([ "connectivity-success", "save-config-success", + "cache-clear-success", "error", ]); From ae74c46d5fe9180832e0ff048b99a7c3985a5283 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:32:52 +0300 Subject: [PATCH 10/74] fix: keep browser cache clear behind server authorization --- src/controller.rs | 4 ---- src/extension.rs | 15 --------------- src/protocol.rs | 3 --- src/setup-event.js | 1 - 4 files changed, 23 deletions(-) diff --git a/src/controller.rs b/src/controller.rs index 51251ce..8f70946 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -150,10 +150,6 @@ impl Controller { self.emit_error(&id, AuthErrorCode::InvalidRequest); true } - NativeCommandV1::CacheClearBrowser { id } => { - self.emit_error(&id, AuthErrorCode::InvalidRequest); - true - } NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index daf59d1..cbd07a9 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -365,7 +365,6 @@ impl ForeseerExtension { allow_http, } => self.save_setup(id, url, allow_http), NativeCommandV1::SetupStandalone { id } => self.save_standalone_setup(id), - NativeCommandV1::CacheClearBrowser { id } => self.clear_browser_cache(id), NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -438,20 +437,6 @@ impl ForeseerExtension { true } - fn clear_browser_cache(&self, id: String) -> bool { - self.with_inner(|inner| { - let event = if inner.runtime.clear_http_cache() { - NativeEventV1::new(id, "cache-clear-success") - } else { - NativeEventV1::new(id, "error") - .with_error("cache_clear_unavailable") - .with_message("Browser cache is not available yet") - }; - inner.controller.runtime.post_frontend_event(event); - }); - true - } - fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { diff --git a/src/protocol.rs b/src/protocol.rs index ec089ad..f9491c9 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -67,8 +67,6 @@ pub enum NativeCommandV1 { }, #[serde(rename = "setup.standalone")] SetupStandalone { id: String }, - #[serde(rename = "cache.clear-browser")] - CacheClearBrowser { id: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -89,7 +87,6 @@ impl NativeCommandV1 { | Self::SetupCheck { id, .. } | Self::SetupSave { id, .. } | Self::SetupStandalone { id } - | Self::CacheClearBrowser { id } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } diff --git a/src/setup-event.js b/src/setup-event.js index cc7dca7..a5a44cb 100644 --- a/src/setup-event.js +++ b/src/setup-event.js @@ -4,7 +4,6 @@ const eventTypes = Object.freeze([ "connectivity-success", "save-config-success", - "cache-clear-success", "error", ]); From 0c121bebb1c54be99a12bba35cffc338e6d5c33c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:38:38 +0300 Subject: [PATCH 11/74] feat: expose managed child health states --- src/supervisor.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/supervisor.rs b/src/supervisor.rs index 62221e3..a1602b2 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -48,6 +48,13 @@ pub enum SupervisorError { Startup(String), InvalidReady(String), } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeHealth { + Healthy, + Unhealthy, + Exited, +} impl std::fmt::Display for SupervisorError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -212,6 +219,15 @@ impl StandaloneSupervisor { r#"{{"type":"runtime-state","playbackActive":{active}}}"# )); } + /// Poll only after readiness. Callers can require three consecutive + /// unhealthy results before presenting recovery UI. + pub fn health(&mut self) -> RuntimeHealth { + match self.child.try_wait() { + Ok(Some(_)) => RuntimeHealth::Exited, + Ok(None) if self.status_is_healthy() => RuntimeHealth::Healthy, + Ok(None) | Err(_) => RuntimeHealth::Unhealthy, + } + } pub fn shutdown(&mut self) { self.send_control(r#"{"type":"shutdown","deadlineMs":10000}"#); #[cfg(unix)] From 6a9886abf574b11b77b7854e73ff2069710f21d8 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:39:30 +0300 Subject: [PATCH 12/74] feat: track recovery health thresholds --- src/supervisor.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/supervisor.rs b/src/supervisor.rs index a1602b2..fd1961f 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -55,6 +55,29 @@ pub enum RuntimeHealth { Unhealthy, Exited, } + +/// Tracks the recovery threshold without conflating a transient health probe +/// failure with a confirmed child-runtime failure. +#[derive(Debug, Default)] +pub struct RuntimeHealthTracker { + consecutive_unhealthy: u8, +} + +impl RuntimeHealthTracker { + pub fn observe(&mut self, health: RuntimeHealth) -> bool { + match health { + RuntimeHealth::Healthy => { + self.consecutive_unhealthy = 0; + false + } + RuntimeHealth::Exited => true, + RuntimeHealth::Unhealthy => { + self.consecutive_unhealthy = self.consecutive_unhealthy.saturating_add(1); + self.consecutive_unhealthy >= 3 + } + } + } +} impl std::fmt::Display for SupervisorError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -410,4 +433,14 @@ mod tests { bad.origin = "http://localhost:43127".into(); assert!(validate_ready(&bad).is_err()); } + + #[test] + fn health_tracker_requires_three_probe_failures_but_exits_immediately() { + let mut tracker = RuntimeHealthTracker::default(); + assert!(!tracker.observe(RuntimeHealth::Unhealthy)); + assert!(!tracker.observe(RuntimeHealth::Unhealthy)); + assert!(tracker.observe(RuntimeHealth::Unhealthy)); + assert!(!tracker.observe(RuntimeHealth::Healthy)); + assert!(tracker.observe(RuntimeHealth::Exited)); + } } From 51f134408496926341501aa4c5528a101b97f66b Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:46:55 +0300 Subject: [PATCH 13/74] build: bundle pinned Foreseerr runtime --- .github/workflows/release.yml | 101 ++++++++++++++++++++++- foreseerr.rev | 2 +- foreseerr.version | 1 + scripts/check-release-pins.sh | 5 +- scripts/generate-third-party-notices.mjs | 74 +++++++++++++++++ scripts/stage-foreseerr.sh | 26 ++++-- src/main.rs | 2 +- src/supervisor.rs | 2 +- 8 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 foreseerr.version create mode 100644 scripts/generate-third-party-notices.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b822fe..f9e9f35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,34 @@ jobs: path: foreseer-desktop fetch-depth: 0 + - name: Read pinned Foreseerr revision + id: foreseerr + shell: bash + run: | + revision="$(tr -d '[:space:]' < foreseer-desktop/foreseerr.rev)" + test -n "${revision}" + echo "revision=${revision}" >> "${GITHUB_OUTPUT}" + + - name: Checkout pinned Foreseerr + uses: actions/checkout@v4 + with: + repository: selmant/foreseerr + ref: ${{ steps.foreseerr.outputs.revision }} + path: SeerrSuggestArr + submodules: recursive + + - name: Install pinned Node + uses: actions/setup-node@v4 + with: + node-version-file: foreseer-desktop/node.rev + + - name: Validate release pins + working-directory: foreseer-desktop + shell: bash + env: + FORESEERR_DIR: ${{ github.workspace }}/SeerrSuggestArr + run: ./scripts/check-release-pins.sh + - name: Read pinned Jellium revision id: jellium shell: bash @@ -98,6 +126,12 @@ jobs: print "cd /workspace/jellium-desktop" next } + $0 == "# Desktop integration" { + print "# Bundled Foreseerr + Node runtime (resolved relative to the executable)." + print "mkdir -p \"$APPDIR/usr/bin/resources\"" + print "cp -a /workspace/foreseer-desktop/resources/. \"$APPDIR/usr/bin/resources/\"" + print + } { # POSIX awk replacement strings do not support capture-group # backreferences. Every Jellium executable-name occurrence in @@ -158,17 +192,44 @@ jobs: mkdir -p .build/appimage dist docker run --rm \ + --env FORESEERR_DIR=/workspace/SeerrSuggestArr \ + --env FORESEERR_NODE_BIN=/opt/foreseer-node/bin/node \ --env VERSION="${VERSION}" \ --volume "${GITHUB_WORKSPACE}:/workspace" \ --volume "${GITHUB_WORKSPACE}/.build/appimage:/build" \ --volume "${GITHUB_WORKSPACE}/dist:/host-output" \ foreseer-desktop-appimage:ci \ - /workspace/jellium-desktop/dev/linux/appimage/container-build.sh + sh -ec ' + set -eu + node_version="$(tr -d "[:space:]" < /workspace/foreseer-desktop/node.rev)" + architecture="$(uname -m)" + case "$architecture" in + x86_64) node_arch=x64 ;; + aarch64) node_arch=arm64 ;; + *) echo "unsupported Node architecture: $architecture" >&2; exit 1 ;; + esac + wget -q -O /tmp/node.tar.xz "https://nodejs.org/dist/$node_version/node-$node_version-linux-$node_arch.tar.xz" + mkdir -p /opt/foreseer-node + tar -xJf /tmp/node.tar.xz --strip-components=1 -C /opt/foreseer-node + corepack enable + corepack prepare pnpm@10.24.0 --activate + /workspace/foreseer-desktop/scripts/stage-foreseerr.sh /workspace/foreseer-desktop/resources + /workspace/jellium-desktop/dev/linux/appimage/container-build.sh + ' sudo chown -R "$(id -u):$(id -g)" dist .build jellium-desktop/.cache || true test -f "dist/ForeseerDesktop-${VERSION}-x86_64.AppImage" chmod +x "dist/ForeseerDesktop-${VERSION}-x86_64.AppImage" + - name: Verify Linux runtime payload + shell: bash + run: | + set -euo pipefail + test -f foreseer-desktop/resources/foreseerr/launcher.js + test -x foreseer-desktop/resources/node/node + test -f foreseer-desktop/resources/THIRD_PARTY_NOTICES.txt + strings "dist/ForeseerDesktop-${{ steps.version.outputs.version }}-x86_64.AppImage" | grep -q 'foreseerr/launcher.js' + - name: Upload Linux artifact uses: actions/upload-artifact@v4 with: @@ -187,6 +248,41 @@ jobs: path: foreseer-desktop fetch-depth: 0 + - name: Read pinned Foreseerr revision + id: foreseerr + shell: pwsh + run: | + $revision = (Get-Content foreseer-desktop/foreseerr.rev -Raw).Trim() + if (-not $revision) { throw "foreseerr.rev is empty" } + "revision=$revision" >> $env:GITHUB_OUTPUT + + - name: Checkout pinned Foreseerr + uses: actions/checkout@v4 + with: + repository: selmant/foreseerr + ref: ${{ steps.foreseerr.outputs.revision }} + path: SeerrSuggestArr + submodules: recursive + + - name: Install pinned Node + uses: actions/setup-node@v4 + with: + node-version-file: foreseer-desktop/node.rev + + - name: Stage bundled Foreseerr runtime + shell: pwsh + env: + FORESEERR_DIR: ${{ github.workspace }}\SeerrSuggestArr + run: | + corepack enable + corepack prepare pnpm@10.24.0 --activate + $bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' + if (-not (Test-Path $bash)) { throw "Git Bash is required to stage the runtime" } + & $bash -lc 'cd "$(cygpath -u "$GITHUB_WORKSPACE")"; export FORESEERR_DIR="$(cygpath -u "$FORESEERR_DIR")"; ./foreseer-desktop/scripts/stage-foreseerr.sh ./bundle/resources' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path bundle/resources/foreseerr/launcher.js)) { throw "Foreseerr runtime was not staged" } + if (-not (Test-Path bundle/resources/node/node)) { throw "Node runtime was not staged" } + - name: Read pinned Jellium revision id: jellium shell: pwsh @@ -300,6 +396,9 @@ jobs: if (-not (Test-Path bundle/foreseer-desktop.exe)) { throw "foreseer-desktop.exe was not staged" } + if (-not (Test-Path bundle/resources/THIRD_PARTY_NOTICES.txt)) { + throw "bundled runtime notices were not staged" + } Compress-Archive -Path bundle/* -DestinationPath $archive -Force diff --git a/foreseerr.rev b/foreseerr.rev index b616048..f1e86a2 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -0.6.2 +f33497e34fb760acb000c8f73f06df8cb177716f diff --git a/foreseerr.version b/foreseerr.version new file mode 100644 index 0000000..b616048 --- /dev/null +++ b/foreseerr.version @@ -0,0 +1 @@ +0.6.2 diff --git a/scripts/check-release-pins.sh b/scripts/check-release-pins.sh index f1df59d..2bd44a4 100755 --- a/scripts/check-release-pins.sh +++ b/scripts/check-release-pins.sh @@ -4,11 +4,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FORESEERR_DIR="${FORESEERR_DIR:-$ROOT/../SeerrSuggestArr}" test -s "$ROOT/jellium.rev" test -s "$ROOT/foreseerr.rev" +test -s "$ROOT/foreseerr.version" test -s "$ROOT/node.rev" test -f "$FORESEERR_DIR/package.json" -PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" +PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.version")" VERSION="$(node -p "require('$FORESEERR_DIR/package.json').version")" test "$PIN" = "$VERSION" +REVISION="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" +test "$(git -C "$FORESEERR_DIR" rev-parse HEAD)" = "$REVISION" test -f "$FORESEERR_DIR/launcher.js" NODE_PIN="$(tr -d '[:space:]' < "$ROOT/node.rev")" test "$(node --version)" = "$NODE_PIN" diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs new file mode 100644 index 0000000..54c71ff --- /dev/null +++ b/scripts/generate-third-party-notices.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Produce a deterministic, redistributable inventory from the *deployed* +// production tree. This intentionally reports metadata rather than attempting +// to concatenate arbitrary upstream license text. +import { lstat, readdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +const [bundleDirectory, nodeVersion, outputFile] = process.argv.slice(2); +if (!bundleDirectory || !nodeVersion || !outputFile) { + throw new Error('usage: generate-third-party-notices.mjs '); +} + +const packageFiles = new Set(); +async function collect(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === '.bin' || entry.name === '.cache') continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || directory.includes('node_modules')) { + await collect(path); + } + } else if (entry.name === 'package.json' && directory.includes('node_modules')) { + try { + packageFiles.add(await realpath(path)); + } catch { + // A dangling optional-dependency symlink is not a shipped package. + } + } + } +} + +await collect(join(bundleDirectory, 'node_modules')); +const packages = []; +for (const packageFile of packageFiles) { + try { + const pkg = JSON.parse(await readFile(packageFile, 'utf8')); + if (!pkg.name || !pkg.version) continue; + const repository = typeof pkg.repository === 'string' + ? pkg.repository + : pkg.repository?.url ?? ''; + packages.push({ + name: pkg.name, + version: pkg.version, + license: pkg.license ?? 'UNSPECIFIED', + repository, + }); + } catch { + // Ignore malformed optional package metadata; runtime execution does not + // depend on a notice generator parsing it. + } +} +packages.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)); +const unique = packages.filter((pkg, index) => + index === 0 || `${pkg.name}@${pkg.version}` !== `${packages[index - 1].name}@${packages[index - 1].version}`, +); + +const lines = [ + 'Foreseer Desktop third-party notices', + '', + `Node.js ${nodeVersion} — MIT (full text: node/LICENSE)`, + 'Foreseerr — MIT (full text: foreseerr/LICENSE when supplied by its package)', + '', + 'Production npm dependencies:', + ...unique.map((pkg) => `${pkg.name}@${pkg.version} — ${pkg.license}${pkg.repository ? ` — ${pkg.repository}` : ''}`), + '', +]; +await writeFile(outputFile, lines.join('\n')); +console.log(`wrote ${unique.length} dependency notices to ${basename(outputFile)}`); diff --git a/scripts/stage-foreseerr.sh b/scripts/stage-foreseerr.sh index e519bcd..087c4db 100755 --- a/scripts/stage-foreseerr.sh +++ b/scripts/stage-foreseerr.sh @@ -6,7 +6,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SOURCE="${FORESEERR_DIR:-$ROOT/../SeerrSuggestArr}" NODE_BIN="${FORESEERR_NODE_BIN:-$(command -v node)}" DEST="${1:-$ROOT/resources}" -PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.rev")" +VERSION_PIN="$(tr -d '[:space:]' < "$ROOT/foreseerr.version")" NODE_PIN="$(tr -d '[:space:]' < "$ROOT/node.rev")" if [[ ! -x "$NODE_BIN" ]]; then @@ -22,8 +22,8 @@ if [[ ! -f "$SOURCE/package.json" ]]; then exit 1 fi VERSION="$($NODE_BIN -p "require('$SOURCE/package.json').version")" -if [[ "$VERSION" != "$PIN" ]]; then - echo "stage-foreseerr: Foreseerr $VERSION does not match foreseerr.rev $PIN" >&2 +if [[ "$VERSION" != "$VERSION_PIN" ]]; then + echo "stage-foreseerr: Foreseerr $VERSION does not match foreseerr.version $VERSION_PIN" >&2 exit 1 fi @@ -31,13 +31,27 @@ pnpm --dir "$SOURCE" build rm -rf "$DEST/foreseerr" "$DEST/node" mkdir -p "$DEST/foreseerr" "$DEST/node" install -m 0755 "$NODE_BIN" "$DEST/node/node" -install -m 0644 "$SOURCE/launcher.js" "$DEST/foreseerr/launcher.js" -for item in dist .next public node_modules seerr-api.yml; do +# `deploy --prod` gives the managed server an isolated, target-native production +# dependency tree. Do not copy the development checkout's node_modules: it +# contains Cypress, compiler tooling, package-manager stores, and host-native +# modules which are unsafe to ship in a release artifact. +pnpm --dir "$SOURCE" --filter foreseerr --prod deploy --legacy "$DEST/foreseerr" +for item in launcher.js dist .next public seerr-api.yml; do [[ -e "$SOURCE/$item" ]] && cp -a "$SOURCE/$item" "$DEST/foreseerr/" done find "$DEST/foreseerr" -type d \( -name '.cache' -o -name 'cypress' -o -name 'test' -o -name 'tests' \) -prune -exec rm -rf {} + find "$DEST/foreseerr" -type f \( -name '*.map' -o -name '*.ts' -o -name '*.tsx' \) -delete + +# The official Node distribution places its license beside bin/node. Preserve +# it with deterministic notices for every deployed production dependency. +NODE_LICENSE="$(dirname "$NODE_BIN")/../LICENSE" +if [[ -f "$NODE_LICENSE" ]]; then + install -m 0644 "$NODE_LICENSE" "$DEST/node/LICENSE" +fi +"$NODE_BIN" "$ROOT/scripts/generate-third-party-notices.mjs" \ + "$DEST/foreseerr" "$NODE_PIN" "$DEST/THIRD_PARTY_NOTICES.txt" test -x "$DEST/node/node" test -f "$DEST/foreseerr/launcher.js" test -d "$DEST/foreseerr/dist" -echo "stage-foreseerr: staged $PIN in $DEST" +test -f "$DEST/THIRD_PARTY_NOTICES.txt" +echo "stage-foreseerr: staged $VERSION_PIN in $DEST" diff --git a/src/main.rs b/src/main.rs index 5218fcb..2aa9a73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -179,7 +179,7 @@ fn handle_cli_args() -> bool { println!("Cache budget: {}", config.standalone.cache_limit_bytes); println!( "Bundled Foreseerr version: {}", - include_str!("../foreseerr.rev").trim() + include_str!("../foreseerr.version").trim() ); std::process::exit(0); } diff --git a/src/supervisor.rs b/src/supervisor.rs index fd1961f..0f7b20d 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -19,7 +19,7 @@ use crate::config::AppConfig; pub const READY_PREFIX: &str = "FORESEERR_DESKTOP_READY "; pub const READY_PROTOCOL_VERSION: u32 = 1; const READY_TIMEOUT: Duration = Duration::from_secs(90); -const BUNDLED_FORESEERR_VERSION_FILE: &str = include_str!("../foreseerr.rev"); +const BUNDLED_FORESEERR_VERSION_FILE: &str = include_str!("../foreseerr.version"); fn bundled_foreseerr_version() -> &'static str { BUNDLED_FORESEERR_VERSION_FILE.trim() From 8031d28a4ded116dda87da97f17863c9e3f0e182 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:48:56 +0300 Subject: [PATCH 14/74] build: validate pinned standalone runtime contracts --- .github/workflows/release.yml | 26 ++++++++++++++++++++++++++ jellium.rev | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9e9f35..8fa2cf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,24 @@ jobs: path: jellium-desktop submodules: recursive + - name: Audit pinned runtime boundary and protocol contracts + working-directory: foreseer-desktop + shell: bash + env: + JELLIUM_DIR: ${{ github.workspace }}/jellium-desktop + FORESEERR_DIR: ${{ github.workspace }}/SeerrSuggestArr + run: | + set -euo pipefail + ./scripts/boundary-audit.sh + node -e ' + const fs = require("fs"); + const a = JSON.parse(fs.readFileSync(process.env.FORESEERR_DIR + "/protocol/protocol-v1.json")); + const b = JSON.parse(fs.readFileSync("protocol/protocol-v1.json")); + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Foreseerr protocol fixture differs from desktop fixture"); + ' + grep -q 'FORESEERR_DESKTOP_READY' "$FORESEERR_DIR/server/index.ts" + grep -q 'protocolVersion: 1' "$FORESEERR_DIR/server/index.ts" + - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -301,6 +319,14 @@ jobs: path: jellium-desktop submodules: recursive + - name: Audit pinned runtime boundary and protocol contracts + shell: pwsh + run: | + $bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' + if (-not (Test-Path $bash)) { throw "Git Bash is required for the boundary audit" } + & $bash -lc 'cd "$(cygpath -u "$GITHUB_WORKSPACE")/foreseer-desktop"; export JELLIUM_DIR="$(cygpath -u "$GITHUB_WORKSPACE")/jellium-desktop"; export FORESEERR_DIR="$(cygpath -u "$GITHUB_WORKSPACE")/SeerrSuggestArr"; ./scripts/boundary-audit.sh; node -e '\''const fs=require("fs"); const a=JSON.parse(fs.readFileSync(process.env.FORESEERR_DIR + "/protocol/protocol-v1.json")); const b=JSON.parse(fs.readFileSync("protocol/protocol-v1.json")); if(JSON.stringify(a)!==JSON.stringify(b)) throw new Error("Foreseerr protocol fixture differs from desktop fixture");'\''; grep -q FORESEERR_DESKTOP_READY "$FORESEERR_DIR/server/index.ts"; grep -q "protocolVersion: 1" "$FORESEERR_DIR/server/index.ts"' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/jellium.rev b/jellium.rev index 839d9f4..1311be2 100644 --- a/jellium.rev +++ b/jellium.rev @@ -1 +1 @@ -cb4e9d0a73358dda95555fa7f6110d83ef418110 +b6984a80ac7f2d63c42a66e96a1a67a163448c63 From 0e0386f83b3e73b947f94620aaef7ffc2279d94c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:55:25 +0300 Subject: [PATCH 15/74] feat: clear browser cache through native runtime bridge --- protocol/protocol-v1.json | 9 +++++++++ src/assets/foreseer-native.js | 2 ++ src/controller.rs | 3 +++ src/extension.rs | 36 +++++++++++++++++++++++++++++++++++ src/protocol.rs | 20 ++++++++++++++++++- 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 77635ef..6cfc530 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -11,6 +11,7 @@ "auth-bootstrap", "player-events", "session-reset", + "browser-cache-clear", "window-controls", "quit", "setup" @@ -70,6 +71,13 @@ "allowHttp" ] }, + { + "type": "browser-cache.clear", + "fields": [ + "id", + "ticket" + ] + }, { "type": "window.minimize", "fields": [ @@ -108,6 +116,7 @@ "error", "connectivity-success", "save-config-success" + ,"browser-cache-cleared" ], "terminalPlayEventTypes": [ "stopped", diff --git a/src/assets/foreseer-native.js b/src/assets/foreseer-native.js index 3de13f9..c806734 100644 --- a/src/assets/foreseer-native.js +++ b/src/assets/foreseer-native.js @@ -30,6 +30,7 @@ "auth-bootstrap", "player-events", "session-reset", + "browser-cache-clear", "window-controls", "quit", ] @@ -50,6 +51,7 @@ case "app.quit": return post(command); case "auth.complete": + case "browser-cache.clear": return typeof command.ticket === "string" && TICKET.test(command.ticket) ? post(command) : false; diff --git a/src/controller.rs b/src/controller.rs index 8f70946..0352ae3 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -150,6 +150,9 @@ impl Controller { self.emit_error(&id, AuthErrorCode::InvalidRequest); true } + // The extension redeems the server-authorized ticket before it + // reaches this generic controller. + NativeCommandV1::BrowserCacheClear { .. } => true, NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index cbd07a9..709fa4f 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -365,6 +365,9 @@ impl ForeseerExtension { allow_http, } => self.save_setup(id, url, allow_http), NativeCommandV1::SetupStandalone { id } => self.save_standalone_setup(id), + NativeCommandV1::BrowserCacheClear { id, ticket } => { + self.clear_browser_cache(id, ticket) + } NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -437,6 +440,39 @@ impl ForeseerExtension { true } + fn clear_browser_cache(&self, id: String, ticket: String) -> bool { + let Some((agent, endpoint, runtime)) = self + .with_inner(|inner| { + let parsed = url::Url::parse(&inner.frontend_url).ok()?; + let origin = parsed.origin().ascii_serialization(); + Some(( + inner.agent.clone(), + format!("{origin}/api/v1/desktop/browser-cache/redeem"), + inner.runtime.clone(), + )) + }) + .flatten() + else { + return true; + }; + std::thread::spawn(move || { + let accepted = agent + .post(&endpoint) + .send_json(json!({ "ticket": ticket, "protocolVersion": 1 })) + .ok() + .is_some_and(|response| response.status().as_u16() == 204); + let event = if accepted && runtime.clear_http_cache() { + NativeEventV1::new(id, "browser-cache-cleared") + } else { + NativeEventV1::new(id, "error").with_error("browser_cache_clear_failed") + }; + if let Ok(bytes) = serialize_event(&event) { + let _ = runtime.post_message(ExtensionSource::Frontend, &bytes); + } + }); + true + } + fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { diff --git a/src/protocol.rs b/src/protocol.rs index f9491c9..9f65b44 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -67,6 +67,8 @@ pub enum NativeCommandV1 { }, #[serde(rename = "setup.standalone")] SetupStandalone { id: String }, + #[serde(rename = "browser-cache.clear")] + BrowserCacheClear { id: String, ticket: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -87,6 +89,7 @@ impl NativeCommandV1 { | Self::SetupCheck { id, .. } | Self::SetupSave { id, .. } | Self::SetupStandalone { id } + | Self::BrowserCacheClear { id, .. } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } @@ -99,7 +102,11 @@ impl NativeCommandV1 { return Err("invalid_request_id"); } match self { - Self::AuthComplete { ticket, .. } if !valid_ticket(ticket) => Err("invalid_ticket"), + Self::AuthComplete { ticket, .. } | Self::BrowserCacheClear { ticket, .. } + if !valid_ticket(ticket) => + { + Err("invalid_ticket") + } Self::PlayItem { item_id, .. } if !valid_item_id(item_id) => Err("invalid_item_id"), Self::SetupCheck { url, .. } | Self::SetupSave { url, .. } if url.is_empty() || url.len() > crate::config::MAX_FORESEER_URL_LEN => @@ -232,6 +239,17 @@ mod tests { )); } + #[test] + fn parses_browser_cache_clear_ticket() { + let ticket = "a".repeat(TICKET_LENGTH); + let text = + format!(r#"{{"id":"cache-1","type":"browser-cache.clear","ticket":"{ticket}"}}"#); + assert!(matches!( + parse_command(text.as_bytes()), + Ok(NativeCommandV1::BrowserCacheClear { .. }) + )); + } + #[test] fn fixture_matches_package_version_and_limits() { let fixture = include_str!("../protocol/protocol-v1.json"); From 66f10fe2450ce57595df30be62037f2bcecf71d6 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:56:39 +0300 Subject: [PATCH 16/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index f1e86a2..7f6c0c2 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -f33497e34fb760acb000c8f73f06df8cb177716f +5996c34432a5619fccd8b25e10e085ba8b3fe93a From 2084e22594aaef79ccb2e85c1ab8ce9726771f85 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 18:57:35 +0300 Subject: [PATCH 17/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 7f6c0c2..81bc0f7 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -5996c34432a5619fccd8b25e10e085ba8b3fe93a +2b7c35d6102111b2024b1eae8eef51cb9db0cd5c From 74c511f661dfb8c0f722eb8082aa4c3c67c55f7c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:00:34 +0300 Subject: [PATCH 18/74] feat: contain standalone child tree in Windows job --- Cargo.lock | 1 + Cargo.toml | 8 +++++ src/supervisor.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0c65a28..2e0cded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1130,6 +1130,7 @@ dependencies = [ "tracing-subscriber", "ureq", "url", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 00b9c8c..fc73672 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,14 @@ url = "2" tracing = "0.1" libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } + [dev-dependencies] tempfile = "3" tracing-subscriber = "0.3" diff --git a/src/supervisor.rs b/src/supervisor.rs index 0f7b20d..557c153 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -13,6 +13,16 @@ use std::time::{Duration, Instant}; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; use crate::config::AppConfig; @@ -96,10 +106,61 @@ impl std::error::Error for SupervisorError {} pub struct StandaloneSupervisor { child: Child, stdin: Option, + #[cfg(windows)] + job: WindowsJob, pub origin: String, pub diagnostics: Vec, } +/// Owns a Windows Job Object with kill-on-close semantics. Node may spawn +/// helpers for native modules, so terminating only the immediate Node process +/// is insufficient on Windows. +#[cfg(windows)] +struct WindowsJob { + handle: HANDLE, +} + +#[cfg(windows)] +impl WindowsJob { + fn assign(child: &Child) -> std::io::Result { + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error()); + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of::() as u32, + ) + }; + if configured == 0 { + unsafe { CloseHandle(handle) }; + return Err(std::io::Error::last_os_error()); + } + let assigned = unsafe { AssignProcessToJobObject(handle, child.as_raw_handle()) }; + if assigned == 0 { + unsafe { CloseHandle(handle) }; + return Err(std::io::Error::last_os_error()); + } + Ok(Self { handle }) + } + + fn terminate(&self) { + unsafe { TerminateJobObject(self.handle, 1) }; + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + unsafe { CloseHandle(self.handle) }; + } +} + impl StandaloneSupervisor { pub fn start(config: &AppConfig) -> Result { let resource_root = resource_root()?; @@ -161,6 +222,15 @@ impl StandaloneSupervisor { config.standalone.cache_limit_bytes.to_string(), ); let mut child = command.spawn().map_err(SupervisorError::Spawn)?; + #[cfg(windows)] + let job = match WindowsJob::assign(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(SupervisorError::Spawn(error)); + } + }; let stdout = child.stdout.take().expect("stdout piped"); let stderr = child.stderr.take().expect("stderr piped"); let (tx, rx) = mpsc::channel(); @@ -223,6 +293,8 @@ impl StandaloneSupervisor { let supervisor = Self { stdin: child.stdin.take(), child, + #[cfg(windows)] + job, origin: ready.origin, diagnostics, }; @@ -270,7 +342,9 @@ impl StandaloneSupervisor { if let Ok(pid) = i32::try_from(self.child.id()) { unsafe { libc::kill(-pid, libc::SIGKILL) }; } - #[cfg(not(unix))] + #[cfg(windows)] + self.job.terminate(); + #[cfg(all(not(unix), not(windows)))] let _ = self.child.kill(); let _ = self.child.wait(); } From 11015ebdb301766924d12d178cb63934403d1fd8 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:02:12 +0300 Subject: [PATCH 19/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 81bc0f7..890e1d0 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -2b7c35d6102111b2024b1eae8eef51cb9db0cd5c +96d689ee08525122b90dd74736208776de0b7b3f From d9e102e85ba9113027aab5c5b5fb7783339eea44 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:03:11 +0300 Subject: [PATCH 20/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 890e1d0..6e490fc 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -96d689ee08525122b90dd74736208776de0b7b3f +11ac295f7fedf543d39b6a64f3a8f90492f2fd2f From e0ed46cd9d6d5332a24562c14e30fa6a7b15d166 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:03:40 +0300 Subject: [PATCH 21/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 6e490fc..51c9c6d 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -11ac295f7fedf543d39b6a64f3a8f90492f2fd2f +ed7429ccb803ca948324e80d8a6c6bf12ed926a6 From 24ef9e2d9fb1a829b4240f3f63ffa63e72c70460 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:04:45 +0300 Subject: [PATCH 22/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 51c9c6d..1b6a414 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -ed7429ccb803ca948324e80d8a6c6bf12ed926a6 +4c98db2918ceaa2c1b84b6ca818ff76f7bcbd01e From b6f7d23bcc78342d762b1a444b0fec38ab964ee4 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:05:43 +0300 Subject: [PATCH 23/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 1b6a414..4635e5f 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -4c98db2918ceaa2c1b84b6ca818ff76f7bcbd01e +191d8a5bddbcb184b6f18b31e8bb29e15452f8d2 From 0b2185d79837cc0ba5b029ccb4664c76f7359700 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:06:44 +0300 Subject: [PATCH 24/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 4635e5f..4a55eb2 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -191d8a5bddbcb184b6f18b31e8bb29e15452f8d2 +285d19d3b362974678a75077762cdc8f9a5b834c From bc846bbe8003a2a57fc414ae284548e06c50f4f8 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:07:45 +0300 Subject: [PATCH 25/74] feat: show recovery screen after standalone failure --- protocol/protocol-v1.json | 3 ++- src/assets/foreseer-native.js | 25 ++++++++++++++++++++++++ src/extension.rs | 36 +++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 6cfc530..cb86c40 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -116,7 +116,8 @@ "error", "connectivity-success", "save-config-success" - ,"browser-cache-cleared" + ,"browser-cache-cleared", + "runtime-failed" ], "terminalPlayEventTypes": [ "stopped", diff --git a/src/assets/foreseer-native.js b/src/assets/foreseer-native.js index c806734..865b737 100644 --- a/src/assets/foreseer-native.js +++ b/src/assets/foreseer-native.js @@ -18,6 +18,28 @@ } } + function showRuntimeRecovery(message) { + if (isSetupDocument) return; + document.title = "Foreseer Recovery"; + document.body.replaceChildren(); + const root = document.createElement("main"); + root.style.cssText = "max-width:42rem;margin:12vh auto;padding:2rem;font-family:system-ui,sans-serif;line-height:1.5"; + const heading = document.createElement("h1"); + heading.textContent = "Foreseer needs to restart"; + const detail = document.createElement("p"); + detail.textContent = message || "The bundled Foreseerr server stopped responding."; + const hint = document.createElement("p"); + hint.textContent = "Close and reopen Foreseer to start the local server again. Your standalone data was not removed."; + const quit = document.createElement("button"); + quit.type = "button"; + quit.textContent = "Quit"; + quit.addEventListener("click", function () { + api.send({ type: "app.quit", id: crypto.randomUUID() }); + }); + root.append(heading, detail, hint, quit); + document.body.append(root); + } + const api = { protocolVersion: 1, hostName: "foreseer-desktop", @@ -88,6 +110,9 @@ } } if (!detail || typeof detail !== "object") return; + if (detail.type === "runtime-failed") { + showRuntimeRecovery(detail.message); + } if (detail.type === "auth-challenge" || detail.type === "error") { console.info("[ForeseerNative] host event", detail.type, detail.id); } diff --git a/src/extension.rs b/src/extension.rs index 709fa4f..71fc904 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -14,7 +14,7 @@ use crate::config::{AppConfig, AppMode, validate_foreseer_url}; use crate::controller::{AppState, Controller, ControllerEvent, Presentation, RuntimeOps}; use crate::protocol::{NativeCommandV1, NativeEventV1, parse_command, serialize_event}; use crate::session::SessionBootstrap; -use crate::supervisor::StandaloneSupervisor; +use crate::supervisor::{RuntimeHealthTracker, StandaloneSupervisor}; struct HandleRuntime { handle: RuntimeHandle, @@ -246,10 +246,11 @@ impl HostExtension for ForeseerExtension { allow_insecure_http, agent, setup_agent, - runtime, + runtime: runtime.clone(), pending_bootstrap: None, }); } + self.monitor_standalone_runtime(runtime); } fn admit_message(&self, source: ExtensionSource, _origin: &str, payload: &[u8]) -> bool { @@ -306,6 +307,37 @@ impl HostExtension for ForeseerExtension { } impl ForeseerExtension { + /// Poll only after CEF is live. Three failed status probes (or an exited + /// child) transition the active frontend to its local recovery view. This + /// keeps the extension's exact origin unchanged; it does not navigate to + /// a broad or attacker-controlled error URL. + fn monitor_standalone_runtime(&self, runtime: RuntimeHandle) { + let Some(supervisor) = self.standalone_supervisor.clone() else { + return; + }; + std::thread::spawn(move || { + let mut tracker = RuntimeHealthTracker::default(); + loop { + std::thread::sleep(Duration::from_secs(10)); + let failed = supervisor + .lock() + .map(|mut child| tracker.observe(child.health())) + .unwrap_or(true); + if !failed { + continue; + } + let _ = runtime.set_presentation(JfnPresentation::Frontend); + let event = NativeEventV1::new("runtime", "runtime-failed") + .with_error("standalone_runtime_failed") + .with_message("The bundled Foreseerr server stopped responding."); + if let Ok(bytes) = serialize_event(&event) { + let _ = runtime.post_message(ExtensionSource::Frontend, &bytes); + } + break; + } + }); + } + fn admit_frontend(&self, payload: &[u8]) -> bool { let Ok(command) = parse_command(payload) else { tracing::warn!(target: "ForeseerExtension", "rejected malformed frontend command"); From 82b29943b34a4fcc42233539410888664cf5782e Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:08:05 +0300 Subject: [PATCH 26/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 4a55eb2..cc46725 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -285d19d3b362974678a75077762cdc8f9a5b834c +7a075883bfe9a759a180434cec09d32a1d94ca0b From 68f9587154d5205d6352738ed4facbc460b6efc8 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:08:54 +0300 Subject: [PATCH 27/74] fix: suppress recovery during normal shutdown --- src/extension.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/extension.rs b/src/extension.rs index 71fc904..be7d2ef 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -1,6 +1,9 @@ //! Jellium `HostExtension` adapter for Foreseer protocol v1. -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, Ordering}, +}; use std::time::Duration; use jfn_rust::{ @@ -85,6 +88,7 @@ pub struct ForeseerExtension { state: Mutex>, self_weak: Mutex>>, standalone_supervisor: Option>>, + runtime_shutting_down: Arc, } impl ForeseerExtension { @@ -118,6 +122,7 @@ impl ForeseerExtension { state: Mutex::new(None), self_weak: Mutex::new(None), standalone_supervisor, + runtime_shutting_down: Arc::new(AtomicBool::new(false)), }); if let Ok(mut slot) = extension.self_weak.lock() { *slot = Some(Arc::downgrade(&extension)); @@ -282,6 +287,7 @@ impl HostExtension for ForeseerExtension { } } RuntimeEvent::ShutdownBeginning => { + self.runtime_shutting_down.store(true, Ordering::Release); if let Some(supervisor) = self.standalone_supervisor.clone() { std::thread::spawn(move || { if let Ok(mut supervisor) = supervisor.lock() { @@ -315,10 +321,14 @@ impl ForeseerExtension { let Some(supervisor) = self.standalone_supervisor.clone() else { return; }; + let shutting_down = Arc::clone(&self.runtime_shutting_down); std::thread::spawn(move || { let mut tracker = RuntimeHealthTracker::default(); loop { std::thread::sleep(Duration::from_secs(10)); + if shutting_down.load(Ordering::Acquire) { + return; + } let failed = supervisor .lock() .map(|mut child| tracker.observe(child.health())) @@ -326,6 +336,9 @@ impl ForeseerExtension { if !failed { continue; } + if shutting_down.load(Ordering::Acquire) { + return; + } let _ = runtime.set_presentation(JfnPresentation::Frontend); let event = NativeEventV1::new("runtime", "runtime-failed") .with_error("standalone_runtime_failed") From bc5a8dd9c40005c9b4bde2cdc180a02eb81f5e53 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:11:40 +0300 Subject: [PATCH 28/74] feat: retry standalone runtime on original port --- protocol/protocol-v1.json | 7 +++++ src/assets/foreseer-native.js | 13 ++++++++- src/controller.rs | 2 ++ src/extension.rs | 53 +++++++++++++++++++++++++++++++++++ src/protocol.rs | 3 ++ src/supervisor.rs | 33 +++++++++++++++++++++- 6 files changed, 109 insertions(+), 2 deletions(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index cb86c40..c796da2 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -78,6 +78,12 @@ "ticket" ] }, + { + "type": "runtime.retry", + "fields": [ + "id" + ] + }, { "type": "window.minimize", "fields": [ @@ -118,6 +124,7 @@ "save-config-success" ,"browser-cache-cleared", "runtime-failed" + ,"runtime-recovered" ], "terminalPlayEventTypes": [ "stopped", diff --git a/src/assets/foreseer-native.js b/src/assets/foreseer-native.js index 865b737..1b24f4f 100644 --- a/src/assets/foreseer-native.js +++ b/src/assets/foreseer-native.js @@ -36,7 +36,14 @@ quit.addEventListener("click", function () { api.send({ type: "app.quit", id: crypto.randomUUID() }); }); - root.append(heading, detail, hint, quit); + const retry = document.createElement("button"); + retry.type = "button"; + retry.textContent = "Retry"; + retry.addEventListener("click", function () { + retry.disabled = true; + api.send({ type: "runtime.retry", id: crypto.randomUUID() }); + }); + root.append(heading, detail, hint, retry, quit); document.body.append(root); } @@ -67,6 +74,7 @@ switch (command.type) { case "auth.challenge": case "session.clear": + case "runtime.retry": case "window.minimize": case "window.toggle-maximize": case "window.toggle-fullscreen": @@ -113,6 +121,9 @@ if (detail.type === "runtime-failed") { showRuntimeRecovery(detail.message); } + if (detail.type === "runtime-recovered") { + window.location.reload(); + } if (detail.type === "auth-challenge" || detail.type === "error") { console.info("[ForeseerNative] host event", detail.type, detail.id); } diff --git a/src/controller.rs b/src/controller.rs index 0352ae3..feb4521 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -153,6 +153,8 @@ impl Controller { // The extension redeems the server-authorized ticket before it // reaches this generic controller. NativeCommandV1::BrowserCacheClear { .. } => true, + // The extension owns retry state and exact-port rebinding. + NativeCommandV1::RuntimeRetry { .. } => true, NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index be7d2ef..9c72bea 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -89,6 +89,7 @@ pub struct ForeseerExtension { self_weak: Mutex>>, standalone_supervisor: Option>>, runtime_shutting_down: Arc, + runtime_failed: Arc, } impl ForeseerExtension { @@ -123,6 +124,7 @@ impl ForeseerExtension { self_weak: Mutex::new(None), standalone_supervisor, runtime_shutting_down: Arc::new(AtomicBool::new(false)), + runtime_failed: Arc::new(AtomicBool::new(false)), }); if let Ok(mut slot) = extension.self_weak.lock() { *slot = Some(Arc::downgrade(&extension)); @@ -322,6 +324,7 @@ impl ForeseerExtension { return; }; let shutting_down = Arc::clone(&self.runtime_shutting_down); + let runtime_failed = Arc::clone(&self.runtime_failed); std::thread::spawn(move || { let mut tracker = RuntimeHealthTracker::default(); loop { @@ -339,6 +342,7 @@ impl ForeseerExtension { if shutting_down.load(Ordering::Acquire) { return; } + runtime_failed.store(true, Ordering::Release); let _ = runtime.set_presentation(JfnPresentation::Frontend); let event = NativeEventV1::new("runtime", "runtime-failed") .with_error("standalone_runtime_failed") @@ -413,6 +417,7 @@ impl ForeseerExtension { NativeCommandV1::BrowserCacheClear { id, ticket } => { self.clear_browser_cache(id, ticket) } + NativeCommandV1::RuntimeRetry { id } => self.retry_standalone_runtime(id), NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -518,6 +523,54 @@ impl ForeseerExtension { true } + fn retry_standalone_runtime(&self, id: String) -> bool { + if !self.runtime_failed.swap(false, Ordering::AcqRel) { + self.with_inner(|inner| { + inner.controller.runtime.post_frontend_event( + NativeEventV1::new(id, "error").with_error("runtime_retry_unavailable"), + ); + }); + return true; + } + let Some((supervisor, runtime)) = self + .standalone_supervisor + .clone() + .zip(self.with_inner(|inner| inner.runtime.clone())) + else { + return true; + }; + let Some(this) = self.upgrade() else { + return true; + }; + std::thread::spawn(move || { + let recovered: Result<(), String> = match supervisor.lock() { + Ok(mut child) => child + .retry_on_original_port() + .map_err(|error| error.to_string()), + Err(_) => Err("Standalone supervisor is unavailable".into()), + }; + match recovered { + Ok(()) => { + let event = NativeEventV1::new(id, "runtime-recovered"); + if let Ok(bytes) = serialize_event(&event) { + let _ = runtime.post_message(ExtensionSource::Frontend, &bytes); + } + this.monitor_standalone_runtime(runtime); + } + Err(message) => { + this.runtime_failed.store(true, Ordering::Release); + let event = NativeEventV1::new(id, "error") + .with_error("runtime_retry_failed") + .with_message(message); + if let Ok(bytes) = serialize_event(&event) { + let _ = runtime.post_message(ExtensionSource::Frontend, &bytes); + } + } + } + }); + true + } + fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { diff --git a/src/protocol.rs b/src/protocol.rs index 9f65b44..6f27d00 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -69,6 +69,8 @@ pub enum NativeCommandV1 { SetupStandalone { id: String }, #[serde(rename = "browser-cache.clear")] BrowserCacheClear { id: String, ticket: String }, + #[serde(rename = "runtime.retry")] + RuntimeRetry { id: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -90,6 +92,7 @@ impl NativeCommandV1 { | Self::SetupSave { id, .. } | Self::SetupStandalone { id } | Self::BrowserCacheClear { id, .. } + | Self::RuntimeRetry { id } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } diff --git a/src/supervisor.rs b/src/supervisor.rs index 557c153..ec172d2 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -110,6 +110,7 @@ pub struct StandaloneSupervisor { job: WindowsJob, pub origin: String, pub diagnostics: Vec, + config: AppConfig, } /// Owns a Windows Job Object with kill-on-close semantics. Node may spawn @@ -163,6 +164,10 @@ impl Drop for WindowsJob { impl StandaloneSupervisor { pub fn start(config: &AppConfig) -> Result { + Self::start_on_port(config, 0) + } + + fn start_on_port(config: &AppConfig, port: u16) -> Result { let resource_root = resource_root()?; let node = resource_root .join("node") @@ -216,7 +221,7 @@ impl StandaloneSupervisor { .env("CACHE_DIRECTORY", &cache_dir) .env("LOG_DIRECTORY", &log_dir) .env("HOST", "127.0.0.1") - .env("PORT", "0") + .env("PORT", port.to_string()) .env( "FORESEER_CACHE_LIMIT_BYTES", config.standalone.cache_limit_bytes.to_string(), @@ -297,6 +302,7 @@ impl StandaloneSupervisor { job, origin: ready.origin, diagnostics, + config: config.clone(), }; if !supervisor.status_is_healthy() { return Err(SupervisorError::Startup( @@ -314,6 +320,31 @@ impl StandaloneSupervisor { r#"{{"type":"runtime-state","playbackActive":{active}}}"# )); } + + /// Restart on the original loopback port. The CEF extension descriptor is + /// bound to that exact origin, so a different port is a recoverable error + /// that requires a full application relaunch rather than a weakened allow + /// list. + pub fn retry_on_original_port(&mut self) -> Result<(), SupervisorError> { + let original_origin = self.origin.clone(); + let port = url::Url::parse(&original_origin) + .ok() + .and_then(|url| url.port()) + .ok_or_else(|| { + SupervisorError::Startup("Could not recover the previous loopback port".into()) + })?; + let config = self.config.clone(); + self.shutdown(); + let mut replacement = Self::start_on_port(&config, port)?; + if replacement.origin != original_origin { + replacement.shutdown(); + return Err(SupervisorError::Startup( + "Bundled Foreseerr did not rebind the previous loopback port".into(), + )); + } + *self = replacement; + Ok(()) + } /// Poll only after readiness. Callers can require three consecutive /// unhealthy results before presenting recovery UI. pub fn health(&mut self) -> RuntimeHealth { From 218d09398c99bb3296d5d70e0985fe078455b99b Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:12:00 +0300 Subject: [PATCH 29/74] build: advance pinned Foreseerr runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index cc46725..cafa35d 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -7a075883bfe9a759a180434cec09d32a1d94ca0b +724b96e180597e03e96cc033f760875ec6339aed From 30c5ed0fce39b1509dbf29840d17ac01d788e4c8 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:12:22 +0300 Subject: [PATCH 30/74] test: cover standalone runtime retry command --- src/protocol.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/protocol.rs b/src/protocol.rs index 6f27d00..c05587f 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -253,6 +253,15 @@ mod tests { )); } + #[test] + fn parses_runtime_retry_without_optional_fields() { + let command = br#"{"id":"recovery-1","type":"runtime.retry"}"#; + assert!(matches!( + parse_command(command), + Ok(NativeCommandV1::RuntimeRetry { .. }) + )); + } + #[test] fn fixture_matches_package_version_and_limits() { let fixture = include_str!("../protocol/protocol-v1.json"); From f7f9f1392fba7e259427b6e134753f012be8798c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:15:21 +0300 Subject: [PATCH 31/74] fix: safely render standalone startup failures --- src/main.rs | 2 +- src/setup.html | 2 ++ src/setup.rs | 31 +++++++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2aa9a73..45d526a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,7 +47,7 @@ fn main() { let child = match StandaloneSupervisor::start(&config) { Ok(child) => child, Err(error) => { - let setup_html = setup::get_setup_html(&format!("

{error}

")); + let setup_html = setup::get_setup_html(&error.to_string()); let url = format!( "data:text/html;base64,{}", base64::engine::general_purpose::STANDARD.encode(setup_html) diff --git a/src/setup.html b/src/setup.html index 10b199a..76b7c9b 100644 --- a/src/setup.html +++ b/src/setup.html @@ -197,6 +197,8 @@

FORESEER

Use the bundled server or connect to an existing one

+ {{RECOVERY_MESSAGE}} +
diff --git a/src/setup.rs b/src/setup.rs index 29609a7..481bb60 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,12 +1,30 @@ const SETUP_HTML_TEMPLATE: &str = include_str!("setup.html"); const SETUP_EVENT_JS: &str = include_str!("setup-event.js"); -pub fn get_setup_html(api_base: &str) -> String { +pub fn get_setup_html(recovery_message: &str) -> String { + let recovery = if recovery_message.is_empty() { + String::new() + } else { + format!( + r#""#, + escape_html(recovery_message) + ) + }; + SETUP_HTML_TEMPLATE - .replace("{{API_BASE}}", api_base) + .replace("{{RECOVERY_MESSAGE}}", &recovery) .replace("{{SETUP_EVENT_JS}}", SETUP_EVENT_JS) } +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + #[cfg(test)] mod tests { use super::get_setup_html; @@ -21,6 +39,15 @@ mod tests { assert!(html.contains("setup.standalone")); assert!(html.contains("Use Standalone")); assert!(!html.contains("{{SETUP_EVENT_JS}}")); + assert!(!html.contains("{{RECOVERY_MESSAGE}}")); assert!(!html.contains("jelliumHost")); } + + #[test] + fn setup_page_escapes_startup_recovery_errors() { + let html = get_setup_html("Unable to start "); + + assert!(html.contains("Unable to start <script>alert('xss')</script>")); + assert!(!html.contains("")); + } } From 800a3bff935885ceb6a4e389e4a228553f0da4d4 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:16:37 +0300 Subject: [PATCH 32/74] build: pin managed runtime startup cleanup --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index cafa35d..e9d2871 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -724b96e180597e03e96cc033f760875ec6339aed +e4418f1277091be39a583e702271a90c2a978aeb From e6f2d2bffdc2a427577e4e97b0e79cc58d9c7b93 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:19:35 +0300 Subject: [PATCH 33/74] build: pin shared memory cache runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index e9d2871..057cf98 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -e4418f1277091be39a583e702271a90c2a978aeb +c1d393b93ea8e3fa71f46d0856c8f16ed6b06436 From ed08d0b590c4af75bb49d9e435ec4021a7b0a741 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:20:36 +0300 Subject: [PATCH 34/74] build: pin complete image cache clearing --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 057cf98..6e57572 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -c1d393b93ea8e3fa71f46d0856c8f16ed6b06436 +5d578b15a92fc951b4128a32f911e54efaa71af8 From fb1eacd34fee1eaefbccf9eae119fe11a0de5c5b Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:23:40 +0300 Subject: [PATCH 35/74] feat: open standalone logs from recovery UI --- protocol/protocol-v1.json | 7 +++++++ src/assets/foreseer-native.js | 9 +++++++- src/controller.rs | 1 + src/extension.rs | 39 +++++++++++++++++++++++++++++++++++ src/protocol.rs | 12 +++++++++++ src/setup-event.js | 1 + src/setup.html | 12 ++++++++++- 7 files changed, 79 insertions(+), 2 deletions(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index c796da2..f34ecde 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -84,6 +84,12 @@ "id" ] }, + { + "type": "runtime.open-logs", + "fields": [ + "id" + ] + }, { "type": "window.minimize", "fields": [ @@ -125,6 +131,7 @@ ,"browser-cache-cleared", "runtime-failed" ,"runtime-recovered" + ,"logs-opened" ], "terminalPlayEventTypes": [ "stopped", diff --git a/src/assets/foreseer-native.js b/src/assets/foreseer-native.js index 1b24f4f..8d15dea 100644 --- a/src/assets/foreseer-native.js +++ b/src/assets/foreseer-native.js @@ -43,7 +43,13 @@ retry.disabled = true; api.send({ type: "runtime.retry", id: crypto.randomUUID() }); }); - root.append(heading, detail, hint, retry, quit); + const logs = document.createElement("button"); + logs.type = "button"; + logs.textContent = "Open Logs"; + logs.addEventListener("click", function () { + api.send({ type: "runtime.open-logs", id: crypto.randomUUID() }); + }); + root.append(heading, detail, hint, retry, logs, quit); document.body.append(root); } @@ -75,6 +81,7 @@ case "auth.challenge": case "session.clear": case "runtime.retry": + case "runtime.open-logs": case "window.minimize": case "window.toggle-maximize": case "window.toggle-fullscreen": diff --git a/src/controller.rs b/src/controller.rs index feb4521..1c4ef67 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -155,6 +155,7 @@ impl Controller { NativeCommandV1::BrowserCacheClear { .. } => true, // The extension owns retry state and exact-port rebinding. NativeCommandV1::RuntimeRetry { .. } => true, + NativeCommandV1::RuntimeOpenLogs { .. } => true, NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index 9c72bea..d4b9b5a 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -1,5 +1,6 @@ //! Jellium `HostExtension` adapter for Foreseer protocol v1. +use std::process::Command; use std::sync::{ Arc, Mutex, Weak, atomic::{AtomicBool, Ordering}, @@ -418,6 +419,7 @@ impl ForeseerExtension { self.clear_browser_cache(id, ticket) } NativeCommandV1::RuntimeRetry { id } => self.retry_standalone_runtime(id), + NativeCommandV1::RuntimeOpenLogs { id } => self.open_standalone_logs(id), NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -571,6 +573,26 @@ impl ForeseerExtension { true } + fn open_standalone_logs(&self, id: String) -> bool { + let result = AppConfig::standalone_log_directory() + .ok_or_else(|| "Standalone log directory is unavailable".to_string()) + .and_then(|directory| { + std::fs::create_dir_all(&directory) + .map_err(|error| format!("Could not prepare log directory: {error}"))?; + open_directory(&directory) + }); + self.with_inner(|inner| { + let event = match result { + Ok(()) => NativeEventV1::new(id, "logs-opened"), + Err(message) => NativeEventV1::new(id, "error") + .with_error("open_logs_failed") + .with_message(message), + }; + inner.controller.runtime.post_frontend_event(event); + }); + true + } + fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { @@ -723,3 +745,20 @@ impl ForeseerExtension { } } } + +fn open_directory(directory: &std::path::Path) -> Result<(), String> { + #[cfg(target_os = "linux")] + let command = ("xdg-open", directory); + #[cfg(target_os = "windows")] + let command = ("explorer.exe", directory); + #[cfg(target_os = "macos")] + let command = ("open", directory); + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + return Err("Opening logs is unsupported on this platform".into()); + + Command::new(command.0) + .arg(command.1) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open logs: {error}")) +} diff --git a/src/protocol.rs b/src/protocol.rs index c05587f..f786cf6 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -71,6 +71,8 @@ pub enum NativeCommandV1 { BrowserCacheClear { id: String, ticket: String }, #[serde(rename = "runtime.retry")] RuntimeRetry { id: String }, + #[serde(rename = "runtime.open-logs")] + RuntimeOpenLogs { id: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -93,6 +95,7 @@ impl NativeCommandV1 { | Self::SetupStandalone { id } | Self::BrowserCacheClear { id, .. } | Self::RuntimeRetry { id } + | Self::RuntimeOpenLogs { id } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } @@ -262,6 +265,15 @@ mod tests { )); } + #[test] + fn parses_runtime_open_logs_without_optional_fields() { + let command = br#"{"id":"recovery-1","type":"runtime.open-logs"}"#; + assert!(matches!( + parse_command(command), + Ok(NativeCommandV1::RuntimeOpenLogs { .. }) + )); + } + #[test] fn fixture_matches_package_version_and_limits() { let fixture = include_str!("../protocol/protocol-v1.json"); diff --git a/src/setup-event.js b/src/setup-event.js index a5a44cb..9b3d452 100644 --- a/src/setup-event.js +++ b/src/setup-event.js @@ -4,6 +4,7 @@ const eventTypes = Object.freeze([ "connectivity-success", "save-config-success", + "logs-opened", "error", ]); diff --git a/src/setup.html b/src/setup.html index 76b7c9b..6da1187 100644 --- a/src/setup.html +++ b/src/setup.html @@ -215,6 +215,7 @@

FORESEER

+
@@ -251,7 +252,7 @@

FORESEER

} const ok = host.send({ id: reqId, - type: action === 'test' ? 'setup.check' : action === 'standalone' ? 'setup.standalone' : 'setup.save', + type: action === 'test' ? 'setup.check' : action === 'standalone' ? 'setup.standalone' : action === 'logs' ? 'runtime.open-logs' : 'setup.save', url, allowHttp: !!allowHttp, }); @@ -274,6 +275,15 @@

FORESEER

} } + async function openLogs() { + try { + await nativeCall('logs', '', false); + showStatus('Opened standalone logs.', 'success'); + } catch (err) { + showStatus(err.message, 'error'); + } + } + async function testConnection() { const url = document.getElementById('serverUrl').value.trim(); const allowHttp = document.getElementById('allowHttp').checked; From 47e58a1cc3715ee52a3cf95b285b9d423262cec4 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:23:50 +0300 Subject: [PATCH 36/74] build: pin native recovery log contract --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 6e57572..9c9e046 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -5d578b15a92fc951b4128a32f911e54efaa71af8 +92398793e252b6516a1c878d8b9cce9b4bfa0493 From 114129b622899fcfcbe3916c57de8ddadd5d0056 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:24:28 +0300 Subject: [PATCH 37/74] feat: attempt one automatic standalone recovery --- src/extension.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/extension.rs b/src/extension.rs index d4b9b5a..a3f2b72 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -91,6 +91,7 @@ pub struct ForeseerExtension { standalone_supervisor: Option>>, runtime_shutting_down: Arc, runtime_failed: Arc, + automatic_restart_attempted: Arc, } impl ForeseerExtension { @@ -126,6 +127,7 @@ impl ForeseerExtension { standalone_supervisor, runtime_shutting_down: Arc::new(AtomicBool::new(false)), runtime_failed: Arc::new(AtomicBool::new(false)), + automatic_restart_attempted: Arc::new(AtomicBool::new(false)), }); if let Ok(mut slot) = extension.self_weak.lock() { *slot = Some(Arc::downgrade(&extension)); @@ -326,6 +328,7 @@ impl ForeseerExtension { }; let shutting_down = Arc::clone(&self.runtime_shutting_down); let runtime_failed = Arc::clone(&self.runtime_failed); + let automatic_restart_attempted = Arc::clone(&self.automatic_restart_attempted); std::thread::spawn(move || { let mut tracker = RuntimeHealthTracker::default(); loop { @@ -343,6 +346,20 @@ impl ForeseerExtension { if shutting_down.load(Ordering::Acquire) { return; } + if !automatic_restart_attempted.swap(true, Ordering::AcqRel) { + let recovered = supervisor + .lock() + .map(|mut child| child.retry_on_original_port()) + .is_ok_and(|result| result.is_ok()); + if recovered { + tracker = RuntimeHealthTracker::default(); + let event = NativeEventV1::new("runtime", "runtime-recovered"); + if let Ok(bytes) = serialize_event(&event) { + let _ = runtime.post_message(ExtensionSource::Frontend, &bytes); + } + continue; + } + } runtime_failed.store(true, Ordering::Release); let _ = runtime.set_presentation(JfnPresentation::Frontend); let event = NativeEventV1::new("runtime", "runtime-failed") From 59c2ab8a5d9c45f7646da5804ecaf39ccf38b07a Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:25:24 +0300 Subject: [PATCH 38/74] build: pin scheduler shutdown cleanup --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 9c9e046..8b97184 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -92398793e252b6516a1c878d8b9cce9b4bfa0493 +52a56fbc4ed4d7ad58f0f6c203366860506accf4 From c5e7ed8e95106cc2ba4cbba37d9bd3be19df6ca0 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:28:01 +0300 Subject: [PATCH 39/74] feat: record verified schema in upgrade backups --- src/supervisor.rs | 97 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 9 deletions(-) diff --git a/src/supervisor.rs b/src/supervisor.rs index ec172d2..1c9ebf1 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -38,6 +38,8 @@ fn bundled_foreseerr_version() -> &'static str { #[derive(Debug, Deserialize, Serialize)] struct RuntimeVersion { foreseerr_version: String, + #[serde(default)] + schema_version: u32, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -194,7 +196,7 @@ impl StandaloneSupervisor { ] { std::fs::create_dir_all(directory).map_err(SupervisorError::Spawn)?; } - backup_before_upgrade(&config_dir)?; + let upgrade_backup = backup_before_upgrade(&config_dir)?; let mut command = Command::new(node); command .arg(launcher) @@ -295,21 +297,35 @@ impl StandaloneSupervisor { ready.foreseerr_version ))); } - let supervisor = Self { + let mut supervisor = Self { stdin: child.stdin.take(), child, #[cfg(windows)] job, - origin: ready.origin, + origin: ready.origin.clone(), diagnostics, config: config.clone(), }; if !supervisor.status_is_healthy() { + supervisor.shutdown(); return Err(SupervisorError::Startup( "Bundled Foreseerr did not pass its status health check".into(), )); } - write_runtime_version(&config_dir, &ready.foreseerr_version)?; + if let Err(error) = write_runtime_version( + &config_dir, + &ready.foreseerr_version, + ready.schema_version, + ) { + supervisor.shutdown(); + return Err(error); + } + if let Some(backup) = upgrade_backup { + if let Err(error) = complete_upgrade_backup(&backup, &ready) { + supervisor.shutdown(); + return Err(error); + } + } return Ok(supervisor); } } @@ -453,16 +469,16 @@ fn redact(line: &str) -> String { } } -fn backup_before_upgrade(config_dir: &Path) -> Result<(), SupervisorError> { +fn backup_before_upgrade(config_dir: &Path) -> Result, SupervisorError> { let state_file = config_dir.join("state/runtime-version.json"); let previous = fs::read_to_string(&state_file) .ok() .and_then(|text| serde_json::from_str::(&text).ok()); let Some(previous) = previous else { - return Ok(()); + return Ok(None); }; if previous.foreseerr_version == bundled_foreseerr_version() { - return Ok(()); + return Ok(None); } let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -489,7 +505,9 @@ fn backup_before_upgrade(config_dir: &Path) -> Result<(), SupervisorError> { } let metadata = serde_json::json!({ "previousVersion": previous.foreseerr_version, + "previousSchemaVersion": previous.schema_version, "newVersion": bundled_foreseerr_version(), + "newSchemaVersion": null, "createdAt": timestamp, }); fs::write( @@ -508,13 +526,33 @@ fn backup_before_upgrade(config_dir: &Path) -> Result<(), SupervisorError> { let oldest = entries.remove(0); fs::remove_dir_all(oldest.path()).map_err(SupervisorError::Spawn)?; } - Ok(()) + Ok(Some(backup)) +} + +fn complete_upgrade_backup(backup: &Path, ready: &ReadyRecord) -> Result<(), SupervisorError> { + let metadata_path = backup.join("metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).map_err(SupervisorError::Spawn)?) + .map_err(|error| SupervisorError::Startup(error.to_string()))?; + metadata["newVersion"] = serde_json::Value::String(ready.foreseerr_version.clone()); + metadata["newSchemaVersion"] = serde_json::Value::from(ready.schema_version); + fs::write( + metadata_path, + serde_json::to_vec_pretty(&metadata) + .map_err(|error| SupervisorError::Startup(error.to_string()))?, + ) + .map_err(SupervisorError::Spawn) } -fn write_runtime_version(config_dir: &Path, version: &str) -> Result<(), SupervisorError> { +fn write_runtime_version( + config_dir: &Path, + version: &str, + schema_version: u32, +) -> Result<(), SupervisorError> { let state = config_dir.join("state/runtime-version.json"); let payload = serde_json::to_vec_pretty(&RuntimeVersion { foreseerr_version: version.into(), + schema_version, }) .map_err(|error| SupervisorError::Startup(error.to_string()))?; fs::write(state, payload).map_err(SupervisorError::Spawn) @@ -548,4 +586,45 @@ mod tests { assert!(!tracker.observe(RuntimeHealth::Healthy)); assert!(tracker.observe(RuntimeHealth::Exited)); } + + #[test] + fn upgrade_backup_records_schema_only_after_verified_readiness() { + let temporary = tempfile::tempdir().unwrap(); + let config_dir = temporary.path(); + fs::create_dir_all(config_dir.join("state")).unwrap(); + fs::write( + config_dir.join("state/runtime-version.json"), + r#"{"foreseerr_version":"0.0.1","schema_version":123}"#, + ) + .unwrap(); + + let backup = backup_before_upgrade(config_dir).unwrap().unwrap(); + let pending: serde_json::Value = + serde_json::from_slice(&fs::read(backup.join("metadata.json")).unwrap()).unwrap(); + assert_eq!(pending["previousSchemaVersion"], 123); + assert!(pending["newSchemaVersion"].is_null()); + + complete_upgrade_backup( + &backup, + &ReadyRecord { + protocol_version: READY_PROTOCOL_VERSION, + pid: 1, + origin: "http://127.0.0.1:43127".into(), + foreseerr_version: bundled_foreseerr_version().into(), + commit: "test".into(), + schema_version: 456, + }, + ) + .unwrap(); + let complete: serde_json::Value = + serde_json::from_slice(&fs::read(backup.join("metadata.json")).unwrap()).unwrap(); + assert_eq!(complete["newSchemaVersion"], 456); + } + + #[test] + fn legacy_runtime_version_defaults_schema_to_zero() { + let state: RuntimeVersion = + serde_json::from_str(r#"{"foreseerr_version":"0.6.0"}"#).unwrap(); + assert_eq!(state.schema_version, 0); + } } From 6b48ea4c57c3f16d4de8e5f405618077a21f1d97 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:28:09 +0300 Subject: [PATCH 40/74] build: pin readiness schema metadata runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 8b97184..a376443 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -52a56fbc4ed4d7ad58f0f6c203366860506accf4 +61705f9c3f74f71399ab6e7ffc89f5b2074fa575 From 541f230a04f1637eafde5f241e44256f569b5797 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:28:59 +0300 Subject: [PATCH 41/74] fix: block upgrade backup while child owns data --- src/supervisor.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/supervisor.rs b/src/supervisor.rs index 1c9ebf1..0cca05e 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -23,6 +23,10 @@ use windows_sys::Win32::System::JobObjects::{ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, }; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, STILL_ACTIVE, +}; use crate::config::AppConfig; @@ -196,6 +200,7 @@ impl StandaloneSupervisor { ] { std::fs::create_dir_all(directory).map_err(SupervisorError::Spawn)?; } + ensure_no_active_instance_lock(&config_dir)?; let upgrade_backup = backup_before_upgrade(&config_dir)?; let mut command = Command::new(node); command @@ -469,6 +474,51 @@ fn redact(line: &str) -> String { } } +fn ensure_no_active_instance_lock(config_dir: &Path) -> Result<(), SupervisorError> { + let lock = config_dir.join("state/instance.lock"); + if !lock.is_file() { + return Ok(()); + } + let pid = fs::read_to_string(&lock) + .ok() + .and_then(|text| text.trim().parse::().ok()); + if pid.is_some_and(process_is_alive) { + return Err(SupervisorError::Startup( + "Another Foreseer Desktop instance owns this data directory".into(), + )); + } + Ok(()) +} + +#[cfg(unix)] +fn process_is_alive(pid: u32) -> bool { + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + let result = unsafe { libc::kill(pid, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +fn process_is_alive(pid: u32) -> bool { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + // An inaccessible process must be treated as live: proceeding would + // risk modifying a database an existing desktop child owns. + return true; + } + let mut exit_code = 0; + let queried = unsafe { GetExitCodeProcess(handle, &mut exit_code) } != 0; + unsafe { CloseHandle(handle) }; + queried && exit_code == STILL_ACTIVE +} + +#[cfg(all(not(unix), not(windows)))] +fn process_is_alive(_pid: u32) -> bool { + // Unsupported platforms fail closed whenever a parseable lock is found. + true +} + fn backup_before_upgrade(config_dir: &Path) -> Result, SupervisorError> { let state_file = config_dir.join("state/runtime-version.json"); let previous = fs::read_to_string(&state_file) @@ -627,4 +677,18 @@ mod tests { serde_json::from_str(r#"{"foreseerr_version":"0.6.0"}"#).unwrap(); assert_eq!(state.schema_version, 0); } + + #[test] + fn active_instance_lock_prevents_upgrade_backup_work() { + let temporary = tempfile::tempdir().unwrap(); + let state = temporary.path().join("state"); + fs::create_dir_all(&state).unwrap(); + fs::write( + state.join("instance.lock"), + format!("{}\n", std::process::id()), + ) + .unwrap(); + + assert!(ensure_no_active_instance_lock(temporary.path()).is_err()); + } } From de307409113a3ffe0d7ae970bbee32f473127fc4 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:30:26 +0300 Subject: [PATCH 42/74] fix: reap managed child on readiness failure --- src/supervisor.rs | 97 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/src/supervisor.rs b/src/supervisor.rs index 0cca05e..6a1b22c 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -265,15 +265,35 @@ impl StandaloneSupervisor { let deadline = Instant::now() + READY_TIMEOUT; let mut diagnostics = Vec::new(); loop { - if let Some(status) = child.try_wait().map_err(SupervisorError::Spawn)? { - return Err(SupervisorError::Startup(format!( - "Bundled Foreseerr exited before readiness ({status}); {}", - diagnostics.join("\n") - ))); + match child.try_wait() { + Ok(Some(status)) => { + cleanup_startup_child( + &mut child, + #[cfg(windows)] + &job, + ); + return Err(SupervisorError::Startup(format!( + "Bundled Foreseerr exited before readiness ({status}); {}", + diagnostics.join("\n") + ))); + } + Ok(None) => {} + Err(error) => { + cleanup_startup_child( + &mut child, + #[cfg(windows)] + &job, + ); + return Err(SupervisorError::Spawn(error)); + } } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { - let _ = child.kill(); + cleanup_startup_child( + &mut child, + #[cfg(windows)] + &job, + ); return Err(SupervisorError::Startup(format!( "Timed out waiting for bundled Foreseerr readiness; {}", diagnostics.join("\n") @@ -287,15 +307,34 @@ impl StandaloneSupervisor { } diagnostics.push(redact(&line)); if !is_stderr && line.starts_with(READY_PREFIX) { - let ready: ReadyRecord = serde_json::from_str(&line[READY_PREFIX.len()..]) - .map_err(|_| { - SupervisorError::InvalidReady( + let ready: ReadyRecord = match serde_json::from_str(&line[READY_PREFIX.len()..]) + { + Ok(ready) => ready, + Err(_) => { + cleanup_startup_child( + &mut child, + #[cfg(windows)] + &job, + ); + return Err(SupervisorError::InvalidReady( "Bundled Foreseerr emitted malformed readiness data".into(), - ) - })?; - validate_ready(&ready)?; + )); + } + }; + if let Err(error) = validate_ready(&ready) { + cleanup_startup_child( + &mut child, + #[cfg(windows)] + &job, + ); + return Err(error); + } if ready.foreseerr_version != bundled_foreseerr_version() { - let _ = child.kill(); + cleanup_startup_child( + &mut child, + #[cfg(windows)] + &job, + ); return Err(SupervisorError::InvalidReady(format!( "Bundled Foreseerr version mismatch (expected {}, got {})", bundled_foreseerr_version(), @@ -426,6 +465,38 @@ impl Drop for StandaloneSupervisor { } } +/// Reap a child that failed before it could become a `StandaloneSupervisor`. +/// On Unix the Node process is a process-group leader; signaling the group +/// avoids leaving native-module helpers behind after malformed readiness or a +/// timeout. Windows uses the kill-on-close job object for the same property. +fn cleanup_startup_child(child: &mut Child, #[cfg(windows)] job: &WindowsJob) { + #[cfg(unix)] + if let Ok(pid) = i32::try_from(child.id()) { + unsafe { libc::kill(-pid, libc::SIGTERM) }; + } + #[cfg(windows)] + job.terminate(); + #[cfg(all(not(unix), not(windows)))] + let _ = child.kill(); + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + #[cfg(unix)] + if let Ok(pid) = i32::try_from(child.id()) { + unsafe { libc::kill(-pid, libc::SIGKILL) }; + } + #[cfg(windows)] + job.terminate(); + #[cfg(all(not(unix), not(windows)))] + let _ = child.kill(); + let _ = child.wait(); +} + fn resource_root() -> Result { let executable = std::env::current_exe().map_err(SupervisorError::Spawn)?; let base = executable From 486a98528899fc4902d4ae84d9a8bfff0bf986a0 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:31:33 +0300 Subject: [PATCH 43/74] build: pin native browser cache redemption fix --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index a376443..bfd4af0 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -61705f9c3f74f71399ab6e7ffc89f5b2074fa575 +f90c4e67046bc41a99b1bf774cb0393ea537c786 From 19bf038f88b33bd1c8f55bb49661ebe71b46ca06 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:32:41 +0300 Subject: [PATCH 44/74] feat: signal managed runtime when CEF is ready --- src/extension.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/extension.rs b/src/extension.rs index a3f2b72..ce6f989 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -260,6 +260,14 @@ impl HostExtension for ForeseerExtension { pending_bootstrap: None, }); } + // This is the first point at which the managed child knows its CEF + // frontend is live. A false playback state starts its delayed, + // coalesced desktop catch-up pass without running work during startup. + if let Some(supervisor) = &self.standalone_supervisor + && let Ok(mut supervisor) = supervisor.lock() + { + supervisor.set_playback_active(false); + } self.monitor_standalone_runtime(runtime); } From 55c36aa10c812ce127d0285a7e0116cc478a5a28 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:32:48 +0300 Subject: [PATCH 45/74] build: pin CEF-ready catch-up runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index bfd4af0..eae2abf 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -f90c4e67046bc41a99b1bf774cb0393ea537c786 +ad77a962b699e359cd1be4843308305106fc2b95 From a86cc7c277d8b9e2de06aff3fd9a8bd9422de829 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:33:37 +0300 Subject: [PATCH 46/74] feat: relaunch after enabling standalone mode --- src/extension.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/extension.rs b/src/extension.rs index ce6f989..5ae4de8 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -507,6 +507,16 @@ impl ForeseerExtension { }); return true; } + if let Err(message) = relaunch_application() { + self.with_inner(|inner| { + inner.controller.runtime.post_frontend_event( + NativeEventV1::new(id, "error") + .with_error("restart_failed") + .with_message(message), + ); + }); + return true; + } self.with_inner(|inner| { inner .controller @@ -771,6 +781,18 @@ impl ForeseerExtension { } } +fn relaunch_application() -> Result<(), String> { + let executable = std::env::current_exe() + .map_err(|error| format!("Could not locate Foreseer executable: {error}"))?; + Command::new(executable) + .env_remove("FORESEER_SETUP_RELAUNCHED") + .env_remove("FORESEER_URL") + .env_remove("FORESEER_ALLOW_INSECURE_HTTP") + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not restart Foreseer: {error}")) +} + fn open_directory(directory: &std::path::Path) -> Result<(), String> { #[cfg(target_os = "linux")] let command = ("xdg-open", directory); From b89b31ebd583ec5900a9b6591bea66cda687911c Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:34:55 +0300 Subject: [PATCH 47/74] feat: offer remote mode from runtime recovery --- protocol/protocol-v1.json | 7 +++++++ src/assets/foreseer-native.js | 10 ++++++++- src/controller.rs | 1 + src/extension.rs | 38 +++++++++++++++++++++++++++++++++++ src/protocol.rs | 12 +++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index f34ecde..0a53ce0 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -90,6 +90,12 @@ "id" ] }, + { + "type": "runtime.open-setup", + "fields": [ + "id" + ] + }, { "type": "window.minimize", "fields": [ @@ -132,6 +138,7 @@ "runtime-failed" ,"runtime-recovered" ,"logs-opened" + ,"setup-opened" ], "terminalPlayEventTypes": [ "stopped", diff --git a/src/assets/foreseer-native.js b/src/assets/foreseer-native.js index 8d15dea..543a7cc 100644 --- a/src/assets/foreseer-native.js +++ b/src/assets/foreseer-native.js @@ -49,7 +49,14 @@ logs.addEventListener("click", function () { api.send({ type: "runtime.open-logs", id: crypto.randomUUID() }); }); - root.append(heading, detail, hint, retry, logs, quit); + const remote = document.createElement("button"); + remote.type = "button"; + remote.textContent = "Use Remote Mode"; + remote.addEventListener("click", function () { + remote.disabled = true; + api.send({ type: "runtime.open-setup", id: crypto.randomUUID() }); + }); + root.append(heading, detail, hint, retry, logs, remote, quit); document.body.append(root); } @@ -82,6 +89,7 @@ case "session.clear": case "runtime.retry": case "runtime.open-logs": + case "runtime.open-setup": case "window.minimize": case "window.toggle-maximize": case "window.toggle-fullscreen": diff --git a/src/controller.rs b/src/controller.rs index 1c4ef67..a757e35 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -156,6 +156,7 @@ impl Controller { // The extension owns retry state and exact-port rebinding. NativeCommandV1::RuntimeRetry { .. } => true, NativeCommandV1::RuntimeOpenLogs { .. } => true, + NativeCommandV1::RuntimeOpenSetup { .. } => true, NativeCommandV1::WindowMinimize { .. } => { self.runtime.minimize(); true diff --git a/src/extension.rs b/src/extension.rs index 5ae4de8..cd03b85 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -445,6 +445,7 @@ impl ForeseerExtension { } NativeCommandV1::RuntimeRetry { id } => self.retry_standalone_runtime(id), NativeCommandV1::RuntimeOpenLogs { id } => self.open_standalone_logs(id), + NativeCommandV1::RuntimeOpenSetup { id } => self.open_remote_setup(id), NativeCommandV1::PlayItem { id, item_id } => { tracing::info!( target: "ForeseerExtension", @@ -628,6 +629,30 @@ impl ForeseerExtension { true } + fn open_remote_setup(&self, id: String) -> bool { + match relaunch_setup() { + Ok(()) => { + self.with_inner(|inner| { + inner + .controller + .runtime + .post_frontend_event(NativeEventV1::new(id, "setup-opened")); + inner.controller.runtime.request_shutdown(); + }); + } + Err(message) => { + self.with_inner(|inner| { + inner.controller.runtime.post_frontend_event( + NativeEventV1::new(id, "error") + .with_error("setup_open_failed") + .with_message(message), + ); + }); + } + } + true + } + fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { @@ -793,6 +818,19 @@ fn relaunch_application() -> Result<(), String> { .map_err(|error| format!("Could not restart Foreseer: {error}")) } +fn relaunch_setup() -> Result<(), String> { + let executable = std::env::current_exe() + .map_err(|error| format!("Could not locate Foreseer executable: {error}"))?; + Command::new(executable) + .arg("--setup") + .env_remove("FORESEER_SETUP_RELAUNCHED") + .env_remove("FORESEER_URL") + .env_remove("FORESEER_ALLOW_INSECURE_HTTP") + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open Foreseer setup: {error}")) +} + fn open_directory(directory: &std::path::Path) -> Result<(), String> { #[cfg(target_os = "linux")] let command = ("xdg-open", directory); diff --git a/src/protocol.rs b/src/protocol.rs index f786cf6..dc02505 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -73,6 +73,8 @@ pub enum NativeCommandV1 { RuntimeRetry { id: String }, #[serde(rename = "runtime.open-logs")] RuntimeOpenLogs { id: String }, + #[serde(rename = "runtime.open-setup")] + RuntimeOpenSetup { id: String }, #[serde(rename = "window.minimize")] WindowMinimize { id: String }, #[serde(rename = "window.toggle-maximize")] @@ -96,6 +98,7 @@ impl NativeCommandV1 { | Self::BrowserCacheClear { id, .. } | Self::RuntimeRetry { id } | Self::RuntimeOpenLogs { id } + | Self::RuntimeOpenSetup { id } | Self::WindowMinimize { id } | Self::WindowToggleMaximize { id } | Self::WindowToggleFullscreen { id } @@ -274,6 +277,15 @@ mod tests { )); } + #[test] + fn parses_runtime_open_setup_without_optional_fields() { + let command = br#"{"id":"recovery-1","type":"runtime.open-setup"}"#; + assert!(matches!( + parse_command(command), + Ok(NativeCommandV1::RuntimeOpenSetup { .. }) + )); + } + #[test] fn fixture_matches_package_version_and_limits() { let fixture = include_str!("../protocol/protocol-v1.json"); From 367f0065ee78c2aadbb7bfaec5798f4bdc394d69 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:35:02 +0300 Subject: [PATCH 48/74] build: pin remote recovery protocol runtime --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index eae2abf..530a3de 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -ad77a962b699e359cd1be4843308305106fc2b95 +f66793f2790aca5840f5e6907999201bc7c78777 From 57a3f68a76e45cd51efc4273e92f391d54521564 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:35:53 +0300 Subject: [PATCH 49/74] build: pin managed restart cleanup --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 530a3de..be7bc46 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -f66793f2790aca5840f5e6907999201bc7c78777 +da6ad0a8eb4e1fc63ac9ac2eb7ca2e2989bfd135 From 91de8feab3acc8b15306306b578dd59383e7d0db Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:37:09 +0300 Subject: [PATCH 50/74] build: pin initial setup catch-up fix --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index be7bc46..309d5b8 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -da6ad0a8eb4e1fc63ac9ac2eb7ca2e2989bfd135 +78a13fb68968e7de6e6ac050ff328ca77728efbb From 756a5055a27416b964c18171456dbebdb2a82eac Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:39:35 +0300 Subject: [PATCH 51/74] build: pin bound-port diagnostics fix --- foreseerr.rev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreseerr.rev b/foreseerr.rev index 309d5b8..649291c 100644 --- a/foreseerr.rev +++ b/foreseerr.rev @@ -1 +1 @@ -78a13fb68968e7de6e6ac050ff328ca77728efbb +7816e514d0ce3c1f8449a5c088ccde060add4267 From 6ea740dbea98ce2cca74681b101e50225cd37954 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:40:09 +0300 Subject: [PATCH 52/74] feat: add quit action to startup recovery --- src/setup.html | 9 ++++++++- src/setup.rs | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/setup.html b/src/setup.html index 6da1187..2f5933a 100644 --- a/src/setup.html +++ b/src/setup.html @@ -218,6 +218,7 @@

FORESEER

+
@@ -252,7 +253,7 @@

FORESEER

} const ok = host.send({ id: reqId, - type: action === 'test' ? 'setup.check' : action === 'standalone' ? 'setup.standalone' : action === 'logs' ? 'runtime.open-logs' : 'setup.save', + type: action === 'test' ? 'setup.check' : action === 'standalone' ? 'setup.standalone' : action === 'logs' ? 'runtime.open-logs' : action === 'quit' ? 'app.quit' : 'setup.save', url, allowHttp: !!allowHttp, }); @@ -284,6 +285,12 @@

FORESEER

} } + function quitForeseer() { + nativeCall('quit', '', false).catch(() => { + showStatus('Could not quit Foreseer.', 'error'); + }); + } + async function testConnection() { const url = document.getElementById('serverUrl').value.trim(); const allowHttp = document.getElementById('allowHttp').checked; diff --git a/src/setup.rs b/src/setup.rs index 481bb60..91eb39c 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -38,6 +38,7 @@ mod tests { assert!(html.contains("foreseerNative")); assert!(html.contains("setup.standalone")); assert!(html.contains("Use Standalone")); + assert!(html.contains("Quit")); assert!(!html.contains("{{SETUP_EVENT_JS}}")); assert!(!html.contains("{{RECOVERY_MESSAGE}}")); assert!(!html.contains("jelliumHost")); From 5cce53f5079354226e85ebaad4525ffbc9eae5f2 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:40:28 +0300 Subject: [PATCH 53/74] feat: label standalone recovery retry explicitly --- src/setup.html | 2 +- src/setup.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/setup.html b/src/setup.html index 2f5933a..240eb7a 100644 --- a/src/setup.html +++ b/src/setup.html @@ -214,7 +214,7 @@

FORESEER

- + diff --git a/src/setup.rs b/src/setup.rs index 91eb39c..fc9d992 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -2,6 +2,11 @@ const SETUP_HTML_TEMPLATE: &str = include_str!("setup.html"); const SETUP_EVENT_JS: &str = include_str!("setup-event.js"); pub fn get_setup_html(recovery_message: &str) -> String { + let standalone_label = if recovery_message.is_empty() { + "Use Standalone" + } else { + "Retry Standalone" + }; let recovery = if recovery_message.is_empty() { String::new() } else { @@ -13,6 +18,7 @@ pub fn get_setup_html(recovery_message: &str) -> String { SETUP_HTML_TEMPLATE .replace("{{RECOVERY_MESSAGE}}", &recovery) + .replace("{{STANDALONE_ACTION_LABEL}}", standalone_label) .replace("{{SETUP_EVENT_JS}}", SETUP_EVENT_JS) } @@ -41,6 +47,7 @@ mod tests { assert!(html.contains("Quit")); assert!(!html.contains("{{SETUP_EVENT_JS}}")); assert!(!html.contains("{{RECOVERY_MESSAGE}}")); + assert!(!html.contains("{{STANDALONE_ACTION_LABEL}}")); assert!(!html.contains("jelliumHost")); } @@ -50,5 +57,6 @@ mod tests { assert!(html.contains("Unable to start <script>alert('xss')</script>")); assert!(!html.contains("")); + assert!(html.contains("Retry Standalone")); } } From 5dfb884b804c3f45de8588004bf411bbdf66adad Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:41:02 +0300 Subject: [PATCH 54/74] fix: scrub hosted database variables from child --- src/supervisor.rs | 50 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/supervisor.rs b/src/supervisor.rs index 6a1b22c..704d700 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -34,6 +34,30 @@ pub const READY_PREFIX: &str = "FORESEERR_DESKTOP_READY "; pub const READY_PROTOCOL_VERSION: u32 = 1; const READY_TIMEOUT: Duration = Duration::from_secs(90); const BUNDLED_FORESEERR_VERSION_FILE: &str = include_str!("../foreseerr.version"); +const CLEARED_CHILD_ENV: &[&str] = &[ + "PORT", + "HOST", + "CONFIG_DIRECTORY", + "CACHE_DIRECTORY", + "LOG_DIRECTORY", + "NODE_OPTIONS", + "NODE_PATH", + "DB_TYPE", + "DB_HOST", + "DB_PORT", + "DB_USER", + "DB_PASS", + "DB_NAME", + "DB_SOCKET_PATH", + "DB_USE_SSL", + "DB_SSL_REJECT_UNAUTHORIZED", + "DB_SSL_CA", + "DB_SSL_CA_FILE", + "DB_SSL_KEY", + "DB_SSL_KEY_FILE", + "DB_SSL_CERT", + "DB_SSL_CERT_FILE", +]; fn bundled_foreseerr_version() -> &'static str { BUNDLED_FORESEERR_VERSION_FILE.trim() @@ -211,15 +235,7 @@ impl StandaloneSupervisor { .stderr(Stdio::piped()); #[cfg(unix)] command.process_group(0); - for key in [ - "PORT", - "HOST", - "CONFIG_DIRECTORY", - "CACHE_DIRECTORY", - "LOG_DIRECTORY", - "NODE_OPTIONS", - "NODE_PATH", - ] { + for key in CLEARED_CHILD_ENV { command.env_remove(key); } command @@ -762,4 +778,20 @@ mod tests { assert!(ensure_no_active_instance_lock(temporary.path()).is_err()); } + + #[test] + fn managed_child_scrubs_hosted_database_and_node_overrides() { + for variable in [ + "DB_TYPE", + "DB_HOST", + "DB_PORT", + "DB_USER", + "DB_PASS", + "DB_NAME", + "NODE_OPTIONS", + "NODE_PATH", + ] { + assert!(CLEARED_CHILD_ENV.contains(&variable)); + } + } } From 3079102a5e387f9b12dcd98d1ba4e834315bc458 Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sat, 22 Aug 2026 19:43:49 +0300 Subject: [PATCH 55/74] build: pin generic Jellium cache APIs --- docs/jellium-patch-manifest.md | 4 ++++ jellium.rev | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/jellium-patch-manifest.md b/docs/jellium-patch-manifest.md index 44c8f73..a7831a2 100644 --- a/docs/jellium-patch-manifest.md +++ b/docs/jellium-patch-manifest.md @@ -25,6 +25,10 @@ recorded here before `scripts/boundary-audit.sh` will pass. | `0382e02` | `0382e0217e91f93f9233a0a6e7e2798961b968a0` | runtime fix | Prevent Linux CEF mallinfo overflow abort | | `873d1e3` | `873d1e3221be19d5140d3e9ca53972d1099dc0a2` | runtime fix | Resume mpv presentation after compositor suspension | | `cb4e9d0` | `cb4e9d0a73358dda95555fa7f6110d83ef418110` | runtime fix | Stop in-page `` option. The in-page dropdown overlay was reaching the modal's click-outside handler (browser-native popups do not). +- Adopt a new Jellyfin server Id after the media server is reinstalled, instead + of stalling on ConnectionManager `ServerMismatch` because cached credentials + and the `/login` hash gate blocked session bootstrap. + ## 0.2.9 — 2026-08-14 ### Fixed diff --git a/scripts/stage-foreseerr.sh b/scripts/stage-foreseerr.sh index 6ab9272..f79031d 100755 --- a/scripts/stage-foreseerr.sh +++ b/scripts/stage-foreseerr.sh @@ -60,6 +60,11 @@ find "$DEST/foreseerr" -mindepth 1 -maxdepth 1 \ for item in launcher.js dist .next public seerr-api.yml; do [[ -e "$SOURCE/$item" ]] && cp -a "$SOURCE/$item" "$DEST/foreseerr/" done +# Next.js' custom-server production startup still requires a pages/ or app/ +# directory to exist even when every route is already compiled in .next. +# Keep an empty runtime marker so the staged desktop bundle can boot without +# shipping the source route tree. +mkdir -p "$DEST/foreseerr/pages" find "$DEST/foreseerr" -type d \( -name '.cache' -o -name 'cypress' -o -name 'test' -o -name 'tests' \) -prune -exec rm -rf {} + find "$DEST/foreseerr" -type f \( -name '*.map' -o -name '*.ts' -o -name '*.tsx' -o -name '*.tsbuildinfo' \) -delete rm -rf "$DEST/foreseerr/.next/cache" "$DEST/foreseerr/.next/turbopack" "$DEST/foreseerr/.next/dev" diff --git a/src/assets/jellyfin-session.js b/src/assets/jellyfin-session.js index 570fec9..b12ab9b 100644 --- a/src/assets/jellyfin-session.js +++ b/src/assets/jellyfin-session.js @@ -42,11 +42,65 @@ } catch (_) {} } + function normalizeAddress(value) { + return String(value || "").replace(/\/$/, ""); + } + + function persistBootstrapServer(bootstrap) { + try { + const key = "jellyfin_credentials"; + const raw = window.localStorage.getItem(key); + const creds = raw ? JSON.parse(raw) : {}; + if (!creds || typeof creds !== "object") return; + if (!Array.isArray(creds.Servers)) creds.Servers = []; + const expected = normalizeAddress(bootstrap.serverUrl); + const matchesUrl = (server) => + [server.ManualAddress, server.LocalAddress, server.RemoteAddress] + .filter(Boolean) + .some((address) => normalizeAddress(address) === expected); + let server = creds.Servers.find(matchesUrl); + if (!server) { + server = { + ManualAddress: expected, + manualAddressOnly: true, + LastConnectionMode: 2, + }; + creds.Servers.unshift(server); + } + server.Id = bootstrap.serverId; + server.AccessToken = bootstrap.accessToken; + server.UserId = bootstrap.userId; + server.DateLastAccessed = Date.now(); + window.localStorage.setItem(key, JSON.stringify(creds)); + } catch (_) {} + } + + function adoptBootstrapClient(client, bootstrap) { + persistBootstrapServer(bootstrap); + client.serverAddress(bootstrap.serverUrl); + if (typeof client.serverId === "function") { + client.serverId(bootstrap.serverId); + } + if (typeof client.deviceId === "function" && bootstrap.deviceId) { + client.deviceId(bootstrap.deviceId); + } + if (typeof client.setAuthenticationInfo === "function") { + client.setAuthenticationInfo(bootstrap.accessToken, bootstrap.userId); + return; + } + if (typeof client.userId === "function") { + client.userId(bootstrap.userId); + } + if (typeof client.accessToken === "function") { + client.accessToken(bootstrap.accessToken); + } + } + function acknowledge(bootstrap, client) { const currentUserId = typeof client.getCurrentUserId === "function" ? client.getCurrentUserId() : client.userId(); - const normalizedAddress = String(client.serverAddress()).replace(/\/$/, ""); - const expectedAddress = bootstrap.serverUrl.replace(/\/$/, ""); + const normalizedAddress = normalizeAddress(client.serverAddress()); + const expectedAddress = normalizeAddress(bootstrap.serverUrl); const matches = normalizedAddress === expectedAddress && client.serverId() === bootstrap.serverId && @@ -98,12 +152,13 @@ ) { const hasExpectedIdentity = client.getCurrentUserId() === bootstrap.userId && - client.accessToken() === bootstrap.accessToken; + client.accessToken() === bootstrap.accessToken && + client.serverId() === bootstrap.serverId; if (!hasExpectedIdentity) { - if (!location.hash.toLowerCase().includes("/login")) return; - client.serverAddress(bootstrap.serverUrl); - if (client.serverId() !== bootstrap.serverId) return; - client.setAuthenticationInfo(bootstrap.accessToken, bootstrap.userId); + // Private webview: Foreseer redeem already proved this origin+token. + // After a Jellyfin reinstall, cached credentials keep the old server + // Id and ConnectionManager sits in ServerMismatch (not /login). + adoptBootstrapClient(client, bootstrap); } if (validationGeneration === bootstrap.generation) return; validationGeneration = bootstrap.generation; @@ -122,11 +177,7 @@ return; } if (typeof client.userId !== "function") return; - client.serverAddress(bootstrap.serverUrl); - client.serverId(bootstrap.serverId); - client.userId(bootstrap.userId); - client.deviceId(bootstrap.deviceId); - client.accessToken(bootstrap.accessToken); + adoptBootstrapClient(client, bootstrap); acknowledge(bootstrap, client); } catch (_) {} } diff --git a/src/config.rs b/src/config.rs index 73316d2..5903d74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,11 +87,12 @@ pub fn validate_bootstrap_server_url(input: &str) -> Result {} + "http" if is_local_http_host(host) => {} + "http" => return Err(ForeseerUrlError::InsecureHttpNonLocalHost), + _ => return Err(ForeseerUrlError::UnsupportedScheme), } if !parsed.username().is_empty() || parsed.password().is_some() { return Err(ForeseerUrlError::CredentialsNotAllowed); @@ -285,10 +286,12 @@ mod tests { assert!(validate_foreseer_url("http://127.0.0.1", true).is_ok()); } #[test] - fn bootstrap_urls_are_always_https() { + fn bootstrap_http_is_only_for_private_hosts() { assert_eq!( validate_bootstrap_server_url("http://jellyfin.example").unwrap_err(), - ForeseerUrlError::InsecureHttpNotAllowed + ForeseerUrlError::InsecureHttpNonLocalHost ); + assert!(validate_bootstrap_server_url("http://192.168.40.3:8096").is_ok()); + assert!(validate_bootstrap_server_url("https://jellyfin.example").is_ok()); } } diff --git a/src/extension.rs b/src/extension.rs index bba3797..421ea59 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -301,12 +301,13 @@ impl HostExtension for ForeseerExtension { } RuntimeEvent::ShutdownBeginning => { self.runtime_shutting_down.store(true, Ordering::Release); - if let Some(supervisor) = self.standalone_supervisor.clone() { - std::thread::spawn(move || { - if let Ok(mut supervisor) = supervisor.lock() { - supervisor.shutdown(); - } - }); + // Shut down on this thread. A detached helper can be killed + // when the process exits, leaving a spinning Node child that + // still holds instance.lock and the SQLite WAL. + if let Some(supervisor) = &self.standalone_supervisor + && let Ok(mut supervisor) = supervisor.lock() + { + supervisor.shutdown(); } } _ => {} diff --git a/src/session.rs b/src/session.rs index 36efe94..7a0216b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -129,10 +129,17 @@ mod tests { } #[test] - fn rejects_http_bootstrap_and_redacts() { + fn rejects_public_http_bootstrap_and_redacts() { let mut bad = sample(); bad.server_url = "http://jellyfin.example/".into(); assert!(bad.validate_shape().is_err()); assert_eq!(redact_secrets(r#"{"accessToken":"secret"}"#), "[redacted]"); } + + #[test] + fn accepts_private_http_bootstrap() { + let mut lan = sample(); + lan.server_url = "http://192.168.40.3:8096".into(); + assert!(lan.validate_shape().is_ok()); + } } diff --git a/src/supervisor.rs b/src/supervisor.rs index f24a6ad..005e262 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -72,7 +72,7 @@ fn bundled_foreseerr_revision() -> &'static str { struct RuntimeVersion { foreseerr_version: String, #[serde(default)] - schema_version: u32, + schema_version: u64, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -83,7 +83,7 @@ pub struct ReadyRecord { pub origin: String, pub foreseerr_version: String, pub commit: String, - pub schema_version: u32, + pub schema_version: u64, } #[derive(Debug)] @@ -249,6 +249,9 @@ impl StandaloneSupervisor { } command .env("FORESEERR_RUNTIME", "desktop") + // The staged bundle contains a production Next.js build and must + // initialize its SQLite schema before loading migrated settings. + .env("NODE_ENV", "production") .env("CONFIG_DIRECTORY", &config_dir) .env("CACHE_DIRECTORY", &cache_dir) .env("LOG_DIRECTORY", &log_dir) @@ -380,6 +383,16 @@ impl StandaloneSupervisor { upgrade_backup, successful_status_recorded: false, }; + // Keep draining stdout/stderr for the child's lifetime. + // Dropping `rx` here used to make the reader threads exit and + // close the pipes; the next Node write then raised EPIPE. + // Next.js source-maps that as uncaughtException, logs it to + // stderr, and livelocks the event loop so /login never + // renders. + drop(tx); + std::thread::spawn(move || { + while rx.recv().is_ok() {} + }); return Ok(supervisor); } } @@ -431,14 +444,14 @@ impl StandaloneSupervisor { } } pub fn shutdown(&mut self) { - self.send_control(r#"{"type":"shutdown","deadlineMs":10000}"#); + self.send_control(r#"{"type":"shutdown","deadlineMs":2000}"#); #[cfg(unix)] if let Ok(pid) = i32::try_from(self.child.id()) { // The child is the leader of a dedicated group, so this also // reaches Node helpers spawned for native modules. unsafe { libc::kill(-pid, libc::SIGTERM) }; } - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { if self.child.try_wait().ok().flatten().is_some() { return; @@ -710,7 +723,7 @@ fn complete_upgrade_backup(backup: &Path, ready: &ReadyRecord) -> Result<(), Sup fn write_runtime_version( config_dir: &Path, version: &str, - schema_version: u32, + schema_version: u64, ) -> Result<(), SupervisorError> { let state = config_dir.join("state/runtime-version.json"); let payload = serde_json::to_vec_pretty(&RuntimeVersion { From 415c0923bfa2b8bbe1f33674bea7261bb91f3b1f Mon Sep 17 00:00:00 2001 From: Selman Trabzon Date: Sun, 23 Aug 2026 00:01:27 +0300 Subject: [PATCH 74/74] feat: in-process remote setup and Jellyfin HTTPS fallback Keep setup on the existing CEF session, accept http/https from the URL scheme, and retry play bootstrap on the external Jellyfin host when LAN fails. Co-authored-by: Cursor --- README.md | 4 +- docs/integration-plan.md | 99 ------ docs/jellium-patch-delta.md | 109 ------- docs/migration-v2.md | 54 ---- docs/upgrade-runbook.md | 63 ---- protocol/protocol-v1.json | 3 +- scripts/omarchy-launch-debug.sh | 35 ++ scripts/seed-standalone-connections.sh | 37 +++ scripts/test-windows-release.sh | 310 ++++++++++++++++++ src/assets/foreseer-native.js | 62 ++++ src/auth.rs | 15 + src/config.rs | 77 ++--- src/controller.rs | 66 +++- src/extension.rs | 134 ++++++-- src/lib.rs | 1 + src/main.rs | 24 +- src/session.rs | 20 +- src/setup.html | 423 ++++++++++++++----------- src/setup.rs | 28 +- 19 files changed, 949 insertions(+), 615 deletions(-) delete mode 100644 docs/integration-plan.md delete mode 100644 docs/jellium-patch-delta.md delete mode 100644 docs/migration-v2.md delete mode 100644 docs/upgrade-runbook.md create mode 100755 scripts/omarchy-launch-debug.sh create mode 100755 scripts/seed-standalone-connections.sh create mode 100755 scripts/test-windows-release.sh diff --git a/README.md b/README.md index 63fe8bb..a82d6a1 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ cargo run -- --remote https://foreseer.example.com # Set the combined transient cache budget (images + CEF HTTP cache): cargo run -- --cache-limit 2147483648 -# Allow HTTP (non-HTTPS) server URL: -cargo run -- --set-url http://192.168.1.50:5055 --allow-http +# HTTP or HTTPS — the URL scheme is the choice: +cargo run -- --set-url http://192.168.1.50:5055 # Temporary environment variable override (does not modify config.json): FORESEER_URL=https://foreseer.example cargo run diff --git a/docs/integration-plan.md b/docs/integration-plan.md deleted file mode 100644 index b7eb974..0000000 --- a/docs/integration-plan.md +++ /dev/null @@ -1,99 +0,0 @@ -# Foreseer Desktop Integration Plan (protocol v1) - -Status: host-extension integration is released as Foreseer Desktop `v0.2.9`. -Automated boundary and protocol gates are enabled; Linux Wayland/X11 acceptance -and the 50-cycle playback soak remain manual release gates. - -## Goal - -One Foreseer product in two environments: - -- **Browser:** full web app; play actions use ordinary Jellyfin links. -- **Foreseer Desktop:** same hosted UI detects `window.foreseerNative` (protocol - v1), reuses the signed-in user's linked Jellyfin identity, and plays through - Jellium/mpv in one window. Terminal playback restores the Foreseer route - without exposing private Jellyfin Web. - -Foreseer Desktop + the maintained thin Jellium fork are the supported native -stack. Product stability comes first; upstreaming is opportunistic. - -## Non-goals - -- Do not move the Foreseer product into Jellium. -- Do not reimplement Jellyfin Web playback negotiation in Foreseer. -- Do not expose mpv, filesystem, shell, CEF handles, or Jellyfin tokens to the - hosted page. -- Do not make the browser build depend on the desktop binary. -- Do not put Foreseer protocol, origins, tickets, or product JS into Jellium. - -## Ownership - -| Concern | Owner | -| --- | --- | -| Discovery, requests, library UI, ordinary browser play | SeerrSuggestArr | -| `window.foreseerNative` detection + ticket issue (`protocolVersion: 1`) | SeerrSuggestArr | -| Protocol v1, controller, injected assets, config, pins | foreseer-desktop | -| CEF/mpv/compositor/window + generic `HostExtension` seam | Jellium thin fork | -| Jellyfin playback negotiation / resume | Jellyfin Web via Jellium private layer | - -## Runtime shape - -```mermaid -flowchart LR - F[Hosted Foreseer UI] -->|HTTPS cookie session| S[Foreseer server] - F -->|foreseerNative.send| D[Foreseer Desktop extension] - D -->|HostExtension / RuntimeHandle| J[Jellium thin fork] - J -->|private authenticated layer| W[Jellyfin Web] - W --> M[mpv] - S -->|single-use redeem| D - F -. browser .-> L[Jellyfin link] -``` - -## Protocol v1 (product boundary) - -Canonical fixture: [`protocol/protocol-v1.json`](../protocol/protocol-v1.json), -with a byte-equivalent copy in the Foreseerr repository. - -- Global: frozen `window.foreseerNative` with `protocolVersion: 1`, - `hostName: 'foreseer-desktop'`, and `send(command)`. -- Events: `foreseer:native-event` with `{ protocolVersion, id, type, ... }`. -- Commands are intent-level only (`auth.*`, `play.item`, `session.clear`, - window/app/setup). No tokens, device IDs, or mpv commands on the page wire. -- Absent / unusable `foreseerNative` → ordinary browser playback. -- Resume ticks stay Jellyfin-owned (`startPositionTicksInProtocol: false`). - -## Opaque Jellium API (public surface) - -Foreseer may import only the `host-extension` exports from `jfn-rust`: - -- `HostOptions::with_extension` -- `HostExtension` / `HostExtensionDescriptor` -- `ExtensionSource` / `FrontendSource` -- `Presentation` (`Frontend` / `PrimaryWebPreparing` / `PrimaryWeb`) / - `RuntimeEvent` / `RuntimeHandle` -- `jfn_app_main_with` -- related config errors / payload limit constants - -Everything else (protocol parsing, auth redeem, setup HTML, product JS) lives in -Foreseer. Stock Jellium with no extension configured must behave as upstream. - -## Fork maintenance - -- Pin: [`jellium.rev`](../jellium.rev) -- Upstream base: [`jellium.upstream-base`](../jellium.upstream-base) -- Approved commits: [`docs/jellium-patch-manifest.md`](jellium-patch-manifest.md) -- Upgrade steps: [`docs/upgrade-runbook.md`](upgrade-runbook.md) -- Gates: `scripts/boundary-audit.sh`, `scripts/patch-delta.sh` - -## Security notes - -- Hosted Foreseer is untrusted input even when expected. -- Exact-origin allowlisting and payload size limits stay in the native host. -- Tickets are single-use, short-lived, challenge-bound; never log tokens/tickets. -- Frontend responses must not include access tokens or device IDs. - -## Packaging / soak - -Linux from-source is the current support surface. Packaged installers and the -Phase 7 Wayland/X11 50-cycle soak remain acceptance gates before calling the -migration complete. diff --git a/docs/jellium-patch-delta.md b/docs/jellium-patch-delta.md deleted file mode 100644 index 85aea62..0000000 --- a/docs/jellium-patch-delta.md +++ /dev/null @@ -1,109 +0,0 @@ -# Jellium thin-fork patch delta - -- upstream base: `28f2cf16a1f1b819884dd6a72919ca55bdf9bd73` -- pin / HEAD: `bf647ab54b45737c38025f734980719700f16909` -- checkout: `/c/Users/selma/projects/jellium-desktop` - -## Commits - -bf647ab fix(windows): open select dropdowns in-page like Linux -ffa6e22 fix(windows): unmap hidden DComp visuals after playback -d04f440 fix(host-extension): preserve playback OSD interaction -bc3122d fix(host-extension): keep presentation terminology generic -0579346 fix(host-extension): prepare primary web before playback -db9ca5a fix(host-extension): inject host scripts without built-ins -ff71888 fix(host-extension): avoid shutdown callback deadlock -bf59292 docs: describe Foreseer runtime boundary -946e947 fix: gate host extension Arc import -bce89c3 fix(wayland): publish CEF copy/paste via native clipboard -478ce60 fix(cef): align dropdowns and GPU compositing -ecde360 fix: unmap hidden GPU CEF layers and serialize presentation -ce5d4b5 fix: drop unused HostOptions::has_extension helper -0a12974 fix(wayland): log upstream mpv-proxy protocol errors -de3c381 fix(wayland): use full-buffer viewport during WSI resize -c9e8deb feat: add generic host-extension seam for embedding binaries - -## Diffstat - -``` - README.md | 35 +- - src/Cargo.lock | 3 + - src/Cargo.toml | 1 + - src/jfn_cef/Cargo.toml | 5 + - src/jfn_cef/src/app.rs | 9 +- - src/jfn_cef/src/business_extension.rs | 702 ++++++++++++++++++++++++++++ - src/jfn_cef/src/business_overlay.rs | 21 + - src/jfn_cef/src/business_web.rs | 5 + - src/jfn_cef/src/client.rs | 14 +- - src/jfn_cef/src/client/events.rs | 45 ++ - src/jfn_cef/src/client/popup.rs | 38 +- - src/jfn_cef/src/client_impl/context_menu.rs | 18 + - src/jfn_cef/src/client_impl/keyboard.rs | 17 +- - src/jfn_cef/src/client_impl/render.rs | 10 + - src/jfn_cef/src/extension.rs | 299 ++++++++++++ - src/jfn_cef/src/ffi.rs | 3 + - src/jfn_cef/src/injection.rs | 83 +++- - src/jfn_cef/src/lib.rs | 10 + - src/jfn_rust/Cargo.toml | 6 + - src/jfn_rust/examples/host_extension.rs | 75 +++ - src/jfn_rust/src/app.rs | 63 ++- - src/jfn_rust/src/host.rs | 75 +++ - src/jfn_rust/src/lib.rs | 9 + - src/jfn_rust/src/manager.rs | 6 + - src/platform_abi/src/lib.rs | 6 + - src/platform_abi/src/mpv_host.rs | 4 + - src/wayland/src/clipboard.rs | 23 +- - src/wayland/src/layer.rs | 14 + - src/wayland/src/layer_actor.rs | 52 +-- - src/wayland/src/make_platform.rs | 19 +- - src/wayland/src/mpv_host.rs | 20 + - src/wayland/src/mpv_proxy/app.rs | 19 + - src/wayland/src/mpv_proxy/mod.rs | 2 +- - src/web/mpv-video-player.js | 19 +- - src/web/select-menu.js | 104 +++-- - src/windows/src/lib.rs | 6 +- - src/windows/src/render/layer.rs | 19 +- - src/windows/src/render/mod.rs | 59 ++- - 38 files changed, 1790 insertions(+), 128 deletions(-) -``` - -## File list - -- README.md -- src/Cargo.lock -- src/Cargo.toml -- src/jfn_cef/Cargo.toml -- src/jfn_cef/src/app.rs -- src/jfn_cef/src/business_extension.rs -- src/jfn_cef/src/business_overlay.rs -- src/jfn_cef/src/business_web.rs -- src/jfn_cef/src/client.rs -- src/jfn_cef/src/client/events.rs -- src/jfn_cef/src/client/popup.rs -- src/jfn_cef/src/client_impl/context_menu.rs -- src/jfn_cef/src/client_impl/keyboard.rs -- src/jfn_cef/src/client_impl/render.rs -- src/jfn_cef/src/extension.rs -- src/jfn_cef/src/ffi.rs -- src/jfn_cef/src/injection.rs -- src/jfn_cef/src/lib.rs -- src/jfn_rust/Cargo.toml -- src/jfn_rust/examples/host_extension.rs -- src/jfn_rust/src/app.rs -- src/jfn_rust/src/host.rs -- src/jfn_rust/src/lib.rs -- src/jfn_rust/src/manager.rs -- src/platform_abi/src/lib.rs -- src/platform_abi/src/mpv_host.rs -- src/wayland/src/clipboard.rs -- src/wayland/src/layer.rs -- src/wayland/src/layer_actor.rs -- src/wayland/src/make_platform.rs -- src/wayland/src/mpv_host.rs -- src/wayland/src/mpv_proxy/app.rs -- src/wayland/src/mpv_proxy/mod.rs -- src/web/mpv-video-player.js -- src/web/select-menu.js -- src/windows/src/lib.rs -- src/windows/src/render/layer.rs -- src/windows/src/render/mod.rs diff --git a/docs/migration-v2.md b/docs/migration-v2.md deleted file mode 100644 index ada23ad..0000000 --- a/docs/migration-v2.md +++ /dev/null @@ -1,54 +0,0 @@ -# Protocol v1 / host-extension migration - -## Pins and baselines - -| Item | Value | -|------|-------| -| Protocol version | `1` | -| Old Foreseer baseline tag | `v0.2-baseline` (`5ce0e350319d6323c6d2ef47fad232fbe8842d36`) | -| Old Jellium release pin (`jellium.rev` at baseline) | `1242b0e6c48fc272cf1852b392501f75b71cd6d9` (tag `external-frontend-v1-archive`) | -| Old Jellium local hardened tip | preserved on `archive/local-runtime-fixes-20260812` | -| New upstream base (`upstream/main` at worktree create) | `28f2cf16a1f1b819884dd6a72919ca55bdf9bd73` | -| Thin fork branch / checkout | `main` at `/home/selmant/Projects/jellium-desktop` | -| Thin fork tip | `bf647ab54b45737c38025f734980719700f16909` | -| Foreseer host-extension integration | released from `main` as Desktop `v0.2.9` | -| Foreseerr web contract | protocol v1 fixture on `develop` | - -## Worktree rules - -- Keep runtime experiments on their archive branch until they have passed the - normal Jellium and Foreseer release gates. -- All host-extension maintenance happens in the canonical Desktop and Jellium - `main` branches. - -## Status - -- [x] Phase 0 baselines and worktrees -- [x] Phase 1 generic `host-extension` seam -- [x] Phase 2 fork triage -- [x] Phase 3 Foreseer protocol + controller -- [x] Phase 4 assets + live adapter -- [x] Phase 5 Seerr v2 (code complete; deploy before desktop release) -- [x] Phase 6 gates + docs (upstream PRs deferred / non-blocking) -- [ ] Phase 7 Linux acceptance + cutover - - [x] 7.1 Automated gates (local): Jellium stock + `host-extension`; Foreseer fmt/test/clippy/harness/boundary-audit; Seerr `pnpm test` - - [ ] 7.2 Manual Linux matrix + 50-cycle soak - - [ ] 7.3 Cutover (`jellium.rev` already candidate-pinned; push thin fork + deploy Seerr first) - - [ ] 7.4 Completion check - -## Upstream PR drafts (Phase 6.4, non-blocking) - -Open against upstream Jellium without Foreseer names/endpoints in titles or bodies: - -1. Wayland full-buffer viewport during WSI resize (`de3c381`) -2. mpv-proxy protocol error logging (`0a12974`) -3. Generic host-extension seam / structured transport (`c9e8deb`) — after 1–2 land or as follow-up - - -## Triage notes (Phase 2) - -- Dropped: CEF severity mapping (already on upstream `28f2cf1`). -- Ported: Wayland full-buffer viewport during WSI resize; mpv-proxy protocol error logging. -- Skipped for Jellium (move to Foreseer private-web asset): dirty `input-plugin.js` resume generation/getItem behavior. -- Root-window diagnostics: upstream calloop path already logs dispatch/source failures; dirty hunk not ported. -- Left behind: old `external-frontend` product protocol, `external-host.js`, protocol fixtures, auth/config traits. diff --git a/docs/upgrade-runbook.md b/docs/upgrade-runbook.md deleted file mode 100644 index 75cc253..0000000 --- a/docs/upgrade-runbook.md +++ /dev/null @@ -1,63 +0,0 @@ -# Jellium thin-fork upgrade runbook - -Use this when rebasing the maintained thin fork onto newer upstream Jellium. - -## Prerequisites - -- Clean worktree for the thin fork (`main` branch). -- Foreseer Desktop worktree that will consume the new pin. -- Recorded upstream base in `jellium.upstream-base` and pin in `jellium.rev`. - -## Steps - -1. **Fetch upstream** - ```sh - git -C "$JELLIUM" fetch upstream - git -C "$JELLIUM" checkout main - ``` -2. **Rebase thin branch** - ```sh - git -C "$JELLIUM" rebase upstream/main - # resolve conflicts; keep the host-extension generic; drop product leakage - ``` -3. **Update recorded base** after a successful rebase onto the new tip: - ```sh - git -C "$JELLIUM" merge-base --is-ancestor "$(cat jellium.upstream-base)" HEAD - printf '%s\n' "$(git -C "$JELLIUM" rev-parse upstream/main)" > jellium.upstream-base - ``` - Prefer setting `jellium.upstream-base` to the upstream commit the thin branch now sits on (usually `upstream/main` at rebase time). -4. **Refresh patch manifest** - - List `git log --oneline $(cat jellium.upstream-base)..HEAD` - - Update `docs/jellium-patch-manifest.md` - - Run `scripts/patch-delta.sh docs/jellium-patch-delta.md` -5. **Boundary audit** - ```sh - JELLIUM_DIR="$JELLIUM" ./scripts/boundary-audit.sh - ``` -6. **Stock + feature tests (Jellium)** - ```sh - cargo test -p jfn-cef - cargo test -p jfn-rust - cargo test -p jfn-cef --features host-extension - cargo test -p jfn-rust --features host-extension - ``` -7. **Foreseer tests** - ```sh - cargo fmt -- --check - cargo test - cargo clippy --all-targets -- -D warnings - node scripts/protocol-v1-harness.mjs - JELLIUM_DIR="$JELLIUM" ./scripts/boundary-audit.sh - ``` -8. **Linux matrix (manual)** — Wayland and X11 smoke from `docs/migration-v2.md` Phase 7 checklist. -9. **Update pin** - ```sh - git -C "$JELLIUM" rev-parse HEAD > jellium.rev - ``` -10. **Commit** Foreseer pin + manifest + delta docs together. - -## Failure modes - -- Boundary audit leakage → move product strings/assets into Foreseer; never “allowlist” product names in Jellium. -- Unapproved commits → either drop them or document them in the patch manifest with rationale. -- Stock regression → fix in the thin fork before bumping `jellium.rev`. diff --git a/protocol/protocol-v1.json b/protocol/protocol-v1.json index 0a53ce0..35c7e4a 100644 --- a/protocol/protocol-v1.json +++ b/protocol/protocol-v1.json @@ -199,7 +199,8 @@ "userId", "deviceId", "accessToken", - "bootstrapGeneration" + "bootstrapGeneration", + "fallbackServerUrl" ], "nativeOnlyFields": [ "accessToken", diff --git a/scripts/omarchy-launch-debug.sh b/scripts/omarchy-launch-debug.sh new file mode 100755 index 0000000..5ce485f --- /dev/null +++ b/scripts/omarchy-launch-debug.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Omarchy desktop launcher for the debug binary. Captures stdout/stderr and +# points Foreseer's rotating log at $ROOT/logs so crashes are not lost. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEBUG="$ROOT/target/debug" +BIN="$DEBUG/foreseer-desktop" +LOG_DIR="$ROOT/logs" + +mkdir -p "$LOG_DIR" + +shopt -s nullglob +old=( "$LOG_DIR"/launch-*.log ) +if (( ${#old[@]} > 20 )); then + printf '%s\n' "${old[@]}" | sort | head -n $(( ${#old[@]} - 20 )) | xargs -r rm -f +fi + +STAMP="$(date +%Y%m%d-%H%M%S)" +LAUNCH_LOG="$LOG_DIR/launch-${STAMP}.log" +APP_LOG="$LOG_DIR/foreseer-desktop.log" + +{ + echo "=== Foreseer Desktop debug launch $STAMP pid=$$ ===" + echo "bin=$BIN" + echo "app_log=$APP_LOG" +} >"$LAUNCH_LOG" +ln -sfn "$LAUNCH_LOG" "$LOG_DIR/launch-latest.log" + +export LD_LIBRARY_PATH="$DEBUG${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export FORESEER_LOG_FILE="$APP_LOG" +export FORESEER_LOG_LEVEL="${FORESEER_LOG_LEVEL:-debug}" + +cd "$ROOT" +exec "$BIN" "$@" >>"$LAUNCH_LOG" 2>&1 diff --git a/scripts/seed-standalone-connections.sh b/scripts/seed-standalone-connections.sh new file mode 100755 index 0000000..b77bc00 --- /dev/null +++ b/scripts/seed-standalone-connections.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Copy the complete main Foreseer settings profile into a standalone profile. +# This includes Jellyfin, Radarr, Sonarr, notification, and linked providers; +# run it only when the standalone is intentionally meant to use those live +# services. +set -euo pipefail + +REMOTE="${FORESEER_MAIN_REMOTE:-root@pve}" +CONTAINER="${FORESEER_MAIN_CONTAINER:-420}" +TEST_ROOT="${FORESEER_TEST_ROOT:-$HOME/.local/share/foreseer-desktop-test}" +SETTINGS="$TEST_ROOT/standalone/settings.json" + +if [[ ! -f "$SETTINGS" ]]; then + echo "Standalone settings not found: $SETTINGS" >&2 + exit 1 +fi + +umask 077 +payload="$(mktemp)" +trap 'rm -f "$payload"' EXIT + +# Keep sensitive values out of terminal output. The payload is written with +# owner-only permissions and contains the full settings profile. +tailscale ssh "$REMOTE" \ + "pct exec $CONTAINER -- /run/current-system/sw/bin/cat /opt/foreseer/config/settings.json" \ + >"$payload" + +node - "$SETTINGS" "$payload" <<'NODE' +const fs = require('fs'); +const [settingsPath, payloadPath] = process.argv.slice(2); +const settings = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); +fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { + mode: 0o600, +}); +NODE + +echo "Seeded the complete main Foreseer settings profile into the standalone profile." diff --git a/scripts/test-windows-release.sh b/scripts/test-windows-release.sh new file mode 100755 index 0000000..702942d --- /dev/null +++ b/scripts/test-windows-release.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +# Fetch a Windows portable ZIP and attach it to the existing libvirt Windows VM. +# Wine/Proton cannot run this build (CEF + mpv). Use the win11 KVM guest. +set -euo pipefail + +LIBVIRT_URI="${LIBVIRT_URI:-qemu:///system}" +VM_NAME="${FORESEER_WIN_VM:-win11}" +REPO="${FORESEER_GH_REPO:-selmant/foreseerr-desktop}" +ARTIFACT_NAME="${FORESEER_WIN_ARTIFACT:-foreseer-desktop-windows-x64}" +CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/foreseer-desktop/windows-test" +# qemu:///system runs as libvirt-qemu and cannot traverse $HOME (700). +ISO="${FORESEER_WIN_ISO:-/var/tmp/foreseer-desktop/foreseer.iso}" +CDROM_TARGET="${FORESEER_WIN_CDROM:-sdb}" +ZIPS="$CACHE/zips" +STAMP="$CACHE/iso.stamp" + +usage() { + cat <<'EOF' +Usage: scripts/test-windows-release.sh [options] [zip] + +Fetch a Foreseer Desktop Windows x64 ZIP, pack it into a DVD ISO, start the +libvirt Windows VM, insert the ISO, and open virt-viewer. + +Cached zips are reused. The ISO lives under /var/tmp so qemu can read it. + +virt-viewer fullscreen: Ctrl+Alt (release grab), then F11. Mouse at the top +edge shows the toolbar. + +Options: + --release [TAG] Use a GitHub release asset (default: latest) + --run [ID] Use a workflow artifact (default: latest successful release.yml run) + --local ZIP Use an existing zip (same as passing ZIP as the argument) + --no-viewer Start/attach only; do not open virt-viewer + --no-start Pack and attach only; VM must already be running + --pack-only Download and build the ISO; do not touch the VM + -h, --help Show this help + +Env: + FORESEER_WIN_VM libvirt domain (default: win11) + FORESEER_GH_REPO GitHub repo (default: selmant/foreseerr-desktop) + FORESEER_WIN_ISO ISO path (default: /var/tmp/foreseer-desktop/foreseer.iso) + LIBVIRT_URI default qemu:///system + +Inside Windows: This PC → FORESEER DVD drive, extract the zip to Desktop, +run foreseer-desktop.exe. CEF will not run cleanly from a read-only path. +QXL is software graphics, so this is a launch/login/play smoke test. +EOF +} + +need() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || { + echo "test-windows-release: missing command: $cmd" >&2 + exit 1 + } +} + +virsh_cmd() { + virsh -c "$LIBVIRT_URI" "$@" +} + +log() { + printf 'test-windows-release: %s\n' "$*" +} + +SOURCE="latest-release" +RELEASE_TAG="" +RUN_ID="" +ZIP_PATH="" +OPEN_VIEWER=1 +START_VM=1 +PACK_ONLY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --release) + SOURCE="release" + if [[ $# -ge 2 && "$2" != -* ]]; then + RELEASE_TAG="$2" + shift + fi + ;; + --run) + SOURCE="run" + if [[ $# -ge 2 && "$2" != -* && "$2" != *.zip ]]; then + RUN_ID="$2" + shift + fi + ;; + --local) + SOURCE="local" + ZIP_PATH="${2:-}" + [[ -n "$ZIP_PATH" ]] || { echo "test-windows-release: --local needs a zip path" >&2; exit 1; } + shift + ;; + --no-viewer) + OPEN_VIEWER=0 + ;; + --no-start) + START_VM=0 + ;; + --pack-only) + PACK_ONLY=1 + OPEN_VIEWER=0 + ;; + --) + shift + break + ;; + -*) + echo "test-windows-release: unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + SOURCE="local" + ZIP_PATH="$1" + ;; + esac + shift +done + +need virsh +need xorriso +if [[ "$PACK_ONLY" -ne 1 ]]; then + need virt-viewer +fi + +mkdir -p "$CACHE" "$ZIPS" "$(dirname "$ISO")" + +zip_id() { + stat -c '%n %s %Y' "$1" +} + +find_cached_zip() { + local name="$1" + local candidate + for candidate in "$ZIPS/$name" "$CACHE/download/$name"; do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +use_zip() { + local src="$1" + local name + name="$(basename "$src")" + if [[ "$src" != "$ZIPS/$name" ]]; then + if [[ ! -f "$ZIPS/$name" ]]; then + log "caching $name" + cp -a "$src" "$ZIPS/$name" + fi + ZIP_PATH="$ZIPS/$name" + else + ZIP_PATH="$src" + fi + log "using $ZIP_PATH" +} + +fetch_zip() { + case "$SOURCE" in + local) + [[ -f "$ZIP_PATH" ]] || { + echo "test-windows-release: zip not found: $ZIP_PATH" >&2 + exit 1 + } + use_zip "$ZIP_PATH" + ;; + run) + need gh + if [[ -z "$RUN_ID" ]]; then + RUN_ID="$(gh run list --repo "$REPO" --workflow release.yml --status success --limit 1 --json databaseId --jq '.[0].databaseId')" + [[ -n "$RUN_ID" ]] || { + echo "test-windows-release: no successful release.yml run found" >&2 + exit 1 + } + fi + local run_dir="$ZIPS/run-$RUN_ID" + local existing + existing="$(find "$run_dir" -name '*.zip' -print -quit 2>/dev/null || true)" + if [[ -n "$existing" ]]; then + log "reusing cached artifact from run $RUN_ID" + use_zip "$existing" + return + fi + log "downloading artifact $ARTIFACT_NAME from run $RUN_ID" + mkdir -p "$run_dir" + gh run download "$RUN_ID" --repo "$REPO" --name "$ARTIFACT_NAME" --dir "$run_dir" + existing="$(find "$run_dir" -name '*.zip' -print -quit)" + [[ -n "$existing" ]] || { + echo "test-windows-release: no zip in artifact $ARTIFACT_NAME" >&2 + exit 1 + } + use_zip "$existing" + ;; + latest-release|release) + need gh + local view_args=() + if [[ -n "$RELEASE_TAG" ]]; then + view_args=("$RELEASE_TAG") + fi + local name + name="$(gh release view "${view_args[@]}" --repo "$REPO" --json assets --jq '.assets[] | select(.name | test("windows-x64\\.zip$")) | .name')" + [[ -n "$name" ]] || { + echo "test-windows-release: no windows-x64.zip on release ${RELEASE_TAG:-latest}" >&2 + exit 1 + } + local cached + if cached="$(find_cached_zip "$name")"; then + log "reusing cached $name" + use_zip "$cached" + return + fi + log "downloading $name from GitHub release ${RELEASE_TAG:-latest}" + gh release download "${view_args[@]}" --repo "$REPO" --pattern "$name" --dir "$ZIPS" + use_zip "$ZIPS/$name" + ;; + *) + echo "test-windows-release: unknown source $SOURCE" >&2 + exit 1 + ;; + esac +} + +pack_iso() { + local current expected + current="$(zip_id "$ZIP_PATH") $ISO" + if [[ -f "$ISO" && -f "$STAMP" ]]; then + expected="$(cat "$STAMP")" + if [[ "$expected" == "$current" ]]; then + log "reusing ISO $ISO ($(du -h "$ISO" | awk '{print $1}'))" + return + fi + fi + + log "building ISO from $(basename "$ZIP_PATH")" + xorriso -as mkisofs -R -J -V FORESEER -o "$ISO" "$ZIP_PATH" >/dev/null 2>&1 + chmod 644 "$ISO" + printf '%s\n' "$current" >"$STAMP" + log "ISO $ISO ($(du -h "$ISO" | awk '{print $1}'))" +} + +wait_running() { + local i + for i in $(seq 1 30); do + if [[ "$(virsh_cmd domstate "$VM_NAME")" == running ]]; then + return 0 + fi + sleep 1 + done + echo "test-windows-release: $VM_NAME did not reach running" >&2 + exit 1 +} + +insert_iso() { + log "inserting ISO into $CDROM_TARGET" + virsh_cmd change-media "$VM_NAME" "$CDROM_TARGET" "$ISO" --live + log "DVD $CDROM_TARGET now has volume label FORESEER" +} + +fetch_zip +pack_iso + +if [[ "$PACK_ONLY" -eq 1 ]]; then + log "packed $ISO (VM left untouched)" + exit 0 +fi + +if ! virsh_cmd dominfo "$VM_NAME" >/dev/null 2>&1; then + echo "test-windows-release: libvirt domain '$VM_NAME' not found on $LIBVIRT_URI" >&2 + exit 1 +fi + +state="$(virsh_cmd domstate "$VM_NAME")" +if [[ "$state" != running ]]; then + if [[ "$START_VM" -ne 1 ]]; then + echo "test-windows-release: $VM_NAME is $state and --no-start was set" >&2 + exit 1 + fi + log "starting $VM_NAME" + virsh_cmd start "$VM_NAME" +fi +wait_running +insert_iso + +cat <, access_token: Option, bootstrap_generation: Option, + fallback_server_url: Option, +} + +fn optional_bootstrap_url(value: Option) -> Result, AuthErrorCode> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_empty() { + return Ok(None); + } + if value.len() > crate::config::MAX_FORESEER_URL_LEN { + return Err(AuthErrorCode::InvalidBootstrapResponse); + } + Ok(Some(value)) } fn required_bootstrap_field(value: Option, max: usize) -> Result { @@ -154,6 +168,7 @@ pub fn parse_redemption_bootstrap(body: &str) -> Result &'static str { @@ -31,20 +28,11 @@ impl ForeseerUrlError { Self::UnsupportedScheme => "Server URL must use HTTP or HTTPS", Self::MissingHost => "Server URL must include a host", Self::CredentialsNotAllowed => "Server URL must not include credentials", - Self::InsecureHttpNotAllowed => { - "Server URL must use HTTPS unless HTTP is explicitly allowed" - } - Self::InsecureHttpNonLocalHost => { - "HTTP is allowed only for localhost or a private IP address" - } } } } -pub fn validate_foreseer_url( - input: &str, - allow_insecure_http: bool, -) -> Result { +pub fn validate_foreseer_url(input: &str) -> Result { if input.is_empty() { return Err(ForeseerUrlError::Invalid); } @@ -55,44 +43,25 @@ pub fn validate_foreseer_url( if !matches!(parsed.scheme(), "http" | "https") { return Err(ForeseerUrlError::UnsupportedScheme); } - let host = parsed.host_str().ok_or(ForeseerUrlError::MissingHost)?; + if parsed.host_str().is_none() { + return Err(ForeseerUrlError::MissingHost); + } if !parsed.username().is_empty() || parsed.password().is_some() { return Err(ForeseerUrlError::CredentialsNotAllowed); } - if parsed.scheme() == "http" && (!allow_insecure_http || !is_local_http_host(host)) { - return Err(if allow_insecure_http { - ForeseerUrlError::InsecureHttpNonLocalHost - } else { - ForeseerUrlError::InsecureHttpNotAllowed - }); - } Ok(parsed.origin().ascii_serialization()) } -fn is_local_http_host(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") { - return true; - } - match host.parse::() { - Ok(IpAddr::V4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), - Ok(IpAddr::V6(ip)) => { - ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() - } - Err(_) => false, - } -} - pub fn validate_bootstrap_server_url(input: &str) -> Result { if input.is_empty() || input.len() > MAX_FORESEER_URL_LEN { return Err(ForeseerUrlError::Invalid); } let parsed = Url::parse(input).map_err(|_| ForeseerUrlError::Invalid)?; - let host = parsed.host_str().ok_or(ForeseerUrlError::MissingHost)?; - match parsed.scheme() { - "https" => {} - "http" if is_local_http_host(host) => {} - "http" => return Err(ForeseerUrlError::InsecureHttpNonLocalHost), - _ => return Err(ForeseerUrlError::UnsupportedScheme), + if parsed.host_str().is_none() { + return Err(ForeseerUrlError::MissingHost); + } + if !matches!(parsed.scheme(), "http" | "https") { + return Err(ForeseerUrlError::UnsupportedScheme); } if !parsed.username().is_empty() || parsed.password().is_some() { return Err(ForeseerUrlError::CredentialsNotAllowed); @@ -181,12 +150,10 @@ impl AppConfig { Self::config_file_path().is_some_and(|p| p.exists()) } pub fn is_configured(&self) -> bool { - self.mode == AppMode::Standalone - || validate_foreseer_url(&self.remote.server_url, self.remote.allow_insecure_http) - .is_ok() + self.mode == AppMode::Standalone || validate_foreseer_url(&self.remote.server_url).is_ok() } pub fn remote_url(&self) -> Result { - validate_foreseer_url(&self.remote.server_url, self.remote.allow_insecure_http) + validate_foreseer_url(&self.remote.server_url) } pub fn load() -> Self { let mut config = Self::config_file_path() @@ -278,20 +245,24 @@ mod tests { assert_eq!(MIN_CACHE_LIMIT_BYTES, 128 * 1024 * 1024); } #[test] - fn insecure_foreseer_urls_require_local_override() { + fn http_and_https_foreseer_urls_are_accepted() { + assert!(validate_foreseer_url("https://example.com").is_ok()); + assert!(validate_foreseer_url("http://127.0.0.1").is_ok()); + assert!(validate_foreseer_url("http://example.com").is_ok()); + assert!(validate_foreseer_url("http://foreseer.lan:5055").is_ok()); assert_eq!( - validate_foreseer_url("http://example.com", false).unwrap_err(), - ForeseerUrlError::InsecureHttpNotAllowed + validate_foreseer_url("ftp://example.com").unwrap_err(), + ForeseerUrlError::UnsupportedScheme ); - assert!(validate_foreseer_url("http://127.0.0.1", true).is_ok()); } #[test] - fn bootstrap_http_is_only_for_private_hosts() { - assert_eq!( - validate_bootstrap_server_url("http://jellyfin.example").unwrap_err(), - ForeseerUrlError::InsecureHttpNonLocalHost - ); + fn bootstrap_http_follows_the_url_scheme() { + assert!(validate_bootstrap_server_url("http://jellyfin.example").is_ok()); assert!(validate_bootstrap_server_url("http://192.168.40.3:8096").is_ok()); assert!(validate_bootstrap_server_url("https://jellyfin.example").is_ok()); + assert_eq!( + validate_bootstrap_server_url("ftp://jellyfin.example").unwrap_err(), + ForeseerUrlError::UnsupportedScheme + ); } } diff --git a/src/controller.rs b/src/controller.rs index a757e35..97ba8ef 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -120,6 +120,15 @@ impl Controller { self.in_setup } + pub fn enter_setup(&mut self) { + self.in_setup = true; + self.state = AppState::Setup; + self.setup_generation = self.setup_generation.wrapping_add(1); + self.active_request_id = None; + self.expected_session = None; + self.pending_bootstrap = None; + } + pub fn active_request_id(&self) -> Option<&str> { self.active_request_id.as_deref() } @@ -234,6 +243,19 @@ impl Controller { } } ControllerEvent::BootstrapFailed { request_id, code } => { + if let Some((pending_id, bootstrap)) = self.pending_bootstrap.as_mut() + && pending_id == &request_id + && let Some(fallback) = bootstrap.fallback_server_url.take() + && fallback != bootstrap.server_url + { + bootstrap.server_url = fallback; + if let Ok(expected) = bootstrap.expected() { + self.expected_session = Some(expected); + let url = bootstrap.server_url.clone(); + let _ = self.runtime.navigate_primary_web(&url); + return; + } + } self.pending_bootstrap = None; self.state = AppState::Degraded; self.emit_error(&request_id, code); @@ -352,7 +374,7 @@ impl Controller { self.emit_error(&id, AuthErrorCode::InvalidRequest); return true; } - if let Err(err) = validate_foreseer_url(&url, allow_http) { + if let Err(err) = validate_foreseer_url(&url) { self.runtime.post_frontend_event( NativeEventV1::new(id, "error") .with_error("invalid_request") @@ -365,12 +387,12 @@ impl Controller { true } - fn on_setup_save(&mut self, id: String, url: String, allow_http: bool) -> bool { + fn on_setup_save(&mut self, id: String, url: String, _allow_http: bool) -> bool { if !self.in_setup { self.emit_error(&id, AuthErrorCode::InvalidRequest); return true; } - match validate_foreseer_url(&url, allow_http) { + match validate_foreseer_url(&url) { Ok(normalized) => { self.setup_generation = self.setup_generation.wrapping_add(1); self.in_setup = false; @@ -454,6 +476,7 @@ mod tests { device_id: "dev".into(), access_token: "tok".into(), bootstrap_generation: "gen-1".into(), + fallback_server_url: None, } } @@ -490,6 +513,27 @@ mod tests { assert!(ctl.runtime.events.iter().any(|e| e.event_type == "ready")); } + #[test] + fn jellyfin_fallback_url_is_tried_after_bootstrap_failure() { + let mut ctl = Controller::new(MockRuntime::default(), false); + let epoch = ctl.auth_epoch(); + let mut bootstrap = bootstrap(); + bootstrap.server_url = "http://192.168.40.3:8096".into(); + bootstrap.fallback_server_url = Some("https://jellyfin.example".into()); + ctl.handle_event(ControllerEvent::AuthRedeemed { + request_id: "a1".into(), + bootstrap, + auth_epoch: epoch, + }); + assert_eq!(ctl.runtime.navigations[0], "http://192.168.40.3:8096"); + ctl.handle_event(ControllerEvent::BootstrapFailed { + request_id: "a1".into(), + code: AuthErrorCode::InvalidBootstrapResponse, + }); + assert_eq!(ctl.runtime.navigations[1], "https://jellyfin.example"); + assert_eq!(ctl.state(), AppState::Authenticating); + } + #[test] fn play_replace_and_terminal_restores_before_event() { let mut ctl = Controller::new(MockRuntime::default(), false); @@ -557,6 +601,22 @@ mod tests { ); } + #[test] + fn enter_setup_reopens_setup_authority() { + let mut ctl = Controller::new(MockRuntime::default(), false); + assert!(!ctl.in_setup()); + ctl.enter_setup(); + assert!(ctl.in_setup()); + assert_eq!(ctl.state(), AppState::Setup); + ctl.handle_command(NativeCommandV1::SetupSave { + id: "s1".into(), + url: "https://foreseer.example".into(), + allow_http: false, + }); + assert!(!ctl.in_setup()); + assert_eq!(ctl.runtime.setup_navs[0], "https://foreseer.example"); + } + #[test] fn shutdown_cancels_without_deadlock() { let mut ctl = Controller::new(MockRuntime::default(), false); diff --git a/src/extension.rs b/src/extension.rs index 421ea59..83f654e 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -1,6 +1,6 @@ //! Jellium `HostExtension` adapter for Foreseer protocol v1. -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::{ Arc, Mutex, Weak, atomic::{AtomicBool, Ordering}, @@ -513,6 +513,18 @@ impl ForeseerExtension { }); return true; } + if let Some(origin) = self.standalone_origin() { + return self.with_inner(|inner| { + inner.frontend_url = origin.clone(); + inner.allow_insecure_http = true; + inner.controller.handle_command(NativeCommandV1::SetupSave { + id, + url: origin, + allow_http: true, + }) + }) + .unwrap_or(false); + } if let Err(message) = relaunch_application() { self.with_inner(|inner| { inner.controller.runtime.post_frontend_event( @@ -533,6 +545,12 @@ impl ForeseerExtension { true } + fn standalone_origin(&self) -> Option { + let supervisor = self.standalone_supervisor.as_ref()?; + let origin = supervisor.lock().ok()?.origin.clone(); + (!origin.is_empty()).then_some(origin) + } + fn clear_browser_cache(&self, id: String, ticket: String) -> bool { let Some((agent, endpoint, runtime)) = self .with_inner(|inner| { @@ -635,22 +653,35 @@ impl ForeseerExtension { } fn open_remote_setup(&self, id: String) -> bool { - match relaunch_setup() { - Ok(()) => { + let url = crate::setup::setup_document_url(""); + let opened = self.with_inner(|inner| { + let ok = inner.runtime.enter_setup_document(&url); + if ok { + inner.controller.enter_setup(); + inner.frontend_url = url.clone(); + inner.allow_insecure_http = true; + } + ok + }); + match opened { + Some(true) => { + tracing::info!( + target: "ForeseerExtension", + "loaded setup document in the current window" + ); self.with_inner(|inner| { inner .controller .runtime .post_frontend_event(NativeEventV1::new(id, "setup-opened")); - inner.controller.runtime.request_shutdown(); }); } - Err(message) => { + Some(false) | None => { self.with_inner(|inner| { inner.controller.runtime.post_frontend_event( NativeEventV1::new(id, "error") .with_error("setup_open_failed") - .with_message(message), + .with_message("Could not open the setup page"), ); }); } @@ -658,7 +689,7 @@ impl ForeseerExtension { true } - fn start_setup_check(&self, id: String, url: String, allow_http: bool) -> bool { + fn start_setup_check(&self, id: String, url: String, _allow_http: bool) -> bool { let Some((generation, agent)) = self .with_inner(|inner| { if !inner.controller.in_setup() { @@ -667,7 +698,7 @@ impl ForeseerExtension { ); return None; } - if let Err(err) = validate_foreseer_url(&url, allow_http) { + if let Err(err) = validate_foreseer_url(&url) { inner.controller.runtime.post_frontend_event( NativeEventV1::new(id.clone(), "error") .with_error("invalid_request") @@ -689,7 +720,7 @@ impl ForeseerExtension { return true; }; std::thread::spawn(move || { - let result = match validate_foreseer_url(&url, allow_http) { + let result = match validate_foreseer_url(&url) { Ok(normalized) => match url::Url::parse(&normalized) { Ok(parsed) => { let test_url = parsed @@ -718,8 +749,8 @@ impl ForeseerExtension { true } - fn save_setup(&self, id: String, url: String, allow_http: bool) -> bool { - let normalized = match validate_foreseer_url(&url, allow_http) { + fn save_setup(&self, id: String, url: String, _allow_http: bool) -> bool { + let normalized = match validate_foreseer_url(&url) { Ok(url) => url, Err(err) => { let _ = self.with_inner(|inner| { @@ -735,21 +766,21 @@ impl ForeseerExtension { let mut config = AppConfig::load(); config.mode = AppMode::Remote; config.remote.server_url = normalized.clone(); - config.remote.allow_insecure_http = allow_http; + config.remote.allow_insecure_http = normalized.starts_with("http://"); let _ = config.save(); if let Ok(mut url_guard) = self.frontend_url.lock() { *url_guard = normalized.clone(); } if let Ok(mut allow_guard) = self.allow_insecure_http.lock() { - *allow_guard = allow_http; + *allow_guard = config.remote.allow_insecure_http; } self.with_inner(|inner| { inner.frontend_url = normalized.clone(); - inner.allow_insecure_http = allow_http; + inner.allow_insecure_http = config.remote.allow_insecure_http; inner.controller.handle_command(NativeCommandV1::SetupSave { id, url: normalized, - allow_http, + allow_http: config.remote.allow_insecure_http, }) }) .unwrap_or(false) @@ -812,28 +843,69 @@ impl ForeseerExtension { } fn relaunch_application() -> Result<(), String> { + spawn_successor() +} + +/// Jellium refuses a second instance while this process still owns the IPC +/// socket. Spawn a delayed successor so shutdown can drop that lock first. +fn spawn_successor() -> Result<(), String> { let executable = std::env::current_exe() .map_err(|error| format!("Could not locate Foreseer executable: {error}"))?; - Command::new(executable) - .env_remove("FORESEER_SETUP_RELAUNCHED") + tracing::info!( + target: "ForeseerExtension", + "scheduling successor after this instance exits" + ); + spawn_delayed_successor(&executable) +} + +#[cfg(unix)] +fn unix_shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(unix)] +fn spawn_delayed_successor(executable: &std::path::Path) -> Result<(), String> { + use std::os::unix::process::CommandExt; + let script = format!( + "sleep 2; exec {}", + unix_shell_quote(&executable.to_string_lossy()) + ); + Command::new("/bin/sh") + .arg("-c") + .arg(script) .env_remove("FORESEER_URL") .env_remove("FORESEER_ALLOW_INSECURE_HTTP") + .env_remove("FORESEER_SETUP_RELAUNCHED") + .stdin(Stdio::null()) + .process_group(0) .spawn() .map(|_| ()) .map_err(|error| format!("Could not restart Foreseer: {error}")) } -fn relaunch_setup() -> Result<(), String> { - let executable = std::env::current_exe() - .map_err(|error| format!("Could not locate Foreseer executable: {error}"))?; - Command::new(executable) - .arg("--setup") - .env_remove("FORESEER_SETUP_RELAUNCHED") +#[cfg(windows)] +fn spawn_delayed_successor(executable: &std::path::Path) -> Result<(), String> { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let quoted_exe = format!("\"{}\"", executable.display()); + let cmdline = format!("ping -n 3 127.0.0.1 >nul & {quoted_exe}"); + Command::new("cmd") + .args(["/C", &cmdline]) .env_remove("FORESEER_URL") .env_remove("FORESEER_ALLOW_INSECURE_HTTP") + .env_remove("FORESEER_SETUP_RELAUNCHED") + .stdin(Stdio::null()) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) .spawn() .map(|_| ()) - .map_err(|error| format!("Could not open Foreseer setup: {error}")) + .map_err(|error| format!("Could not restart Foreseer: {error}")) +} + +#[cfg(not(any(unix, windows)))] +fn spawn_delayed_successor(_executable: &std::path::Path) -> Result<(), String> { + Err("Restarting Foreseer is unsupported on this platform".into()) } fn open_directory(directory: &std::path::Path) -> Result<(), String> { @@ -852,3 +924,17 @@ fn open_directory(directory: &std::path::Path) -> Result<(), String> { .map(|_| ()) .map_err(|error| format!("Could not open logs: {error}")) } + +#[cfg(all(test, unix))] +mod tests { + use super::unix_shell_quote; + + #[test] + fn unix_shell_quote_escapes_spaces_and_quotes() { + assert_eq!( + unix_shell_quote("/opt/foreseer desktop"), + "'/opt/foreseer desktop'" + ); + assert_eq!(unix_shell_quote("a'b"), "'a'\\''b'"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 43b2f96..dab1099 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod controller; pub mod extension; pub mod protocol; pub mod session; +pub mod setup; pub mod supervisor; pub use config::{ diff --git a/src/main.rs b/src/main.rs index dc91ad0..22ba92b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -use base64::Engine; use directories::ProjectDirs; use foreseer_desktop::config::{AppConfig, AppMode, MIN_CACHE_LIMIT_BYTES, validate_foreseer_url}; use foreseer_desktop::extension::ForeseerExtension; +use foreseer_desktop::setup::setup_document_url; use foreseer_desktop::supervisor::StandaloneSupervisor; use jfn_rust::{HostExtensionDescriptor, HostOptions}; use std::ffi::OsStr; @@ -9,8 +9,6 @@ use std::path::PathBuf; use std::process::Command; use std::sync::{Arc, Mutex}; -mod setup; - const SETUP_RELAUNCH_ENV: &str = "FORESEER_SETUP_RELAUNCHED"; fn main() { @@ -33,9 +31,7 @@ fn main() { let mut standalone: Option>> = None; let (descriptor, frontend_url, allow_insecure_http) = if needs_setup { - let setup_html = setup::get_setup_html(""); - let base64_html = base64::engine::general_purpose::STANDARD.encode(setup_html); - let url = format!("data:text/html;base64,{base64_html}"); + let url = setup_document_url(""); let descriptor = HostExtensionDescriptor::from_setup_document( &url, vec![frontend_script], @@ -48,11 +44,7 @@ fn main() { let child = match StandaloneSupervisor::start(&config) { Ok(child) => child, Err(error) => { - let setup_html = setup::get_setup_html(&error.to_string()); - let url = format!( - "data:text/html;base64,{}", - base64::engine::general_purpose::STANDARD.encode(setup_html) - ); + let url = setup_document_url(&error.to_string()); let descriptor = HostExtensionDescriptor::from_setup_document( &url, vec![frontend_script], @@ -78,7 +70,7 @@ fn main() { (descriptor, url, true) } else { let url = - validate_foreseer_url(&config.remote.server_url, config.remote.allow_insecure_http) + validate_foreseer_url(&config.remote.server_url) .expect("validated configured Foreseer URL"); let descriptor = HostExtensionDescriptor::from_url( &url, @@ -155,7 +147,6 @@ fn handle_cli_args() -> bool { println!(" --set-url Compatibility alias for --remote"); println!(" --standalone Switch to bundled standalone mode"); println!(" --cache-limit Set standalone transient cache budget in bytes"); - println!(" --allow-http Allow insecure HTTP when saving server URL"); println!(" --show-config Display current config file path and settings"); println!(" --help, -h Show this help message"); std::process::exit(0); @@ -193,8 +184,7 @@ fn handle_cli_args() -> bool { std::process::exit(1); } let url = args[2].clone(); - let allow_http = args.iter().any(|arg| arg == "--allow-http"); - let url = match validate_foreseer_url(&url, allow_http) { + let url = match validate_foreseer_url(&url) { Ok(url) => url, Err(error) => { eprintln!("Error: {}", error.message()); @@ -204,8 +194,8 @@ fn handle_cli_args() -> bool { let mut config = AppConfig::load(); config.mode = AppMode::Remote; - config.remote.server_url = url; - config.remote.allow_insecure_http = allow_http; + config.remote.server_url = url.clone(); + config.remote.allow_insecure_http = url.starts_with("http://"); if let Err(e) = config.save() { eprintln!("Error saving config: {}", e); std::process::exit(1); diff --git a/src/session.rs b/src/session.rs index 7a0216b..c6b2d3e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -13,6 +13,7 @@ pub struct SessionBootstrap { pub device_id: String, pub access_token: String, pub bootstrap_generation: String, + pub fallback_server_url: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,6 +36,14 @@ impl SessionBootstrap { pub fn validate_shape(&self) -> Result<(), &'static str> { validate_bootstrap_server_url(&self.server_url) .map_err(|_| "invalid_bootstrap_response")?; + if let Some(fallback) = &self.fallback_server_url { + validate_bootstrap_server_url(fallback) + .map_err(|_| "invalid_bootstrap_response")?; + } + self.validate_ids() + } + + fn validate_ids(&self) -> Result<(), &'static str> { for (value, name) in [ (&self.server_id, "server_id"), (&self.user_id, "user_id"), @@ -53,7 +62,7 @@ impl SessionBootstrap { } pub fn expected(&self) -> Result { - self.validate_shape()?; + self.validate_ids()?; let origin = url::Url::parse(&self.server_url) .map(|u| u.origin().ascii_serialization()) .map_err(|_| "invalid_bootstrap_response")?; @@ -107,6 +116,7 @@ mod tests { device_id: "dev".into(), access_token: "tok".into(), bootstrap_generation: "gen-1".into(), + fallback_server_url: None, } } @@ -129,10 +139,10 @@ mod tests { } #[test] - fn rejects_public_http_bootstrap_and_redacts() { - let mut bad = sample(); - bad.server_url = "http://jellyfin.example/".into(); - assert!(bad.validate_shape().is_err()); + fn accepts_http_bootstrap_from_url_scheme() { + let mut http = sample(); + http.server_url = "http://jellyfin.example/".into(); + assert!(http.validate_shape().is_ok()); assert_eq!(redact_secrets(r#"{"accessToken":"secret"}"#), "[redacted]"); } diff --git a/src/setup.html b/src/setup.html index 240eb7a..91f735d 100644 --- a/src/setup.html +++ b/src/setup.html @@ -3,224 +3,290 @@ - Foreseer Desktop - Server Setup + Foreseer — Connect to a server - -
- - +
+ +

Foreseer

+

Connect this app to an existing Foreseerr, or keep using the local server on this computer.

+
+ +
{{RECOVERY_MESSAGE}} -
- - -
- -
- -
- -
- -
- - - - - -
-
+
+ + +

Use https:// or http://. Add a port if it is not 443 or 80.

+ +
+ +
+ + +
+
+ +
or
+ + + + + diff --git a/src/setup.rs b/src/setup.rs index fc9d992..4f5b3c4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,11 +1,13 @@ +use base64::Engine; + const SETUP_HTML_TEMPLATE: &str = include_str!("setup.html"); const SETUP_EVENT_JS: &str = include_str!("setup-event.js"); pub fn get_setup_html(recovery_message: &str) -> String { let standalone_label = if recovery_message.is_empty() { - "Use Standalone" + "Use local Foreseer" } else { - "Retry Standalone" + "Retry local Foreseer" }; let recovery = if recovery_message.is_empty() { String::new() @@ -22,6 +24,14 @@ pub fn get_setup_html(recovery_message: &str) -> String { .replace("{{SETUP_EVENT_JS}}", SETUP_EVENT_JS) } +pub fn setup_document_url(recovery_message: &str) -> String { + let html = get_setup_html(recovery_message); + format!( + "data:text/html;base64,{}", + base64::engine::general_purpose::STANDARD.encode(html.as_bytes()) + ) +} + fn escape_html(value: &str) -> String { value .replace('&', "&") @@ -33,7 +43,14 @@ fn escape_html(value: &str) -> String { #[cfg(test)] mod tests { - use super::get_setup_html; + use super::{get_setup_html, setup_document_url}; + + #[test] + fn setup_document_url_is_a_bounded_data_document() { + let url = setup_document_url(""); + assert!(url.starts_with("data:text/html;base64,")); + assert!(url.len() <= 256 * 1024); + } #[test] fn setup_page_embeds_the_typed_protocol_listener() { @@ -43,7 +60,8 @@ mod tests { assert!(html.contains("detail.message")); assert!(html.contains("foreseerNative")); assert!(html.contains("setup.standalone")); - assert!(html.contains("Use Standalone")); + assert!(html.contains("Use local Foreseer")); + assert!(html.contains("Connect")); assert!(html.contains("Quit")); assert!(!html.contains("{{SETUP_EVENT_JS}}")); assert!(!html.contains("{{RECOVERY_MESSAGE}}")); @@ -57,6 +75,6 @@ mod tests { assert!(html.contains("Unable to start <script>alert('xss')</script>")); assert!(!html.contains("")); - assert!(html.contains("Retry Standalone")); + assert!(html.contains("Retry local Foreseer")); } }