From 3274a5518560501bc15a955a4e67bda5500487c0 Mon Sep 17 00:00:00 2001 From: Jim Martin Date: Sat, 5 Sep 2026 23:12:21 -0500 Subject: [PATCH] feat: add guided computer setup and safe configuration editing --- README.md | 2 +- docs/ARCHITECTURE.md | 27 +++++ docs/UI.md | 34 ++++-- remote_desktops/host.py | 10 +- remote_desktops/worker.py | 10 +- src/launcher.rs | 4 +- src/main.rs | 15 ++- src/settings.rs | 216 +++++++++++++++++++++++++++++++++++ tests/integration.py | 120 ++++++++++++++++++++ ui/CMakeLists.txt | 11 +- ui/Manager.cpp | 65 ++++++++++- ui/Manager.h | 8 +- ui/main.cpp | 14 ++- ui/qml/Main.qml | 11 +- ui/qml/SetupDialog.qml | 231 ++++++++++++++++++++++++++++++++++++++ ui/tests/manager.cpp | 77 +++++++++++++ 16 files changed, 832 insertions(+), 23 deletions(-) create mode 100644 src/settings.rs create mode 100644 ui/qml/SetupDialog.qml diff --git a/README.md b/README.md index 70ed09b..825d227 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Hypertile Scene like any other application. The standalone backend is implemented on `develop`: a Rust daemon and CLI with Moonlight process supervision, journaled host recovery, and per-computer desktop launcher entries. The Qt graphical manager now provides connection controls for -configured computers. Installable packaging is not implemented yet. +adding, editing, and connecting computers paired in Moonlight. Installable packaging is not implemented yet. `main` remains the locked project bootstrap; there is no published app release. The host adapters come from the remote-stream work in diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 30723f8..60f7616 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -117,3 +117,30 @@ a 60-second acknowledgement bound, with uncertainty reported if it expires; only the CLI process is stopped, not the independent daemon or its host work. No synchronous process/socket wait occurs on the GUI thread. These are resource bounds, not measured latency or CPU claims. + +### Settings ownership + +The Rust `settings catalog|get|test|save` CLI owns guided configuration editing. +Draft JSON arrives on bounded stdin, not shell arguments. The Python boundary +reads Moonlight's paired-host metadata, validates the full candidate config, +and performs the existing authenticated app-list probe with an external display +adapter. No setup operation prepares a host, writes recovery, starts the daemon, +or launches a streaming client. Certificates and SSH details are not sent to Qt. + +A save merges only explicitly editable fields into the latest configuration. +Existing computer IDs, pairing identities and window titles cannot be edited; +other profiles and advanced display/SSH fields survive unchanged. Removed +computers with saved sessions cannot be re-added through the wizard under a +second recovery identity. Existing sessions retain their immutable config and +profile snapshots, including during reconnect and recovery. Atomic config +replacement lets a concurrent new connection read a complete old or new config; +there is no new lock on the session worker or video path. + +An opaque content fingerprint detects stale drafts. Cooperative saves use a +nonblocking file lock, recheck the revision under that lock, then fsync and +atomically replace a private configuration file. Manual editors should honor +`computers.lock`; uncooperative writes racing the final rename cannot be made +transactional by an advisory lock. The fingerprint is a conflict detector, not +an authentication token. Testing is required by the UI before saving; the CLI +also permits validated offline saves for scripting. Test results prove only the +pairing/network/app-list check, not actual streaming performance. diff --git a/docs/UI.md b/docs/UI.md index 3cb14ec..a59a8f7 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -1,6 +1,6 @@ # Desktop manager -The Qt 6/QML manager is a separate application for configured computers. It +The Qt 6/QML manager is a separate application for setting up and connecting computers. It shows connection state, selects an existing profile, connects, focuses, disconnects, reconnects, restores a pending host display, and installs a per-computer application launcher. It has no video renderer and owns no host @@ -66,15 +66,33 @@ These preview and screenshot options require `--demo`. ## Current scope -This first manager manages existing configuration. Pairing still happens in -Moonlight; adding/editing computer and profile configuration follows the -[backend setup guide](BACKEND.md). The in-app help explains that flow. A guided -pairing/configuration editor is subsequent work; this UI does not offer a -placeholder form or write unvalidated settings. +Choose **Add computer** to select a computer already paired in Moonlight, +confirm its name and address, choose desktop quality, then test and save. The +check authenticates through Moonlight and verifies the Desktop app is listed. +It does not start video, capture input, or change a host display. Pairing and +certificate storage stay in Moonlight. If no paired computers appear, complete +pairing there and refresh the setup list. + +New profiles use the host’s existing display and automatic decoder selection. +Advanced controls expose resolution, frame rate, bitrate, codec, mouse mode, +and audio policy. **Edit** changes an existing computer and its default profile; +other profiles, SSH configuration, host display adapters, and window identity +are preserved. Changes apply after disconnecting and starting a new connection; +Reconnect continues using the active session’s snapshot. + +The save button requires a successful check of the current draft. Editing any +field invalidates that check. Validation or reachability failures leave the +saved configuration untouched. A revision conflict asks you to reopen setup, +so another editor’s changes are not silently overwritten. Cancel discards the +draft. Saving and optional launcher installation are separate operations: a +launcher failure leaves the computer saved and can be retried from the main +screen. Existing launcher entries can be updated from the final setup step. + +Use `--demo --setup-preview computer` (or `preferences`, `advanced`, `check`) +to preview each setup page with synthetic data. Demo settings stay in memory. The manager does not install itself, register a system service, migrate legacy -configuration, or change Hypertile. Packaging and a graphical setup wizard are -separate delivery steps. +configuration, or change Hypertile. Packaging remains a separate delivery step. ## Omarchy themes diff --git a/remote_desktops/host.py b/remote_desktops/host.py index 4212890..fa99739 100644 --- a/remote_desktops/host.py +++ b/remote_desktops/host.py @@ -38,7 +38,10 @@ def resolution(value): def configuration(path): - value = load(path, {"version": 1, "computers": {}}) + return configuration_value(load(path, {"version": 1, "computers": {}})) + + +def configuration_value(value): require(value.get("version") == 1 and isinstance(value.get("computers"), dict), "unsupported computers.json schema") identities = set() titles = set() @@ -53,6 +56,9 @@ def configuration(path): require(not any(ord(c) < 32 for c in computer["title"]), "title must not contain control characters") require(computer["title"] not in titles, "computer window titles must be unique for launcher matching") titles.add(computer["title"]) + if "name" in computer: + require(isinstance(computer["name"], str) and 1 <= len(computer["name"]) <= 100 + and not any(ord(c) < 32 or ord(c) == 127 for c in computer["name"]), "name must be 1–100 characters without control characters") require(isinstance(computer.get("profiles"), dict) and computer["profiles"], "computer needs profiles") require(computer.get("platform", "unknown") in ("macos", "windows", "linux", "unknown"), "invalid host platform") require("default_profile" not in computer or computer["default_profile"] in computer["profiles"], "unknown default profile") @@ -109,7 +115,7 @@ def moonlight_hosts(): # Certificate material never leaves Moonlight's own configuration. result[value.lower()] = {"name": hosts.get(prefix + "hostname"), "paired": bool(hosts.get(prefix + "srvcert")), - "address": hosts.get(prefix + "manualaddress")} + "address": hosts.get(prefix + "manualaddress") or hosts.get(prefix + "localaddress") or hosts.get(prefix + "remoteaddress")} return result diff --git a/remote_desktops/worker.py b/remote_desktops/worker.py index 8ba296c..9d09983 100644 --- a/remote_desktops/worker.py +++ b/remote_desktops/worker.py @@ -8,7 +8,7 @@ import os from pathlib import Path import sys -from .host import Host, configuration, prepare, restore, require, stream_argv +from .host import Host, configuration, configuration_value, moonlight_hosts, UUID, prepare, restore, require, stream_argv from .mac_display import same_setting, manages_mode from .storage import atomic_json, read_json @@ -43,6 +43,14 @@ def health(record, host): def operation(request): if request["operation"] == "validate": return configuration(Path(request["config"])) + if request["operation"] == "validate-value": + return configuration_value(request["value"]) + if request["operation"] == "paired": + return [{"pairing_uuid": key, "name": h["name"], "host": h["address"] or ""} + for key, h in moonlight_hosts().items() if UUID.fullmatch(key) and h["paired"] and h["name"]] + if request["operation"] == "setup-probe": + Host(request["computer"], {"display": {"adapter": "external"}}).probe() + return {"authenticated": True} path = Path(request["path"]) with path.with_suffix(".lock").open("a") as lock: fcntl.flock(lock, fcntl.LOCK_EX) diff --git a/src/launcher.rs b/src/launcher.rs index 589ecd6..0858d3b 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -59,7 +59,9 @@ fn exec_arg(s: &str) -> Result { } fn entry(binary: &Path, name: &str, config: &Value) -> Result { let title = config["title"].as_str().context("missing window title")?; - let label = title.strip_suffix(" - Moonlight").unwrap_or(title); + let label = config["name"] + .as_str() + .unwrap_or_else(|| title.strip_suffix(" - Moonlight").unwrap_or(title)); let executable = exec_arg(binary.to_str().context("executable path is not UTF-8")?)?; let computer = exec_arg(name)?; Ok(format!( diff --git a/src/main.rs b/src/main.rs index 5c0c7ba..49b8eb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod desktop; mod host; mod launcher; mod server; +mod settings; mod storage; mod supervisor; use anyhow::{Result, bail}; @@ -61,6 +62,11 @@ enum Action { computer: Option, }, Computers, + /// Guided computer setup and editing. + Settings { + #[command(subcommand)] + action: settings::Action, + }, #[command(hide = true)] Supervise { #[arg(long)] @@ -122,6 +128,13 @@ async fn run(cli: Cli) -> Result<()> { ); return Ok(()); } + if let Action::Settings { action } = &cli.command { + println!( + "{}", + serde_json::to_string_pretty(&settings::run(&paths, action).await?)? + ); + return Ok(()); + } if matches!(cli.command, Action::Computers) { let value = host::call(json!({"operation":"validate","config":paths.config})).await?; let entries = value @@ -130,7 +143,7 @@ async fn run(cli: Cli) -> Result<()> { .iter() .map(|(name, c)| { let title = c["title"].as_str().unwrap_or(name); - json!({"computer":name, "name":title.strip_suffix(" - Moonlight").unwrap_or(title), + json!({"computer":name, "name":c["name"].as_str().unwrap_or_else(|| title.strip_suffix(" - Moonlight").unwrap_or(title)), "host":c["host"], "platform":c["platform"], "default_profile":c["default_profile"], "profiles":c["profiles"].as_object().unwrap().keys().collect::>()}) }) diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..59721cd --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,216 @@ +//! Configuration editing is independent of session intent. Active sessions retain +//! their immutable config snapshot; edits apply to the next fresh connection. +use crate::{ + host, + storage::{self, Paths}, +}; +use anyhow::{Context, Result, bail}; +use clap::Subcommand; +use serde_json::{Value, json}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::{self, Read}, +}; + +#[derive(Subcommand)] +pub enum Action { + /// Read paired Moonlight computers and an opaque configuration revision. + Catalog, + /// Read editable settings without SSH or display-management details. + Get { computer: String }, + /// Check a JSON draft from stdin without starting a stream or changing a display. + Test, + /// Atomically save a JSON draft from stdin; existing sessions keep their snapshot. + Save, +} +fn load(paths: &Paths) -> Result { + match storage::read(&paths.config) { + Ok(v) => Ok(v), + Err(e) if !paths.config.exists() => { + // Do not replace a dangling symlink or an inaccessible existing file. + if paths.config.symlink_metadata().is_ok() { + return Err(e); + } + Ok(json!({"version":1,"computers":{}})) + } + Err(e) => Err(e), + } +} +fn revision(value: &Value) -> String { + let mut hash = DefaultHasher::new(); + value.to_string().hash(&mut hash); + format!("{:016x}", hash.finish()) +} +fn editable(id: &str, c: &Value, rev: &str) -> Value { + let profile = c["default_profile"].as_str().unwrap_or_else(|| { + if c["profiles"].get("desktop").is_some() { + "desktop" + } else { + c["profiles"].as_object().unwrap().keys().next().unwrap() + } + }); + let p = &c["profiles"][profile]; + json!({"computer":id,"revision":rev,"name":c["name"].as_str().unwrap_or_else(|| c["title"].as_str().unwrap_or(id).trim_end_matches(" - Moonlight")), + "host":c["host"],"platform":c["platform"].as_str().unwrap_or("unknown"), + "profile":profile,"profiles":c["profiles"],"stream_resolution":p["stream_resolution"], + "fps":p.get("fps").unwrap_or(&json!(60)),"bitrate":p.get("bitrate").unwrap_or(&json!(60000)), + "codec":p.get("codec").unwrap_or(&json!("HEVC")),"input":p.get("input").unwrap_or(&json!("absolute")), + "audio":p.get("audio").unwrap_or(&json!("focus"))}) +} +// Only the common stream settings cross the UI boundary. Display adapters and +// SSH details are deliberately omitted and preserved by the merge below. +fn redact_profiles(draft: &mut Value) { + if let Some(profiles) = draft["profiles"].as_object_mut() { + for p in profiles.values_mut() { + p.as_object_mut().unwrap().retain(|k, _| { + [ + "stream_resolution", + "fps", + "bitrate", + "codec", + "input", + "audio", + ] + .contains(&k.as_str()) + }); + } + } +} +async fn candidate(paths: &Paths, mut value: Value, draft: &Value) -> Result { + if draft["revision"].as_str() != Some(&revision(&value)) { + bail!("Settings changed elsewhere. Close setup and reopen it before saving."); + } + let id = draft["computer"].as_str().context("missing computer ID")?; + if !storage::valid_id(id) { + bail!("invalid computer ID"); + } + let profile = draft["profile"].as_str().context("missing profile")?; + let computers = value["computers"] + .as_object_mut() + .context("invalid computers schema")?; + let mut computer = if let Some(old) = computers.get(id) { + if draft.get("pairing_uuid").is_some() { + bail!("Computer already exists; reopen it to edit."); + } + if old["profiles"].get(profile).is_none() { + bail!("unknown profile"); + } + old.clone() + } else { + let pairing = draft["pairing_uuid"] + .as_str() + .context("choose a paired computer")?; + let hosts = host::call(json!({"operation":"paired"})).await?; + let known = hosts + .as_array() + .context("invalid pairing list")? + .iter() + .find(|v| v["pairing_uuid"].as_str() == Some(pairing)) + .context("Pair this computer in Moonlight, then refresh setup.")?; + // Reusing a removed computer's identity under a new ID could create a + // second recovery owner. Require restoring its original configuration. + let entries = match std::fs::read_dir(paths.state.join("sessions")) { + Ok(entries) => Some(entries), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + if let Some(entries) = entries { + for entry in entries { + let entry = entry?; + let saved = entry.path().join("session.json"); + if saved.exists() { + let record: Value = storage::read(&saved)?; + if record["config"]["pairing_uuid"] + .as_str() + .is_some_and(|p| p.eq_ignore_ascii_case(pairing)) + { + bail!( + "This computer has a saved session. Restore its original configuration before adding it again." + ); + } + } + } + } + json!({"pairing_uuid":pairing,"title":format!("{} - Moonlight", known["name"].as_str().context("Moonlight computer needs a name")?), + "profiles":{profile:{"display":{"adapter":"external"},"decoder":"auto"}}}) + }; + for key in ["name", "host", "platform"] { + computer[key] = draft[key].clone(); + } + computer["default_profile"] = json!(profile); + for key in [ + "stream_resolution", + "fps", + "bitrate", + "codec", + "input", + "audio", + ] { + computer["profiles"][profile][key] = draft[key].clone(); + } + computers.insert(id.into(), computer); + host::call(json!({"operation":"validate-value","value":value})).await?; + Ok(value) +} +pub async fn run(paths: &Paths, action: &Action) -> Result { + let value = load(paths)?; + host::call(json!({"operation":"validate-value","value":value})).await?; + match action { + Action::Catalog => { + let mut paired = host::call(json!({"operation":"paired"})).await?; + for item in paired.as_array_mut().context("invalid pairing list")? { + item["configured"] = + json!(value["computers"].as_object().unwrap().values().any(|c| { + c["pairing_uuid"] + .as_str() + .zip(item["pairing_uuid"].as_str()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) + })); + } + Ok(json!({"revision":revision(&value),"paired":paired})) + } + Action::Get { computer } => { + let c = value["computers"] + .get(computer) + .context("unknown computer")?; + let mut result = editable(computer, c, &revision(&value)); + redact_profiles(&mut result); + Ok(result) + } + Action::Test | Action::Save => { + let mut input = String::new(); + io::stdin().take(65537).read_to_string(&mut input)?; + if input.len() > 65536 { + bail!("settings request too large"); + } + let draft: Value = serde_json::from_str(&input).context("invalid settings JSON")?; + let next = candidate(paths, value, &draft).await?; + if matches!(action, Action::Test) { + // An external adapter makes this strictly a pairing/network/app + // check, even when the saved profile manages the host display. + let c = &next["computers"][draft["computer"].as_str().unwrap()]; + host::call(json!({"operation":"setup-probe","computer":c})).await?; + Ok( + json!({"tested":true,"message":"Moonlight authenticated and found Desktop. Video and input are checked when you connect."}), + ) + } else { + storage::private_dir(paths.config.parent().context("missing config directory")?)?; + let _lock = storage::lock(&paths.config.with_extension("lock"), true) + .context("Another settings save is in progress. Try again.")?; + if revision(&load(paths)?) != draft["revision"].as_str().unwrap_or("") { + bail!("Settings changed elsewhere. Close setup and reopen it before saving."); + } + if paths + .config + .symlink_metadata() + .is_ok_and(|m| m.file_type().is_symlink()) + { + bail!("Configuration is a symbolic link. Edit its source file directly."); + } + storage::write(&paths.config, &next)?; + Ok(json!({"saved":true,"computer":draft["computer"]})) + } + } + } +} diff --git a/tests/integration.py b/tests/integration.py index 9cda02d..57072e7 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -392,5 +392,125 @@ def test_restart_during_prepare_finishes_cancelled_recovery_without_launch(self) self.assertFalse(self.cli("status", "laptop")["recovery_pending"]) + +class SettingsTests(unittest.TestCase): + """Real config validation and writes; synthetic Moonlight and no networking.""" + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.env = {**os.environ, "HOME": str(self.root), "XDG_CONFIG_HOME": str(self.root / "config"), + "XDG_RUNTIME_DIR": str(self.root / "runtime"), "XDG_STATE_HOME": str(self.root / "state"), + "REMOTE_DESKTOPS_HELPERS": str(self.root / "helpers")} + self.env.pop("PYTHONPATH", None) + package = self.root / "helpers/remote_desktops" + package.mkdir(parents=True) + (package / "__init__.py").write_text("__path__.append(" + repr(str(ROOT / "remote_desktops")) + ")\n") + (package / "worker.py").write_text( + "import contextlib\nfrom remote_desktops import host\n" + "host.socket.create_connection = lambda *a, **k: contextlib.nullcontext()\n" + "exec(compile(open(" + repr(str(ROOT / "remote_desktops/worker.py")) + ").read(), 'worker.py', 'exec'))\n") + bindir = self.root / "bin" + bindir.mkdir() + self.env["PATH"] = str(bindir) + os.pathsep + self.env["PATH"] + moonlight = bindir / "moonlight" + moonlight.write_text("#!/bin/sh\ncase \"$1\" in --version) echo 'Moonlight v6.1.0';; list) echo \"${FAKE_APP-Desktop}\";; *) exit 99;; esac\n") + moonlight.chmod(0o700) + self.uuid = "11111111-2222-3333-4444-555555555555" + paired = self.root / "config/Moonlight Game Streaming Project/Moonlight.conf" + paired.parent.mkdir(parents=True) + paired.write_text("[hosts]\n1\\uuid=" + self.uuid + "\n1\\hostname=Home PC\n1\\srvcert=synthetic-test-only\n1\\localaddress=home.example.net\n") + self.config = self.root / "config/remote-desktops/computers.json" + + def cli(self, *args, draft=None, ok=True): + p = subprocess.run([str(BIN), "--json", "settings", *args], input=json.dumps(draft) if draft else None, + env=self.env, capture_output=True, text=True, timeout=10) + self.assertEqual(p.returncode == 0, ok, p.stderr) + return json.loads(p.stdout) if ok else p.stderr + + def draft(self): + catalog = self.cli("catalog") + self.assertNotIn("synthetic-test-only", json.dumps(catalog)) + self.assertEqual(catalog["paired"][0]["host"], "home.example.net") + return {"computer": "home", "pairing_uuid": self.uuid, "revision": catalog["revision"], + "name": "Home", "host": "home.example.net", "platform": "linux", "profile": "desktop", + "stream_resolution": "1920x1080", "fps": 60, "bitrate": 30000, + "codec": "auto", "input": "absolute", "audio": "focus"} + + def test_setup_checks_and_saves_without_starting_daemon_or_stream(self): + draft = self.draft() + self.assertTrue(self.cli("test", draft=draft)["tested"]) + self.assertFalse(self.config.exists()) + self.cli("save", draft=draft) + value = json.loads(self.config.read_text())["computers"]["home"] + self.assertEqual(value["title"], "Home PC - Moonlight") + self.assertEqual(value["profiles"]["desktop"]["display"], {"adapter": "external"}) + self.assertEqual(self.config.stat().st_mode & 0o777, 0o600) + self.assertFalse((self.root / "runtime").exists()) + self.assertFalse((self.root / "state").exists()) + self.assertTrue(self.cli("catalog")["paired"][0]["configured"]) + + def test_edits_preserve_other_profiles_display_ssh_and_window_identity(self): + self.cli("save", draft=self.draft()) + value = json.loads(self.config.read_text()) + c = value["computers"]["home"] + c["platform"] = "macos" + c["ssh"] = {"user": "synthetic"} + c["profiles"]["desktop"]["display"] = {"adapter": "macos"} + c["profiles"]["desktop"].update(hdr=True, system_keys="always") + c["profiles"]["presentation"] = {"stream_resolution": "3840x2160"} + value["extra"] = "preserve" + self.config.write_text(json.dumps(value)) + session = self.root / "state/remote-desktops/sessions/home/session.json" + session.parent.mkdir(parents=True) + session.write_text(json.dumps({"desired": True, "phase": "window-ready", "config": c})) + snapshot = session.read_bytes() + draft = self.cli("get", "home") + self.assertNotIn("ssh", draft) + self.assertNotIn("display", draft["profiles"]["desktop"]) + draft.update(name="Renamed", stream_resolution="2560x1440", title="ignored", ssh={"user":"ignored"}) + self.cli("save", draft=draft) + after = json.loads(self.config.read_text()) + expected = value + expected["computers"]["home"]["name"] = "Renamed" + expected["computers"]["home"]["profiles"]["desktop"]["stream_resolution"] = "2560x1440" + self.assertEqual(after, expected) + self.assertEqual(session.read_bytes(), snapshot) + + def test_stale_invalid_duplicate_and_failed_probe_do_not_write(self): + draft = self.draft() + self.env["FAKE_APP"] = "Unavailable" + self.assertIn("pairing-or-app-required", self.cli("test", draft=draft, ok=False)) + self.assertFalse(self.config.exists()) + self.cli("save", draft=draft) + original = self.config.read_bytes() + self.assertIn("changed elsewhere", self.cli("save", draft=draft, ok=False)) + duplicate = self.draft() + duplicate["computer"] = "duplicate" + self.assertIn("same pairing", self.cli("save", draft=duplicate, ok=False)) + bad = self.cli("get", "home") + bad["fps"] = 0 + self.assertIn("invalid FPS", self.cli("save", draft=bad, ok=False)) + self.assertEqual(self.config.read_bytes(), original) + + def test_locked_save_leaves_configuration_unchanged(self): + self.cli("save", draft=self.draft()) + before = self.config.read_bytes() + draft = self.cli("get", "home") + draft["name"] = "Changed" + with self.config.with_suffix(".lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + self.assertIn("save is in progress", self.cli("save", draft=draft, ok=False)) + self.assertEqual(self.config.read_bytes(), before) + + def test_removed_computer_session_cannot_gain_a_second_owner(self): + draft = self.draft() + directory = self.root / "state/remote-desktops/sessions/original" + directory.mkdir(parents=True) + (directory / "session.json").write_text(json.dumps({"config":{"pairing_uuid":self.uuid}})) + self.assertIn("saved session", self.cli("save", draft=draft, ok=False)) + self.assertFalse(self.config.exists()) + + if __name__ == "__main__": unittest.main() diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index 1837b84..5958bb6 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -5,13 +5,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Quick QuickControls2 Network Test) qt_add_executable(remote-desktops-manager main.cpp Manager.cpp Manager.h Theme.cpp Theme.h) -qt_add_resources(remote-desktops-manager qml PREFIX "/" FILES qml/Main.qml qml/ActionButton.qml qml/ComputerGlyph.qml) +qt_add_resources(remote-desktops-manager qml PREFIX "/" FILES qml/Main.qml qml/ActionButton.qml qml/ComputerGlyph.qml qml/SetupDialog.qml) target_link_libraries(remote-desktops-manager PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Network) target_compile_options(remote-desktops-manager PRIVATE -Wall -Wextra -Wpedantic) enable_testing() qt_add_executable(manager-tests tests/manager.cpp Manager.cpp Manager.h Theme.cpp Theme.h) target_link_libraries(manager-tests PRIVATE Qt6::Test Qt6::Network Qt6::Quick Qt6::QuickControls2) -qt_add_resources(manager-tests qmlTests PREFIX "/" FILES qml/Main.qml qml/ActionButton.qml qml/ComputerGlyph.qml) +qt_add_resources(manager-tests qmlTests PREFIX "/" FILES qml/Main.qml qml/ActionButton.qml qml/ComputerGlyph.qml qml/SetupDialog.qml) add_test(NAME manager COMMAND manager-tests) set_tests_properties(manager PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") add_test(NAME qml-smoke COMMAND remote-desktops-manager --demo --smoke-test) @@ -25,3 +25,10 @@ add_test(NAME qml-compact COMMAND remote-desktops-manager --demo --compact --smo set_tests_properties(qml-compact PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") add_test(NAME qml-light COMMAND remote-desktops-manager --demo --state restore-pending --theme-file ${CMAKE_CURRENT_SOURCE_DIR}/tests/light.toml --smoke-test) set_tests_properties(qml-light PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +add_test(NAME qml-setup COMMAND remote-desktops-manager --demo --setup-preview computer --compact --smoke-test) +set_tests_properties(qml-setup PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") + +foreach(page preferences advanced check) + add_test(NAME qml-setup-${page} COMMAND remote-desktops-manager --demo --setup-preview ${page} --compact --smoke-test) + set_tests_properties(qml-setup-${page} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +endforeach() diff --git a/ui/Manager.cpp b/ui/Manager.cpp index a8dbb7b..eeab22d 100644 --- a/ui/Manager.cpp +++ b/ui/Manager.cpp @@ -57,7 +57,7 @@ void Manager::refresh() { if (m_demo) { publish(); return; } loadCatalog(); poll(); } -void Manager::process(QStringList arguments, std::function complete) { +void Manager::process(QStringList arguments, std::function complete, QByteArray input) { auto *job = new QProcess(this); auto *deadline = new QTimer(job); deadline->setSingleShot(true); @@ -84,6 +84,7 @@ void Manager::process(QStringList arguments, std::functionkill(); finish(false, "The request timed out. Its outcome may still be pending; refresh before retrying."); }); + connect(job, &QProcess::started, this, [job, input] { job->write(input); job->closeWriteChannel(); }); job->start(m_backend, arguments); deadline->start(60000); } @@ -185,3 +186,65 @@ void Manager::demoState(QString phase) { s["error"] = phase == "restore-pending" ? "The host is unreachable. Its original display settings are saved; restore when it is reachable again." : ""; m_sessions[0] = s; publish(); } + +void Manager::setup(QString action, QVariantMap draft) { + if (m_setupBusy || !QSet{"catalog", "get", "test", "save"}.contains(action)) return; + m_setupBusy = true; publish(); + auto complete = [this, action, draft](bool ok, QByteArray data) { + m_setupBusy = false; + QJsonParseError error; + auto document = QJsonDocument::fromJson(data, &error); + const bool valid = ok && error.error == QJsonParseError::NoError && document.isObject(); + if (valid && action == "save" && !m_demo) { + auto entry = QJsonObject::fromVariantMap(draft); + entry["default_profile"] = entry["profile"]; + entry["profiles"] = QJsonArray{entry["profile"]}; + bool found = false; + for (qsizetype i = 0; i < m_catalog.size(); ++i) if (m_catalog[i].toObject()["computer"] == entry["computer"]) { + auto old = m_catalog[i].toObject(); + for (const auto &key : {"name", "host", "platform", "default_profile"}) old[key] = entry[key]; + m_catalog[i] = old; found = true; + } + if (!found) m_catalog.append(entry); + m_notice = "Computer saved. Changes apply to the next new connection."; + } + emit setupFinished(action, valid, valid ? document.object().toVariantMap() : QVariantMap{}, + valid ? QString{} : ok ? "The backend returned invalid settings." : QString::fromUtf8(data).trimmed()); + if (valid && action == "save") refresh(); + publish(); + }; + if (m_demo) { + QTimer::singleShot(200, this, [this, action, draft, complete] { + QJsonObject result; + if (action == "catalog") { + result = QJsonObject{{"revision", "preview"}, {"paired", QJsonArray{QJsonObject{{"pairing_uuid", "11111111-2222-3333-4444-555555555555"}, {"name", "Home workstation"}, {"host", "home.example.net"}, {"configured", m_demoDrafts.contains("home-workstation-11111111")}}}}}; + } else if (action == "get") { + auto id = draft["computer"].toString(); + if (m_demoDrafts.contains(id)) result = QJsonObject::fromVariantMap(m_demoDrafts[id]); + else for (const auto &entry : m_catalog) if (entry.toObject()["computer"].toString() == id) { + result = entry.toObject(); result["profile"] = "desktop"; result["revision"] = "preview"; + result["stream_resolution"] = "1920x1080"; result["fps"] = 60; result["bitrate"] = 30000; + result["codec"] = "auto"; result["input"] = "absolute"; result["audio"] = "focus"; + result["profiles"] = QJsonObject{{"desktop", QJsonObject{{"stream_resolution", "1920x1080"}, {"fps", 60}, {"bitrate", 30000}}}}; + } + } else if (action == "test") result["tested"] = true; + else { + auto saved = draft; saved.remove("pairing_uuid"); + saved["profiles"] = QVariantMap{{draft["profile"].toString(), draft}}; + m_demoDrafts[draft["computer"].toString()] = saved; + auto entry = QJsonObject::fromVariantMap(draft); + entry["default_profile"] = entry["profile"]; entry["profiles"] = QJsonArray{entry["profile"]}; + bool found = false; + for (qsizetype i=0; i complete); + void process(QStringList arguments, std::function complete, QByteArray input = {}); QString m_backend, m_socketPath, m_error, m_notice; bool m_demo, m_loading = true, m_available = false, m_active = true, m_catalogLoading = false; QJsonArray m_catalog, m_sessions; QSet m_busy; + bool m_setupBusy = false; + QMap m_demoDrafts; QTimer m_poll; QLocalSocket *m_socket = nullptr; }; diff --git a/ui/main.cpp b/ui/main.cpp index 488b755..5e98081 100644 --- a/ui/main.cpp +++ b/ui/main.cpp @@ -23,11 +23,12 @@ int main(int argc, char **argv) { parser.addOption({"backend", "Absolute path to the Rust CLI.", "path"}); parser.addOption({"smoke-test", "Render the demo and exit with failure on QML warnings."}); parser.addOption({"screenshot", "Save an isolated demo rendering and exit.", "path"}); + parser.addOption({"setup-preview", "Show guided setup (computer, preferences, advanced, check).", "page"}); parser.addOption({"compact", "Render the preview at the minimum supported size."}); parser.addOption({"state", "Initial demo state (restore-pending, preflight, idle, empty, unavailable).", "phase"}); parser.process(app); const bool demo = parser.isSet("demo"); - if ((parser.isSet("smoke-test") || parser.isSet("screenshot") || parser.isSet("state") || parser.isSet("compact")) && !demo) return 2; + if ((parser.isSet("smoke-test") || parser.isSet("screenshot") || parser.isSet("state") || parser.isSet("compact") || parser.isSet("setup-preview")) && !demo) return 2; QString backend = parser.value("backend"); if (backend.isEmpty()) { backend = QCoreApplication::applicationDirPath() + "/remote-desktops"; @@ -50,6 +51,17 @@ int main(int argc, char **argv) { engine.rootContext()->setContextProperty("theme", &theme); engine.load(QUrl("qrc:/qml/Main.qml")); if (engine.rootObjects().isEmpty()) return 1; + if (parser.isSet("setup-preview")) { + auto *setup = engine.rootObjects().first()->findChild("setupDialog"); + QMetaObject::invokeMethod(setup, "begin", Q_ARG(QVariant, QVariant(""))); + const auto page = parser.value("setup-preview"); + if (page != "computer") QTimer::singleShot(350, setup, [setup, page] { + QVariantMap host{{"name", "Home workstation"}, {"host", "home.example.net"}, {"pairing_uuid", "11111111-2222-3333-4444-555555555555"}}; + QMetaObject::invokeMethod(setup, "choose", Q_ARG(QVariant, QVariant(host))); + if (page == "advanced") setup->setProperty("advanced", true); + if (page == "check") { setup->setProperty("step", 2); setup->setProperty("tested", true); } + }); + } if (parser.isSet("compact")) { engine.rootObjects().first()->setProperty("width", 820); engine.rootObjects().first()->setProperty("height", 650); } if (parser.isSet("smoke-test") || parser.isSet("screenshot")) { QTimer::singleShot(900, &app, [&] { diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index baaa12d..56a6361 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -51,8 +51,8 @@ ApplicationWindow { manager.act(selected.computer, recovering ? "restore" : connected ? "focus" : "connect", profile) } Shortcut { sequence: "Ctrl+R"; onActivated: manager.refresh() } - Shortcut { sequence: "Ctrl+Return"; enabled: root.canAct; onActivated: root.action() } - Shortcut { sequences: [StandardKey.Cancel]; onActivated: { help.close(); details.close() } } + Shortcut { sequence: "Ctrl+Return"; enabled: root.canAct && !setup.visible; onActivated: root.action() } + Shortcut { sequences: [StandardKey.Cancel]; onActivated: { help.close(); details.close(); if (!manager.setupBusy) setup.close() } } component Caption: Label { color: theme.colors.secondary; font.pixelSize: 12; font.letterSpacing: 1.4 } component Body: Label { textFormat: Text.PlainText; color: theme.colors.secondary; wrapMode: Text.WordWrap; lineHeight: 1.25 } component Divider: Rectangle { color: theme.colors.border; height: 1; Layout.fillWidth: true } @@ -115,6 +115,7 @@ ApplicationWindow { } ActionButton { Layout.fillWidth: true; text: "Refresh computers"; hint: "Refresh settings and connection status · Ctrl+R"; enabled: !manager.loading; onClicked: manager.refresh() } Item { height: 14 } + ActionButton { objectName: "addComputer"; Layout.fillWidth: true; primary: true; text: "+ Add computer"; enabled: !manager.setupBusy; onClicked: setup.begin("") } ActionButton { Layout.fillWidth: true; text: "Setup & help"; onClicked: help.open() } Divider { Layout.topMargin: 22; Layout.bottomMargin: 18 } RowLayout { @@ -204,7 +205,7 @@ ApplicationWindow { ActionButton { visible: !root.selected && !manager.loading Layout.topMargin: 20 - primary: true; text: "Set up a computer"; onClicked: help.open() + primary: true; text: "Set up a computer"; onClicked: setup.begin("") } RowLayout { visible: !!root.selected @@ -253,6 +254,7 @@ ApplicationWindow { Layout.fillWidth: true; Layout.topMargin: 18; visible: !!root.selected Button { text: "Add to app launcher"; flat: true; palette.windowText: theme.colors.secondary; enabled: !!root.selected && !root.selected.busy && !root.selected.unconfigured; onClicked: manager.act(root.selected.computer, "launcher") } Item { Layout.fillWidth: true } + Button { text: "Edit"; enabled: !!root.selected && !root.selected.unconfigured && !manager.setupBusy; flat: true; onClicked: setup.begin(root.selected.computer) } Button { text: "Connection details"; flat: true; palette.windowText: theme.colors.muted; onClicked: details.open() } } Item { Layout.preferredHeight: 28 } @@ -260,6 +262,7 @@ ApplicationWindow { } } } + SetupDialog { id: setup; onSaved: computer => { root.selectedId = computer; manager.refresh() } } Dialog { id: help objectName: "helpDialog" @@ -269,7 +272,7 @@ ApplicationWindow { ColumnLayout { width: parent.width; spacing: 18 Body { Layout.fillWidth: true; text: "Remote Desktops manages your connections. Each remote desktop opens in its own Moonlight window." } - Body { Layout.fillWidth: true; text: "1. Pair your computer in Moonlight.\n2. Add its connection settings using the setup guide.\n3. Refresh this list, choose a profile, and connect." } + Body { Layout.fillWidth: true; text: "1. Pair your computer in Moonlight.\n2. Choose Add computer and follow the setup steps.\n3. Test, save, and connect." } Body { Layout.fillWidth: true; text: "Add a computer to your app launcher to open it directly. In Hypertile Scenes, choose that launcher as an ordinary app." } ActionButton { text: "Open setup guide ↗"; onClicked: Qt.openUrlExternally("https://github.com/jdvmi00/remote-desktops/blob/develop/docs/BACKEND.md") } Divider {} diff --git a/ui/qml/SetupDialog.qml b/ui/qml/SetupDialog.qml new file mode 100644 index 0000000..7447f37 --- /dev/null +++ b/ui/qml/SetupDialog.qml @@ -0,0 +1,231 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: setup + objectName: "setupDialog" + anchors.centerIn: parent + width: Math.min(parent.width - 48, 660) + height: Math.min(parent.height - 48, step === 1 ? 650 : 560) + padding: 24 + background: Rectangle { radius: 16; color: theme.colors.bg; border.color: theme.colors.border } + header: Item { + implicitHeight: 68 + Label { anchors.left: parent.left; anchors.leftMargin: 24; anchors.verticalCenter: parent.verticalCenter; text: setup.title; color: theme.colors.text; font.pixelSize: 21; font.weight: Font.DemiBold } + } + modal: true + closePolicy: manager.setupBusy ? Popup.NoAutoClose : Popup.CloseOnEscape + title: editing ? "Computer settings" : "Add a computer" + property bool editing: false + property int step: 0 + property var draft: ({}) + property var paired: [] + property string revision: "" + property string error: "" + property bool tested: false + property bool loaded: false + property bool advanced: false + signal saved(string computer) + function begin(computer) { + if (manager.setupBusy) return + editing = !!computer; step = editing ? 1 : 0; draft = {}; paired = [] + error = ""; tested = false; loaded = false; advanced = false + launcher.checked = !editing + open() + manager.setup(editing ? "get" : "catalog", computer ? {computer:computer} : {}) + } + function set(key, value) { + const next = Object.assign({}, draft); next[key] = value; draft = next + tested = false; error = "" + } + function choose(host) { + const slug = host.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "computer" + draft = {computer:slug + "-" + host.pairing_uuid.slice(0,8), pairing_uuid:host.pairing_uuid, + revision:revision, name:host.name, host:host.host, platform:"unknown", profile:"desktop", + stream_resolution:"1920x1080", fps:60, bitrate:30000, codec:"auto", input:"absolute", audio:"focus"} + step = 1; tested = false; error = "" + } + readonly property bool valid: loaded && (draft.name || "").trim().length > 0 + && /^[A-Za-z0-9][A-Za-z0-9.:-]{0,252}$/.test(draft.host || "") + && /^[0-9]{3,5}x[0-9]{3,5}$/.test(draft.stream_resolution || "") + Connections { + target: manager + function onSetupFinished(action, ok, result, message) { + if (!setup.visible) return + if (!ok) { setup.error = message; return } + setup.error = "" + if (action === "catalog") { setup.paired = result.paired; setup.revision = result.revision; setup.loaded = true } + if (action === "get") { setup.draft = result; setup.loaded = true } + if (action === "test") setup.tested = true + if (action === "save") { + const id = setup.draft.computer + setup.close(); setup.saved(id) + if (launcher.checked) manager.act(id, "launcher") + } + } + } + component Body: Label { textFormat: Text.PlainText; Layout.fillWidth: true; wrapMode: Text.WordWrap; color: theme.colors.secondary; lineHeight: 1.2 } + component FieldLabel: Label { color: theme.colors.secondary; font.pixelSize: 12 } + function reveal(item) { + const flick = scroll.contentItem + const p = item.mapToItem(flick.contentItem, 0, 0) + if (p.y < flick.contentY) flick.contentY = p.y + else if (p.y + item.height > flick.contentY + flick.height) + flick.contentY = Math.min(flick.contentHeight - flick.height, p.y + item.height - flick.height) + } + component Input: TextField { + onActiveFocusChanged: if (activeFocus) setup.reveal(this) + implicitHeight: 44; leftPadding: 12; rightPadding: 12 + color: theme.colors.text; selectionColor: theme.colors.accent; selectedTextColor: theme.colors.onAccent + background: Rectangle { radius: 8; color: theme.colors.surface; border.color: parent.activeFocus ? theme.colors.accent : theme.colors.border } + } + component Select: ComboBox { + onActiveFocusChanged: if (activeFocus) setup.reveal(this) + implicitHeight: 44 + background: Rectangle { radius: 8; color: theme.colors.surface; border.color: parent.visualFocus ? theme.colors.accent : theme.colors.border } + } + contentItem: ColumnLayout { + spacing: 16 + RowLayout { + spacing: 8 + Repeater { + model: ["Computer", "Preferences", "Check & save"] + delegate: Label { + required property string modelData + required property int index + text: (index + 1) + " " + modelData + color: setup.step === index ? theme.colors.accentText : theme.colors.muted + font.weight: setup.step === index ? Font.DemiBold : Font.Normal + Layout.fillWidth: true + } + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.colors.border } + ScrollView { + id: scroll + ScrollBar.vertical.policy: setup.advanced ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded + Layout.fillWidth: true; Layout.fillHeight: true + contentWidth: availableWidth; clip: true + ColumnLayout { + width: parent.width; spacing: 16 + ColumnLayout { + visible: setup.step === 0 + Layout.fillWidth: true; spacing: 14 + Body { text: "Choose a computer you have paired in Moonlight."; color: theme.colors.text; font.pixelSize: 18 } + Body { visible: !setup.paired.length && !manager.setupBusy; text: "No paired computers found. Open Moonlight, add your computer and complete pairing. Then refresh this list." } + Repeater { + model: setup.paired + delegate: ItemDelegate { + required property var modelData + Layout.fillWidth: true; implicitHeight: 68 + enabled: !modelData.configured && !manager.setupBusy + Accessible.name: modelData.name + (modelData.configured ? ", already added" : "") + onClicked: setup.choose(modelData) + background: Rectangle { radius: 9; color: parent.hovered ? theme.colors.hover : theme.colors.surface; border.color: parent.visualFocus ? theme.colors.accent : theme.colors.border } + contentItem: ColumnLayout { + Label { textFormat: Text.PlainText; text: modelData.name; color: theme.colors.text; font.weight: Font.DemiBold } + Body { text: modelData.configured ? "Already added — edit it from your computer list" : modelData.host || "You’ll enter its address next"; font.pixelSize: 12 } + } + } + } + ActionButton { text: "Refresh paired computers"; enabled: !manager.setupBusy; onClicked: manager.setup("catalog") } + Body { text: "Pairing stays in Moonlight. Remote Desktops uses the same trusted computer."; font.pixelSize: 12 } + } + ColumnLayout { + visible: setup.step === 1 && setup.loaded + enabled: !manager.setupBusy + Layout.fillWidth: true; spacing: 10 + Body { text: setup.editing ? "Make this computer feel right for your work." : "A few details, then you’re ready."; color: theme.colors.text; font.pixelSize: 18 } + FieldLabel { text: "Computer name" } + Input { objectName: "setupName"; Layout.fillWidth: true; text: setup.draft.name || ""; maximumLength: 100; Accessible.name: "Computer name"; onTextEdited: setup.set("name", text) } + FieldLabel { text: "Address" } + Input { Layout.fillWidth: true; text: setup.draft.host || ""; placeholderText: "Hostname or IP address"; maximumLength: 253; Accessible.name: "Computer address"; onTextEdited: setup.set("host", text) } + Body { text: "Used to check reachability. Moonlight’s saved address is used for the stream."; font.pixelSize: 11 } + RowLayout { + Layout.fillWidth: true; spacing: 16 + ColumnLayout { + Layout.fillWidth: true + FieldLabel { text: "Operating system" } + Select { Layout.fillWidth: true; model: [{label:"Not specified",value:"unknown"},{label:"macOS",value:"macos"},{label:"Windows",value:"windows"},{label:"Linux",value:"linux"}]; textRole: "label"; valueRole: "value"; currentIndex: Math.max(0, ["unknown","macos","windows","linux"].indexOf(setup.draft.platform)); Accessible.name: "Operating system"; onActivated: setup.set("platform", currentValue) } + } + ColumnLayout { + Layout.fillWidth: true + FieldLabel { text: setup.editing ? "Default profile" : "Desktop quality" } + Select { + Layout.fillWidth: true + model: setup.editing ? Object.keys(setup.draft.profiles || {}) : ["Balanced · 1080p / 60 fps", "Sharper · 1440p / 60 fps", "Detailed · 4K / 60 fps", "Custom"] + currentIndex: { + if (setup.editing) return Math.max(0, model.indexOf(setup.draft.profile)) + const i = ["1920x1080", "2560x1440", "3840x2160"].indexOf(setup.draft.stream_resolution) + return i >= 0 && setup.draft.fps === 60 && setup.draft.bitrate === [30000,45000,80000][i] ? i : 3 + } + Accessible.name: "Default profile" + onActivated: { + if (setup.editing) { + const p = setup.draft.profiles[currentText] + setup.set("profile", currentText) + for (const key of ["stream_resolution", "fps", "bitrate", "codec", "input", "audio"]) + setup.set(key, p[key] === undefined ? ({fps:60,bitrate:60000,codec:"HEVC",input:"absolute",audio:"focus"})[key] : p[key]) + } else if (currentIndex === 3) setup.advanced = true + else { + const i = currentIndex + setup.set("fps", 60) + setup.set("stream_resolution", ["1920x1080", "2560x1440", "3840x2160"][i]) + setup.set("bitrate", [30000, 45000, 80000][i]) + } + } + } + } + } + Button { text: setup.advanced ? "▾ Advanced stream settings" : "▸ Advanced stream settings"; flat: true; onClicked: setup.advanced = !setup.advanced } + GridLayout { + visible: setup.advanced + Layout.fillWidth: true; columns: 2; columnSpacing: 16; rowSpacing: 8 + FieldLabel { text: "Resolution" } + FieldLabel { text: "Frame rate" } + Input { Layout.fillWidth: true; text: setup.draft.stream_resolution || ""; Accessible.name: "Stream resolution"; onTextEdited: setup.set("stream_resolution", text) } + SpinBox { onActiveFocusChanged: if (activeFocus) setup.reveal(this); Layout.fillWidth: true; from: 20; to: 240; value: setup.draft.fps || 60; editable: true; Accessible.name: "Frames per second"; onValueModified: setup.set("fps", value) } + FieldLabel { text: "Bitrate (kbps)" } + FieldLabel { text: "Codec" } + SpinBox { onActiveFocusChanged: if (activeFocus) setup.reveal(this); Layout.fillWidth: true; from: 1000; to: 200000; stepSize: 1000; value: setup.draft.bitrate || 30000; editable: true; Accessible.name: "Bitrate in kilobits per second"; onValueModified: setup.set("bitrate", value) } + Select { Layout.fillWidth: true; model: ["auto", "HEVC", "H.264", "AV1"]; currentIndex: Math.max(0,model.indexOf(setup.draft.codec)); Accessible.name: "Codec"; onActivated: setup.set("codec", currentText) } + FieldLabel { text: "Mouse mode" } + FieldLabel { text: "Audio" } + Select { Layout.fillWidth: true; model: ["absolute", "relative"]; currentIndex: Math.max(0,model.indexOf(setup.draft.input)); Accessible.name: "Mouse mode"; onActivated: setup.set("input", currentText) } + Select { Layout.fillWidth: true; model: ["focus", "continuous", "host"]; currentIndex: Math.max(0,model.indexOf(setup.draft.audio)); Accessible.name: "Audio policy"; onActivated: setup.set("audio", currentText) } + } + Body { text: setup.editing ? "Existing display management is preserved. Changes apply after disconnecting and starting a new connection." : "The host keeps its current display settings. You can tune stream quality later."; font.pixelSize: 12 } + } + ColumnLayout { + visible: setup.step === 2 + Layout.fillWidth: true; spacing: 16 + Body { text: setup.tested ? (setup.editing ? "Your changes are ready to save." : "Your computer is ready to add.") : "Let’s check the connection."; color: theme.colors.text; font.pixelSize: 22; font.weight: Font.DemiBold } + Body { text: (setup.draft.name || "") + "\n" + (setup.draft.host || "") + "\n" + (setup.draft.stream_resolution || "") + " · " + (setup.draft.fps || 60) + " fps" } + Body { text: setup.tested ? (manager.demo ? "Simulated check passed. No real computer was contacted." : "Moonlight authenticated and found the Desktop app. Video and input will be checked when you connect.") : "This checks reachability, Moonlight pairing and the Desktop app. It won’t start a stream or change the host’s display."; color: setup.tested ? theme.colors.success : theme.colors.secondary } + ActionButton { objectName: "setupTest"; text: manager.setupBusy ? "Checking…" : setup.tested ? "Check again" : "Test connection"; enabled: !manager.setupBusy; onClicked: { setup.tested = false; manager.setup("test", setup.draft) } } + CheckBox { id: launcher; text: setup.editing ? "Update app launcher entry" : "Add to app launcher"; checked: true; enabled: !manager.setupBusy } + Body { text: "Open this computer directly from your launcher, or choose it as an app in a Hypertile Scene."; font.pixelSize: 12 } + } + } + } + Body { visible: manager.setupBusy && setup.step !== 2; text: "Working…" } + Body { objectName: "setupError"; visible: !!setup.error; text: setup.error; color: theme.colors.warning } + } + footer: Item { + implicitHeight: 76 + RowLayout { + anchors.fill: parent; anchors.margins: 16 + spacing: 10 + ActionButton { text: "Cancel"; enabled: !manager.setupBusy; onClicked: setup.close() } + Item { Layout.fillWidth: true } + ActionButton { text: "Back"; visible: setup.step > (setup.editing ? 1 : 0); enabled: !manager.setupBusy; onClicked: { setup.step--; setup.error = "" } } + ActionButton { + objectName: "setupNext"; primary: true; visible: setup.step > 0 + text: setup.step === 2 ? "Save computer" : "Continue" + enabled: !manager.setupBusy && setup.valid && (setup.step !== 2 || setup.tested) + onClicked: { if (setup.step === 2) manager.setup("save", setup.draft); else setup.step = 2 } + } + } + } +} diff --git a/ui/tests/manager.cpp b/ui/tests/manager.cpp index 7f21c4b..71df193 100644 --- a/ui/tests/manager.cpp +++ b/ui/tests/manager.cpp @@ -51,6 +51,49 @@ private slots: window->close(); QCOMPARE(m.computers()[1].toMap()["phase"].toString(), QString("window-ready")); } + void guidedSetupTestGateEditAndCancel() { + Manager m("/must-not-run", "/must-not-connect", true); + Theme theme("/missing/palette"); + QQmlApplicationEngine engine; + QSignalSpy warnings(&engine, &QQmlEngine::warnings); + engine.rootContext()->setContextProperty("manager", &m); + engine.rootContext()->setContextProperty("theme", &theme); + engine.load(QUrl("qrc:/qml/Main.qml")); + QVERIFY(!engine.rootObjects().isEmpty()); + auto *window = qobject_cast(engine.rootObjects().first()); + auto *dialog = window->findChild("setupDialog"); + QVERIFY(dialog); + QVERIFY(QMetaObject::invokeMethod(dialog, "begin", Q_ARG(QVariant, QVariant("")))); + QTRY_VERIFY(dialog->property("loaded").toBool()); + QVariantMap host{{"name", "Home workstation"}, {"host", "home.example.net"}, {"pairing_uuid", "11111111-2222-3333-4444-555555555555"}}; + QVERIFY(QMetaObject::invokeMethod(dialog, "choose", Q_ARG(QVariant, QVariant(host)))); + auto *next = dialog->findChild("setupNext"); + auto *test = dialog->findChild("setupTest"); + QVERIFY(next && test); + QVERIFY(next->property("enabled").toBool()); + QVERIFY(QMetaObject::invokeMethod(next, "clicked")); + QCOMPARE(dialog->property("step").toInt(), 2); + QVERIFY(!next->property("enabled").toBool()); + QVERIFY(QMetaObject::invokeMethod(test, "clicked")); + QTRY_VERIFY(next->property("enabled").toBool()); + QVERIFY(QMetaObject::invokeMethod(dialog, "set", Q_ARG(QVariant, QVariant("name")), Q_ARG(QVariant, QVariant("My home computer")))); + QVERIFY(!next->property("enabled").toBool()); + QVERIFY(QMetaObject::invokeMethod(test, "clicked")); + QTRY_VERIFY(next->property("enabled").toBool()); + QVERIFY(QMetaObject::invokeMethod(next, "clicked")); + QTRY_VERIFY(!dialog->property("visible").toBool()); + QCOMPARE(m.computers().size(), 4); + QCOMPARE(m.computers().last().toMap()["name"].toString(), QString("My home computer")); + QVERIFY(QMetaObject::invokeMethod(dialog, "begin", Q_ARG(QVariant, QVariant("home-workstation-11111111")))); + QTRY_VERIFY(dialog->property("loaded").toBool()); + QCOMPARE(dialog->property("step").toInt(), 1); + QVERIFY(QMetaObject::invokeMethod(dialog, "set", Q_ARG(QVariant, QVariant("name")), Q_ARG(QVariant, QVariant("Discard me")))); + QTest::keyClick(window, Qt::Key_Escape); + QTRY_VERIFY(!dialog->property("visible").toBool()); + QCOMPARE(m.computers().last().toMap()["name"].toString(), QString("My home computer")); + QCOMPARE(m.computers().first().toMap()["phase"].toString(), QString("window-ready")); + QCOMPARE(warnings.count(), 0); + } void themeSurvivesAtomicFilesAndDirectoryReplacement() { QTemporaryDir temp; QString directory = temp.path() + "/current/theme"; @@ -158,6 +201,40 @@ fi QCOMPARE(args.readAll(), QByteArray("--json\nconnect\ntest\n--profile\ndesktop\n")); QVERIFY(m.notice().contains("Request accepted")); } + void setupUsesBoundedStdinAndReportsFailures() { + QTemporaryDir temp; + QString binary = temp.path() + "/fake backend"; + QFile script(binary); QVERIFY(script.open(QIODevice::WriteOnly)); + script.write(R"(#!/bin/sh +if [ "$2" = computers ]; then + printf '%s\n' '[]' +else + printf '%s\n' "$@" > "${0}.args" + cat > "${0}.input" + sleep 0.1 + printf '%s\n' 'synthetic connection failure' >&2 + exit 1 +fi +)"); + script.close(); script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); + Manager m(binary, temp.path() + "/missing.socket"); + QTRY_VERIFY(!m.loading()); + QSignalSpy replies(&m, &Manager::setupFinished); + QVariantMap draft{{"name", "Literal $(text) with spaces"}, {"computer", "example"}}; + m.setup("test", draft); + QVERIFY(m.setupBusy()); + m.setup("save", draft); // One outstanding settings request. + QTRY_COMPARE(replies.count(), 1); + QVERIFY(!m.setupBusy()); + QCOMPARE(replies.first()[0].toString(), QString("test")); + QVERIFY(!replies.first()[1].toBool()); + QVERIFY(replies.first()[3].toString().contains("synthetic connection failure")); + QFile args(binary + ".args"); QVERIFY(args.open(QIODevice::ReadOnly)); + QCOMPARE(args.readAll(), QByteArray("--json\nsettings\ntest\n")); + QFile input(binary + ".input"); QVERIFY(input.open(QIODevice::ReadOnly)); + QCOMPARE(QJsonDocument::fromJson(input.readAll()).object().toVariantMap(), draft); + QVERIFY(m.computers().isEmpty()); + } void missingBackendIsActionable() { Manager m("/missing/backend", "/missing/socket"); QTRY_VERIFY(!m.loading());