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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ Hypertile Scene like any other application.
## Status

The standalone backend is implemented on `develop`: a Rust daemon and CLI with
Moonlight process supervision and journaled host recovery. The graphical manager,
desktop launcher entries, and installable packaging are not implemented yet.
Moonlight process supervision, journaled host recovery, and per-computer desktop
launcher entries. The graphical manager and installable packaging are 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 Expand Up @@ -45,12 +45,16 @@ Build and run from the checkout on Linux (Python 3 and Rust are required):
cargo build --locked --release
./target/release/remote-desktops --help
./target/release/remote-desktops computers
./target/release/remote-desktops launcher install macbook
./target/release/remote-desktops open macbook
./target/release/remote-desktops connect macbook
./target/release/remote-desktops status --json
./target/release/remote-desktops disconnect macbook
```

Configure and pair your computers first; see [backend usage](docs/BACKEND.md).
See [desktop launchers and window matching](docs/LAUNCHERS.md) for application
menu entries, existing-window reuse, and the matching contract for future Scenes.
The first connection starts the daemon if needed. Commands acknowledge intent;
use `status` to observe connection or restoration progress.

Expand Down
10 changes: 9 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,20 @@ Moonlight retains hardware decoding and its own rendering/frame pacing.
## Window behavior

Moonlight launches in windowed mode. The controller never creates workspace
rules, reserves tiles, changes geometry, or repairs a user's placement. Window
rules, reserves tiles, sets window positions/sizes, or repairs a user's placement. Window
creation, title, visibility, and movement are observed through Hyprland events;
event bursts are coalesced before a snapshot query. Visibility can update idle
inhibition on the owned window. Explicit focus/reconnect/disconnect can focus
or close an owned window, but reconciliation never moves it.

Each new matched stream window receives a stable per-computer tag and one
fullscreen reset to counter Omarchy's startup rule. The reset is consumed
durably before dispatch and is not replayed on repeated opens, workspace changes,
or daemon restart. A crash between persistence and dispatch may leave the initial
fullscreen state unchanged; this choice avoids unexpectedly undoing user intent
after recovery. Existing windows from the earlier schema are adopted without
resetting fullscreen. See [launcher identity](LAUNCHERS.md).

The CLI also runs without a Hyprland observer. In that case it reports process
state (`running`), with no window identity or focus control. `window-ready` is
evidence of a matched window, not a measured first video frame.
Expand Down
12 changes: 6 additions & 6 deletions docs/BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ Qt, and a graphical user session to show remote windows. Hyprland window control
uses its current Lua dispatch and stable window IDs, without any Hypertile Lua
module. The Rust toolchain is pinned in `rust-toolchain.toml`.

Omarchy currently ships a Moonlight window rule that requests fullscreen, even
when the client is launched with `--display-mode windowed`. Use Super+F to leave
fullscreen when testing ordinary window movement. The backend does not override
desktop window rules. Per-app launcher/packaging integration must address this
default without repeatedly undoing the user's later fullscreen choices.
Omarchy ships a Moonlight rule that requests fullscreen even with
`--display-mode windowed`. The backend now clears fullscreen once on the first
matched window of a new stream. Later user fullscreen choices are preserved.
See [launchers](LAUNCHERS.md) for identity and startup details.

## Configuration

Expand Down Expand Up @@ -85,7 +84,8 @@ their recovery journals before an explicitly planned handoff. The new daemon
refuses an active legacy controller and refuses matching outstanding legacy
session/recovery intent. It never adopts old journal files automatically.

No installer or live migration is included in this feature. Run the binary
Desktop entries can be installed explicitly with `launcher install COMPUTER`.
No package installer or automatic live migration is included. Run the binary
from its checkout so it can find the Python helper package. A future package
can supply the helper root using `REMOTE_DESKTOPS_HELPERS`; this is a local
development/package setting, not a remote host option.
Expand Down
7 changes: 4 additions & 3 deletions docs/EXTRACTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

The first backend extraction is implemented on `develop`; see
[architecture](ARCHITECTURE.md), [usage](BACKEND.md), and
[provenance](PROVENANCE.md). No live configuration or controller ownership has
been migrated. Launcher entries, graphical UI, Scenes support, and packaging
remain subsequent features.
[provenance](PROVENANCE.md). Per-computer [launcher entries](LAUNCHERS.md) and
window matching are implemented. Live MacBook handoff has been tested explicitly;
there is no automatic migration. Graphical UI, generic Scenes support, and
packaging remain subsequent features.

## Boundary

Expand Down
97 changes: 97 additions & 0 deletions docs/LAUNCHERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Desktop launchers and window identity

## Install from a source build

Configure and pair the computer using [backend setup](BACKEND.md), then:

```sh
cargo build --release --locked
./target/release/remote-desktops launcher list
./target/release/remote-desktops launcher install macbook
```

The application menu now contains **MacBook (Remote Desktop)**, using the
configured Moonlight title without its ` - Moonlight` suffix. Repeat `install`
for each configured computer. It creates
`$XDG_DATA_HOME/applications/remote-desktops-COMPUTER.desktop`, defaulting to
`~/.local/share/applications`. It never copies pairing certificates or creates
workspace rules. Entries use the standard `computer` icon.

The entry runs `remote-desktops open COMPUTER` with the absolute path of the
binary that installed it. Keep this source checkout and release binary in place;
rerun `launcher install` after moving the checkout. This is a development
installation, not a portable package or a published Omarchy release.

`open` selects the configured default profile, starts the daemon if needed,
waits up to sixty seconds for a matched window, and focuses it. Reopening an
active computer reuses its client. A pending connection continues in the daemon
if the launcher times out. Errors appear as desktop notifications when
`notify-send` is available; CLI errors also go to stderr. `connect` remains the
immediate, asynchronous command for scripts; `open` is intended for launchers.

An active legacy Hypertile stream controller still requires explicit handoff.
Installing an entry does not stop another controller or migrate host state.

Update an entry by running the same install command. Remove it with:

```sh
./target/release/remote-desktops launcher remove macbook
```

Removal affects only that generated desktop entry, not configuration, a running
connection, or its recovery journal. Installer/remover refuse symlinks and files
without their ownership markers. Exec arguments follow desktop-entry escaping,
without invoking a shell. See the [desktop entry Exec specification](https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html).

## Matching contract for Scenes

Moonlight Qt 6.1 hardcodes a common Wayland app ID and overwrites SDL WM-class
environment variables. A distinct desktop filename does not change the stream
window's class. See [Moonlight 6.1 source](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/main.cpp).
No Moonlight fork or library injection is required here.

`launcher list` and session `status` expose this metadata:

```json
{
"desktop_id": "remote-desktops-macbook.desktop",
"match": {
"class": "com.moonlight_stream.Moonlight",
"title": "MacBook - Moonlight",
"tag": "remote-desktops-macbook"
}
}
```

- Use the desktop ID to launch, and exact class **plus title** to match a window
across launches. Configuration rejects duplicate final titles, because they
cannot identify different computers reliably. Configure distinct host names
in Moonlight/Sunshine if two hosts currently produce the same title.
- The backend assigns the static Hyprland tag only after matching its owned
process and final title. Generic compositor integration may also use that
tag. Temporary renderer/startup windows are not considered ready targets.
- Live actions additionally verify process ownership, window address, PID,
numeric stable ID, class, and title. Neither a tag nor a title alone authorizes
closing/signaling a process.
- Entries intentionally omit `StartupWMClass`; it cannot promise separate
Wayland taskbar grouping for clients that share Moonlight's app ID.

The metadata is implemented; consuming it in generic Hypertile Scenes remains
a separate feature. Class-only matching is insufficient for simultaneous hosts.

## Initial window state

Omarchy's default Moonlight rule requests fullscreen. On first matching a new
stream window, Remote Desktops assigns its tag and clears internal/client
fullscreen once. The compositor continues to choose placement and size. A brief
startup fullscreen transition is possible before the matching event arrives.

There is no ongoing fullscreen or placement correction. User fullscreen choices
survive repeated opens, workspace changes, and daemon restart. Reconnect creates
a new stream window with fresh windowed startup. Existing windows adopted from
the older backend are preserved; reconnect once to get their new startup tag.

The startup action is consumed durably before dispatch to avoid replay after a
crash. If initialization fails, status records an error and the user can leave
fullscreen manually. No global Omarchy rule is edited, so independently launched
Moonlight sessions keep their normal desktop policy.
24 changes: 24 additions & 0 deletions docs/VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,27 @@ latency were not independently verified. Windows live streaming and recovery
still require separate host validation. These observations supplement the
isolated regression suite; they do not turn mock performance samples into
streaming performance measurements.

## Per-computer launcher validation

The next live MacBook test installed the generated desktop entry and launched
it using `gtk-launch remote-desktops-macbook`, exercising the desktop entry's
actual Exec command. `desktop-file-validate` accepted the entry. The final window
had internal/client fullscreen state zero, the expected exact class/title, and
the static `remote-desktops-macbook` tag. Video negotiated at 2560x1440/60.

The test then explicitly enabled fullscreen and reopened the desktop entry.
After twelve seconds, both fullscreen and the original client PID were preserved.
Restarting the daemon and opening the desktop entry again adopted the same
process without resetting fullscreen. There was exactly one matching window.

The automated suite additionally covers launcher installation/removal, preserving
unrelated files, Exec escaping, unique configured window titles, two concurrent
clients, ignoring temporary startup titles, and startup policy only once per
matched window. The current suite contains eight Rust tests, thirty Python
repository/host tests, and fourteen daemon integration tests.

The earlier fullscreen integration issue is now handled once at initial window
matching. A brief startup transition remains possible. Separate Wayland taskbar
grouping is not promised because Moonlight shares one application ID. Generic
Hypertile Scenes consumption and GUI configuration remain separate features.
4 changes: 4 additions & 0 deletions remote_desktops/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def configuration(path):
value = load(path, {"version": 1, "computers": {}})
require(value.get("version") == 1 and isinstance(value.get("computers"), dict), "unsupported computers.json schema")
identities = set()
titles = set()
for name, computer in value["computers"].items():
require(NAME.fullmatch(name), "invalid computer ID")
require(isinstance(computer.get("host"), str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.:-]{0,252}", computer["host"]), "invalid host")
Expand All @@ -49,6 +50,9 @@ def configuration(path):
require(identity not in identities, "two computers refer to the same pairing identity")
identities.add(identity)
require(isinstance(computer.get("title"), str) and 1 <= len(computer["title"]) <= 250, "title must be the final Moonlight window title")
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"])
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
12 changes: 12 additions & 0 deletions src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ pub async fn action(window: &Window, action: &str) -> Result<()> {
}
_ => bail!("unsupported window action"),
};
dispatch_checked(window, dispatch).await
}
pub async fn initialize(window: &Window, computer: &str) -> Result<()> {
let tag = lua_string(&format!("+remote-desktops-{computer}"));
dispatch_checked(
window,
&format!("hl.dsp.window.tag({{window='address:'..w.address,tag={tag}}})"),
)
.await?;
dispatch_checked(window, "hl.dsp.window.fullscreen_state({window='address:'..w.address,internal=0,client=0,action='set'})").await
}
async fn dispatch_checked(window: &Window, dispatch: &str) -> Result<()> {
// Revalidate all identity fields inside the compositor, atomically with
// the dispatch. Never act on a recycled address based on a cached snapshot.
let code = format!(
Expand Down
Loading