Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Hypertile Scene like any other application.
The standalone backend is implemented on `develop`: a Rust daemon and CLI with
Moonlight process supervision, journaled host recovery, and per-computer desktop
launcher entries. The Qt graphical manager now provides connection controls for
configured computers. Installable packaging is not implemented yet.
adding, editing, and connecting computers paired in Moonlight. Installable packaging is not implemented yet.
`main` remains the locked project bootstrap; there is no published app release.

The host adapters come from the remote-stream work in
Expand Down
27 changes: 27 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,30 @@ a 60-second acknowledgement bound, with uncertainty reported if it expires;
only the CLI process is stopped, not the independent daemon or its host work.
No synchronous process/socket wait occurs on the GUI thread. These are resource
bounds, not measured latency or CPU claims.

### Settings ownership

The Rust `settings catalog|get|test|save` CLI owns guided configuration editing.
Draft JSON arrives on bounded stdin, not shell arguments. The Python boundary
reads Moonlight's paired-host metadata, validates the full candidate config,
and performs the existing authenticated app-list probe with an external display
adapter. No setup operation prepares a host, writes recovery, starts the daemon,
or launches a streaming client. Certificates and SSH details are not sent to Qt.

A save merges only explicitly editable fields into the latest configuration.
Existing computer IDs, pairing identities and window titles cannot be edited;
other profiles and advanced display/SSH fields survive unchanged. Removed
computers with saved sessions cannot be re-added through the wizard under a
second recovery identity. Existing sessions retain their immutable config and
profile snapshots, including during reconnect and recovery. Atomic config
replacement lets a concurrent new connection read a complete old or new config;
there is no new lock on the session worker or video path.

An opaque content fingerprint detects stale drafts. Cooperative saves use a
nonblocking file lock, recheck the revision under that lock, then fsync and
atomically replace a private configuration file. Manual editors should honor
`computers.lock`; uncooperative writes racing the final rename cannot be made
transactional by an advisory lock. The fingerprint is a conflict detector, not
an authentication token. Testing is required by the UI before saving; the CLI
also permits validated offline saves for scripting. Test results prove only the
pairing/network/app-list check, not actual streaming performance.
34 changes: 26 additions & 8 deletions docs/UI.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Desktop manager

The Qt 6/QML manager is a separate application for configured computers. It
The Qt 6/QML manager is a separate application for setting up and connecting computers. It
shows connection state, selects an existing profile, connects, focuses,
disconnects, reconnects, restores a pending host display, and installs a
per-computer application launcher. It has no video renderer and owns no host
Expand Down Expand Up @@ -66,15 +66,33 @@ These preview and screenshot options require `--demo`.

## Current scope

This first manager manages existing configuration. Pairing still happens in
Moonlight; adding/editing computer and profile configuration follows the
[backend setup guide](BACKEND.md). The in-app help explains that flow. A guided
pairing/configuration editor is subsequent work; this UI does not offer a
placeholder form or write unvalidated settings.
Choose **Add computer** to select a computer already paired in Moonlight,
confirm its name and address, choose desktop quality, then test and save. The
check authenticates through Moonlight and verifies the Desktop app is listed.
It does not start video, capture input, or change a host display. Pairing and
certificate storage stay in Moonlight. If no paired computers appear, complete
pairing there and refresh the setup list.

New profiles use the host’s existing display and automatic decoder selection.
Advanced controls expose resolution, frame rate, bitrate, codec, mouse mode,
and audio policy. **Edit** changes an existing computer and its default profile;
other profiles, SSH configuration, host display adapters, and window identity
are preserved. Changes apply after disconnecting and starting a new connection;
Reconnect continues using the active session’s snapshot.

The save button requires a successful check of the current draft. Editing any
field invalidates that check. Validation or reachability failures leave the
saved configuration untouched. A revision conflict asks you to reopen setup,
so another editor’s changes are not silently overwritten. Cancel discards the
draft. Saving and optional launcher installation are separate operations: a
launcher failure leaves the computer saved and can be retried from the main
screen. Existing launcher entries can be updated from the final setup step.

Use `--demo --setup-preview computer` (or `preferences`, `advanced`, `check`)
to preview each setup page with synthetic data. Demo settings stay in memory.

The manager does not install itself, register a system service, migrate legacy
configuration, or change Hypertile. Packaging and a graphical setup wizard are
separate delivery steps.
configuration, or change Hypertile. Packaging remains a separate delivery step.

## Omarchy themes

Expand Down
10 changes: 8 additions & 2 deletions remote_desktops/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ def resolution(value):


def configuration(path):
value = load(path, {"version": 1, "computers": {}})
return configuration_value(load(path, {"version": 1, "computers": {}}))


def configuration_value(value):
require(value.get("version") == 1 and isinstance(value.get("computers"), dict), "unsupported computers.json schema")
identities = set()
titles = set()
Expand All @@ -53,6 +56,9 @@ def configuration(path):
require(not any(ord(c) < 32 for c in computer["title"]), "title must not contain control characters")
require(computer["title"] not in titles, "computer window titles must be unique for launcher matching")
titles.add(computer["title"])
if "name" in computer:
require(isinstance(computer["name"], str) and 1 <= len(computer["name"]) <= 100
and not any(ord(c) < 32 or ord(c) == 127 for c in computer["name"]), "name must be 1–100 characters without control characters")
require(isinstance(computer.get("profiles"), dict) and computer["profiles"], "computer needs profiles")
require(computer.get("platform", "unknown") in ("macos", "windows", "linux", "unknown"), "invalid host platform")
require("default_profile" not in computer or computer["default_profile"] in computer["profiles"], "unknown default profile")
Expand Down Expand Up @@ -109,7 +115,7 @@ def moonlight_hosts():
# Certificate material never leaves Moonlight's own configuration.
result[value.lower()] = {"name": hosts.get(prefix + "hostname"),
"paired": bool(hosts.get(prefix + "srvcert")),
"address": hosts.get(prefix + "manualaddress")}
"address": hosts.get(prefix + "manualaddress") or hosts.get(prefix + "localaddress") or hosts.get(prefix + "remoteaddress")}
return result


Expand Down
10 changes: 9 additions & 1 deletion remote_desktops/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import os
from pathlib import Path
import sys
from .host import Host, configuration, prepare, restore, require, stream_argv
from .host import Host, configuration, configuration_value, moonlight_hosts, UUID, prepare, restore, require, stream_argv
from .mac_display import same_setting, manages_mode
from .storage import atomic_json, read_json

Expand Down Expand Up @@ -43,6 +43,14 @@ def health(record, host):
def operation(request):
if request["operation"] == "validate":
return configuration(Path(request["config"]))
if request["operation"] == "validate-value":
return configuration_value(request["value"])
if request["operation"] == "paired":
return [{"pairing_uuid": key, "name": h["name"], "host": h["address"] or ""}
for key, h in moonlight_hosts().items() if UUID.fullmatch(key) and h["paired"] and h["name"]]
if request["operation"] == "setup-probe":
Host(request["computer"], {"display": {"adapter": "external"}}).probe()
return {"authenticated": True}
path = Path(request["path"])
with path.with_suffix(".lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
Expand Down
4 changes: 3 additions & 1 deletion src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ fn exec_arg(s: &str) -> Result<String> {
}
fn entry(binary: &Path, name: &str, config: &Value) -> Result<String> {
let title = config["title"].as_str().context("missing window title")?;
let label = title.strip_suffix(" - Moonlight").unwrap_or(title);
let label = config["name"]
.as_str()
.unwrap_or_else(|| title.strip_suffix(" - Moonlight").unwrap_or(title));
let executable = exec_arg(binary.to_str().context("executable path is not UTF-8")?)?;
let computer = exec_arg(name)?;
Ok(format!(
Expand Down
15 changes: 14 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod desktop;
mod host;
mod launcher;
mod server;
mod settings;
mod storage;
mod supervisor;
use anyhow::{Result, bail};
Expand Down Expand Up @@ -61,6 +62,11 @@ enum Action {
computer: Option<String>,
},
Computers,
/// Guided computer setup and editing.
Settings {
#[command(subcommand)]
action: settings::Action,
},
#[command(hide = true)]
Supervise {
#[arg(long)]
Expand Down Expand Up @@ -122,6 +128,13 @@ async fn run(cli: Cli) -> Result<()> {
);
return Ok(());
}
if let Action::Settings { action } = &cli.command {
println!(
"{}",
serde_json::to_string_pretty(&settings::run(&paths, action).await?)?
);
return Ok(());
}
if matches!(cli.command, Action::Computers) {
let value = host::call(json!({"operation":"validate","config":paths.config})).await?;
let entries = value
Expand All @@ -130,7 +143,7 @@ async fn run(cli: Cli) -> Result<()> {
.iter()
.map(|(name, c)| {
let title = c["title"].as_str().unwrap_or(name);
json!({"computer":name, "name":title.strip_suffix(" - Moonlight").unwrap_or(title),
json!({"computer":name, "name":c["name"].as_str().unwrap_or_else(|| title.strip_suffix(" - Moonlight").unwrap_or(title)),
"host":c["host"], "platform":c["platform"], "default_profile":c["default_profile"],
"profiles":c["profiles"].as_object().unwrap().keys().collect::<Vec<_>>()})
})
Expand Down
Loading