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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
- name: Remote streams
run: python3 test/stream.py && python3 test/windows_display.py && lua test/stream.lua
- name: Scenes and content
run: python3 test/scenes.py && lua test/scenes.lua && node test/content.js
run: python3 test/scenes.py && python3 test/apps.py && lua test/scenes.lua && node test/content.js
- name: Stream quality and reconnect
run: python3 test/quality.py && lua test/quality.lua
- name: Managed layout browsing
Expand Down
24 changes: 10 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ everything else in place, and is the one to run again after every update:
- the engine and bridge into `~/.config/hypr/`, and the two shipped layouts
into `~/.config/hypr/layouts/` (a layout that already exists is left alone)
- `hypertile-ctl` into `~/.local/bin/`
- the session recovery service, started by the layout loader
- the session recovery and independent Scenes services, started by the layout loader
- a `require("hypr.hypertile-layouts")` line in `hyprland.lua`
- the bar widget after the workspaces (skipped when it is already on the bar)
- a **Layouts** entry in the `SUPER+SPACE` menu (`--no-menu` skips it)
Expand Down Expand Up @@ -369,20 +369,16 @@ installing: `HYPERTILE_SRC=$PWD bin/hypertile-ctl list`.

## Remote desktops

Remote desktops can be assigned to named zones with `hypertile-ctl stream`.
Use `SUPER+SHIFT+Arrow` to swap a ready stream with a neighboring window; its
zone reservation and saved assignment move with it.
Scenes can launch or reuse installed apps in named zones, including each
computer's Remote Desktops launcher. Placement happens once; subsequent window
moves and closes stay under your control. The independent Scenes service does
not own remote connections or host display settings.

The overlay’s **Scenes** tab shows what each zone holds and assigns
computers/profiles, local apps, and Empty to zones. Save and restore workspace
scenes there or with `hypertile-ctl scene`.
See [scenes and content](docs/SCENES.md) for the UI, CLI, and stable zone references.
See [remote desktop setup and recovery](docs/STREAMS.md) for computer profiles,
the Mac display adapter, and the Windows externally managed profile.

[Performance reports](docs/STREAM-QUALITY.md) measure reconnect timing, retain
Moonlight decoder summaries, and record readability at each view size. Use
Reconnect from the Scenes tab's zone controls.
Use the overlay’s **Scenes** tab or `hypertile-ctl scene` to assign apps, local
windows, or Empty, then save the arrangement. See [scenes and content](docs/SCENES.md)
for setup, migration from legacy stream sources, and recovery behavior.
The [legacy stream tools](docs/STREAMS.md) remain available to restore existing
host journals while migrating to Remote Desktops.

## License

Expand Down
6 changes: 3 additions & 3 deletions bin/hypertile-ctl
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,12 @@ end
local argv = { ... }
local cmd = table.remove(argv, 1)
if cmd == "stream" or cmd == "computers" or cmd == "scene" then
local command = src and src ~= "" and (src .. "/bin/hypertile-stream")
or ((os.getenv("HOME") or "") .. "/.local/bin/hypertile-stream")
local entry = cmd == "scene" and "hypertile-scenes" or "hypertile-stream"
local command = src and src ~= "" and (src .. "/bin/" .. entry)
or ((os.getenv("HOME") or "") .. "/.local/bin/" .. entry)
local words = { command }
if cmd == "computers" then words[#words + 1] = "computers" end
if cmd == "scene" then
words[#words + 1] = "scene"
if #argv == 0 then argv[1] = "current" end
end
if cmd == "stream" and #argv == 0 then argv[1] = "status" end
Expand Down
12 changes: 12 additions & 0 deletions bin/hypertile-scenes
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""Entry point for the installed scene service (or HYPERTILE_SRC)."""
import os
from pathlib import Path
import runpy
import sys

root = Path(os.environ["HYPERTILE_SRC"]) if os.environ.get("HYPERTILE_SRC") else Path(
os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "hypertile"
sys.path.insert(0, str(root / "session"))
sys.path.insert(0, str(root / "stream"))
runpy.run_path(str(root / "stream/scene_service.py"), run_name="__main__")
98 changes: 58 additions & 40 deletions dev
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Link and apply a Hypertile checkout to an existing Omarchy installation."""
import argparse
from contextlib import contextmanager, nullcontext
from contextlib import contextmanager, nullcontext, ExitStack
import fcntl
import hashlib
import json
Expand Down Expand Up @@ -56,7 +56,7 @@ def groups():
return {
"lua": sorted(ROOT.glob("hypertile*.lua")),
"cli": [ROOT / "bin/hypertile-ctl"],
"session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "bin").glob("hypertile-stream")),
"session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "bin").glob("hypertile-stream")), *sorted((ROOT / "bin").glob("hypertile-scenes")),
*sorted((ROOT / "session").glob("*.py")), *sorted((ROOT / "stream").glob("*.py")),
*sorted((ROOT / "stream/windows").glob("*.ps1")), *sorted((ROOT / "stream/windows").glob("*.cs"))],
"shell": [ROOT / "manifest.json", *sorted(p for p in (ROOT / "plugin").rglob("*") if p.is_file())],
Expand Down Expand Up @@ -166,44 +166,62 @@ def wait_for(probe, description, seconds=10):

@contextmanager
def stopped_session():
stream_lock = None
if (BIN / "hypertile-stream").exists():
path = STATE / "streams/writer.lock"
with ExitStack() as stack:
scene_lock = None
if (BIN / "hypertile-scenes").exists():
path = STATE / "scenes/writer.lock"
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
scene_lock = stack.enter_context(path.open("a"))
def scene_available():
try:
fcntl.flock(scene_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except BlockingIOError:
return False
if not scene_available():
run(BIN / "hypertile-scenes", "stop", timeout=10)
wait_for(scene_available, "scene service to stop")
stream_lock = None
if (BIN / "hypertile-stream").exists():
path = STATE / "streams/writer.lock"
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
stream_lock = stack.enter_context(path.open("a"))
def stream_available():
try:
# Exclude the legacy writer while permitting Remote Desktops
# to keep its shared migration guard throughout deployment.
fcntl.flock(stream_lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
return True
except BlockingIOError:
return False
if not stream_available():
status = json.loads(run(BIN / "hypertile-stream", "status", "--json", timeout=3).stdout)
if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"):
raise RuntimeError("legacy stream controller belongs to another compositor")
if any(r.get("desired") or r.get("journal") for r in status.get("computers", [])):
raise RuntimeError("disconnect/restore legacy Hypertile streams before applying runtime changes")
run(BIN / "hypertile-stream", "stop", timeout=55)
wait_for(stream_available, "stream controller to stop")
path = STATE / "sessions/writer.lock"
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
stream_lock = path.open("a")
def stream_available():
try:
fcntl.flock(stream_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except BlockingIOError:
return False
if not stream_available():
run(BIN / "hypertile-stream", "stop", timeout=55)
wait_for(stream_available, "stream controller to stop")
path = STATE / "sessions/writer.lock"
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with path.open("a") as lock:
def available():
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except BlockingIOError:
return False
if not available():
status = session_status()
if not status:
raise RuntimeError("session writer is busy but not responding; no files were changed")
if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"):
raise RuntimeError("session service belongs to another compositor; use its terminal or --instance")
run(BIN / "hypertile-session", "stop")
wait_for(available, "session service to stop")
# Hold the writer lock across installation/reload. The loader cannot
# start a watcher against a half-updated set of runtime files.
try:
with path.open("a") as lock:
def available():
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except BlockingIOError:
return False
if not available():
status = session_status()
if not status:
raise RuntimeError("session writer is busy but not responding; no files were changed")
if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"):
raise RuntimeError("session service belongs to another compositor; use its terminal or --instance")
run(BIN / "hypertile-session", "stop")
wait_for(available, "session service to stop")
# Hold the writer lock across installation/reload. The loader cannot
# start a watcher against a half-updated set of runtime files.
yield
finally:
if stream_lock:
stream_lock.close()


def lua_string(value):
Expand All @@ -223,8 +241,8 @@ def reload_lua():


def start_session():
if (BIN / "hypertile-stream").exists():
command = shlex.join(["env", "-u", "HYPERTILE_SRC", str(BIN / "hypertile-stream"), "daemon"])
if (BIN / "hypertile-scenes").exists():
command = shlex.join(["env", "-u", "HYPERTILE_SRC", str(BIN / "hypertile-scenes"), "daemon"])
run("hyprctl", "eval", f"hl.exec_cmd({lua_string(command)})")
settings = read_json(CONFIG / "hypertile/session.json", {})
if settings.get("enabled", True) is False:
Expand Down
Loading
Loading