diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 737c85a..e84dc75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cargo test --locked cargo build --locked - name: Qt manager dependencies - run: sudo apt-get update && sudo apt-get install -y cmake qt6-base-dev qt6-declarative-dev qml6-module-qtqml qml6-module-qtqml-models qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts qml6-module-qtquick-templates qml6-module-qtquick-window qml6-module-qtqml-workerscript + run: sudo apt-get update && sudo apt-get install -y cmake qt6-base-dev qt6-declarative-dev qml6-module-qtqml qml6-module-qtqml-models qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts qml6-module-qtquick-templates qml6-module-qtquick-window qml6-module-qtqml-workerscript libqt6svg6 - name: Qt manager build and isolated tests run: | cmake -S ui -B build/ui -DCMAKE_BUILD_TYPE=Release diff --git a/README.md b/README.md index 825d227..0d57889 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ cargo build --locked --release ./target/release/remote-desktops connect macbook ./target/release/remote-desktops status --json ./target/release/remote-desktops disconnect macbook +./target/release/remote-desktops start +./target/release/remote-desktops settings remove macbook ``` Configure and pair your computers first; see [backend usage](docs/BACKEND.md). @@ -84,9 +86,10 @@ Moonlight, or Sunshine application. ## Graphical manager -A separate Qt 6/QML manager is available on `develop` for existing configured -computers. It provides connection controls, profile selection, display recovery, -and per-computer launchers. Closing it leaves connections running. +A separate Qt 6/QML manager is available on `develop`. It adds, edits, and +removes computers paired in Moonlight, and provides connection controls, +profile selection, display recovery, and per-computer launchers, following the +active Omarchy theme and desktop font. Closing it leaves connections running. See [UI build, preview, and interaction guide](docs/UI.md). Pairing and computer configuration still use Moonlight and the backend setup guide; no installable diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60f7616..d6ed0d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,10 @@ Moonlight retains hardware decoding and its own rendering/frame pacing. - Session intent and recovery use separate files. Rust writes `session.json`; the Python helper alone writes `recovery.json` under an operation lock. Both use private atomic file replacement and fsync. +- A `forget` command drops a session record only when it is settled: not + desired, no live client, phase idle or attention, and no pending recovery + journal. Removing the record also drops its wake channel, which ends the + session worker before it can touch a record a later connect creates. - Disconnect intent is durable immediately. An already-started host operation may finish, but its journal is retained and restored before any client launch. Cancelling a task must never erase an uncertain host write. @@ -108,6 +112,15 @@ implementation. Configuration listing runs the Rust CLI once at startup and on explicit refresh, exposing only computer ID/name, host, platform, default profile, and profile names. Pairing material is not part of this listing. Commands use an absolute executable and an argv array, never a shell string. +Remove runs `settings remove`; Start service runs `start`. The only other +process the manager starts is the installed Moonlight executable, on an explicit +Open Moonlight request during setup, detached and without arguments. Window +size and the selected computer persist through QSettings; the preview never +writes them. + +The status reply also carries the session's launch time, Moonlight version, +retry attempt count, and next retry time so the manager can show elapsed +session time and retry countdowns without inventing figures. Status uses asynchronous QLocalSocket request/reply framing, a 1.5-second request timeout, a 2 MB reply limit, and at most one outstanding request. The diff --git a/docs/BACKEND.md b/docs/BACKEND.md index a1e7272..62c6708 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -49,8 +49,19 @@ cargo build --locked --release ./target/release/remote-desktops reconnect macbook ./target/release/remote-desktops disconnect macbook ./target/release/remote-desktops restore macbook +./target/release/remote-desktops start +./target/release/remote-desktops settings remove macbook ``` +`start` starts the daemon if it is not running and reports status without +connecting anything. `settings remove` deletes a computer's configuration and +its generated launcher entry, and forgets its session record only when nothing +owns it: it refuses while the session is desired, a client is alive, the +session is still finishing, or a recovery journal is pending. With the daemon +running, the record is dropped through the daemon's `forget` command; +otherwise the CLI holds the daemon's writer lock while deleting the directory. +A computer removed this way can be added again from the same pairing. + Connect/disconnect/restore/reconnect start the daemon on demand. To run it in the foreground for development, use `remote-desktops daemon`. A CLI command returning successfully means the intent was accepted; inspect `status` for diff --git a/docs/UI.md b/docs/UI.md index a59a8f7..3083933 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -1,10 +1,11 @@ # Desktop manager -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 -recovery. Closing it leaves the daemon and Moonlight sessions running. +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, installs a +per-computer application launcher, and removes computers. It has no video +renderer and owns no host recovery. Closing it leaves the daemon and Moonlight +sessions running. ## Run from a checkout @@ -17,76 +18,118 @@ cmake --build build/ui --parallel 2 build/ui/remote-desktops-manager --backend "$PWD/target/debug/remote-desktops" ``` -The UI requires Qt 6.4 or later: Quick, Quick Controls 2, Network, and the Qt -Test development module for the test target. No Qt libraries are linked into -the Rust backend. The manager accepts an absolute backend path; otherwise it -looks beside itself and then on PATH. Configuration and socket paths follow -the backend's XDG conventions. +The UI requires Qt 6.4 or later: Quick, Quick Controls 2, Network, the SVG +image format plugin for its icons, and the Qt Test development module for the +test target. No Qt libraries are linked into the Rust backend. The manager +accepts an absolute backend path; otherwise it looks beside itself and then on +PATH. Configuration and socket paths follow the backend's XDG conventions. Opening the manager reads configured computers and status. It does not start a -connection automatically. An explicit Connect starts the daemon when necessary. -Do not use a development binary to take over active production sessions merely -to test the UI. Use the isolated preview instead: +connection automatically. An explicit Connect, or the Start service action shown +when the service is not answering, starts the daemon. Window size and the last +selected computer are kept in the application's QSettings file; the preview +never writes them. Do not use a development binary to take over active +production sessions merely to test the UI. Use the isolated preview instead: ```sh build/ui/remote-desktops-manager --demo build/ui/remote-desktops-manager --demo --state restore-pending +build/ui/remote-desktops-manager --demo --state many --compact +build/ui/remote-desktops-manager --demo --dialog help ctest --test-dir build/ui --output-on-failure ``` -Demo actions never spawn the backend or access its socket. The three example -computers are synthetic. `--state` also supports `idle`, `preflight`, `empty`, -and `unavailable`; `--compact` exercises the minimum window size. With an -offscreen platform, `--screenshot /tmp/manager.png` exports the rendered demo. -These preview and screenshot options require `--demo`. +Demo actions never spawn the backend or access its socket; they walk through +the same intermediate phases a real session reports. The example computers are +synthetic. `--state` supports `idle`, `connecting`, `preflight`, `running`, +`attention`, `restore-pending`, `empty`, `unavailable`, `many` (twelve +computers), and `unconfigured` (a removed computer with a pending restore). +`--dialog` opens `help`, `details`, `remove`, `notice`, or `error`. `--compact` +renders the minimum window size. With an offscreen platform, +`--screenshot /tmp/manager.png` exports the rendered demo. These preview and +screenshot options require `--demo`. + +## Layout + +- The header carries the application identity, the background service + indicator (running, not responding, or starting; click it to check again), + refresh, and help. +- The sidebar lists computers with a status dot and label per row, scrolls + with a visible bar, and holds the one global primary action, Add computer. +- The main pane shows the selected computer's name, platform, and address, + then a status card, the action row, the profile, and tertiary actions (Edit, + Add to app launcher, Details, Remove). Nothing else competes for the space. +- When the service is not answering, one banner names the cause and offers + Start service and Check again. Rows keep their last known label with a + hollow dot, and the card says which state was last known. +- Transient messages appear as a toast over the main pane. Informational + messages fade after five seconds; errors stay until dismissed or replaced. + Accepted connection commands produce no message because status shows them. + +## Status card + +The card states what is happening, what it means, and what to do next, with a +tone that follows the state: neutral, connected, or warning. It lists only +values the daemon reports: time since the client launched, the profile, +whether an owned window was detected, the negotiated video size and rate from +Moonlight's own log, the Moonlight version, the next retry with its attempt +count, and the last error message inline. Nothing is invented: no thumbnails, +latency, or quality figures. Window detection is an identity match, not proof +of a rendered frame. ## Interaction design -- A stable computer list sits beside the selected connection. Selection is - retained by computer ID, rather than by a changing row index. +- Selection is retained by computer ID rather than a row index. Tab reaches + the list; Up, Down, Home, and End move through it; typing a letter jumps to + the next matching name; Enter or a double-click runs the primary action. + Ctrl+Enter runs the primary action, Ctrl+R refreshes, Ctrl+N adds a + computer, and Escape closes a dialog. - One primary action follows state: Connect, Open desktop, or Restore display. - Repeat actions are suppressed while an acknowledgement is pending. A request - acknowledgement is not presented as a successful connection. -- Profiles cannot change during a desired session; disconnect first. Reconnect - restarts the selected client. Disconnect cancels pending connection intent - and asks the daemon to restore its owned host settings. -- Host recovery errors remain visible, with technical detail available on - demand. Recovery records for removed computers remain in the list. There is - deliberately no one-click abandonment of the original host settings. -- Status loss preserves last-known records and labels them unavailable; it - does not pretend that a running remote session disconnected. Refresh retries - observation. Connect/Restore use the CLI's existing daemon-start behavior. -- No thumbnails or performance figures are invented. The device drawing is an - illustration. Window-ready is an identity match, not proof of a rendered - video frame or measured latency. -- Tab navigates controls, arrow keys navigate the focused computer list, - Ctrl+Enter invokes the primary action, Ctrl+R refreshes, and Escape closes - a dialog. Focus rings, accessible names, textual statuses, and restrained - hover transitions complement color cues. - -## Current scope + While a transition runs, the primary action is disabled and labeled with the + phase, and Cancel sits beside it, never in the position Disconnect uses. A + running client without an observed window disables Open desktop; Reconnect + and Disconnect remain. Repeat actions are suppressed while a request is + pending. A request acknowledgement is not presented as a connection. +- Profiles cannot change during a desired session; disconnect first. A + computer with a single profile shows it as text rather than a control. +- Host recovery errors stay visible inline in the card, with a details dialog + for identity, state, window, client, and copyable text. Recovery records for + removed computers remain listed until restored. There is deliberately no + one-click abandonment of the original host settings. +- Remove asks for confirmation, then calls `settings remove`. The backend + refuses while the computer is connected, still finishing a session, or has + a pending display restore, and the reason is shown as an error message. +- Focus rings, accessible names and roles, textual statuses, and restrained + animation on state changes complement color cues. + +## Guided setup 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; +confirm its name and address, choose desktop quality, then check and save. +Steps are shown with completed, current, and upcoming markers; editing an +existing computer skips the first step and shows two. If Moonlight has no +paired computers, the dialog explains pairing and offers Open Moonlight, which +launches the installed `moonlight` executable, and Refresh list. Pairing and +certificate storage stay in Moonlight. + +Continue validates the name, address, and resolution and shows the rule under +each field that needs attention. The check runs automatically when the last +step opens and can be repeated with Check again. It authenticates through +Moonlight and verifies the Desktop app is listed; it does not start video, +capture input, or change a host display. A revision conflict offers Reload, +which refreshes the saved revision for a new computer or reloads the saved +settings for an existing one. The summary lists name, address, operating +system, quality, mouse, and audio in plain language. Save requires a passing +check of the current draft; editing any field invalidates it. Escape or Cancel +with unsaved changes asks before discarding. + +New profiles use the host's existing display and automatic decoder selection. +Advanced controls expose resolution, frame rate, bitrate in Mbit/s, codec, +mouse mode (direct or relative pointer), and audio (play here and mute when +unfocused, always play here, or keep audio on the host), each with a one-line +explanation. **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. +are preserved. Changes apply after disconnecting and starting a new connection. Use `--demo --setup-preview computer` (or `preferences`, `advanced`, `check`) to preview each setup page with synthetic data. Demo settings stay in memory. @@ -94,21 +137,28 @@ 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 remains a separate delivery step. -## Omarchy themes +## Omarchy themes and type The manager automatically reads the active Omarchy palette from `$XDG_STATE_HOME/omarchy/current/theme/colors.toml` (normally -`~/.local/state/omarchy/current/theme/colors.toml`). Background, foreground, -accent, surfaces, and warning/destructive colors follow that theme. Derived -colors support light and dark palettes, with contrast-adjusted text for -buttons and recovery messages. +`~/.local/state/omarchy/current/theme/colors.toml`). It uses `mode`, +`background`, `foreground`, `accent`, `lighter_background`, +`dark_background`, `selection`, `muted`, `red`, `yellow`, and `green`; only +the first three are required. Every shared foreground, including disabled +text and status colors, is adjusted until it reads at WCAG AA contrast on +every surface it is drawn on, in light and dark palettes alike. + +Text uses the desktop's application font as provided by the platform theme; +no family is hardcoded. A five-step type scale is derived from that font's +size, with the smallest step never below nine points. Filesystem notifications update the palette without restarting the manager or its connections, including when Omarchy replaces the entire theme directory. -There are no installed hooks, theme-file writes, subprocesses, or periodic -palette polling. Outside Omarchy, a built-in palette is used. Partial, malformed, -or temporarily missing theme files retain the last complete palette. +There are no installed hooks, theme-file writes, or periodic palette polling. +Outside Omarchy, a built-in palette is used. Partial, malformed, or +temporarily missing theme files retain the last complete palette. For isolated visual checks, pass `--theme-file /path/to/colors.toml`; the light fixture is `ui/tests/light.toml`. The reader accepts flat quoted `#RRGGBB` color -assignments and ignores non-color metadata. It never executes theme code. +assignments and the `mode` key and ignores other metadata. It never executes +theme code. diff --git a/src/main.rs b/src/main.rs index 49b8eb0..c4235e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,8 @@ enum Action { Status { computer: Option, }, + /// Start the background service if needed and report status; no connection is made. + Start, Computers, /// Guided computer setup and editing. Settings { @@ -166,6 +168,7 @@ async fn run(cli: Cli) -> Result<()> { } Action::Focus { computer } => json!({"command":"focus","computer":computer}), Action::Status { computer } => json!({"command":"status","computer":computer}), + Action::Start => json!({"command":"status"}), _ => unreachable!(), }; if matches!( @@ -175,6 +178,7 @@ async fn run(cli: Cli) -> Result<()> { | Action::Disconnect { .. } | Action::Restore { .. } | Action::Reconnect { .. } + | Action::Start ) && tokio::net::UnixStream::connect(paths.socket()) .await .is_err() diff --git a/src/server.rs b/src/server.rs index 43d5e6f..0e2dc60 100644 --- a/src/server.rs +++ b/src/server.rs @@ -148,7 +148,8 @@ impl Manager { json!({"computer":r.computer,"profile":r.profile,"desired":r.desired,"phase":r.phase,"error":r.error, "generation":r.generation,"pid":if supervisor::alive(&job) {job.pid} else {None},"window":r.window, "evidence":r.evidence,"recovery_pending":pending,"recovery_error":recovery_error, - "resolved":recovery["resolved"],"launcher":crate::launcher::identity(&r.computer,&r.config)}) + "resolved":recovery["resolved"],"launcher":crate::launcher::identity(&r.computer,&r.config), + "client_version":r.client_version,"launched_at":r.launched_at,"attempts":r.attempts,"next_retry":r.next_retry}) } pub async fn command(self: &Arc, request: Value) -> Result { let action = request["command"].as_str().context("missing command")?; @@ -164,6 +165,30 @@ impl Manager { if !storage::valid_id(name) { bail!("invalid computer ID"); } + if action == "forget" { + // Only a fully settled session may be dropped; a pending recovery + // journal or live client keeps its owner record. + let mut sessions = self.sessions.lock().unwrap(); + let r = sessions.get(name).context("computer has no session")?; + if r.desired || supervisor::alive(&self.job(r)) { + bail!("Disconnect this computer before removing it."); + } + let recovery = self.paths.session(name).join("recovery.json"); + if recovery.exists() && host::pending(&recovery)? { + bail!("restore-pending: restore the host display before removing this computer"); + } + if !matches!(r.phase.as_str(), "idle" | "attention") { + bail!( + "This computer is still finishing its last session. Wait until it is idle before removing it." + ); + } + sessions.remove(name); + // Dropping the wake sender ends the worker before its next step. + self.wakes.lock().unwrap().remove(name); + drop(sessions); + fs::remove_dir_all(self.paths.session(name))?; + return Ok(json!({"forgotten":true,"computer":name})); + } if action == "connect" { // Validation/SSH work never holds the global state lock. let computers = @@ -318,6 +343,11 @@ impl Manager { let mut windows = self.windows.clone(); let mut health_due = 0; loop { + // A forgotten session drops its wake sender; stop before touching + // any record a later connect may create under the same name. + if wake.has_changed().is_err() { + return; + } let result = self.step(&name, &mut health_due).await; let delay = match result { Ok(delay) => delay, @@ -348,7 +378,7 @@ impl Manager { .get(&name) .is_ok_and(|r| r.desired && r.token.is_some()); tokio::select! { - _=wake.changed()=>{}, + changed=wake.changed()=>{ if changed.is_err() { return; } }, _=windows.changed(), if observing && windows.has_changed().is_ok()=>{}, _=tokio::time::sleep(delay)=>{}, } diff --git a/src/settings.rs b/src/settings.rs index 59721cd..2c1794e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -23,6 +23,41 @@ pub enum Action { Test, /// Atomically save a JSON draft from stdin; existing sessions keep their snapshot. Save, + /// Remove a computer, its launcher entry, and its settled session record. + Remove { computer: String }, +} +// A session record is only dropped once nothing owns it: no desired intent, +// no live client, and no pending host recovery journal. +async fn forget(paths: &Paths, computer: &str) -> Result<()> { + let directory = paths.session(computer); + if !directory.join("session.json").exists() { + return Ok(()); + } + if tokio::net::UnixStream::connect(paths.socket()) + .await + .is_ok() + { + crate::server::request(paths, &json!({"command":"forget","computer":computer})).await?; + return Ok(()); + } + // No service answers. Holding its writer lock proves none owns the record. + let _writer = storage::lock(&paths.state.join("writer.lock"), true) + .context("The service is running but not responding. Try again.")?; + let record: Value = storage::read(&directory.join("session.json"))?; + if record["desired"] == true { + bail!("Disconnect this computer before removing it."); + } + let recovery = directory.join("recovery.json"); + if recovery.exists() && host::pending(&recovery)? { + bail!("restore-pending: restore the host display before removing this computer"); + } + if !matches!(record["phase"].as_str(), Some("idle" | "attention")) { + bail!( + "This computer is still finishing its last session. Wait until it is idle before removing it." + ); + } + std::fs::remove_dir_all(&directory)?; + Ok(()) } fn load(paths: &Paths) -> Result { match storage::read(&paths.config) { @@ -178,6 +213,48 @@ pub async fn run(paths: &Paths, action: &Action) -> Result { redact_profiles(&mut result); Ok(result) } + Action::Remove { computer } => { + if !storage::valid_id(computer) { + bail!("invalid computer ID"); + } + let configured = value["computers"].get(computer).is_some(); + if !configured && !paths.session(computer).join("session.json").exists() { + bail!("unknown computer"); + } + forget(paths, computer).await?; + let launcher = crate::launcher::Action::Remove { + computer: computer.clone(), + }; + let launcher = match crate::launcher::run(paths, &launcher).await { + Ok(v) => v["removed"].clone(), + // A launcher file another application owns is preserved. + Err(e) if e.to_string().contains("another application") => json!(false), + Err(e) => return Err(e), + }; + if configured { + 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 paths + .config + .symlink_metadata() + .is_ok_and(|m| m.file_type().is_symlink()) + { + bail!("Configuration is a symbolic link. Edit its source file directly."); + } + let mut next = load(paths)?; + if next["computers"] + .as_object_mut() + .context("invalid computers schema")? + .remove(computer) + .is_some() + { + host::call(json!({"operation":"validate-value","value":next})).await?; + storage::write(&paths.config, &next)?; + } + } + Ok(json!({"removed":true,"computer":computer,"launcher":launcher})) + } Action::Test | Action::Save => { let mut input = String::new(); io::stdin().take(65537).read_to_string(&mut input)?; diff --git a/tests/integration.py b/tests/integration.py index 57072e7..7bfc824 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -23,6 +23,8 @@ request=json.load(sys.stdin) if request['operation']=='validate': result=json.loads(Path(request['config']).read_text())['computers'] +elif request['operation']=='validate-value': + result=request['value']['computers'] else: path=Path(request['path']) with path.with_suffix('.lock').open('a') as lock: @@ -377,6 +379,59 @@ def test_workspace_and_geometry_changes_never_place_or_restart_the_window(self): self.assertEqual(calls.read_text().count("fullscreen_state"), 2, "only final owned windows receive startup policy") + def daemons(self): + # Detached daemons started by the CLI are found through their private HOME. + found = [] + for entry in Path("/proc").iterdir(): + try: + argv = entry.joinpath("cmdline").read_bytes().split(b"\0") + environ = entry.joinpath("environ").read_bytes().split(b"\0") + except (OSError, ValueError): + continue + if argv[:2] == [str(BIN).encode(), b"daemon"] and ("HOME=" + str(self.root)).encode() in environ: + found.append(int(entry.name)) + return found + + def test_start_launches_the_service_without_connecting(self): + self.stop_daemon() + try: + self.assertEqual(self.cli("start")["computers"], []) + self.assertTrue((self.root / "runtime/remote-desktops/control.sock").exists()) + self.assertFalse(self.session().exists()) + self.assertEqual(self.cli("start")["computers"], []) + self.assertEqual(len(self.daemons()), 1) + finally: + for pid in self.daemons(): + os.kill(pid, signal.SIGTERM) + self.wait(lambda: not self.daemons()) + + def test_remove_forgets_only_settled_sessions_and_updates_settings(self): + config = self.root / "config/remote-desktops/computers.json" + self.connect() + refused = self.cli("settings", "remove", "laptop", check=False) + self.assertNotEqual(refused.returncode, 0) + self.assertIn("Disconnect", refused.stderr) + self.assertIn("laptop", json.loads(config.read_text())["computers"]) + (self.session() / "conflict").touch() + self.cli("disconnect", "laptop") + self.wait(lambda: self.cli("status", "laptop")["phase"] == "restore-pending") + self.assertIn("restore", self.cli("settings", "remove", "laptop", check=False).stderr) + self.assertTrue(self.session().exists()) + self.cli("release", "laptop", "--keep-host-settings") + self.wait(lambda: self.cli("status", "laptop")["phase"] == "idle") + launcher = Path(self.cli("launcher", "install", "laptop")["installed"]) + removed = self.cli("settings", "remove", "laptop") + self.assertEqual(removed, {"removed": True, "computer": "laptop", "launcher": True}) + self.assertEqual(self.cli("status")["computers"], []) + self.assertFalse(self.session().exists()) + self.assertFalse(launcher.exists()) + self.assertNotIn("laptop", json.loads(config.read_text())["computers"]) + self.assertEqual([c["computer"] for c in self.cli("computers")], ["other"]) + self.assertIn("unknown computer", self.cli("settings", "remove", "laptop", check=False).stderr) + # The forgotten worker is gone; the same name connects again from a clean record. + self.assertEqual(self.cli("connect", "laptop", check=False).returncode, 1) + self.assertIsNotNone(self.connect("other")) + def test_restart_during_prepare_finishes_cancelled_recovery_without_launch(self): directory = self.session() directory.mkdir(parents=True) @@ -503,6 +558,27 @@ def test_locked_save_leaves_configuration_unchanged(self): self.assertIn("save is in progress", self.cli("save", draft=draft, ok=False)) self.assertEqual(self.config.read_bytes(), before) + def test_remove_without_service_drops_settled_session_and_keeps_pending_recovery(self): + self.cli("save", draft=self.draft()) + directory = self.root / "state/remote-desktops/sessions/home" + directory.mkdir(parents=True) + (directory / "session.json").write_text(json.dumps({"desired": False, "phase": "idle", "config": {"pairing_uuid": self.uuid}})) + (directory / "recovery.json").write_text(json.dumps({"journal": {"output": {"original": "1"}}})) + self.assertIn("restore", self.cli("remove", "home", ok=False)) + self.assertIn("home", json.loads(self.config.read_text())["computers"]) + (directory / "recovery.json").write_text(json.dumps({"journal": {}})) + with (self.root / "state/remote-desktops/writer.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + self.assertIn("not responding", self.cli("remove", "home", ok=False)) + self.assertTrue(directory.exists()) + self.assertEqual(self.cli("remove", "home"), {"removed": True, "computer": "home", "launcher": False}) + self.assertFalse(directory.exists()) + self.assertEqual(json.loads(self.config.read_text())["computers"], {}) + self.assertIn("unknown computer", self.cli("remove", "home", ok=False)) + # Without a stale session record the same paired computer can be added again. + self.cli("save", draft=self.draft()) + self.assertIn("home", json.loads(self.config.read_text())["computers"]) + def test_removed_computer_session_cannot_gain_a_second_owner(self): draft = self.draft() directory = self.root / "state/remote-desktops/sessions/original" diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index 5958bb6..4149adf 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -4,31 +4,46 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Quick QuickControls2 Network Test) +set(QML_FILES + qml/Main.qml qml/SetupDialog.qml qml/ActionButton.qml qml/IconButton.qml qml/Icon.qml qml/Tip.qml + qml/Bar.qml qml/Field.qml qml/Check.qml qml/Spin.qml qml/Select.qml qml/StatusDot.qml + qml/Sheet.qml qml/Confirm.qml qml/Toast.qml qml/ComputerGlyph.qml) +set(ICON_FILES + qml/icons/alert.svg qml/icons/arrow-left.svg qml/icons/check.svg qml/icons/chevron-down.svg qml/icons/chevron-right.svg qml/icons/close.svg + qml/icons/copy.svg qml/icons/external.svg qml/icons/grid.svg qml/icons/help.svg qml/icons/info.svg + qml/icons/laptop.svg qml/icons/minus.svg qml/icons/monitor.svg qml/icons/pencil.svg qml/icons/play.svg + qml/icons/plus.svg qml/icons/power.svg qml/icons/refresh.svg qml/icons/trash.svg) 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 qml/SetupDialog.qml) +qt_add_resources(remote-desktops-manager qml PREFIX "/" FILES ${QML_FILES} ${ICON_FILES}) 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 qml/SetupDialog.qml) +qt_add_resources(manager-tests qmlTests PREFIX "/" FILES ${QML_FILES} ${ICON_FILES}) +set(TEST_ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") add_test(NAME manager COMMAND manager-tests) -set_tests_properties(manager PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +set_tests_properties(manager PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") add_test(NAME qml-smoke COMMAND remote-desktops-manager --demo --smoke-test) -set_tests_properties(qml-smoke PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +set_tests_properties(qml-smoke PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") -foreach(state idle preflight restore-pending empty unavailable) +foreach(state idle connecting preflight running attention restore-pending empty unavailable many unconfigured) add_test(NAME qml-${state} COMMAND remote-desktops-manager --demo --state ${state} --smoke-test) - set_tests_properties(qml-${state} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") + set_tests_properties(qml-${state} PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") endforeach() -add_test(NAME qml-compact COMMAND remote-desktops-manager --demo --compact --smoke-test) -set_tests_properties(qml-compact PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") +add_test(NAME qml-compact COMMAND remote-desktops-manager --demo --state many --compact --smoke-test) +set_tests_properties(qml-compact PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") 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") +set_tests_properties(qml-light PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") 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") +set_tests_properties(qml-setup PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") 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") + set_tests_properties(qml-setup-${page} PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") +endforeach() + +foreach(surface help details remove notice error) + add_test(NAME qml-dialog-${surface} COMMAND remote-desktops-manager --demo --state idle --dialog ${surface} --smoke-test) + set_tests_properties(qml-dialog-${surface} PROPERTIES ENVIRONMENT "${TEST_ENVIRONMENT}") endforeach() diff --git a/ui/Manager.cpp b/ui/Manager.cpp index eeab22d..8e7d830 100644 --- a/ui/Manager.cpp +++ b/ui/Manager.cpp @@ -1,23 +1,33 @@ #include "Manager.h" #include +#include #include #include #include #include -#include +#include #include +namespace { +qint64 now() { return QDateTime::currentSecsSinceEpoch(); } +} Manager::Manager(QString backend, QString socket, bool demo, QObject *parent) : QObject(parent), m_backend(std::move(backend)), m_socketPath(std::move(socket)), m_demo(demo) { m_poll.setInterval(2000); connect(&m_poll, &QTimer::timeout, this, &Manager::poll); + // Pairing happens in Moonlight; the manager only offers to open it. + m_moonlight = QStandardPaths::findExecutable("moonlight"); + if (m_moonlight.isEmpty()) m_moonlight = QStandardPaths::findExecutable("moonlight-qt"); if (m_demo) { m_catalog = QJsonDocument::fromJson(R"([ {"computer":"studio","name":"Studio Mac","host":"studio.example.net","platform":"macos","default_profile":"desktop","profiles":["desktop","presentation"]}, {"computer":"work","name":"Work laptop","host":"work.example.net","platform":"windows","default_profile":"desktop","profiles":["desktop"]}, {"computer":"lab","name":"Linux workstation","host":"lab.example.net","platform":"linux","default_profile":"desktop","profiles":["desktop"]} ])").array(); - m_sessions = QJsonDocument::fromJson(R"([{"computer":"studio","profile":"desktop","phase":"window-ready","desired":true,"window":{"address":"demo"}},{"computer":"work","phase":"idle","desired":false},{"computer":"lab","phase":"idle","desired":false}])").array(); + m_sessions = QJsonDocument::fromJson(R"([{"computer":"studio","profile":"desktop","phase":"window-ready","desired":true,"window":{"address":"demo"},"client_version":"6.1.0","evidence":{"negotiated_video":{"width":2560,"height":1440,"fps":60}}},{"computer":"work","phase":"idle","desired":false},{"computer":"lab","phase":"idle","desired":false}])").array(); + auto studio = m_sessions[0].toObject(); + studio["launched_at"] = now() - 754; + m_sessions[0] = studio; m_available = true; m_loading = false; } else { QTimer::singleShot(0, this, &Manager::refresh); @@ -46,8 +56,16 @@ QVariantList Manager::computers() const { } return out; } +QString Manager::label(const QString &computer) const { + for (const auto &entry : m_catalog) if (entry.toObject()["computer"].toString() == computer) { + const auto name = entry.toObject()["name"].toString(); + if (!name.isEmpty()) return name; + } + return computer; +} void Manager::publish() { emit changed(); } -void Manager::clearNotice() { m_notice.clear(); publish(); } +void Manager::clearNotice() { m_notice.clear(); m_noticeError = false; publish(); } +void Manager::notify(QString text, bool error) { m_notice = std::move(text); m_noticeError = error; publish(); } void Manager::setActive(bool active) { m_active = active; if (m_demo) return; @@ -79,7 +97,10 @@ void Manager::process(QStringList arguments, std::function(&QProcess::finished), this, [output, failure, finish](int code, QProcess::ExitStatus status) { const bool ok = code == 0 && status == QProcess::NormalExit; - finish(ok, ok ? *output : (failure->isEmpty() ? QByteArray("The request failed. Refresh to check the current state.") : *failure)); + QByteArray message = failure->trimmed(); + // The CLI prefixes its own name; the manager already provides that context. + if (message.startsWith("remote-desktops: ")) message = message.mid(17); + finish(ok, ok ? *output : (message.isEmpty() ? QByteArray("The request failed. Refresh to check the current state.") : message)); }); connect(deadline, &QTimer::timeout, this, [job, finish] { job->kill(); finish(false, "The request timed out. Its outcome may still be pending; refresh before retrying."); @@ -144,20 +165,37 @@ void Manager::act(QString computer, QString action, QString profile) { for (const auto &entry : computers()) if (entry.toMap()["computer"].toString() == computer) known = true; if (!known) return; if (m_demo) { - m_busy.insert(computer); publish(); - QTimer::singleShot(650, this, [this, computer, action, profile] { + // The preview walks through the same intermediate phases a real + // session reports, so transitional states can be seen and tested. + auto set = [this, computer](std::function change) { for (qsizetype i = 0; i < m_sessions.size(); ++i) if (m_sessions[i].toObject()["computer"].toString() == computer) { - auto s = m_sessions[i].toObject(); - if (action != "focus" && action != "launcher") { - bool connected = action == "connect" || action == "reconnect"; - s["phase"] = connected ? "window-ready" : "idle"; s["desired"] = connected; - s["window"] = connected ? QJsonValue(QJsonObject{{"address", "demo"}}) : QJsonValue(); - s["error"] = QJsonValue(); s["recovery_pending"] = false; - if (!profile.isEmpty()) s["profile"] = profile; - m_sessions[i] = s; - } + auto s = m_sessions[i].toObject(); change(s); m_sessions[i] = s; + } + }; + m_busy.insert(computer); m_notice.clear(); m_noticeError = false; publish(); + QTimer::singleShot(350, this, [this, computer, action, profile, set] { + m_busy.remove(computer); + if (action == "focus" || action == "launcher") { + m_notice = action == "launcher" ? "Preview only — a launcher entry would be installed." : "Preview only — the desktop window would be focused."; + publish(); return; } - m_busy.remove(computer); m_notice = "Preview only — no real computer was changed."; publish(); + const bool connecting = action == "connect" || action == "reconnect"; + set([&](QJsonObject &s) { + s["phase"] = action == "connect" ? "connecting" : action == "reconnect" ? "reconnecting" : action == "restore" ? "restoring" : "stopping"; + s["desired"] = connecting; s["window"] = QJsonValue(); s["error"] = QJsonValue(); + if (!profile.isEmpty()) s["profile"] = profile; + }); + publish(); + QTimer::singleShot(900, this, [this, connecting, set] { + set([&](QJsonObject &s) { + s["phase"] = connecting ? "window-ready" : "idle"; + s["window"] = connecting ? QJsonValue(QJsonObject{{"address", "demo"}}) : QJsonValue(); + s["recovery_pending"] = false; s["error"] = QJsonValue(); + if (connecting) { s["launched_at"] = now(); s["client_version"] = "6.1.0"; } + else s.remove("launched_at"); + }); + publish(); + }); }); return; } @@ -165,25 +203,83 @@ void Manager::act(QString computer, QString action, QString profile) { if (action == "launcher") args << "launcher" << "install" << computer; else args << action << computer; if (action == "connect" && !profile.isEmpty()) args << "--profile" << profile; - m_busy.insert(computer); m_notice.clear(); publish(); + m_busy.insert(computer); m_notice.clear(); m_noticeError = false; publish(); process(args, [this, computer, action](bool ok, QByteArray data) { m_busy.remove(computer); - m_notice = ok ? (action == "launcher" ? "Launcher added. Find this computer in your app launcher and Scenes." : "Request accepted. Connection status will update shortly.") - : QString::fromUtf8(data).trimmed(); + // Accepted connection commands are visible through status itself; + // only results with no other visible effect are announced. + if (!ok) { m_notice = QString::fromUtf8(data).trimmed(); m_noticeError = true; } + else if (action == "launcher") m_notice = label(computer) + " was added to your app launcher."; poll(); publish(); }); } +void Manager::remove(QString computer) { + if (m_busy.contains(computer)) return; + const QString name = label(computer); + auto forget = [this, computer] { + for (qsizetype i = m_catalog.size() - 1; i >= 0; --i) if (m_catalog[i].toObject()["computer"].toString() == computer) m_catalog.removeAt(i); + for (qsizetype i = m_sessions.size() - 1; i >= 0; --i) if (m_sessions[i].toObject()["computer"].toString() == computer) m_sessions.removeAt(i); + m_demoDrafts.remove(computer); + }; + m_busy.insert(computer); m_notice.clear(); m_noticeError = false; publish(); + if (m_demo) { + QTimer::singleShot(400, this, [this, computer, name, forget] { + m_busy.remove(computer); forget(); + m_notice = name + " was removed from the preview."; publish(); + }); + return; + } + process({"--json", "settings", "remove", computer}, [this, computer, name, forget](bool ok, QByteArray data) { + m_busy.remove(computer); + if (ok) { forget(); m_notice = name + " was removed."; } + else { m_notice = QString::fromUtf8(data).trimmed(); m_noticeError = true; } + loadCatalog(); poll(); publish(); + }); +} +void Manager::startService() { + if (m_serviceBusy) return; + if (m_demo) { m_available = true; m_notice = "Preview only — the service is simulated."; m_noticeError = false; publish(); return; } + m_serviceBusy = true; m_notice.clear(); m_noticeError = false; publish(); + process({"--json", "start"}, [this](bool ok, QByteArray data) { + m_serviceBusy = false; + if (!ok) { m_notice = QString::fromUtf8(data).trimmed(); m_noticeError = true; } + poll(); publish(); + }); +} +void Manager::openMoonlight() { + if (m_demo) { m_notice = "Preview only — Moonlight would open for pairing."; m_noticeError = false; publish(); return; } + if (m_moonlight.isEmpty() || !QProcess::startDetached(m_moonlight, {})) { + m_notice = "Moonlight could not be started. Open it from your app launcher to pair a computer."; m_noticeError = true; publish(); + } +} void Manager::copy(QString text) { QGuiApplication::clipboard()->setText(text); } void Manager::demoState(QString phase) { if (!m_demo) return; if (phase == "empty") { m_catalog = {}; m_sessions = {}; publish(); return; } if (phase == "unavailable") { m_available = false; publish(); return; } + if (phase == "many") { + for (int i = 1; i <= 9; ++i) { + const QString id = QString("extra-%1").arg(i); + m_catalog.append(QJsonObject{{"computer", id}, {"name", QString("Office desk %1").arg(i)}, {"host", id + ".example.net"}, {"platform", "linux"}, {"default_profile", "desktop"}, {"profiles", QJsonArray{"desktop"}}}); + m_sessions.append(QJsonObject{{"computer", id}, {"phase", "idle"}, {"desired", false}}); + } + publish(); return; + } + if (phase == "unconfigured") { + m_sessions.append(QJsonObject{{"computer", "old-desk"}, {"phase", "restore-pending"}, {"desired", false}, {"recovery_pending", true}, + {"error", "host-unreachable: the host did not answer. Its original display settings are saved."}}); + publish(); return; + } if (m_sessions.isEmpty()) return; auto s = m_sessions[0].toObject(); - s["phase"] = phase; s["desired"] = phase == "window-ready" || phase == "preflight"; + s["phase"] = phase; s["desired"] = phase == "window-ready" || phase == "preflight" || phase == "connecting" || phase == "running"; s["window"] = phase == "window-ready" ? QJsonValue(QJsonObject{{"address", "demo"}}) : QJsonValue(); s["recovery_pending"] = phase == "restore-pending"; - s["error"] = phase == "restore-pending" ? "The host is unreachable. Its original display settings are saved; restore when it is reachable again." : ""; + s["error"] = phase == "restore-pending" ? "The host is unreachable. Its original display settings are saved; restore when it is reachable again." + : phase == "attention" ? "host-unreachable: Sunshine did not answer on studio.example.net:47989" : ""; + if (phase == "running") { s["launched_at"] = now() - 41; s["client_version"] = "6.1.0"; s.remove("evidence"); } + else if (phase != "window-ready") { s.remove("launched_at"); s.remove("evidence"); } + if (phase == "preflight") { s["attempts"] = 2; s["next_retry"] = now() + 5; s["error"] = "host-unreachable: Sunshine did not answer; retrying"; } m_sessions[0] = s; publish(); } @@ -206,7 +302,7 @@ void Manager::setup(QString action, QVariantMap draft) { m_catalog[i] = old; found = true; } if (!found) m_catalog.append(entry); - m_notice = "Computer saved. Changes apply to the next new connection."; + m_notice = entry["name"].toString() + " was saved. Changes apply to the next new connection."; m_noticeError = false; } emit setupFinished(action, valid, valid ? document.object().toVariantMap() : QVariantMap{}, valid ? QString{} : ok ? "The backend returned invalid settings." : QString::fromUtf8(data).trimmed()); @@ -214,7 +310,7 @@ void Manager::setup(QString action, QVariantMap draft) { publish(); }; if (m_demo) { - QTimer::singleShot(200, this, [this, action, draft, complete] { + QTimer::singleShot(action == "test" ? 1100 : 300, 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")}}}}}; @@ -238,7 +334,7 @@ void Manager::setup(QString action, QVariantMap draft) { for (qsizetype i=0; i complete, QByteArray input = {}); - QString m_backend, m_socketPath, m_error, m_notice; + QString m_backend, m_socketPath, m_error, m_notice, m_moonlight; bool m_demo, m_loading = true, m_available = false, m_active = true, m_catalogLoading = false; + bool m_noticeError = false, m_setupBusy = false, m_serviceBusy = 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/Theme.cpp b/ui/Theme.cpp index aed7fba..ca7f137 100644 --- a/ui/Theme.cpp +++ b/ui/Theme.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -20,50 +22,81 @@ double contrast(const QColor &a, const QColor &b) { double x = luminance(a), y = luminance(b); return (std::max(x,y)+.05)/(std::min(x,y)+.05); } +// Nudge a foreground toward black or white until it reads at WCAG AA (4.5:1). QColor readable(QColor text, const QColor &background) { const QColor target = contrast(Qt::black, background) > contrast(Qt::white, background) ? Qt::black : Qt::white; for (int i=0; i<100 && contrast(text, background)<4.5; ++i) text = mix(text, target, .05); return text; } } -QVariantMap Theme::palette(const QMap &source) { +QVariantMap Theme::palette(const QMap &source, const QString &mode) { auto value = [&](const QString &key, QColor fallback) { return source.value(key, fallback); }; - QColor bg = value("background", QColor("#141c20")); - QColor text = readable(value("foreground", QColor("#f0f2ed")), bg); - QColor accent = value("accent", QColor("#afe6c5")); - QColor surface = value("lighter_background", mix(bg, text, .06)); - QColor sidebar = value("dark_background", mix(bg, Qt::black, .15)); - QColor warningBg = mix(bg, value("yellow", QColor("#e9bd83")), .12); - QColor selected = mix(bg, accent, .14); - QColor secondary = readable(mix(bg, text, .74), bg); - QColor border = mix(bg, text, .24); - // Shared foreground must remain readable on every ordinary surface. - for (const auto &background : {surface, sidebar, selected}) text = readable(text, background); + const QColor bg = value("background", QColor("#141c20")); + // Omarchy declares its mode; a bare palette is classified by luminance. + const bool light = mode == "light" || (mode.isEmpty() && luminance(bg) > .35); + const QColor accent = value("accent", QColor("#afe6c5")); + const QColor yellow = value("yellow", QColor(light ? "#8a6a1a" : "#e9bd83")); + const QColor red = value("red", QColor(light ? "#a83a3a" : "#f2b2a7")); + const QColor green = value("green", accent); + QColor text = readable(value("foreground", QColor(light ? "#2b2f36" : "#f0f2ed")), bg); + // Optional Omarchy keys refine derived tones; background, foreground and + // accent alone still produce a complete palette. + const QColor surface = value("lighter_background", mix(bg, text, light ? .045 : .06)); + const QColor sidebar = value("dark_background", mix(bg, Qt::black, light ? .035 : .15)); + const QColor selected = source.contains("selection") ? mix(bg, value("selection", accent), .6) : mix(bg, accent, .14); + const QColor hover = mix(surface, text, .06); + const QColor cardStart = mix(bg, accent, .10), cardEnd = mix(bg, accent, .03); + const QColor warningBg = mix(bg, yellow, .12), successBg = mix(bg, green, .12), dangerBg = mix(bg, red, .12); + const QColor disabled = mix(bg, text, .05); + QColor secondary = mix(bg, text, .74); + QColor muted = source.contains("muted") ? value("muted", text) : mix(bg, text, .58); + // Every shared foreground is checked on every surface it can be drawn on. + const QList grounds{bg, sidebar, surface, selected, hover, cardStart, warningBg, successBg, dangerBg}; + auto everywhere = [&](QColor c) { for (const auto &g : grounds) c = readable(c, g); return c; }; + text = everywhere(text); secondary = everywhere(secondary); muted = everywhere(muted); QVariantMap result; - auto put = [&](const char *key, QColor color) { result[key] = color; }; - put("bg", bg); put("sidebar", sidebar); put("surface", surface); - put("text", text); put("secondary", secondary); put("muted", readable(mix(bg,text,.58), bg)); - put("border", border); put("hover", mix(surface,text,.06)); - put("accent", accent); put("onAccent", readable(bg,accent)); - put("accentHover", mix(accent, text,.15)); put("accentPressed", mix(accent,bg,.12)); - put("selected", selected); put("selectedBorder", mix(bg,accent,.45)); - put("accentText", readable(accent,selected)); - put("warningBg", warningBg); put("warning", readable(value("yellow",QColor("#e9bd83")),warningBg)); - put("warningBorder", mix(bg,value("yellow",QColor("#e9bd83")),.45)); - put("danger", readable(value("red",QColor("#f2b2a7")),surface)); - put("success", readable(value("green",accent),sidebar)); - put("heroStart", mix(bg,accent,.12)); put("heroEnd", mix(bg,accent,.025)); - put("screen", mix(bg,accent,.08)); put("tile1", mix(bg,accent,.21)); - put("tile2", mix(bg,accent,.38)); put("tile3", mix(bg,accent,.28)); - put("disabled", mix(bg,text,.06)); put("disabledText", mix(bg,text,.48)); + auto put = [&](const char *key, const QVariant &v) { result[key] = v; }; + put("mode", light ? "light" : "dark"); + put("bg", bg); put("sidebar", sidebar); put("surface", surface); put("hover", hover); + put("text", text); put("secondary", secondary); put("muted", muted); + put("border", mix(bg, text, light ? .16 : .22)); put("borderStrong", mix(bg, text, light ? .3 : .38)); + put("accent", accent); put("onAccent", readable(bg, accent)); + put("accentHover", mix(accent, text, .15)); put("accentPressed", mix(accent, bg, .12)); + put("accentText", everywhere(readable(accent, selected))); + put("selected", selected); put("selectedBorder", mix(bg, accent, .45)); + put("warning", everywhere(readable(yellow, warningBg))); put("warningBg", warningBg); put("warningBorder", mix(bg, yellow, .45)); + put("danger", everywhere(readable(red, dangerBg))); put("dangerBg", dangerBg); put("dangerBorder", mix(bg, red, .45)); + put("success", everywhere(readable(green, successBg))); put("successBg", successBg); put("successBorder", mix(bg, green, .45)); + put("cardStart", cardStart); put("cardEnd", cardEnd); + put("disabled", disabled); put("disabledText", readable(mix(bg, text, .5), disabled)); + put("overlay", QColor(0, 0, 0, light ? 70 : 140)); + put("shadow", QColor(0, 0, 0, light ? 35 : 120)); + const QColor tooltipBg = mix(surface, text, .1); + put("tooltipBg", tooltipBg); put("tooltipText", readable(text, tooltipBg)); return result; } +QVariantMap Theme::scale(double base) { + if (!(base > 0)) base = 10.5; + auto half = [](double v) { return std::round(v * 2) / 2; }; + const double body = std::max(10.0, half(base)); + QVariantMap t; + t["caption"] = std::max(9.0, half(base * .86)); + t["body"] = body; + t["lead"] = half(body * 1.15); + t["subtitle"] = half(body * 1.45); + t["title"] = half(body * 2.1); + return t; +} QString Theme::currentPath() { auto state = qEnvironmentVariable("XDG_STATE_HOME"); if (state.isEmpty()) state = QDir::homePath() + "/.local/state"; return state + "/omarchy/current/theme/colors.toml"; } Theme::Theme(QString path, QObject *parent) : QObject(parent), m_path(std::move(path)), m_colors(palette({})) { + const QFont font = QGuiApplication::font(); + double base = font.pointSizeF(); + if (base <= 0 && font.pixelSize() > 0) base = font.pixelSize() * 72.0 / 96.0; + m_type = scale(base); m_debounce.setSingleShot(true); m_debounce.setInterval(100); connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, [this] { m_debounce.start(); }); connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { m_debounce.start(); }); @@ -93,15 +126,19 @@ void Theme::reload() { if (!file.open(QIODevice::ReadOnly) || file.size() > 65536) return; const QString text = QString::fromUtf8(file.readAll()); QMap source; - // Only the flat hex-color assignments in Omarchy's colors.toml contract - // are interpreted. No shell, external parser, or theme code is executed. + QString mode; + // Only the flat hex-color assignments and the mode key in Omarchy's + // colors.toml contract are interpreted. No theme code is executed. static const QRegularExpression line(R"(^\s*([a-z_]+)\s*=\s*["'](#[0-9a-fA-F]{6})["']\s*(?:#.*)?$)"); + static const QRegularExpression modeLine(R"(^\s*mode\s*=\s*["'](light|dark)["']\s*(?:#.*)?$)"); for (const auto &part : text.split('\n')) { auto match = line.match(part); if (match.hasMatch()) source[match.captured(1)] = QColor(match.captured(2)); + auto m = modeLine.match(part); + if (m.hasMatch()) mode = m.captured(1); } // Keep the last complete palette through a theme replacement or partial write. if (!source.contains("background") || !source.contains("foreground") || !source.contains("accent")) return; - auto next = palette(source); + auto next = palette(source, mode); if (next != m_colors) { m_colors = next; emit changed(); } } diff --git a/ui/Theme.h b/ui/Theme.h index 4db2273..318079a 100644 --- a/ui/Theme.h +++ b/ui/Theme.h @@ -8,18 +8,23 @@ class Theme : public QObject { Q_OBJECT Q_PROPERTY(QVariantMap colors READ colors NOTIFY changed) + Q_PROPERTY(QVariantMap type READ type CONSTANT) public: explicit Theme(QString path, QObject *parent = nullptr); QVariantMap colors() const { return m_colors; } + // Type scale in points, derived from the desktop's application font so + // text follows the system font size the same way colors follow the theme. + QVariantMap type() const { return m_type; } static QString currentPath(); - static QVariantMap palette(const QMap &source); + static QVariantMap palette(const QMap &source, const QString &mode = {}); + static QVariantMap scale(double basePointSize); signals: void changed(); private: void reload(); void watch(); QString m_path; - QVariantMap m_colors; + QVariantMap m_colors, m_type; QFileSystemWatcher m_watcher; QTimer m_debounce; }; diff --git a/ui/main.cpp b/ui/main.cpp index 5e98081..0ddc31f 100644 --- a/ui/main.cpp +++ b/ui/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -25,10 +26,11 @@ int main(int argc, char **argv) { 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.addOption({"dialog", "Open a preview surface (help, details, remove, notice, error).", "name"}); + parser.addOption({"state", "Initial demo state (idle, connecting, preflight, running, attention, restore-pending, empty, unavailable, many, unconfigured).", "phase"}); parser.process(app); const bool demo = parser.isSet("demo"); - if ((parser.isSet("smoke-test") || parser.isSet("screenshot") || parser.isSet("state") || parser.isSet("compact") || parser.isSet("setup-preview")) && !demo) return 2; + if ((parser.isSet("smoke-test") || parser.isSet("screenshot") || parser.isSet("state") || parser.isSet("compact") || parser.isSet("setup-preview") || parser.isSet("dialog")) && !demo) return 2; QString backend = parser.value("backend"); if (backend.isEmpty()) { backend = QCoreApplication::applicationDirPath() + "/remote-desktops"; @@ -51,23 +53,40 @@ int main(int argc, char **argv) { engine.rootContext()->setContextProperty("theme", &theme); engine.load(QUrl("qrc:/qml/Main.qml")); if (engine.rootObjects().isEmpty()) return 1; + auto *window = engine.rootObjects().first(); + // Window size and the last selected computer persist between launches. + // The preview never writes user settings. + QSettings settings; + if (!demo) { + if (settings.contains("window/width") && settings.contains("window/height")) { + window->setProperty("width", qMax(settings.value("window/width").toInt(), window->property("minimumWidth").toInt())); + window->setProperty("height", qMax(settings.value("window/height").toInt(), window->property("minimumHeight").toInt())); + } + window->setProperty("selectedId", settings.value("window/selected").toString()); + QObject::connect(&app, &QCoreApplication::aboutToQuit, &app, [&settings, window] { + settings.setValue("window/width", window->property("width")); + settings.setValue("window/height", window->property("height")); + settings.setValue("window/selected", window->property("selectedId")); + }); + } if (parser.isSet("setup-preview")) { - auto *setup = engine.rootObjects().first()->findChild("setupDialog"); + auto *setup = window->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] { + if (page != "computer") QTimer::singleShot(450, 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 (page == "check") QMetaObject::invokeMethod(setup, "advance"); }); } - if (parser.isSet("compact")) { engine.rootObjects().first()->setProperty("width", 820); engine.rootObjects().first()->setProperty("height", 650); } + if (parser.isSet("compact")) { window->setProperty("width", 880); window->setProperty("height", 600); } + if (parser.isSet("dialog")) QTimer::singleShot(300, window, [window, name = parser.value("dialog")] { QMetaObject::invokeMethod(window, "preview", Q_ARG(QVariant, QVariant(name))); }); if (parser.isSet("smoke-test") || parser.isSet("screenshot")) { - QTimer::singleShot(900, &app, [&] { + QTimer::singleShot(2200, &app, [&] { if (parser.isSet("screenshot")) { - auto *window = qobject_cast(engine.rootObjects().first()); - if (!window || !window->grabWindow().save(parser.value("screenshot"))) warnings = true; + auto *quick = qobject_cast(window); + if (!quick || !quick->grabWindow().save(parser.value("screenshot"))) warnings = true; } app.exit(warnings ? 1 : 0); }); diff --git a/ui/qml/ActionButton.qml b/ui/qml/ActionButton.qml index 7ed7b7f..27abf73 100644 --- a/ui/qml/ActionButton.qml +++ b/ui/qml/ActionButton.qml @@ -1,32 +1,47 @@ import QtQuick import QtQuick.Controls +import QtQuick.Controls.impl Button { id: control property bool primary: false property bool destructive: false + // Quiet buttons are tertiary actions: no surface until hovered. + property bool quiet: false property string hint: "" - implicitHeight: 44 - implicitWidth: Math.max(100, contentItem.implicitWidth + 36) - font.pixelSize: 14 - font.weight: Font.DemiBold + readonly property color contentColor: !enabled ? theme.colors.disabledText + : primary ? theme.colors.onAccent + : destructive ? theme.colors.danger + : quiet ? theme.colors.secondary : theme.colors.text + icon.width: 18; icon.height: 18 + icon.color: contentColor + implicitHeight: quiet ? 36 : 42 + implicitWidth: Math.max(quiet ? 0 : 96, implicitContentWidth + leftPadding + rightPadding) + leftPadding: quiet ? 10 : 18; rightPadding: quiet ? 10 : 18 + spacing: 8 + font.pointSize: theme.type.body + font.weight: quiet ? Font.Medium : Font.DemiBold hoverEnabled: true Accessible.name: text - ToolTip.visible: hovered && hint.length > 0 - ToolTip.text: hint - ToolTip.delay: 650 - contentItem: Text { + contentItem: IconLabel { + icon: control.icon text: control.text font: control.font - color: !control.enabled ? theme.colors.disabledText : control.primary ? theme.colors.onAccent : control.destructive ? theme.colors.danger : theme.colors.text - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + color: control.contentColor + spacing: control.spacing + display: control.display + mirrored: control.mirrored + alignment: Qt.AlignCenter } background: Rectangle { radius: 9 - color: !control.enabled ? theme.colors.disabled : control.primary ? (control.down ? theme.colors.accentPressed : control.hovered ? theme.colors.accentHover : theme.colors.accent) : control.hovered ? theme.colors.hover : theme.colors.surface - border.width: control.visualFocus ? 2 : 1 - border.color: control.visualFocus ? theme.colors.accent : control.primary ? "transparent" : theme.colors.border - Behavior on color { ColorAnimation { duration: 110 } } + color: !control.enabled ? (control.quiet ? "transparent" : theme.colors.disabled) + : control.primary ? (control.down ? theme.colors.accentPressed : control.hovered ? theme.colors.accentHover : theme.colors.accent) + : control.destructive && !control.quiet ? (control.down ? theme.colors.dangerBorder : control.hovered ? theme.colors.dangerBg : theme.colors.surface) + : control.down ? theme.colors.selected : control.hovered ? theme.colors.hover : control.quiet ? "transparent" : theme.colors.surface + border.width: control.visualFocus ? 2 : (control.primary || control.quiet || !control.enabled) ? 0 : 1 + border.color: control.visualFocus ? theme.colors.accent : control.destructive ? theme.colors.dangerBorder : theme.colors.border + Behavior on color { ColorAnimation { duration: 120 } } } + Tip { text: control.hint; visible: control.hovered && control.hint.length > 0 } } diff --git a/ui/qml/Bar.qml b/ui/qml/Bar.qml new file mode 100644 index 0000000..27a0054 --- /dev/null +++ b/ui/qml/Bar.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtQuick.Controls + +// Scroll bar that stays visible whenever there is more content to reach. +ScrollBar { + id: bar + policy: ScrollBar.AsNeeded + implicitWidth: 12; implicitHeight: 12 + padding: 3 + minimumSize: .08 + hoverEnabled: true + contentItem: Rectangle { + implicitWidth: 6; implicitHeight: 6 + radius: 3 + color: bar.pressed || bar.hovered ? theme.colors.muted : theme.colors.borderStrong + opacity: bar.size < 1 ? (bar.active || bar.hovered ? 1 : .85) : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + Behavior on color { ColorAnimation { duration: 120 } } + } +} diff --git a/ui/qml/Check.qml b/ui/qml/Check.qml new file mode 100644 index 0000000..4cf91af --- /dev/null +++ b/ui/qml/Check.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Controls + +CheckBox { + id: control + spacing: 10 + font.pointSize: theme.type.body + indicator: Rectangle { + implicitWidth: 22; implicitHeight: 22; radius: 6 + x: control.leftPadding; y: (control.height - height) / 2 + color: !control.enabled ? theme.colors.disabled : control.checked ? theme.colors.accent : theme.colors.surface + border.width: control.visualFocus ? 2 : 1 + border.color: control.visualFocus ? theme.colors.accent : control.checked ? theme.colors.accent : theme.colors.borderStrong + Behavior on color { ColorAnimation { duration: 120 } } + Icon { glyph: "check"; size: 14; anchors.centerIn: parent; color: control.enabled ? theme.colors.onAccent : theme.colors.disabledText; visible: control.checked } + } + contentItem: Text { + text: control.text; font: control.font + color: control.enabled ? theme.colors.text : theme.colors.disabledText + leftPadding: control.indicator.width + control.spacing + verticalAlignment: Text.AlignVCenter; wrapMode: Text.WordWrap + } +} diff --git a/ui/qml/ComputerGlyph.qml b/ui/qml/ComputerGlyph.qml index 956ac9f..41cdb82 100644 --- a/ui/qml/ComputerGlyph.qml +++ b/ui/qml/ComputerGlyph.qml @@ -1,4 +1,5 @@ import QtQuick +// Simple device outline; laptops get a base line, desktops a stand. Item { id: icon property color ink: theme.colors.secondary diff --git a/ui/qml/Confirm.qml b/ui/qml/Confirm.qml new file mode 100644 index 0000000..5b786d0 --- /dev/null +++ b/ui/qml/Confirm.qml @@ -0,0 +1,26 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Sheet { + id: confirm + property string body: "" + property string actionText: "Continue" + property bool destructive: true + property var callback: null + width: Math.min(parent.width - 48, 440) + function ask(title, body, actionText, callback) { + confirm.title = title; confirm.body = body; confirm.actionText = actionText; confirm.callback = callback + open() + } + Label { Layout.fillWidth: true; text: confirm.body; color: theme.colors.secondary; wrapMode: Text.WordWrap; lineHeight: 1.25 } + footer: Sheet.Footer { + ActionButton { text: "Cancel"; onClicked: confirm.close() } + Item { Layout.fillWidth: true } + ActionButton { + objectName: "confirmAction" + text: confirm.actionText; primary: !confirm.destructive; destructive: confirm.destructive + onClicked: { const run = confirm.callback; confirm.close(); if (run) run() } + } + } +} diff --git a/ui/qml/Field.qml b/ui/qml/Field.qml new file mode 100644 index 0000000..e552292 --- /dev/null +++ b/ui/qml/Field.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtQuick.Controls + +TextField { + id: control + property bool invalid: false + implicitHeight: 42 + leftPadding: 12; rightPadding: 12 + font.pointSize: theme.type.body + color: enabled ? theme.colors.text : theme.colors.disabledText + placeholderTextColor: theme.colors.muted + selectionColor: theme.colors.accent; selectedTextColor: theme.colors.onAccent + background: Rectangle { + radius: 9 + color: control.enabled ? theme.colors.surface : theme.colors.disabled + border.width: control.activeFocus || control.invalid ? 2 : 1 + border.color: control.invalid ? theme.colors.danger : control.activeFocus ? theme.colors.accent : theme.colors.border + Behavior on border.color { ColorAnimation { duration: 120 } } + } +} diff --git a/ui/qml/Icon.qml b/ui/qml/Icon.qml new file mode 100644 index 0000000..da1d580 --- /dev/null +++ b/ui/qml/Icon.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls.impl + +// Tinted line icon from the bundled SVG set. +IconImage { + property string glyph: "" + property int size: 18 + source: glyph ? "qrc:/qml/icons/" + glyph + ".svg" : "" + sourceSize: Qt.size(size, size) + width: size; height: size + color: theme.colors.secondary + fillMode: Image.PreserveAspectFit +} diff --git a/ui/qml/IconButton.qml b/ui/qml/IconButton.qml new file mode 100644 index 0000000..2c6acb1 --- /dev/null +++ b/ui/qml/IconButton.qml @@ -0,0 +1,26 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.impl + +// Icon-only button; the hint doubles as its accessible name. +Button { + id: control + property string name: "" + property string hint: "" + icon.source: name ? "qrc:/qml/icons/" + name + ".svg" : "" + icon.width: 18; icon.height: 18 + icon.color: !enabled ? theme.colors.disabledText : hovered ? theme.colors.text : theme.colors.secondary + implicitWidth: 36; implicitHeight: 36 + hoverEnabled: true + display: AbstractButton.IconOnly + Accessible.name: hint + contentItem: IconLabel { icon: control.icon; display: AbstractButton.IconOnly; alignment: Qt.AlignCenter; mirrored: control.mirrored } + background: Rectangle { + radius: 9 + color: control.down ? theme.colors.selected : control.hovered ? theme.colors.hover : "transparent" + border.width: control.visualFocus ? 2 : 0 + border.color: theme.colors.accent + Behavior on color { ColorAnimation { duration: 120 } } + } + Tip { text: control.hint; visible: control.hovered && control.hint.length > 0 } +} diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 56a6361..d17173f 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -5,9 +5,9 @@ import QtQuick.Layouts ApplicationWindow { id: root width: 1120; height: 760 - minimumWidth: 820; minimumHeight: 650 + minimumWidth: 880; minimumHeight: 600 visible: true - title: manager.demo ? "Remote Desktops · Design preview" : "Remote Desktops" + title: manager.demo ? "Remote Desktops · Preview" : "Remote Desktops" color: theme.colors.bg palette.window: theme.colors.bg palette.windowText: theme.colors.text @@ -19,278 +19,498 @@ ApplicationWindow { palette.highlight: theme.colors.accent palette.highlightedText: theme.colors.onAccent palette.brightText: theme.colors.onAccent - palette.dark: theme.colors.secondary + palette.dark: theme.colors.borderStrong palette.mid: theme.colors.border - font.family: "Inter" - font.pixelSize: 14 + palette.placeholderText: theme.colors.muted + palette.toolTipBase: theme.colors.tooltipBg + palette.toolTipText: theme.colors.tooltipText + // Type follows the desktop font; only the scale is set here. + font.pointSize: theme.type.body + property string selectedId: "" property string chosenProfile: "" + property real nowSeconds: Date.now() / 1000 readonly property var entries: manager.computers readonly property var selected: { for (let i = 0; i < entries.length; ++i) if (entries[i].computer === selectedId) return entries[i] return entries.length ? entries[0] : null } readonly property string phase: selected ? selected.phase : "idle" - readonly property bool connected: selected && !selected.stale && !!selected.window && selected.desired - readonly property bool recovering: phase === "restore-pending" || phase === "attention" && selected && (!!selected.recovery_pending || !!selected.recovery_error) + readonly property bool stale: !!selected && !!selected.stale + readonly property bool connected: !!selected && !stale && !!selected.window && !!selected.desired + readonly property bool recovering: phase === "restore-pending" || (phase === "attention" && !!selected && (!!selected.recovery_pending || !!selected.recovery_error)) readonly property bool transitioning: manager.available && ["preflight", "preparing", "connecting", "reconnecting", "stopping", "restoring", "release-pending"].indexOf(phase) >= 0 + readonly property var profiles: selected ? selected.profiles || [] : [] readonly property string profile: { if (!selected) return "" if (selected.desired && selected.profile) return selected.profile - const profiles = selected.profiles || [] return profiles.indexOf(chosenProfile) >= 0 ? chosenProfile : selected.default_profile || (profiles.indexOf("desktop") >= 0 ? "desktop" : profiles[0]) || "" } - readonly property bool canAct: !!root.selected && !root.selected.busy && !root.transitioning && (!root.selected.unconfigured || root.recovering) + readonly property bool canAct: !!selected && !selected.busy && !transitioning && phase !== "running" && (!selected.unconfigured || recovering) + readonly property string errorText: selected && !stale ? (selected.error || selected.recovery_error || "") : "" + readonly property string headline: { + if (!selected) return "" + if (stale) return "Status unavailable" + if (selected.busy) return "Sending request…" + if (selected.unconfigured) return recovering ? "Display restore needed" : "Removed from settings" + if (recovering) return "Display restore needed" + if (phase === "attention") return "Needs attention" + if (connected) return "Connected" + if (phase === "running") return "Client running" + if (transitioning) return label(phase) + "…" + return "Not connected" + } + readonly property string explanation: { + if (!selected) return "" + if (stale) return "Last known state: " + label(phase).toLowerCase() + ". The background service is not answering, so this may be out of date. An open desktop window keeps working." + if (selected.unconfigured) return recovering ? "This computer was removed from your settings, but its last session could not restore the host display. Restore it before forgetting the record." + : "This computer is no longer in your settings. Its session record is settled and can be removed." + if (recovering) return "The last session could not finish restoring the host display. Bring the computer online, then restore before connecting again." + if (phase === "attention") return "The last connection ended with an error. Check that the computer and Sunshine are reachable, then connect again." + if (connected) return "Your desktop is open in its own Moonlight window. Switch to it, or reconnect to restart the client." + if (phase === "running") return "The Moonlight client is running, but window detection is not available in this session. Open it from your taskbar." + if (phase === "preflight") return "Checking that the computer and Sunshine are reachable." + if (phase === "preparing") return "Preparing the host display for streaming." + if (phase === "connecting") return "Starting Moonlight and waiting for its window." + if (phase === "reconnecting") return "Restarting the client. The saved recovery settings are kept." + if (phase === "stopping") return "Closing the desktop window and restoring the host display." + if (phase === "restoring") return "Restoring the host display settings." + if (phase === "release-pending") return "Releasing the saved recovery record." + return "Connect to open a full desktop in its own window." + } + readonly property var facts: { + const f = [] + if (!selected || stale || selected.unconfigured) return f + if (connected && selected.launched_at > 0) f.push({label: "Connected for", value: duration(selected.launched_at)}) + if (profile) f.push({label: "Profile", value: profile}) + if (selected.desired || phase === "running") f.push({label: "Window", value: selected.window ? "Detected" : phase === "running" ? "Not observed" : "Waiting"}) + const video = selected.evidence ? selected.evidence.negotiated_video : null + if (video && video.width) f.push({label: "Video stream", value: video.width + " × " + video.height + " · " + video.fps + " fps"}) + if (selected.desired && selected.client_version && selected.client_version !== "unknown") f.push({label: "Client", value: "Moonlight " + selected.client_version}) + if (selected.attempts > 0 && selected.next_retry > nowSeconds) f.push({label: "Next attempt", value: "in " + Math.ceil(selected.next_retry - nowSeconds) + " s · attempt " + (selected.attempts + 1) + " of 3"}) + return f + } + readonly property string tone: !selected || stale ? "neutral" : recovering || phase === "attention" ? "warning" : connected ? "success" : "neutral" onSelectedIdChanged: chosenProfile = "" onActiveChanged: manager.setActive(active) function label(p) { - return ({"window-ready":"Connected", running:"Client running", idle:"Disconnected", preflight:"Checking connection", preparing:"Preparing desktop", connecting:"Opening desktop", reconnecting:"Reconnecting", stopping:"Disconnecting", restoring:"Restoring display", "restore-pending":"Restore needed", attention:"Needs attention", "release-pending":"Releasing recovery"})[p] || "Checking status" + return ({"window-ready": "Connected", running: "Client running", idle: "Not connected", preflight: "Checking connection", preparing: "Preparing desktop", + connecting: "Opening desktop", reconnecting: "Reconnecting", stopping: "Disconnecting", restoring: "Restoring display", + "restore-pending": "Restore needed", attention: "Needs attention", "release-pending": "Releasing recovery"})[p] || "Checking status" + } + function platformName(p) { return ({macos: "macOS", windows: "Windows", linux: "Linux"})[p] || "Remote computer" } + function duration(since) { + const s = Math.max(0, Math.floor(nowSeconds - since)) + if (s < 60) return "under a minute" + const m = Math.floor(s / 60) + if (m < 60) return m + " min" + return Math.floor(m / 60) + " h " + (m % 60) + " min" } function action() { if (!canAct) return manager.act(selected.computer, recovering ? "restore" : connected ? "focus" : "connect", profile) } + function removeSelected() { + if (!selected || selected.busy) return + const id = selected.computer, name = selected.name || id + confirm.ask("Remove " + name + "?", + selected.unconfigured ? "The settled session record for this computer will be deleted." + : "Its settings and app launcher entry will be deleted. Pairing stays in Moonlight, so it can be added again later.", + "Remove", function() { manager.remove(id) }) + } + function preview(name) { + // Demo-only: open a secondary surface for screenshots and smoke tests. + if (name === "help") help.open() + else if (name === "details") details.open() + else if (name === "remove") removeSelected() + else if (name === "notice") manager.notify("Studio Mac was added to your app launcher.") + else if (name === "error") manager.notify("Disconnect this computer before removing it.", true) + } + readonly property bool dialogOpen: setup.opened || help.opened || details.opened || confirm.opened + Timer { interval: 1000; repeat: true; running: root.connected || (!!root.selected && root.selected.next_retry > root.nowSeconds); onTriggered: root.nowSeconds = Date.now() / 1000 } Shortcut { sequence: "Ctrl+R"; onActivated: manager.refresh() } - 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 } + Shortcut { sequence: "Ctrl+N"; enabled: !root.dialogOpen && !manager.setupBusy; onActivated: setup.begin("") } + Shortcut { sequence: "Ctrl+Return"; enabled: root.canAct && !root.dialogOpen; onActivated: root.action() } + component Caption: Label { color: theme.colors.muted; font.pointSize: theme.type.caption; font.letterSpacing: 1.1; font.weight: Font.DemiBold } 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 } - RowLayout { + ColumnLayout { anchors.fill: parent spacing: 0 + // Header: identity on the left, global state and actions on the right. Rectangle { - Layout.preferredWidth: root.width < 960 ? 258 : 290 - Layout.fillHeight: true - color: theme.colors.sidebar - ColumnLayout { - anchors.fill: parent; anchors.margins: 24; spacing: 0 - RowLayout { - Layout.topMargin: 10; spacing: 12 - Rectangle { - width: 34; height: 34; radius: 10; color: theme.colors.accent - ComputerGlyph { anchors.centerIn: parent; ink: theme.colors.onAccent } - } - Label { text: "Remote\nDesktops"; color: theme.colors.text; font.pixelSize: 17; font.weight: Font.DemiBold; lineHeight: .95 } + Layout.fillWidth: true; implicitHeight: 56; color: theme.colors.sidebar + RowLayout { + anchors.fill: parent; anchors.leftMargin: 20; anchors.rightMargin: 12; spacing: 10 + Rectangle { width: 30; height: 30; radius: 9; color: theme.colors.accent; ComputerGlyph { anchors.centerIn: parent; ink: theme.colors.onAccent; scale: .8 } } + Label { text: "Remote Desktops"; color: theme.colors.text; font.pointSize: theme.type.lead; font.weight: Font.DemiBold } + Rectangle { + visible: manager.demo; radius: 6; implicitHeight: 22; implicitWidth: previewLabel.implicitWidth + 16; color: theme.colors.selected; border.color: theme.colors.selectedBorder + Label { id: previewLabel; anchors.centerIn: parent; text: "Preview"; color: theme.colors.accentText; font.pointSize: theme.type.caption; font.weight: Font.DemiBold } } - Caption { text: "YOUR COMPUTERS"; Layout.topMargin: 48; Layout.bottomMargin: 16 } - ListView { - id: computerList - objectName: "computerList" - Layout.fillWidth: true; Layout.fillHeight: true - clip: true; spacing: 8; model: root.entries - keyNavigationEnabled: true - Keys.onDownPressed: if (currentIndex + 1 < root.entries.length) root.selectedId = root.entries[currentIndex + 1].computer - Keys.onUpPressed: if (currentIndex > 0) root.selectedId = root.entries[currentIndex - 1].computer - currentIndex: { for (let i=0; i 0; text: root.entries.length; color: theme.colors.muted; font.pointSize: theme.type.caption } + Item { Layout.fillWidth: true } } - Rectangle { - visible: root.recovering || !!(root.selected && root.selected.error) - Layout.fillWidth: true; Layout.topMargin: 20; implicitHeight: recoveryText.implicitHeight + 34; color: theme.colors.warningBg; radius: 12; border.color: theme.colors.warningBorder - ColumnLayout { - id: recoveryText - anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 17; spacing: 7 - Label { text: root.recovering ? "Your display settings are protected" : "This connection needs attention"; color: theme.colors.warning; font.weight: Font.DemiBold } - Body { Layout.fillWidth: true; text: root.recovering ? "The last session could not finish restoring the host display. Bring the computer online, then restore before connecting again." : "Check that the computer and Sunshine are available, then try again."; font.pixelSize: 12; color: theme.colors.warning } - Button { text: "View technical details"; flat: true; palette.windowText: theme.colors.warning; onClicked: details.open() } + ListView { + id: computerList + objectName: "computerList" + Layout.fillWidth: true; Layout.fillHeight: true; Layout.topMargin: 10 + clip: true; spacing: 4; model: root.entries + activeFocusOnTab: true + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: Bar {} + currentIndex: { for (let i = 0; i < root.entries.length; ++i) if (root.entries[i].computer === root.selectedId) return i; return 0 } + Accessible.role: Accessible.List + Accessible.name: "Computers" + function select(i) { + if (i < 0 || i >= root.entries.length) return + root.selectedId = root.entries[i].computer + positionViewAtIndex(i, ListView.Contain) } - } - Rectangle { - Layout.fillWidth: true; Layout.topMargin: 27 - implicitHeight: root.recovering || manager.error || manager.notice || !manager.available ? 220 : 280; radius: 16; color: theme.colors.surface; border.color: theme.colors.border - gradient: Gradient { GradientStop { position: 0; color: theme.colors.heroStart } GradientStop { position: 1; color: theme.colors.heroEnd } } - BusyIndicator { anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 14; width: 28; height: 28; running: root.transitioning || !!(root.selected && root.selected.busy); visible: running; Accessible.name: "Connection operation in progress" } - // Abstract device illustration; never a fabricated remote screenshot. - Rectangle { - width: 220; height: 132; radius: 10; anchors.horizontalCenter: parent.horizontalCenter; y: parent.height < 280 ? 15 : 34 - scale: parent.height < 280 ? 0.75 : 1 - transformOrigin: Item.Top - color: theme.colors.screen; border.color: root.connected ? theme.colors.selectedBorder : theme.colors.border; border.width: 2 - Rectangle { anchors.fill: parent; anchors.margins: 7; radius: 5; color: theme.colors.screen - Rectangle { x: 14; y: 15; width: 52; height: 80; radius: 5; color: theme.colors.tile1 } - Rectangle { x: 76; y: 15; width: 110; height: 35; radius: 5; color: theme.colors.tile2 } - Rectangle { x: 76; y: 60; width: 50; height: 35; radius: 5; color: theme.colors.tile1 } - Rectangle { x: 136; y: 60; width: 50; height: 35; radius: 5; color: theme.colors.tile3 } + Keys.onDownPressed: select(currentIndex + 1) + Keys.onUpPressed: select(currentIndex - 1) + Keys.onPressed: event => { + if (event.key === Qt.Key_Home) { select(0); event.accepted = true } + else if (event.key === Qt.Key_End) { select(root.entries.length - 1); event.accepted = true } + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.action(); event.accepted = true } + else if (event.text.length === 1 && /\S/.test(event.text) && !(event.modifiers & (Qt.ControlModifier | Qt.AltModifier))) { + // Type-ahead: jump to the next computer whose name starts with the key. + const c = event.text.toLowerCase(), n = root.entries.length + for (let step = 1; step <= n; ++step) { + const i = (currentIndex + step) % n + if (((root.entries[i].name || root.entries[i].computer) + "").toLowerCase().startsWith(c)) { select(i); break } + } + event.accepted = true } - Rectangle { anchors.horizontalCenter: parent.horizontalCenter; y: 133; width: 20; height: 12; color: theme.colors.selectedBorder } - Rectangle { anchors.horizontalCenter: parent.horizontalCenter; y: 144; width: 76; height: 3; radius: 2; color: theme.colors.selectedBorder } } - ColumnLayout { - anchors.horizontalCenter: parent.horizontalCenter; y: parent.height < 280 ? 145 : 202; width: parent.width - 40; spacing: 8 - Label { Layout.alignment: Qt.AlignHCenter; text: manager.loading ? "Finding your computers…" : !root.selected ? "A place for every computer" : !manager.available ? "Connection status unavailable" : root.selected.busy ? "Sending request…" : root.transitioning ? root.label(root.phase) + "…" : root.recovering ? "Let's finish restoring your display" : root.connected ? "Your desktop is open" : root.phase === "running" ? "Client started" : "Ready when you are"; color: theme.colors.text; font.pixelSize: 19; font.weight: Font.Medium } - Body { Layout.alignment: Qt.AlignHCenter; horizontalAlignment: Text.AlignHCenter; Layout.fillWidth: true; font.pixelSize: 12; text: !root.selected ? "Set up a paired computer to start your first session." : !manager.available ? "Refresh status, or connect to start the manager again." : root.connected ? "Switch to its window and pick up where you left off." : root.phase === "running" ? "The client is running; window detection is not available." : root.recovering ? "The saved recovery record stays safe until restoration succeeds." : "A full desktop, in its own window." } + delegate: ItemDelegate { + id: row + required property var modelData + required property int index + width: ListView.view.width - (ListView.view.contentHeight > ListView.view.height ? 14 : 0) + height: 60 + hoverEnabled: true + focusPolicy: Qt.NoFocus + readonly property bool selected: root.selected && root.selected.computer === modelData.computer + readonly property string status: root.label(modelData.phase) + Accessible.name: (modelData.name || modelData.computer) + ", " + (modelData.stale ? "last known " : "") + status + onClicked: root.selectedId = modelData.computer + onDoubleClicked: { root.selectedId = modelData.computer; root.action() } + background: Rectangle { + radius: 10 + color: row.selected ? theme.colors.selected : row.hovered ? theme.colors.hover : "transparent" + border.width: row.selected && computerList.activeFocus ? 2 : 1 + border.color: row.selected && computerList.activeFocus ? theme.colors.accent : row.selected ? theme.colors.selectedBorder : "transparent" + Behavior on color { ColorAnimation { duration: 120 } } + } + contentItem: RowLayout { + spacing: 12 + ComputerGlyph { Layout.leftMargin: 4; scale: .9; ink: row.selected ? theme.colors.accentText : theme.colors.muted; laptop: modelData.platform === "macos" || modelData.platform === "windows" } + ColumnLayout { + Layout.fillWidth: true; spacing: 4 + Label { Layout.fillWidth: true; textFormat: Text.PlainText; text: modelData.name || modelData.computer; color: theme.colors.text; font.weight: Font.Medium; elide: Text.ElideRight } + RowLayout { + spacing: 7 + StatusDot { phase: modelData.phase; stale: !!modelData.stale; size: 8 } + Label { Layout.fillWidth: true; text: row.status; color: modelData.stale ? theme.colors.muted : theme.colors.secondary; font.pointSize: theme.type.caption; elide: Text.ElideRight } + } + } + } } } - ActionButton { - visible: !root.selected && !manager.loading - Layout.topMargin: 20 - primary: true; text: "Set up a computer"; onClicked: setup.begin("") - } - RowLayout { - visible: !!root.selected - Layout.fillWidth: true; Layout.topMargin: 24; spacing: 18 + Body { visible: !root.entries.length && !manager.loading; Layout.fillWidth: true; Layout.leftMargin: 10; Layout.topMargin: 6; text: "No computers yet. Add a computer you have paired in Moonlight."; font.pointSize: theme.type.caption } + Body { visible: manager.loading && !root.entries.length; Layout.fillWidth: true; Layout.leftMargin: 10; Layout.topMargin: 6; text: "Finding your computers…"; font.pointSize: theme.type.caption } + ActionButton { objectName: "addComputer"; Layout.fillWidth: true; Layout.topMargin: 14; primary: true; icon.source: "qrc:/qml/icons/plus.svg"; text: "Add computer"; hint: "Add a computer paired in Moonlight · Ctrl+N"; enabled: !manager.setupBusy; onClicked: setup.begin("") } + Body { visible: manager.demo; Layout.fillWidth: true; Layout.topMargin: 12; Layout.leftMargin: 4; text: "Sample computers. Every action is simulated."; color: theme.colors.muted; font.pointSize: theme.type.caption } + } + } + Rectangle { Layout.fillHeight: true; width: 1; color: theme.colors.border } + Item { + id: mainPane + Layout.fillWidth: true; Layout.fillHeight: true + ScrollView { + id: scroller + anchors.fill: parent + contentWidth: availableWidth; contentHeight: page.implicitHeight + clip: true + ScrollBar.vertical: Bar { parent: scroller; x: scroller.width - width; y: 0; height: scroller.height } + ColumnLayout { + id: page + width: scroller.availableWidth + spacing: 0 + // Empty state: warm, and the only place with a headline. ColumnLayout { - Layout.fillWidth: true; spacing: 8 - Caption { text: "CONNECTION PROFILE"; font.pixelSize: 10 } - ComboBox { - id: profiles - Layout.preferredWidth: Math.min(240, root.width / 4) - model: root.selected ? root.selected.profiles || [] : [] - currentIndex: Math.max(0, model.indexOf(root.profile)) - enabled: !!root.selected && !root.selected.desired && !root.selected.busy && !root.recovering && !root.transitioning - onActivated: root.chosenProfile = currentText - Accessible.name: "Connection profile" - palette.text: theme.colors.text; palette.buttonText: theme.colors.text; palette.base: theme.colors.surface; palette.highlight: theme.colors.selectedBorder; palette.highlightedText: theme.colors.text - background: Rectangle { radius: 8; color: theme.colors.surface; border.color: profiles.visualFocus ? theme.colors.accent : theme.colors.border } - ToolTip.visible: hovered && !enabled - ToolTip.text: "Disconnect before changing profiles." - } + visible: !root.selected && !manager.loading + Layout.fillWidth: true; Layout.margins: 36; Layout.topMargin: 72; spacing: 0 + Rectangle { Layout.alignment: Qt.AlignHCenter; width: 68; height: 68; radius: 20; color: theme.colors.selected; border.color: theme.colors.selectedBorder; ComputerGlyph { anchors.centerIn: parent; scale: 1.4; ink: theme.colors.accentText } } + Label { Layout.alignment: Qt.AlignHCenter; Layout.topMargin: 24; text: "Add your first computer"; color: theme.colors.text; font.pointSize: theme.type.subtitle; font.weight: Font.DemiBold } + Body { Layout.alignment: Qt.AlignHCenter; Layout.topMargin: 8; Layout.maximumWidth: 440; horizontalAlignment: Text.AlignHCenter; text: "Pair a computer in Moonlight, then add it here to open its desktop in a window you can move like any other app." } + ActionButton { Layout.alignment: Qt.AlignHCenter; Layout.topMargin: 26; primary: true; icon.source: "qrc:/qml/icons/plus.svg"; text: "Add computer"; enabled: !manager.setupBusy; onClicked: setup.begin("") } + ActionButton { Layout.alignment: Qt.AlignHCenter; Layout.topMargin: 6; quiet: true; icon.source: "qrc:/qml/icons/external.svg"; text: "Open the setup guide"; onClicked: Qt.openUrlExternally("https://github.com/jdvmi00/remote-desktops/blob/develop/docs/BACKEND.md") } } ColumnLayout { - Layout.alignment: Qt.AlignRight; spacing: 9 - Caption { text: "SESSION BEHAVIOR"; font.pixelSize: 10 } - Label { text: "Windowed · Move freely"; color: theme.colors.secondary; font.pixelSize: 13 } - Label { text: "No workspace restrictions"; color: theme.colors.muted; font.pixelSize: 11 } + visible: manager.loading && !root.selected + Layout.fillWidth: true; Layout.margins: 36; Layout.topMargin: 72; spacing: 10 + Label { Layout.alignment: Qt.AlignHCenter; text: "Finding your computers…"; color: theme.colors.secondary; font.pointSize: theme.type.lead } } - } - Divider { Layout.topMargin: 23; Layout.bottomMargin: 23 } - RowLayout { - visible: !!root.selected - Layout.fillWidth: true; spacing: 10 - ActionButton { - objectName: "primaryAction" - primary: true - text: root.selected && root.selected.busy ? "Working…" : root.recovering ? "Restore display" : root.connected ? "Open desktop ↗" : root.transitioning ? "Connecting…" : "Connect ↗" - enabled: root.canAct - hint: root.connected ? "Focus the existing desktop window · Ctrl+Enter" : "Start a desktop session · Ctrl+Enter" - onClicked: root.action() + // Selected computer. + ColumnLayout { + visible: !!root.selected + Layout.fillWidth: true; Layout.margins: 36; Layout.topMargin: 28; spacing: 0 + Label { Layout.fillWidth: true; textFormat: Text.PlainText; text: root.selected ? root.selected.name || root.selected.computer : ""; color: theme.colors.text; font.pointSize: theme.type.title; font.weight: Font.DemiBold; font.letterSpacing: -.5; elide: Text.ElideRight } + RowLayout { + Layout.fillWidth: true; Layout.topMargin: 4; spacing: 8 + Body { text: root.selected ? root.platformName(root.selected.platform) : "" } + Body { visible: !!(root.selected && root.selected.host); text: "·"; color: theme.colors.muted } + Body { Layout.fillWidth: true; visible: !!(root.selected && root.selected.host); text: root.selected ? root.selected.host || "" : ""; elide: Text.ElideRight } + } + // Service banner: one cause, one action. + Rectangle { + visible: (!manager.available && !manager.loading) || !!manager.error + Layout.fillWidth: true; Layout.topMargin: 22 + implicitHeight: bannerColumn.implicitHeight + 32; radius: 12 + color: theme.colors.warningBg; border.color: theme.colors.warningBorder + ColumnLayout { + id: bannerColumn + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 16; spacing: 8 + RowLayout { + spacing: 10 + Icon { glyph: "alert"; color: theme.colors.warning } + Label { Layout.fillWidth: true; text: manager.error ? "Computer settings could not be read" : "The background service is not responding"; color: theme.colors.warning; font.weight: Font.DemiBold; wrapMode: Text.WordWrap } + } + Body { Layout.fillWidth: true; text: manager.error || "Open desktop windows keep running. Start the service to see live status, or check again." } + RowLayout { + Layout.topMargin: 4; spacing: 8 + ActionButton { visible: !manager.available; text: manager.serviceBusy ? "Starting…" : "Start service"; icon.source: "qrc:/qml/icons/play.svg"; enabled: !manager.serviceBusy; hint: "Start the background service without connecting"; onClicked: manager.startService() } + ActionButton { quiet: true; text: "Check again"; icon.source: "qrc:/qml/icons/refresh.svg"; onClicked: manager.refresh() } + } + } + } + // Status card: state, meaning, and only real facts. + Rectangle { + id: card + Layout.fillWidth: true; Layout.topMargin: 22 + implicitHeight: cardColumn.implicitHeight + 44; radius: 14 + color: root.tone === "warning" ? theme.colors.warningBg : root.tone === "success" ? theme.colors.successBg : theme.colors.surface + border.color: root.tone === "warning" ? theme.colors.warningBorder : root.tone === "success" ? theme.colors.successBorder : theme.colors.border + Behavior on implicitHeight { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 200 } } + Behavior on border.color { ColorAnimation { duration: 200 } } + Accessible.role: Accessible.StaticText + Accessible.name: root.headline + ". " + root.explanation + ColumnLayout { + id: cardColumn + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 22; spacing: 12 + RowLayout { + Layout.fillWidth: true; spacing: 12 + StatusDot { phase: root.phase; stale: root.stale; size: 12 } + Label { Layout.fillWidth: true; text: root.headline; color: root.tone === "warning" ? theme.colors.warning : root.tone === "success" ? theme.colors.success : theme.colors.text; font.pointSize: theme.type.lead; font.weight: Font.DemiBold; elide: Text.ElideRight } + } + Body { Layout.fillWidth: true; text: root.explanation } + RowLayout { + visible: root.errorText.length > 0 + Layout.fillWidth: true; spacing: 10 + Icon { glyph: "alert"; size: 16; color: theme.colors.warning; Layout.alignment: Qt.AlignTop; Layout.topMargin: 2 } + Body { Layout.fillWidth: true; text: root.errorText; color: theme.colors.warning; font.pointSize: theme.type.caption } + } + // Indeterminate progress while a request or transition is in flight. + Rectangle { + id: progress + visible: root.transitioning || !!(root.selected && root.selected.busy) || manager.serviceBusy + Layout.fillWidth: true; Layout.topMargin: 4; height: 3; radius: 1.5; color: theme.colors.border; clip: true + Accessible.role: Accessible.ProgressBar + Accessible.name: "Operation in progress" + Rectangle { + width: parent.width * .3; height: parent.height; radius: 1.5; color: theme.colors.accent + SequentialAnimation on x { running: progress.visible; loops: Animation.Infinite; NumberAnimation { from: -progress.width * .3; to: progress.width; duration: 1300; easing.type: Easing.InOutQuad } } + } + } + GridLayout { + visible: root.facts.length > 0 + Layout.fillWidth: true; Layout.topMargin: 6; columns: root.width < 1000 ? 2 : 3; columnSpacing: 24; rowSpacing: 12 + // The model is the count, so delegates survive value updates + // instead of being rebuilt on every status tick. + Repeater { + model: root.facts.length + delegate: ColumnLayout { + required property int index + readonly property var fact: root.facts[index] || ({label: "", value: ""}) + Layout.fillWidth: true; spacing: 2 + Caption { text: fact.label.toUpperCase() } + Label { Layout.fillWidth: true; text: fact.value; color: theme.colors.text; font.weight: Font.Medium; elide: Text.ElideRight } + } + } + } + } + } + // Actions. Cancel sits beside the primary action, never where Disconnect is. + RowLayout { + Layout.fillWidth: true; Layout.topMargin: 16; spacing: 10 + ActionButton { + objectName: "primaryAction" + primary: true + icon.source: "qrc:/qml/icons/" + (root.recovering ? "refresh" : root.connected ? "external" : "play") + ".svg" + text: root.selected && root.selected.busy ? "Working…" : root.recovering ? "Restore display" : root.connected || root.phase === "running" ? "Open desktop" : root.transitioning ? root.label(root.phase) + "…" : "Connect" + // A running client without an observed window cannot be focused from here. + enabled: root.canAct && root.phase !== "running" + hint: root.recovering ? "Retry restoring the host display · Ctrl+Enter" : root.phase === "running" ? "No window was detected; open Moonlight from your taskbar" : root.connected ? "Focus the desktop window · Ctrl+Enter" : "Open a desktop session · Ctrl+Enter" + onClicked: root.action() + } + ActionButton { text: "Cancel"; visible: root.transitioning && !!root.selected && !!root.selected.desired; enabled: !!root.selected && !root.selected.busy; hint: "Stop connecting and restore the host"; onClicked: manager.act(root.selected.computer, "disconnect") } + ActionButton { text: "Reconnect"; icon.source: "qrc:/qml/icons/refresh.svg"; visible: root.connected || root.phase === "running"; enabled: !!root.selected && !root.selected.busy && manager.available && !root.transitioning; hint: "Restart the client; recovery settings are kept"; onClicked: manager.act(root.selected.computer, "reconnect") } + Item { Layout.fillWidth: true } + ActionButton { text: "Disconnect"; icon.source: "qrc:/qml/icons/power.svg"; destructive: true; visible: !!root.selected && !!root.selected.desired && !root.transitioning; enabled: !!root.selected && !root.selected.busy && manager.available; hint: "Close the desktop window and restore host settings"; onClicked: manager.act(root.selected.computer, "disconnect") } + } + RowLayout { + visible: !!root.selected && !root.selected.unconfigured && root.profiles.length > 0 + Layout.fillWidth: true; Layout.topMargin: 20; spacing: 12 + Caption { text: "PROFILE" } + Select { + id: profileSelect + visible: root.profiles.length > 1 + Layout.preferredWidth: Math.min(240, root.width / 4) + model: root.profiles + currentIndex: Math.max(0, root.profiles.indexOf(root.profile)) + enabled: !!root.selected && !root.selected.desired && !root.selected.busy && !root.recovering && !root.transitioning + onActivated: root.chosenProfile = currentText + Accessible.name: "Connection profile" + } + Label { visible: root.profiles.length === 1; text: root.profile; color: theme.colors.text; font.weight: Font.Medium } + Body { Layout.fillWidth: true; visible: root.profiles.length > 1 && !!root.selected && !!root.selected.desired; text: "Disconnect to change the profile."; font.pointSize: theme.type.caption; color: theme.colors.muted; elide: Text.ElideRight } + } + Divider { Layout.topMargin: 22 } + Flow { + visible: !!root.selected + Layout.fillWidth: true; Layout.topMargin: 8; spacing: 2 + ActionButton { quiet: true; icon.source: "qrc:/qml/icons/pencil.svg"; text: "Edit"; visible: !!root.selected && !root.selected.unconfigured; enabled: !manager.setupBusy; hint: "Change the name, address, or stream quality"; onClicked: setup.begin(root.selected.computer) } + ActionButton { quiet: true; icon.source: "qrc:/qml/icons/grid.svg"; text: "Add to app launcher"; visible: !!root.selected && !root.selected.unconfigured; enabled: !!root.selected && !root.selected.busy; hint: "Install a desktop entry that opens this computer directly"; onClicked: manager.act(root.selected.computer, "launcher") } + ActionButton { quiet: true; icon.source: "qrc:/qml/icons/info.svg"; text: "Details"; hint: "Identity, state, and technical messages"; onClicked: details.open() } + ActionButton { quiet: true; destructive: true; icon.source: "qrc:/qml/icons/trash.svg"; text: "Remove"; enabled: !!root.selected && !root.selected.busy && !root.selected.desired && !root.transitioning && !root.recovering; hint: "Remove this computer from Remote Desktops"; onClicked: root.removeSelected() } + } + Item { Layout.preferredHeight: 24 } } - ActionButton { text: "Reconnect"; visible: root.connected; enabled: !!root.selected && !root.selected.busy && manager.available && !root.transitioning; hint: "Restart this client connection"; onClicked: manager.act(root.selected.computer, "reconnect") } - Item { Layout.fillWidth: true } - ActionButton { text: root.transitioning ? "Cancel" : "Disconnect"; destructive: true; visible: !!root.selected && root.selected.desired; enabled: !!root.selected && !root.selected.busy && manager.available; hint: "Close the remote window and restore owned host settings"; onClicked: manager.act(root.selected.computer, "disconnect") } - } - RowLayout { - 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 } + } + Toast { + anchors.horizontalCenter: parent.horizontalCenter; anchors.bottom: parent.bottom; anchors.bottomMargin: 20 + maxWidth: mainPane.width - 48 + text: manager.notice; error: manager.noticeError + onDismissed: manager.clearNotice() } } } } SetupDialog { id: setup; onSaved: computer => { root.selectedId = computer; manager.refresh() } } - Dialog { + Confirm { id: confirm } + Sheet { id: help objectName: "helpDialog" - anchors.centerIn: parent; width: Math.min(root.width - 64, 540) - modal: true; title: "Make yourself at home"; standardButtons: Dialog.Close - palette.window: theme.colors.surface; palette.windowText: theme.colors.text; palette.text: theme.colors.text; palette.buttonText: theme.colors.text - 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. 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 {} - Body { Layout.fillWidth: true; text: "Ctrl+Enter opens the selected desktop. Ctrl+R refreshes. Tab moves between controls. Closing this manager never disconnects a computer."; font.pixelSize: 12 } - Body { Layout.fillWidth: true; text: "Inside Moonlight, Ctrl+Alt+Shift+Z toggles mouse and keyboard capture."; font.pixelSize: 12 } + title: "Setup & help" + width: Math.min(root.width - 64, 560) + Body { Layout.fillWidth: true; text: "Remote Desktops keeps your connections in a background service, so closing this window never disconnects a computer. Each desktop opens in its own Moonlight window that you can move and resize like any app." } + Repeater { + model: ["Pair the computer in Moonlight.", "Choose Add computer and pick it from the paired list.", "Check the connection, save, and connect."] + delegate: RowLayout { + required property string modelData + required property int index + Layout.fillWidth: true; spacing: 12 + Rectangle { width: 24; height: 24; radius: 12; color: theme.colors.selected; border.color: theme.colors.selectedBorder; Label { anchors.centerIn: parent; text: index + 1; color: theme.colors.accentText; font.pointSize: theme.type.caption; font.weight: Font.DemiBold } } + Body { Layout.fillWidth: true; text: modelData; color: theme.colors.text } + } + } + Divider {} + GridLayout { + Layout.fillWidth: true; columns: 2; columnSpacing: 18; rowSpacing: 6 + Repeater { + model: ["Ctrl+Enter", "Connect or open the selected desktop", "Ctrl+N", "Add a computer", "Ctrl+R", "Refresh computers and status", "↑ ↓", "Choose a computer in the list", "Esc", "Close a dialog", "Ctrl+Alt+Shift+Z", "Toggle mouse and keyboard capture inside Moonlight"] + delegate: Label { + required property string modelData + required property int index + Layout.fillWidth: index % 2 === 1 + text: modelData; wrapMode: Text.WordWrap + color: index % 2 === 0 ? theme.colors.text : theme.colors.secondary + font.family: index % 2 === 0 ? "monospace" : root.font.family + font.pointSize: index % 2 === 0 ? theme.type.caption : theme.type.body + } + } + } + footer: Sheet.Footer { + ActionButton { quiet: true; icon.source: "qrc:/qml/icons/external.svg"; text: "Setup guide"; onClicked: Qt.openUrlExternally("https://github.com/jdvmi00/remote-desktops/blob/develop/docs/BACKEND.md") } + Item { Layout.fillWidth: true } + ActionButton { text: "Close"; primary: true; onClicked: help.close() } } } - Dialog { + Sheet { id: details - anchors.centerIn: parent; width: Math.min(root.width - 64, 550) - modal: true; title: "Connection details"; standardButtons: Dialog.Close - palette.window: theme.colors.surface; palette.windowText: theme.colors.text; palette.text: theme.colors.text; palette.buttonText: theme.colors.text - ColumnLayout { - width: parent.width; spacing: 16 - Body { Layout.fillWidth: true; text: root.selected ? "Computer: " + root.selected.computer + "\nProfile: " + root.profile + "\nState: " + root.label(root.phase) + "\nWindow detected: " + (root.selected.window ? "Yes" : "No") : "No computer selected." } - Body { Layout.fillWidth: true; text: root.selected ? root.selected.error || root.selected.recovery_error || "No connection errors reported." : ""; color: theme.colors.warning } - Body { Layout.fillWidth: true; text: "Window detection confirms an owned client window. It does not measure video latency, frame rate, or image quality."; font.pixelSize: 12 } - ActionButton { text: "Copy details"; onClicked: manager.copy(root.selected ? "Computer: " + root.selected.computer + "\nPhase: " + root.phase + "\n" + (root.selected.error || root.selected.recovery_error || "") : "No computer selected") } + title: "Connection details" + subtitle: root.selected ? root.selected.name || root.selected.computer : "" + width: Math.min(root.width - 64, 560) + readonly property var rows: { + if (!root.selected) return [] + const s = root.selected + const r = [["Computer ID", s.computer], ["Profile", root.profile || "—"], ["State", root.label(root.phase) + (root.stale ? " (last known)" : "")], + ["Window", s.window ? "Detected (identity match)" : "Not detected"], ["Client", s.client_version && s.client_version !== "unknown" ? "Moonlight " + s.client_version : "—"]] + if (s.pid) r.push(["Client process", String(s.pid)]) + if (s.launcher && s.launcher.desktop_id) r.push(["Launcher entry", s.launcher.desktop_id]) + return r + } + readonly property string report: rows.map(r => r[0] + ": " + r[1]).join("\n") + "\n" + (root.selected ? root.selected.error || root.selected.recovery_error || "No errors reported." : "") + GridLayout { + Layout.fillWidth: true; columns: 2; columnSpacing: 18; rowSpacing: 6 + Repeater { + model: details.rows.length * 2 + delegate: Label { + required property int index + readonly property var row: details.rows[Math.floor(index / 2)] || ["", ""] + Layout.fillWidth: index % 2 === 1 + text: row[index % 2]; wrapMode: Text.WrapAnywhere; textFormat: Text.PlainText + color: index % 2 === 0 ? theme.colors.muted : theme.colors.text + font.pointSize: index % 2 === 0 ? theme.type.caption : theme.type.body + } + } + } + Body { Layout.fillWidth: true; text: root.selected ? root.selected.error || root.selected.recovery_error || "No errors reported." : "No computer selected."; color: root.errorText ? theme.colors.warning : theme.colors.secondary } + Body { Layout.fillWidth: true; text: "Window detection confirms an owned client window. It does not measure video latency, frame rate, or image quality."; font.pointSize: theme.type.caption; color: theme.colors.muted } + footer: Sheet.Footer { + ActionButton { quiet: true; icon.source: "qrc:/qml/icons/copy.svg"; text: "Copy details"; onClicked: { manager.copy(details.report); manager.notify("Details copied to the clipboard.") } } + Item { Layout.fillWidth: true } + ActionButton { text: "Close"; primary: true; onClicked: details.close() } } } } diff --git a/ui/qml/Select.qml b/ui/qml/Select.qml new file mode 100644 index 0000000..8cae7f2 --- /dev/null +++ b/ui/qml/Select.qml @@ -0,0 +1,63 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ComboBox { + id: control + implicitHeight: 42 + implicitWidth: Math.max(160, implicitContentWidth + leftPadding + rightPadding) + leftPadding: 12; rightPadding: 40 + font.pointSize: theme.type.body + hoverEnabled: true + contentItem: Text { + text: control.displayText; font: control.font + color: control.enabled ? theme.colors.text : theme.colors.disabledText + verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight + } + indicator: Icon { + name: "chevron-down"; size: 16 + x: control.width - width - 13; y: (control.height - height) / 2 + color: control.enabled ? theme.colors.secondary : theme.colors.disabledText + } + background: Rectangle { + radius: 9 + color: !control.enabled ? theme.colors.disabled : control.down || control.hovered ? theme.colors.hover : theme.colors.surface + border.width: control.visualFocus ? 2 : 1 + border.color: control.visualFocus ? theme.colors.accent : control.enabled ? theme.colors.border : "transparent" + Behavior on color { ColorAnimation { duration: 120 } } + } + delegate: ItemDelegate { + id: item + required property var modelData + required property int index + width: ListView.view.width + height: 38 + hoverEnabled: true + highlighted: control.highlightedIndex === index + readonly property string label: control.textRole ? modelData[control.textRole] : modelData + Accessible.name: label + contentItem: RowLayout { + spacing: 8 + Text { Layout.fillWidth: true; text: item.label; font.pointSize: theme.type.body; color: theme.colors.text; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter; font.weight: control.currentIndex === item.index ? Font.DemiBold : Font.Normal } + Icon { glyph: "check"; size: 14; color: theme.colors.accentText; visible: control.currentIndex === item.index } + } + background: Rectangle { radius: 7; color: item.highlighted || item.hovered ? theme.colors.hover : "transparent" } + } + popup: Popup { + y: control.height + 4 + width: control.width + implicitHeight: Math.min(contentItem.implicitHeight + 12, 320) + padding: 6 + palette.window: theme.colors.surface; palette.text: theme.colors.text; palette.highlight: theme.colors.hover + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: control.popup.visible ? control.delegateModel : null + currentIndex: control.highlightedIndex + ScrollBar.vertical: Bar {} + } + background: Rectangle { radius: 10; color: theme.colors.surface; border.color: theme.colors.borderStrong } + enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 100 } } + exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 80 } } + } +} diff --git a/ui/qml/SetupDialog.qml b/ui/qml/SetupDialog.qml index 7447f37..a70d4c8 100644 --- a/ui/qml/SetupDialog.qml +++ b/ui/qml/SetupDialog.qml @@ -2,61 +2,112 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -Dialog { +Sheet { 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 + width: Math.min(parent.width - 48, 680) + height: Math.min(parent.height - 40, 660) + // Escape and Cancel go through requestClose(), which asks before + // discarding a draft. Nothing closes this dialog by accident. + closePolicy: Popup.NoAutoClose + onEscapeRequested: requestClose() title: editing ? "Computer settings" : "Add a computer" + topPadding: 0 property bool editing: false property int step: 0 property var draft: ({}) property var paired: [] property string revision: "" property string error: "" + property string errorAction: "" property bool tested: false property bool loaded: false property bool advanced: false + property bool edited: false + property bool attempted: false + property var touched: ({}) signal saved(string computer) + readonly property var steps: editing ? ["Settings", "Check & save"] : ["Computer", "Settings", "Check & save"] + readonly property int stepIndex: editing ? step - 1 : step + readonly property bool dirty: editing ? edited : step > 0 + readonly property bool conflict: error.indexOf("changed elsewhere") >= 0 + readonly property bool checking: manager.setupBusy && step === 2 + readonly property string nameError: (draft.name || "").trim().length > 0 ? "" : "Enter a name for this computer." + readonly property string hostError: /^[A-Za-z0-9][A-Za-z0-9.:-]{0,252}$/.test(draft.host || "") ? "" : "Enter a hostname or IP address using letters, digits, dots, colons, or dashes." + readonly property string resolutionError: /^[0-9]{3,5}x[0-9]{3,5}$/.test(draft.stream_resolution || "") ? "" : "Use WIDTHxHEIGHT, for example 2560x1440." + readonly property bool valid: loaded && !nameError && !hostError && !resolutionError + readonly property var presets: [{label: "Balanced · 1080p, 60 fps", res: "1920x1080", bitrate: 30000}, {label: "Sharper · 1440p, 60 fps", res: "2560x1440", bitrate: 45000}, {label: "Detailed · 4K, 60 fps", res: "3840x2160", bitrate: 80000}, {label: "Custom", res: "", bitrate: 0}] + readonly property var codecs: [{label: "Automatic", value: "auto"}, {label: "HEVC (H.265)", value: "HEVC"}, {label: "H.264", value: "H.264"}, {label: "AV1", value: "AV1"}] + readonly property var inputs: [{label: "Direct pointer", value: "absolute", hint: "The pointer lands exactly where you point. Best for desktop work."}, {label: "Relative pointer", value: "relative", hint: "Sends movement only. Needed by games that capture the mouse."}] + readonly property var audios: [{label: "Play here, mute when unfocused", value: "focus", hint: "Sound plays on this computer and mutes while the desktop window is not active."}, {label: "Always play here", value: "continuous", hint: "Sound plays on this computer even while the window is in the background."}, {label: "Keep audio on the host", value: "host", hint: "Nothing plays here; the remote computer keeps its sound."}] + readonly property int presetIndex: { + const i = ["1920x1080", "2560x1440", "3840x2160"].indexOf(draft.stream_resolution) + return i >= 0 && draft.fps === 60 && draft.bitrate === presets[i].bitrate && (draft.codec || "auto") === "auto" ? i : 3 + } + function fieldError(key, message) { return message.length > 0 && (attempted || touched[key] === true) ? message : "" } + function valueLabel(list, value) { for (const item of list) if (item.value === value) return item.label; return value || "" } + readonly property var summaryRows: [["Name", draft.name || ""], ["Address", draft.host || ""], ["Operating system", ({macos: "macOS", windows: "Windows", linux: "Linux"})[draft.platform] || "Not specified"], ["Quality", summary()], ["Mouse", valueLabel(inputs, draft.input || "absolute")], ["Audio", valueLabel(audios, draft.audio || "focus")]] + function summary() { return (draft.stream_resolution || "") + " · " + (draft.fps || 60) + " fps · " + Math.round((draft.bitrate || 0) / 1000) + " Mbit/s · " + valueLabel(codecs, draft.codec || "auto") + " codec" } function begin(computer) { if (manager.setupBusy) return editing = !!computer; step = editing ? 1 : 0; draft = {}; paired = [] - error = ""; tested = false; loaded = false; advanced = false + error = ""; errorAction = ""; tested = false; loaded = false; advanced = false; edited = false; attempted = false; touched = {} launcher.checked = !editing open() - manager.setup(editing ? "get" : "catalog", computer ? {computer:computer} : {}) + manager.setup(editing ? "get" : "catalog", computer ? {computer: computer} : {}) + } + function requestClose() { + if (manager.setupBusy) return + if (discard.opened) { discard.close(); return } + if (dirty) discard.ask("Discard changes?", editing ? "Your edits to this computer will not be saved." : "This computer will not be added. You can add it again at any time.", "Discard", function() { setup.close() }) + else close() } function set(key, value) { const next = Object.assign({}, draft); next[key] = value; draft = next - tested = false; error = "" + const t = Object.assign({}, touched); t[key] = true; touched = t + tested = false; error = ""; edited = true } 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 = "" + 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 = ""; attempted = false; touched = {} + } + function advance() { + if (step !== 1) return + if (!valid) { attempted = true; return } + step = 2 + if (!tested) check() + } + function check() { + if (manager.setupBusy) return + tested = false; error = "" + manager.setup("test", draft) + } + function reload() { + error = "" + if (editing) { loaded = false; tested = false; manager.setup("get", {computer: draft.computer}) } + else manager.setup("catalog") + } + 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) } - 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 (!ok) { setup.error = message; setup.errorAction = action; return } + setup.error = ""; setup.errorAction = "" + if (action === "catalog") { + setup.paired = result.paired; setup.revision = result.revision; setup.loaded = true + if (setup.step > 0) { const next = Object.assign({}, setup.draft); next.revision = result.revision; setup.draft = next } + } + if (action === "get") { setup.draft = result; setup.loaded = true; setup.edited = false } if (action === "test") setup.tested = true if (action === "save") { const id = setup.draft.computer @@ -65,167 +116,300 @@ Dialog { } } } - 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 } + Confirm { id: discard; parent: Overlay.overlay } + component Body: Label { textFormat: Text.PlainText; Layout.fillWidth: true; wrapMode: Text.WordWrap; color: theme.colors.secondary; lineHeight: 1.25 } + component FieldLabel: Label { color: theme.colors.text; font.pointSize: theme.type.caption; font.weight: Font.Medium } + component Hint: Label { Layout.fillWidth: true; wrapMode: Text.WordWrap; color: theme.colors.muted; font.pointSize: theme.type.caption } + component Problem: Label { Layout.fillWidth: true; visible: text.length > 0; wrapMode: Text.WordWrap; color: theme.colors.danger; font.pointSize: theme.type.caption; Accessible.role: Accessible.AlertMessage } + component Progress: Rectangle { + id: bar + // Hidden pages keep their own visible flag, so the owner says when to animate. + property bool active: true + Layout.fillWidth: true; height: 3; radius: 1.5; color: theme.colors.border; clip: true + Accessible.role: Accessible.ProgressBar + Rectangle { + width: parent.width * .3; height: parent.height; radius: 1.5; color: theme.colors.accent + SequentialAnimation on x { running: bar.active; loops: Animation.Infinite; NumberAnimation { from: -bar.width * .3; to: bar.width; duration: 1300; easing.type: Easing.InOutQuad } } + } } - 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 + component Input: Field { onActiveFocusChanged: if (activeFocus) setup.reveal(this) } + component Choice: Select { onActiveFocusChanged: if (activeFocus) setup.reveal(this) } + + // Step indicator: done, current, and upcoming steps are visibly different. + RowLayout { + Layout.fillWidth: true; Layout.topMargin: 6; spacing: 0 + Repeater { + model: setup.steps + delegate: RowLayout { + required property string modelData + required property int index + readonly property bool done: index < setup.stepIndex + readonly property bool current: index === setup.stepIndex + Layout.fillWidth: index < setup.steps.length - 1 + spacing: 8 + Rectangle { + width: 24; height: 24; radius: 12 + color: done || current ? theme.colors.accent : "transparent" + border.width: 1.5; border.color: done || current ? theme.colors.accent : theme.colors.borderStrong + Behavior on color { ColorAnimation { duration: 160 } } + Icon { visible: done; anchors.centerIn: parent; glyph: "check"; size: 14; color: theme.colors.onAccent } + Label { visible: !done; anchors.centerIn: parent; text: index + 1; color: current ? theme.colors.onAccent : theme.colors.muted; font.pointSize: theme.type.caption; font.weight: Font.DemiBold } } + Label { text: modelData; color: current ? theme.colors.text : done ? theme.colors.secondary : theme.colors.muted; font.weight: current ? Font.DemiBold : Font.Normal } + Rectangle { visible: index < setup.steps.length - 1; Layout.fillWidth: true; Layout.leftMargin: 10; Layout.rightMargin: 10; height: 1; color: done ? theme.colors.accent : theme.colors.border } } } - 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 + } + ScrollView { + id: scroll + Layout.fillWidth: true; Layout.fillHeight: true + contentWidth: availableWidth; contentHeight: pages.implicitHeight; clip: true + ScrollBar.vertical: Bar { parent: scroll; x: scroll.width - width; y: 0; height: scroll.height } + ColumnLayout { + id: pages + width: scroll.availableWidth; spacing: 16 + // Step: choose a paired computer. ColumnLayout { - width: parent.width; spacing: 16 + visible: setup.step === 0 + Layout.fillWidth: true; spacing: 14 + Body { text: "Choose a computer you have paired in Moonlight."; color: theme.colors.text; font.pointSize: theme.type.lead } 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 } + visible: manager.setupBusy && !setup.paired.length + Layout.fillWidth: true; spacing: 10 + Body { text: "Looking for paired computers…" } + Progress { active: parent.visible } } ColumnLayout { - visible: setup.step === 1 && setup.loaded - enabled: !manager.setupBusy + visible: setup.loaded && !setup.paired.length && !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 + Rectangle { + Layout.fillWidth: true; radius: 12; color: theme.colors.surface; border.color: theme.colors.border + implicitHeight: emptyColumn.implicitHeight + 36 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) } + id: emptyColumn + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 18; spacing: 8 + Label { text: "No paired computers found"; color: theme.colors.text; font.weight: Font.DemiBold } + Body { text: "Pair your computer in Moonlight first: add it there, enter the PIN on the host, and confirm that its apps appear. Then refresh this list." } + RowLayout { + Layout.topMargin: 6; spacing: 8 + ActionButton { visible: manager.moonlightAvailable || manager.demo; icon.source: "qrc:/qml/icons/external.svg"; text: "Open Moonlight"; onClicked: manager.openMoonlight() } + ActionButton { quiet: true; icon.source: "qrc:/qml/icons/refresh.svg"; text: "Refresh list"; enabled: !manager.setupBusy; onClicked: manager.setup("catalog") } + } } - ColumnLayout { + } + } + Repeater { + model: setup.paired + delegate: ItemDelegate { + id: candidate + required property var modelData + Layout.fillWidth: true; implicitHeight: 66 + hoverEnabled: true + enabled: !modelData.configured && !manager.setupBusy + Accessible.name: modelData.name + (modelData.configured ? ", already added" : "") + onClicked: setup.choose(modelData) + background: Rectangle { + radius: 10 + color: !candidate.enabled ? theme.colors.disabled : candidate.hovered ? theme.colors.hover : theme.colors.surface + border.width: candidate.visualFocus ? 2 : 1 + border.color: candidate.visualFocus ? theme.colors.accent : candidate.enabled ? theme.colors.border : "transparent" + Behavior on color { ColorAnimation { duration: 120 } } + } + contentItem: RowLayout { + spacing: 14 + ComputerGlyph { ink: candidate.enabled ? theme.colors.secondary : theme.colors.disabledText } + ColumnLayout { + Layout.fillWidth: true; spacing: 3 + Label { textFormat: Text.PlainText; text: candidate.modelData.name; color: candidate.enabled ? theme.colors.text : theme.colors.disabledText; font.weight: Font.DemiBold; elide: Text.ElideRight; Layout.fillWidth: true } + Label { textFormat: Text.PlainText; text: candidate.modelData.configured ? "Already added. Edit it from your computer list." : candidate.modelData.host || "You will enter its address next"; color: candidate.enabled ? theme.colors.secondary : theme.colors.disabledText; font.pointSize: theme.type.caption; elide: Text.ElideRight; Layout.fillWidth: true } + } + Icon { visible: candidate.enabled; glyph: "chevron-down"; rotation: -90; color: theme.colors.muted } + } + } + } + Hint { visible: setup.paired.length > 0; text: "Pairing and certificates stay in Moonlight. Remote Desktops uses the same trusted computer." } + } + // Step: name, address, and quality. + ColumnLayout { + visible: setup.step === 1 && setup.loaded + enabled: !manager.setupBusy + Layout.fillWidth: true; spacing: 8 + Body { text: setup.editing ? "Change how this computer connects." : "A few details, then a quick check."; color: theme.colors.text; font.pointSize: theme.type.lead; Layout.bottomMargin: 6 } + FieldLabel { text: "Computer name" } + Input { objectName: "setupName"; Layout.fillWidth: true; text: setup.draft.name || ""; maximumLength: 100; invalid: setup.fieldError("name", setup.nameError).length > 0; Accessible.name: "Computer name"; onTextEdited: setup.set("name", text) } + Problem { text: setup.fieldError("name", setup.nameError) } + FieldLabel { Layout.topMargin: 8; text: "Address" } + Input { Layout.fillWidth: true; text: setup.draft.host || ""; placeholderText: "Hostname or IP address"; maximumLength: 253; invalid: setup.fieldError("host", setup.hostError).length > 0; Accessible.name: "Computer address"; onTextEdited: setup.set("host", text) } + Problem { text: setup.fieldError("host", setup.hostError) } + Hint { text: "Used to check reachability. Moonlight's saved address is used for the stream itself." } + RowLayout { + Layout.fillWidth: true; Layout.topMargin: 8; spacing: 16 + ColumnLayout { + Layout.fillWidth: true; spacing: 8 + FieldLabel { text: "Operating system" } + Choice { 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; spacing: 8 + FieldLabel { text: setup.editing ? "Default profile" : "Desktop quality" } + Choice { 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]) - } + model: setup.editing ? Object.keys(setup.draft.profiles || {}) : setup.presets + textRole: setup.editing ? "" : "label" + currentIndex: setup.editing ? Math.max(0, model.indexOf(setup.draft.profile)) : setup.presetIndex + Accessible.name: setup.editing ? "Default profile" : "Desktop quality" + 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 { + setup.set("fps", 60); setup.set("codec", "auto") + setup.set("stream_resolution", setup.presets[currentIndex].res) + setup.set("bitrate", setup.presets[currentIndex].bitrate) } } } } - Button { text: setup.advanced ? "▾ Advanced stream settings" : "▸ Advanced stream settings"; flat: true; onClicked: setup.advanced = !setup.advanced } + } + ActionButton { + Layout.topMargin: 10; quiet: true + icon.source: "qrc:/qml/icons/" + (setup.advanced ? "chevron-down" : "chevron-right") + ".svg" + text: "Advanced stream settings" + onClicked: setup.advanced = !setup.advanced + Accessible.name: (setup.advanced ? "Hide" : "Show") + " advanced stream settings" + } + GridLayout { + visible: setup.advanced + Layout.fillWidth: true; columns: 2; columnSpacing: 16; rowSpacing: 8 + FieldLabel { text: "Resolution" } + FieldLabel { text: "Frame rate" } + ColumnLayout { + Layout.fillWidth: true; spacing: 6 + Input { Layout.fillWidth: true; text: setup.draft.stream_resolution || ""; placeholderText: "2560x1440"; invalid: setup.fieldError("stream_resolution", setup.resolutionError).length > 0; Accessible.name: "Stream resolution"; onTextEdited: setup.set("stream_resolution", text) } + Problem { text: setup.fieldError("stream_resolution", setup.resolutionError) } + } + Spin { onActiveFocusChanged: if (activeFocus) setup.reveal(this); Layout.fillWidth: true; Layout.alignment: Qt.AlignTop; from: 20; to: 240; value: setup.draft.fps || 60; Accessible.name: "Frames per second"; onValueModified: setup.set("fps", value) } + FieldLabel { text: "Bitrate (Mbit/s)" } + FieldLabel { text: "Codec" } + Spin { onActiveFocusChanged: if (activeFocus) setup.reveal(this); Layout.fillWidth: true; from: 1; to: 200; value: Math.round((setup.draft.bitrate || 30000) / 1000); Accessible.name: "Bitrate in megabits per second"; onValueModified: setup.set("bitrate", value * 1000) } + Choice { Layout.fillWidth: true; model: setup.codecs; textRole: "label"; valueRole: "value"; currentIndex: Math.max(0, setup.codecs.map(c => c.value).indexOf(setup.draft.codec || "auto")); Accessible.name: "Codec"; onActivated: setup.set("codec", currentValue) } + FieldLabel { text: "Mouse" } + FieldLabel { text: "Audio" } + ColumnLayout { + Layout.fillWidth: true; spacing: 6 + Choice { id: inputChoice; Layout.fillWidth: true; model: setup.inputs; textRole: "label"; valueRole: "value"; currentIndex: Math.max(0, setup.inputs.map(c => c.value).indexOf(setup.draft.input || "absolute")); Accessible.name: "Mouse mode"; onActivated: setup.set("input", currentValue) } + Hint { text: setup.inputs[inputChoice.currentIndex].hint } + } + ColumnLayout { + Layout.fillWidth: true; spacing: 6 + Choice { id: audioChoice; Layout.fillWidth: true; model: setup.audios; textRole: "label"; valueRole: "value"; currentIndex: Math.max(0, setup.audios.map(c => c.value).indexOf(setup.draft.audio || "focus")); Accessible.name: "Audio"; onActivated: setup.set("audio", currentValue) } + Hint { text: setup.audios[audioChoice.currentIndex].hint } + } + } + Hint { Layout.topMargin: 8; text: setup.editing ? "Display management for this computer is preserved. Changes apply after disconnecting and starting a new connection." : "The host keeps its current display settings. You can tune stream quality later." } + } + ColumnLayout { + visible: setup.step === 1 && !setup.loaded + Layout.fillWidth: true; spacing: 10 + Body { text: "Loading settings…" } + Progress { active: parent.visible } + } + // Step: check and save. + ColumnLayout { + visible: setup.step === 2 + Layout.fillWidth: true; spacing: 16 + Body { text: setup.tested ? (setup.editing ? "Your changes are ready to save." : "Ready to add this computer.") : setup.checking ? "Checking the connection…" : "Checking the connection"; color: theme.colors.text; font.pointSize: theme.type.lead } + Rectangle { + Layout.fillWidth: true; radius: 12; color: theme.colors.surface; border.color: theme.colors.border + implicitHeight: summaryGrid.implicitHeight + 32 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) } + id: summaryGrid + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 16 + columns: 2; columnSpacing: 20; rowSpacing: 8 + // Fixed count: values update in place rather than rebuilding delegates. + Repeater { + model: setup.summaryRows.length * 2 + delegate: Label { + required property int index + readonly property var row: setup.summaryRows[Math.floor(index / 2)] || ["", ""] + Layout.fillWidth: index % 2 === 1 + text: row[index % 2]; textFormat: Text.PlainText; wrapMode: Text.WordWrap + color: index % 2 === 0 ? theme.colors.muted : theme.colors.text + font.pointSize: index % 2 === 0 ? theme.type.caption : theme.type.body + } + } } - 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 } + visible: setup.checking + Layout.fillWidth: true; spacing: 10 + Body { text: "Checking reachability, Moonlight pairing, and the Desktop app. This does not start a stream or change the host display." } + Progress { active: parent.visible } } + RowLayout { + visible: setup.tested && !setup.checking + Layout.fillWidth: true; spacing: 10 + Icon { glyph: "check"; color: theme.colors.success; Layout.alignment: Qt.AlignTop; Layout.topMargin: 2 } + ColumnLayout { + Layout.fillWidth: true; spacing: 4 + Label { text: "Connection check passed"; color: theme.colors.success; font.weight: Font.DemiBold } + Body { text: manager.demo ? "Simulated check. No real computer was contacted." : "Moonlight authenticated and found the Desktop app. Video and input are verified when you connect." } + } + } + Rectangle { + visible: setup.error.length > 0 && setup.step === 2 + Layout.fillWidth: true; radius: 12; color: theme.colors.warningBg; border.color: theme.colors.warningBorder + implicitHeight: problemColumn.implicitHeight + 32 + ColumnLayout { + id: problemColumn + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 16; spacing: 8 + RowLayout { + spacing: 10 + Icon { glyph: "alert"; color: theme.colors.warning } + Label { Layout.fillWidth: true; text: setup.conflict ? "Settings changed elsewhere" : setup.errorAction === "save" ? "The computer could not be saved" : "The connection check failed"; color: theme.colors.warning; font.weight: Font.DemiBold; wrapMode: Text.WordWrap } + } + Body { objectName: "setupError"; text: setup.conflict ? (setup.editing ? "Another editor saved this configuration first. Reload to continue from the latest saved settings; your edits here will be replaced." : "Another editor saved the configuration first. Reload to continue with your draft.") : setup.error; color: theme.colors.warning } + RowLayout { + Layout.topMargin: 4; spacing: 8 + ActionButton { visible: setup.conflict; text: "Reload"; icon.source: "qrc:/qml/icons/refresh.svg"; enabled: !manager.setupBusy; onClicked: setup.reload() } + ActionButton { visible: !setup.conflict; text: "Check again"; icon.source: "qrc:/qml/icons/refresh.svg"; enabled: !manager.setupBusy; onClicked: setup.check() } + } + } + } + Check { id: launcher; text: setup.editing ? "Update the app launcher entry" : "Add to the app launcher"; checked: true; enabled: !manager.setupBusy } + Hint { text: "Opens this computer directly from your launcher, and lets Hypertile Scenes treat it as an ordinary app." } } } - 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 + // Errors outside the check step stay near the controls that caused them. + Rectangle { + visible: setup.error.length > 0 && setup.step !== 2 + Layout.fillWidth: true; radius: 10; color: theme.colors.warningBg; border.color: theme.colors.warningBorder + implicitHeight: earlyError.implicitHeight + 24 RowLayout { - anchors.fill: parent; anchors.margins: 16 - spacing: 10 - ActionButton { text: "Cancel"; enabled: !manager.setupBusy; onClicked: setup.close() } + id: earlyError + anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; anchors.margins: 12; spacing: 10 + Icon { glyph: "alert"; size: 16; color: theme.colors.warning } + Body { text: setup.error; color: theme.colors.warning; font.pointSize: theme.type.caption } + ActionButton { visible: setup.conflict; quiet: true; text: "Reload"; onClicked: setup.reload() } + } + } + // The test button is always present so keyboard users and tests can reach it. + ActionButton { objectName: "setupTest"; visible: false; text: "Check again"; onClicked: setup.check() } + footer: Sheet.Footer { + ActionButton { text: "Cancel"; enabled: !manager.setupBusy; onClicked: setup.requestClose() } + ActionButton { visible: setup.step === 0; quiet: true; icon.source: "qrc:/qml/icons/refresh.svg"; text: "Refresh list"; enabled: !manager.setupBusy; onClicked: manager.setup("catalog") } Item { Layout.fillWidth: true } - ActionButton { text: "Back"; visible: setup.step > (setup.editing ? 1 : 0); enabled: !manager.setupBusy; onClicked: { setup.step--; setup.error = "" } } + ActionButton { text: "Back"; icon.source: "qrc:/qml/icons/arrow-left.svg"; 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 } + icon.source: setup.step === 2 ? "qrc:/qml/icons/check.svg" : "" + enabled: !manager.setupBusy && setup.loaded && (setup.step !== 2 || setup.tested) + onClicked: { if (setup.step === 2) manager.setup("save", setup.draft); else setup.advance() } } } - } } diff --git a/ui/qml/Sheet.qml b/ui/qml/Sheet.qml new file mode 100644 index 0000000..926d709 --- /dev/null +++ b/ui/qml/Sheet.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Themed modal dialog base. Popups do not inherit the window palette, so the +// full palette is set here for any default-styled internals. +Dialog { + id: sheet + property string subtitle: "" + // Modal popups swallow window shortcuts, so Escape is handled here. A + // dialog that opts out of automatic closing gets a signal instead. + signal escapeRequested() + component Footer: Item { + default property alias content: row.data + implicitHeight: row.implicitHeight + 32 + RowLayout { id: row; anchors.fill: parent; anchors.margins: 16; anchors.topMargin: 12; spacing: 10 } + } + anchors.centerIn: parent + width: Math.min(parent.width - 48, 560) + modal: true + focus: true + padding: 24; topPadding: 12 + palette.window: theme.colors.bg; palette.windowText: theme.colors.text; palette.text: theme.colors.text + palette.base: theme.colors.surface; palette.button: theme.colors.surface; palette.buttonText: theme.colors.text + palette.highlight: theme.colors.accent; palette.highlightedText: theme.colors.onAccent + palette.mid: theme.colors.border; palette.dark: theme.colors.borderStrong; palette.light: theme.colors.hover + palette.toolTipBase: theme.colors.tooltipBg; palette.toolTipText: theme.colors.tooltipText; palette.placeholderText: theme.colors.muted + background: Rectangle { radius: 16; color: theme.colors.bg; border.color: theme.colors.borderStrong } + Overlay.modal: Rectangle { color: theme.colors.overlay } + header: ColumnLayout { + spacing: 4 + Label { Layout.leftMargin: 24; Layout.rightMargin: 24; Layout.topMargin: 22; Layout.fillWidth: true; text: sheet.title; color: theme.colors.text; font.pointSize: theme.type.subtitle; font.weight: Font.DemiBold; elide: Text.ElideRight } + Label { visible: sheet.subtitle.length > 0; Layout.leftMargin: 24; Layout.rightMargin: 24; Layout.fillWidth: true; text: sheet.subtitle; color: theme.colors.secondary; wrapMode: Text.WordWrap } + } + contentItem: ColumnLayout { + spacing: 16 + // The content item takes focus inside the popup so key events pass + // through it before the popup's own handling. + focus: true + Keys.onEscapePressed: event => { + if (sheet.closePolicy & Popup.CloseOnEscape) event.accepted = false + else { sheet.escapeRequested(); event.accepted = true } + } + } + enter: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 140 } + NumberAnimation { property: "scale"; from: .97; to: 1; duration: 160; easing.type: Easing.OutCubic } + } + exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 100 } } +} diff --git a/ui/qml/Spin.qml b/ui/qml/Spin.qml new file mode 100644 index 0000000..27bfc59 --- /dev/null +++ b/ui/qml/Spin.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls + +SpinBox { + id: control + implicitHeight: 42 + editable: true + font.pointSize: theme.type.body + leftPadding: 44; rightPadding: 44 + contentItem: TextInput { + text: control.displayText; font: control.font + color: control.enabled ? theme.colors.text : theme.colors.disabledText + selectionColor: theme.colors.accent; selectedTextColor: theme.colors.onAccent + horizontalAlignment: Qt.AlignHCenter; verticalAlignment: Qt.AlignVCenter + readOnly: !control.editable; validator: control.validator; inputMethodHints: control.inputMethodHints + clip: width < implicitWidth + } + component Step: Rectangle { + required property bool pressed + required property bool hovered + required property string glyph + implicitWidth: 40; height: control.height; radius: 9 + color: pressed ? theme.colors.selected : hovered ? theme.colors.hover : "transparent" + Icon { glyph: parent.glyph; size: 16; anchors.centerIn: parent; color: control.enabled ? theme.colors.secondary : theme.colors.disabledText } + } + up.indicator: Step { x: control.width - width; pressed: control.up.pressed; hovered: control.up.hovered; glyph: "plus" } + down.indicator: Step { x: 0; pressed: control.down.pressed; hovered: control.down.hovered; glyph: "minus" } + background: Rectangle { + radius: 9 + color: control.enabled ? theme.colors.surface : theme.colors.disabled + border.width: control.activeFocus ? 2 : 1 + border.color: control.activeFocus ? theme.colors.accent : theme.colors.border + } +} diff --git a/ui/qml/StatusDot.qml b/ui/qml/StatusDot.qml new file mode 100644 index 0000000..658dad2 --- /dev/null +++ b/ui/qml/StatusDot.qml @@ -0,0 +1,30 @@ +import QtQuick + +// Connection state as a colored dot: filled green when a window is ready, +// pulsing accent while a transition runs, warning when attention is needed, +// hollow when disconnected or unknown. A text label always accompanies it. +Item { + id: dot + property string phase: "idle" + property bool stale: false + property int size: 10 + readonly property string kind: stale ? "stale" + : phase === "window-ready" ? "ok" + : phase === "restore-pending" || phase === "attention" ? "warn" + : ["preflight", "preparing", "connecting", "reconnecting", "stopping", "restoring", "release-pending", "running"].indexOf(phase) >= 0 ? "busy" + : "off" + implicitWidth: size; implicitHeight: size + Rectangle { + anchors.fill: parent; radius: width / 2 + color: dot.kind === "ok" ? theme.colors.success : dot.kind === "warn" ? theme.colors.warning : dot.kind === "busy" ? theme.colors.accent : "transparent" + border.width: dot.kind === "off" || dot.kind === "stale" ? 1.5 : 0 + border.color: theme.colors.muted + Behavior on color { ColorAnimation { duration: 200 } } + } + SequentialAnimation on opacity { + running: dot.kind === "busy"; loops: Animation.Infinite + NumberAnimation { to: .35; duration: 700; easing.type: Easing.InOutSine } + NumberAnimation { to: 1; duration: 700; easing.type: Easing.InOutSine } + } + onKindChanged: if (kind !== "busy") opacity = 1 +} diff --git a/ui/qml/Tip.qml b/ui/qml/Tip.qml new file mode 100644 index 0000000..6189645 --- /dev/null +++ b/ui/qml/Tip.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls + +ToolTip { + id: tip + delay: 600 + padding: 8; leftPadding: 11; rightPadding: 11 + font.pointSize: theme.type.caption + contentItem: Text { text: tip.text; font: tip.font; color: theme.colors.tooltipText; wrapMode: Text.WordWrap } + background: Rectangle { color: theme.colors.tooltipBg; radius: 7; border.color: theme.colors.borderStrong } + enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } + exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 90 } } +} diff --git a/ui/qml/Toast.qml b/ui/qml/Toast.qml new file mode 100644 index 0000000..7ceb188 --- /dev/null +++ b/ui/qml/Toast.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Transient message anchored over the content; informational messages fade +// out on their own, errors stay until dismissed or replaced. +Rectangle { + id: toast + property string text: "" + property bool error: false + property int maxWidth: 520 + signal dismissed() + visible: opacity > 0 + opacity: text.length ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + radius: 12 + color: error ? theme.colors.dangerBg : theme.colors.surface + border.color: error ? theme.colors.dangerBorder : theme.colors.borderStrong + implicitWidth: Math.min(maxWidth, row.implicitWidth + 28) + implicitHeight: row.implicitHeight + 22 + Accessible.role: Accessible.AlertMessage + Accessible.name: text + HoverHandler { id: hover } + Timer { interval: 5000; running: toast.text.length > 0 && !toast.error && !hover.hovered; onTriggered: toast.dismissed() } + RowLayout { + id: row + anchors.centerIn: parent; width: parent.width - 28; spacing: 10 + Icon { glyph: toast.error ? "alert" : "info"; color: toast.error ? theme.colors.danger : theme.colors.accentText } + Label { Layout.fillWidth: true; text: toast.text; wrapMode: Text.WordWrap; color: toast.error ? theme.colors.danger : theme.colors.text; font.pointSize: theme.type.body } + IconButton { name: "close"; hint: "Dismiss"; onClicked: toast.dismissed() } + } +} diff --git a/ui/qml/icons/alert.svg b/ui/qml/icons/alert.svg new file mode 100644 index 0000000..247fd20 --- /dev/null +++ b/ui/qml/icons/alert.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/arrow-left.svg b/ui/qml/icons/arrow-left.svg new file mode 100644 index 0000000..068ba34 --- /dev/null +++ b/ui/qml/icons/arrow-left.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/check.svg b/ui/qml/icons/check.svg new file mode 100644 index 0000000..0e4f71e --- /dev/null +++ b/ui/qml/icons/check.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/chevron-down.svg b/ui/qml/icons/chevron-down.svg new file mode 100644 index 0000000..67b4bcb --- /dev/null +++ b/ui/qml/icons/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/chevron-right.svg b/ui/qml/icons/chevron-right.svg new file mode 100644 index 0000000..cb0db3e --- /dev/null +++ b/ui/qml/icons/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/close.svg b/ui/qml/icons/close.svg new file mode 100644 index 0000000..ed40783 --- /dev/null +++ b/ui/qml/icons/close.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/copy.svg b/ui/qml/icons/copy.svg new file mode 100644 index 0000000..f12bbb6 --- /dev/null +++ b/ui/qml/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/external.svg b/ui/qml/icons/external.svg new file mode 100644 index 0000000..76069a4 --- /dev/null +++ b/ui/qml/icons/external.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/grid.svg b/ui/qml/icons/grid.svg new file mode 100644 index 0000000..7feae41 --- /dev/null +++ b/ui/qml/icons/grid.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/help.svg b/ui/qml/icons/help.svg new file mode 100644 index 0000000..9374701 --- /dev/null +++ b/ui/qml/icons/help.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/info.svg b/ui/qml/icons/info.svg new file mode 100644 index 0000000..84afbcf --- /dev/null +++ b/ui/qml/icons/info.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/laptop.svg b/ui/qml/icons/laptop.svg new file mode 100644 index 0000000..0c4a7bd --- /dev/null +++ b/ui/qml/icons/laptop.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/minus.svg b/ui/qml/icons/minus.svg new file mode 100644 index 0000000..6e2296c --- /dev/null +++ b/ui/qml/icons/minus.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/monitor.svg b/ui/qml/icons/monitor.svg new file mode 100644 index 0000000..4737819 --- /dev/null +++ b/ui/qml/icons/monitor.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/pencil.svg b/ui/qml/icons/pencil.svg new file mode 100644 index 0000000..a58a258 --- /dev/null +++ b/ui/qml/icons/pencil.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/play.svg b/ui/qml/icons/play.svg new file mode 100644 index 0000000..49d6b55 --- /dev/null +++ b/ui/qml/icons/play.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/plus.svg b/ui/qml/icons/plus.svg new file mode 100644 index 0000000..49c365c --- /dev/null +++ b/ui/qml/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/power.svg b/ui/qml/icons/power.svg new file mode 100644 index 0000000..c8c75fb --- /dev/null +++ b/ui/qml/icons/power.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/refresh.svg b/ui/qml/icons/refresh.svg new file mode 100644 index 0000000..219132c --- /dev/null +++ b/ui/qml/icons/refresh.svg @@ -0,0 +1 @@ + diff --git a/ui/qml/icons/trash.svg b/ui/qml/icons/trash.svg new file mode 100644 index 0000000..59aaaf3 --- /dev/null +++ b/ui/qml/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/ui/tests/manager.cpp b/ui/tests/manager.cpp index 71df193..0e4c273 100644 --- a/ui/tests/manager.cpp +++ b/ui/tests/manager.cpp @@ -11,6 +11,7 @@ #include #include #include +#include class ManagerTests : public QObject { Q_OBJECT @@ -29,20 +30,20 @@ private slots: QVERIFY(window); auto *primary = window->findChild("primaryAction"); QVERIFY(primary); - QCOMPARE(primary->property("text").toString(), QString("Open desktop ↗")); + QCOMPARE(primary->property("text").toString(), QString("Open desktop")); auto *list = window->findChild("computerList"); QVERIFY(list); list->forceActiveFocus(); QTest::keyClick(window, Qt::Key_Down); QCOMPARE(window->property("selectedId").toString(), QString("work")); - QCOMPARE(primary->property("text").toString(), QString("Connect ↗")); + QCOMPARE(primary->property("text").toString(), QString("Connect")); QVERIFY(QMetaObject::invokeMethod(primary, "clicked")); QVERIFY(!primary->property("enabled").toBool()); - QTRY_COMPARE(primary->property("text").toString(), QString("Open desktop ↗")); + QTRY_COMPARE(primary->property("text").toString(), QString("Open desktop")); window->setProperty("selectedId", "studio"); m.demoState("restore-pending"); QCOMPARE(primary->property("text").toString(), QString("Restore display")); QVERIFY(QMetaObject::invokeMethod(primary, "clicked")); - QTRY_COMPARE(primary->property("text").toString(), QString("Connect ↗")); + QTRY_COMPARE(primary->property("text").toString(), QString("Connect")); auto *help = window->findChild("helpDialog"); QVERIFY(help); QVERIFY(QMetaObject::invokeMethod(help, "open")); QTest::keyClick(window, Qt::Key_Escape); @@ -74,7 +75,7 @@ private slots: QVERIFY(QMetaObject::invokeMethod(next, "clicked")); QCOMPARE(dialog->property("step").toInt(), 2); QVERIFY(!next->property("enabled").toBool()); - QVERIFY(QMetaObject::invokeMethod(test, "clicked")); + QVERIFY(m.setupBusy()); // The check starts on its own when the last step opens. 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()); @@ -89,6 +90,11 @@ private slots: 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); + auto *discard = dialog->findChild("confirmAction"); + QVERIFY(discard); + QTRY_VERIFY(discard->property("visible").toBool()); // Escape asks before discarding edits. + QVERIFY(dialog->property("visible").toBool()); + QVERIFY(QMetaObject::invokeMethod(discard, "clicked")); 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")); @@ -199,7 +205,8 @@ fi QTRY_VERIFY(!m.computers()[0].toMap()["busy"].toBool()); QFile args(binary + ".args"); QVERIFY(args.open(QIODevice::ReadOnly)); QCOMPARE(args.readAll(), QByteArray("--json\nconnect\ntest\n--profile\ndesktop\n")); - QVERIFY(m.notice().contains("Request accepted")); + QVERIFY(m.notice().isEmpty()); // Accepted commands show through status, not a banner. + QVERIFY(!m.noticeError()); } void setupUsesBoundedStdinAndReportsFailures() { QTemporaryDir temp; @@ -235,6 +242,89 @@ fi QCOMPARE(QJsonDocument::fromJson(input.readAll()).object().toVariantMap(), draft); QVERIFY(m.computers().isEmpty()); } + void removeUsesSettingsCommandAndForgetsLocally() { + 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' '[{"computer":"test","name":"Test","profiles":["desktop"]}]' +else + printf '%s\n' "$@" > "${0}.args" + printf '%s\n' '{"removed":true}' +fi +)"); + script.close(); script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); + Manager m(binary, temp.path() + "/missing.socket"); + QTRY_VERIFY(!m.loading()); + QCOMPARE(m.computers().size(), 1); + m.remove("test"); + QVERIFY(m.computers()[0].toMap()["busy"].toBool()); + QTRY_VERIFY(m.notice().contains("Test was removed")); + QVERIFY(!m.noticeError()); + QFile args(binary + ".args"); QVERIFY(args.open(QIODevice::ReadOnly)); + QCOMPARE(args.readAll(), QByteArray("--json\nsettings\nremove\ntest\n")); + } + void failedCommandsBecomeErrorNotices() { + QTemporaryDir temp; + QString binary = temp.path() + "/fake backend"; + QFile script(binary); QVERIFY(script.open(QIODevice::WriteOnly)); + script.write("#!/bin/sh\nif [ \"$2\" = computers ]; then printf '[]\\n'; else printf 'remote-desktops: Disconnect this computer before removing it.\\n' >&2; exit 1; fi\n"); + script.close(); script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); + Manager m(binary, temp.path() + "/missing.socket"); + QTRY_VERIFY(!m.loading()); + m.startService(); + QVERIFY(m.serviceBusy()); + QTRY_VERIFY(!m.serviceBusy()); + QVERIFY(m.noticeError()); + QCOMPARE(m.notice(), QString("Disconnect this computer before removing it.")); + m.clearNotice(); + QVERIFY(m.notice().isEmpty()); QVERIFY(!m.noticeError()); + } + void demoRemoveAndServiceNeverTouchBackend() { + Manager m("/does/not/exist", "/does/not/exist", true); + m.demoState("unavailable"); + QVERIFY(!m.available()); + m.startService(); + QVERIFY(m.available()); + m.remove("work"); + QTRY_COMPARE(m.computers().size(), 2); + QVERIFY(m.notice().contains("Work laptop")); + m.demoState("many"); + QCOMPARE(m.computers().size(), 11); + m.demoState("unconfigured"); + QVERIFY(m.computers().last().toMap()["unconfigured"].toBool()); + } + void paletteReadsModeAndKeepsEveryForegroundReadable() { + auto luminance = [](const QColor &c) { + auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); }; + return .2126 * linear(c.redF()) + .7152 * linear(c.greenF()) + .0722 * linear(c.blueF()); + }; + auto contrast = [&](const QColor &a, const QColor &b) { + double x = luminance(a), y = luminance(b); + return (std::max(x, y) + .05) / (std::min(x, y) + .05); + }; + for (const auto &mode : {QString("light"), QString("dark")}) { + const bool light = mode == "light"; + auto colors = Theme::palette({{"background", QColor(light ? "#f5f1e8" : "#060b1e")}, {"foreground", QColor(light ? "#303640" : "#ffcead")}, + {"accent", QColor(light ? "#365ca8" : "#7d82d9")}, {"yellow", QColor(light ? "#926b16" : "#e9bb4f")}}, mode); + QCOMPARE(colors["mode"].toString(), mode); + for (const auto &ground : {"bg", "sidebar", "surface", "selected", "hover", "cardStart", "warningBg", "successBg"}) + for (const auto &ink : {"text", "secondary", "muted"}) + QVERIFY2(contrast(colors[ink].value(), colors[ground].value()) >= 4.5, qPrintable(QString("%1 on %2 (%3)").arg(ink, ground, mode))); + QVERIFY(contrast(colors["disabledText"].value(), colors["disabled"].value()) >= 4.5); + QVERIFY(contrast(colors["warning"].value(), colors["warningBg"].value()) >= 4.5); + QVERIFY(contrast(colors["success"].value(), colors["bg"].value()) >= 4.5); + QVERIFY(contrast(colors["danger"].value(), colors["surface"].value()) >= 4.5); + } + // A palette declaring light mode stays light even with a dark-looking background key order. + QCOMPARE(Theme::palette({{"background", QColor("#ffffff")}, {"foreground", QColor("#222222")}, {"accent", QColor("#385ac3")}})["mode"].toString(), QString("light")); + auto type = Theme::scale(11); + QVERIFY(type["caption"].toDouble() >= 9); + QVERIFY(type["body"].toDouble() > type["caption"].toDouble()); + QVERIFY(type["title"].toDouble() > type["subtitle"].toDouble()); + QVERIFY(Theme::scale(0)["body"].toDouble() >= 10); // Unknown system font still yields a usable scale. + } void missingBackendIsActionable() { Manager m("/missing/backend", "/missing/socket"); QTRY_VERIFY(!m.loading());