SharpDesk on Linux

sharpdesk-agent turns a Linux box into a SharpDesk host. Sign in once, and your Mac — or your iPhone, through Shortcuts — sharpens its session exactly the way it sharpens another Mac.

Free · no licenceThe Linux agent

It's a single static binary. No package manager, no dependencies, no sudo — everything lands under your own $HOME. SharpDesk is free — no licence, no trial, no card. Sign in with the same SharpDesk account your Macs use, and the box shows up as a host in the Mac panel and in Shortcuts, right alongside them.

Install

curl -fsSLO https://relay.sharpdesk.app/linux/dl/install-0.8.5.sh
echo "c9e031da92fdc2c8225f33cfd54b5300e6041043ad93eae2bdcf60b389bc91c4  install-0.8.5.sh" | sha256sum -c && sh install-0.8.5.sh

Download first, check it, then run it — nothing executes until the digest matches. install-0.8.5.sh is written once and never rewritten (the release script refuses to reuse a versioned name), so that SHA-256 identifies exactly one file for good. It installs agent 0.8.2 and nothing else: the SHA-256 of each architecture's binary is written inside it, so nothing it runs depends on what the server says at install time. Verify the installer and you have transitively verified the binary it installs.

This detects your architecture, downloads the release to ~/.local/bin/sharpdesk-agent, and — if you're running it in a real terminal — walks you through the two steps that follow, prompting Sign in now? and then Install as a background service (systemd --user)?. Piped into a script with no terminal attached, it skips the prompts and just prints the two commands for you to run yourself.

📌There is also an unversioned install.sh that always installs whatever the current release is. It is there for automation that deliberately wants the moving target — but because its contents change, no digest can be published for it ahead of time, so nothing about what it will do can be checked before it runs. Prefer the pinned command above.

Signing in stores a device credential under ~/.config/sharpdesk/. Installing writes a systemd --user unit (sharpdesk.service), enables it, and turns on loginctl linger, so hosting starts on boot and keeps running without an interactive login.

Signing in stores a device credential under ~/.config/sharpdesk/. Installing writes a systemd --user unit (sharpdesk.service), enables it, and turns on loginctl linger, so hosting starts on boot and keeps running without an interactive login.

🔄Installs keep themselves current — signed, checksum-verified, and never mid-session. See Staying current.

Requirements

The agent needs a running graphical session to control, and what it can do depends on which one it finds.

Hyprland (incl. Omarchy)Full support. Virtual displays for Extend, Mac-style mirroring for Mirror, and everything else on this page. Detected by HYPRLAND_INSTANCE_SIGNATURE and hyprctl.
XorgMirror only, by resizing a real output. No virtual displays, so no Extend. Detected by DISPLAY and xrandr.
GNOME on WaylandNot supported yet. Neither backend is detected, so neither Mirror nor Extend is available.

Everything the agent does on Hyprland is runtime-only: applied through hyprctl at session time, never written to your Hyprland config files. A reboot — or just ending the session — leaves you with exactly what you configured yourself.

To see what this machine can do:

sharpdesk-agent status

It reports your sign-in state, which backend (if any) was detected, whether virtual displays are supported here, and whether the systemd service is running.

⚠️If neither backend is found the agent refuses to start rather than half-work, and says so plainly: “No display backend found — needs an active X11 session (xrandr) or Hyprland (hyprctl).”

Staying current

The agent updates itself. It checks once at startup and then every 24 hours, give or take an hour — the interval is jittered on purpose, so a fleet that all installed together doesn't keep hitting the CDN in lockstep forever.

A check fetches a small JSON manifest (/linux/latest) listing each architecture's binary, its SHA-256, and its signature. Five things must hold before anything is swapped, and any one of them failing means no update:

NewerThe manifest's version is actually ahead of the running one.
YoursThere's a binary for this architecture — linux-amd64 or linux-arm64.
IdleNo session is in progress. A live session defers the update to the next cycle rather than pulling the display out from under it.
IntactThe downloaded bytes hash to the SHA-256 the manifest claims.
SignedAn ed25519 signature verifies over the version and the bytes together.

Why the signature covers the version too. Signing only the bytes proves they came from us but says nothing about which release they are — so a genuine, correctly-signed 0.7.2 could be re-served under a manifest claiming to be 0.7.4, and every check would pass while quietly rolling you backwards into whatever that older build's bugs were. Binding the version into the signed payload makes that relabel fail verification outright. An older-style signature that covers only the bytes is refused rather than accepted as a fallback.

The public half of the signing key is compiled into the agent; the private half never leaves 1Password and is piped straight into the signer while a release is cut, so it touches no disk, no argument list, and no repository.

The swap can't leave you with half a binary. The new build is written alongside the old one in the same directory — deliberately, so the rename that follows stays on one filesystem and is therefore atomic — marked executable, and only then renamed over the original. A partial download, a full disk or a power cut can never produce a running agent that is half of two versions: nothing but a completed rename ever changes what the path points at. The agent then exits, and systemd starts the new binary.

🛟An update that lands while a session is somehow still live restores your display before it exits. Otherwise the replacement process would start up with the viewer's resolution already applied, take that for your normal arrangement, and later “restore” you to it.

A failed check is never fatal — it prints why and carries on hosting. sharpdesk-agent update forces a check immediately, and sharpdesk-agent config set autoUpdate false stops the automatic ones for good.

Removing it

systemctl --user disable --now sharpdesk
rm ~/.local/bin/sharpdesk-agent
rm -rf ~/.config/sharpdesk

How sessions behave

From the Mac side nothing changes: pick the Linux host, choose Extend or Mirror, open your viewer. What happens on the Linux end depends on the backend.

Extend — a display that wasn't there before

On Hyprland, Extend doesn't touch your real monitor at all. The agent creates a brand-new virtual output at whatever size the viewer asks for, and that's what the remote session sees. Your physical display keeps running exactly as configured.

The virtual output has no EDID to negotiate with, so it can be any size — including well past anything your real hardware would ever advertise. That's what makes it useful behind capture hardware — a PiKVM, a capture card — that caps the real output's mode list.

Xorg can't do this yet. Rather than quietly falling back to a physical resize, the agent refuses outright, and the reason reaches the Mac panel verbatim:

💬“This Mac's viewer asked for a virtual display, but this Linux session can't create one — only Hyprland supports it so far. Use Mirror mode instead.”

Mirror — the same desktop, both screens

On Hyprland, Mirror is Mac-style. The session drives a virtual display at whatever resolution the viewer asks for, and every enabled physical output is pointed at it using Hyprland's own monitor mirroring: the same desktop on both, full resolution for the remote viewer, scaled to fit on the panel. Your panel's EDID isn't the ceiling here either — the size the viewer asks for is the size the session runs at.

When the two aspect ratios don't match — the normal case when your Mac is matching a window — the panel letterboxes. Black bars, never a stretch.

On Xorg, Mirror changes the mode of the output your box is already driving, bounded by that output's advertised mode list. It's the older, simpler behaviour, and on a single-mode panel there may be nothing to change.

Restore

Restore is automatic. Disconnecting, signing out, or killing the process (SIGTERM/SIGINT) puts back whatever was live before the session started: the resolution that was set before the first Mirror sharpen, the mirror cleared from every physical output, any virtual output the session created destroyed, and the workspaces the mirror pushed aside moved back where they came from. Only those — anything you deliberately keep on the virtual display (see Display modes) is left exactly where you put it.

Nothing is ever written to your Hyprland config either, so anything a session might leave behind is reverted by a compositor restart or a reboot anyway.

💡If the agent itself dies mid-session, the mirror is cleared within seconds of it coming back — but workspaces that were evacuated stay where they are. Drag them back.

Blackout does nothing here. The Mac panel's “black out the Mac's physical display” checkbox has no effect on a Linux host: there's no spare screen to blank instead of the one you're using, so the agent accepts the flag and ignores it rather than failing the session.

Display modes

Two ways the virtual display can live. Hyprland only — on Xorg there's no virtual display to keep.

display sessionThe default. The virtual display is created when a session starts and destroyed when it ends. Between sessions, nothing exists.
display alwaysThe virtual display exists from the moment hosting starts — parked at 1280×720 when idle, resized in place by sessions. The output keeps the name sharpdesk throughout, so a capture tool, recorder or VNC server can bind it once and stay bound.
sharpdesk-agent config set display always

A parked display is still a real second monitor, so one workspace will live on it. The agent parks it far away from your real screens, sharing no edge with them, so your mouse can't wander onto an invisible monitor; sessions bring it back to normal adjacency while they run.

💡If a stray workspace living on the parked display bothers you, pin your workspaces to your physical output in your Hyprland config.

VNC

Managed wayvnc

The agent can run wayvnc for you: it starts one bound to the virtual output when a session brings that output up, and stops it before the output is destroyed.

sharpdesk-agent config set wayvnc auto
sharpdesk-agent config set wayvncPort 5900

It listens on the machine's Tailscale IPv4 — nothing off your tailnet can even connect — on wayvncPort, which is 5900 unless you say otherwise. wayvnc has to be installed; a missing wayvnc or a missing Tailscale address is reported but never fails the session.

💡Pair wayvnc auto with display always and the endpoint is up whenever the machine is, not only while a SharpDesk session runs.

An always-on desktop VNC, alongside SharpDesk

Want VNC to your ordinary desktop all the time, with SharpDesk sessions on top of it? Run your own wayvnc for the physical output, next to the agent's. Two things have to be kept apart.

The control socketwayvnc is one instance per control socket. The agent's wayvnc takes the default ($XDG_RUNTIME_DIR/wayvncctl), so yours must be given a different one with -S. Skip this and whichever starts second simply won't — this is the trap.
The portGive it a port other than wayvncPort. 5901 beside the agent's 5900.

A systemd --user unit at ~/.config/systemd/user/wayvnc-desktop.service:

[Unit]
Description=wayvnc on the physical display
After=graphical-session.target
PartOf=graphical-session.target

[Service]
Type=simple
ExecStart=/usr/bin/wayvnc -g -r -o eDP-1 \
  -S %t/wayvncctl-desktop 100.x.y.z 5901
# wayvnc ignores SIGTERM; without this, every stop waits out the timeout.
KillSignal=SIGKILL
Restart=on-failure

[Install]
WantedBy=graphical-session.target

Swap eDP-1 for your own output name and 100.x.y.z for this machine's Tailscale address (tailscale ip -4). %t is systemd's own expansion for $XDG_RUNTIME_DIR, so the control socket lands beside the agent's without colliding with it. Then systemctl --user daemon-reload and systemctl --user enable --now wayvnc-desktop.

⚠️One catch: while a Mirror session is live, the mirroring panel disappears from the Wayland client world entirely, so a wayvnc bound to it is serving nothing (see Known caveats). Pause it for the duration with the session hooks below.
sharpdesk-agent config set onSessionStart \
  'if [ "$SHARPDESK_MODE" = mirror ]; then systemctl --user stop wayvnc-desktop; fi'

sharpdesk-agent config set onSessionEnd 'systemctl --user start wayvnc-desktop'

Only the start hook tests the mode — Extend sessions never need the pause, since your physical output carries on exactly as it was throughout. The end hook runs unconditionally on purpose: starting a unit that's already running does nothing, and that way nothing can leave your desktop VNC stopped.

Session hooks & the status feed

Two seams for everything that isn't SharpDesk. Hooks are the write side: run something when a session starts or ends. The status feed is the read side: look at what's happening right now.

onSessionStart / onSessionEnd

Two shell commands, run by /bin/sh at session boundaries with the session's details in the environment.

SHARPDESK_PHASEThe session phase — idle, applying, active, restoring or failed.
SHARPDESK_MODEextend or mirror.
SHARPDESK_WIDTH
SHARPDESK_HEIGHT
SHARPDESK_SCALE
The geometry the session applied.
SHARPDESK_OUTPUTThe virtual output's name, when the session has one.
SHARPDESK_PEERThe name of the device that started the session.
⚠️Always quote "$SHARPDESK_PEER". It is peer-supplied text — a device name somebody else chose — and an unquoted expansion inside a shell command is exactly the wrong place for it.
sharpdesk-agent config set onSessionStart \
  'logger -t sharpdesk "$SHARPDESK_PEER: ${SHARPDESK_WIDTH}x${SHARPDESK_HEIGHT}"'

Hooks are supervised but never fatal: the agent journals when one starts and what it exited with, and a hook that fails or hangs can't take the session down with it. A long-running hook is fine — a capture tool held open for the session's lifetime is the whole idea.

The status feed

The agent writes ~/.local/state/sharpdesk/status.json: the phase, the peer device, the applied resolution and scale, the mode, the wayvnc endpoint if one is serving, and when the session started.

updatedAt is a heartbeat, not just a change marker — the file is rewritten at least every 30 seconds even when nothing is happening. So a reader can tell “quiet” from “dead”: if updatedAt is older than that, the agent isn't running.

The same feed drives the terminal's live view:

sharpdesk-agent status --watch

A bar widget for the Omarchy shell reads it too — coming to omarchyplugins.com.

Configuration

sharpdesk-agent config prints the effective configuration. config set <key> <value> and config unset <key> edit ~/.config/sharpdesk/config.json; keys the agent doesn't recognise are left alone.

scaleclient (default) — virtual displays follow the viewer's scale, so a Retina Mac gets a 2× HiDPI display automatically. Or pin 1–3 for “this box is always 2×”.
displaysession (default) or always. See Display modes.
wayvncoff (default) or auto — the agent starts wayvnc on the virtual output for the life of a session. See Managed wayvnc.
wayvncPortThe port managed wayvnc listens on. 5900 by default.
onSessionStart
onSessionEnd
Shell commands run at session boundaries, with SHARPDESK_* in the environment. See Session hooks.
notificationson (default) or off — desktop toasts when a session starts and ends.
outputWhich physical output to drive. The first connected one, unless you name another.
autoUpdatetrue (default). Set false to stop the agent updating itself.
baseURLThe relay the agent talks to. Leave it alone unless you've been told otherwise.
💡Everything above output takes effect at the next session, with no restart. output, autoUpdate and baseURL are read when the service starts — run systemctl --user restart sharpdesk after changing those.

Controlling other machines

The same binary works in both directions. A Linux box that can be sharpened can also do the sharpening — to a Mac, or to another Linux box — with no second install and no separate sign-in. One device registration, one identity, both roles. It is free, like the rest of SharpDesk.

From the command line

sharpdesk-agent hosts                              # what's on this account
sharpdesk-agent sharpen mac-mini --size 2560x1440  # resize it
sharpdesk-agent sharpen mac-mini --match-window    # ...to the focused window's size
sharpdesk-agent restore mac-mini                   # put it back

The machine is named by device id, exact name, or a unique case-insensitive prefix — sharpen plex is enough when only one machine starts with “plex”. A prefix matching two machines is an error listing both, never a silent pick, and the box refuses to target itself.

--size W×HThe size to apply, e.g. --size 2560x1440.
--match-windowUse the currently focused window's size instead, measured once, when you run the command. The target does not follow the window afterwards. Needs a running Hyprland session on this box.
--modeextend (default) or mirror. Same meanings as above — but they describe what the target does.
--scale1–3, default 1. Density belongs to the machine being controlled — that's what its own scale setting is for — so a scale invented here would quietly override its choice.

Sizes are validated against the target's own limits before anything goes over the network, so an impossible size costs no round trip and tells you why. Neither sizing flag is an error rather than a guessed default: the size a machine ends up at should be one somebody asked for.

⏳The target keeps the new size until you run restore, it goes offline, or the eight-hour session ceiling expires. Exit codes: 0 done, 1 the target refused or wasn't reachable, 2 a problem with the command or this machine's sign-in.

A machine-readable roster

sharpdesk-agent hosts --json

The same roster as JSON, for anything that has to parse it. It exists because device names contain spaces and parentheses — “iPad Pro 11-inch (M5)” — so the column layout that reads well in a terminal cannot be split reliably.

Each entry adds two fields the human listing implies but doesn't state: isSelf, and canHost — whether the machine can be sharpened at all. Phones and tablets run the SharpDesk controller and have published keys, so nothing further down the stack would stop a list from offering them, but they can't host a display. Deciding that once, here, keeps every caller from inventing its own answer.

From the Omarchy panel

📦The bar widget described here is still coming to omarchyplugins.com. Everything above works today from any terminal; this is what it looks like once the plugin is installed.

The plugin's panel has a CONTROL section listing every machine on the account that can be sharpened. Pick a Size and a Mode, then use the two buttons on a machine's row: sharpen it, or hand its displays back. Size and Mode apply to the next action and reset to Match window and Extend each time the panel opens, so yesterday's choice never silently applies to today's machine.

The panel is deliberately stateless. It offers the two verbs and reports what the far machine said back; it never claims to know whether a session is currently yours, because a one-shot command keeps no such record — a session can end from the other side, expire at the eight-hour cap, or be taken over by another device.

It also keeps working when this box's own sharpdesk-agent service isn't running: controlling other machines talks to your account, not to the local agent. What it needs is a signed-in box.

⚠️Extend needs Hyprland on the target. Point it at a headless server and the sharpen is refused — with that machine's own sentence, relayed to you unchanged, telling you to use Mirror instead. That's the same refusal described under Extend, arriving from the other direction.

Known caveats

Three things worth knowing before they surprise you.

A mirroring panel is invisible to screenshot tools

While a Mirror session is live, the physical output disappears from the Wayland client world — grim and anything else that enumerates outputs simply won't find it. Local screenshot and capture tools can't see the panel until the session ends. If you need capture during a session, capture the virtual display instead; display always keeps it there for exactly this.

Switching Extend → Mirror mid-session can crash the Omarchy shell

Switching a live session from Extend to Mirror can currently take the Omarchy shell down with it. It's an upstream Quickshell issue and is being reported. Until it's fixed, restore the session and sharpen straight into Mirror rather than switching modes while one is running.

Whole-layout capture sees a huge canvas under display always

The parked display sits deliberately far away from your physical screens, which means anything capturing the whole layout rather than one named output sees an enormous, mostly-empty canvas. Bind the specific output you want.

Stuck? Write to [email protected] — a human reads it.