Skip to content

Latest commit

 

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Moonlight Sync

A Decky Loader plugin that runs moonlight-steam-sync, the command-line tool that lives in this repo's cli/, from Game Mode. One press of Sync now in the Quick Access menu lists what your Moonlight host publishes, adds a dressed Steam shortcut (with artwork from Steam's CDN and SteamGridDB) for each title, and restarts Steam once so the library shows them. Games your Deck's Steam account already owns get a hidden shortcut instead of a second tile, and a Stream button on the game's own library page that launches it. A Titles page lists every title with what it was matched to, and is where a wrong match gets fixed.

Nothing runs on the gaming PC: the plugin needs only a stock Sunshine / Apollo / GeForce host that the Deck's Moonlight client is paired with.

Status: released through v0.12.0. Its first device run (a generic SteamOS machine) produced the v0.1.1 fixes; it has not been run on a Steam Deck yet (DEVICE-CHECKLIST.md). Each release bundles the moonlight-steam-sync CLI built from the same commit, at the same version, and needs CLI 0.4.0 or newer.

Requirements

  • SteamOS (Steam Deck or a Deck-like) with Decky Loader.
  • A Moonlight client on the Deck (native moonlight or the Flathub flatpak), already paired with your host.
  • moonlight-steam-sync 0.4.0 or newer. The plugin bundles the version it was built for and installs it for you (below), so there is nothing to install separately.

Install

Moonlight Sync is not in the Decky plugin store and will not be: the store does not accept plugins written mostly with generative AI, which this one was, and it does not list plugins that can update themselves (see Decky's wiki, Submitting plugins and Plugin safety). So the first install is a manual one: from the GitHub release with the one-liner below, or by hand ("Manual install"). From 0.12.0 on, the plugin then updates itself from Game Mode ("Updating"). To run something newer than the latest release, build from source (see "Developing") or use the Moonlight-Sync artifact of a CI run as the manual zip.

curl -fsSL https://raw.githubusercontent.com/episode6/moonlight-steam-sync-decky/main/install.sh | sh

install.sh downloads the latest (or a MOONLIGHT_SYNC_VERSION-pinned) release's Moonlight-Sync.zip and its .sha256, verifies the checksum, removes any previous Moonlight Sync/ install so files an older release shipped and the new one no longer does cannot linger, unzips the new one into ~/homebrew/plugins/ and restarts plugin_loader so the new plugin loads. Both the install and the restart run through sudo (Decky's plugin directory belongs to root on a stock install), so you will be asked for your password twice on the terminal; the script never runs sudo non-interactively. A stock Steam Deck ships with no password for the deck user, so if you have never set one, run passwd in a Desktop Mode terminal first. It is safe to re-run: it always re-downloads and reinstalls, even when already current, so re-running it is also how you pick up a new release.

Manual install

Download Moonlight-Sync.zip from a release, copy it to the Deck, and in a Desktop Mode terminal or over SSH:

sudo rm -rf ~/homebrew/plugins/"Moonlight Sync"
sudo unzip -o Moonlight-Sync.zip -d ~/homebrew/plugins/
sudo systemctl restart plugin_loader

The zip holds a single Moonlight Sync/ directory, which is the plugin's directory under ~/homebrew/plugins/. Restarting plugin_loader loads it; Moonlight Sync then appears in the Quick Access menu's Decky tab.

Uninstall

sudo rm -rf ~/homebrew/plugins/"Moonlight Sync"
sudo systemctl restart plugin_loader

This removes only the plugin. It never touches the CLI it bundled (~/.local/bin/moonlight-steam-sync), your Steam shortcuts, or anything under the Steam directory (the CLI is the only writer there, see "Hard rules" in AGENTS.md); uninstall the CLI separately if you want it gone too: rm ~/.local/bin/moonlight-steam-sync, plus its config, cache and state directories if you want those gone as well (see cli/README.md, "What it touches on disk").

None of this has been run on a real Steam Deck yet; see DEVICE-CHECKLIST.md for the full list of on-device checks to run once you have one.

Updating

From 0.12.0 on, the plugin updates itself through Decky's own installer, from Game Mode, with no terminal. Releases before 0.12.0 have no updater: update those once with install.sh (above), and every later release is offered in the plugin.

The check. Each time the plugin loads (once per Steam start, and once more whenever Decky reloads it) it asks GitHub for the list of releases. When one is newer than the installed version, a toast says "Version X is available. Settings → Updates", and the last row of the Quick Access panel reads Update to X; it opens Settings → Updates. Turn the check off with Check for updates automatically on that page; Check now still asks whenever you press it. Nothing is ever installed without your say-so, and while the plugin is toggled off there is no check, no toast, no row and no Updates page.

Installing. Settings → Updates shows the installed version, the channel, the latest release, what is new in it, and Update to X. Pressing it first downloads the release's Moonlight-Sync.zip: the plugin's backend fetches it from this repository's releases on GitHub, checks it against the SHA-256 GitHub reports for it and against the release's own checksum file, and checks that it is a build of Moonlight Sync, all before Decky is involved. The button reads Downloading… meanwhile, and a Cancel button under it stops the download (nothing is installed, and nothing is said). Then the settings page closes and Decky asks "Are you sure you want to update Moonlight Sync to version X?". That dialog is Decky's and is the only confirmation: Cancel changes nothing; Confirm has Decky remove the old copy, unpack the checked zip and load it, showing its progress in its own tab. Only the plugin reloads: no loader restart and no Steam restart. Decky is only ever handed a zip the plugin has already downloaded and checked, never a web address; a release without a SHA-256 is never offered. The update is refused, with a toast, while a sync is running, while layouts are being applied and during a SteamGridDB key fetch, and no sync starts while the zip downloads or Decky is being asked.

Install another version on the same page lists every release from 0.12.0 on (older ones have no updater, so installing one would leave install.sh as the only way forward): the installed one again (reinstall), an older one (Decky asks to downgrade), or a newer one. It downloads and goes through the same dialog.

Channel. The second row of the page picks what the plugin follows:

  • Releases (the default): the tested releases, as above.
  • A branch (main, and any branch the developer publishes builds of): that branch's newest build, rebuilt on every push to it. Builds of a branch are untested and can break syncing. Choosing one asks first, "Follow main?", with Follow and Cancel; choosing Releases asks nothing, and you can return to it here at any time. On a branch the page's button reads Switch to main @ abc1234 (the branch and its commit) whenever the branch has a build you do not have, and the automatic check announces a new push as it does a release. Each install from a branch checks GitHub again first, so what is downloaded is the build that is there now. Decky's dialog names it as, say, "0.12.0 (main @ abc1234)", since a branch's build carries the last release's version number. Installed then reads 0.12.0 · main @ abc1234 · built …, and Settings → About's Built from and Commit rows say the same with the whole commit.
  • Back to Releases: pick Releases; the page then offers Switch to X, the newest release, which installs it over the branch's build (Decky asks to overwrite). Install another version works from a branch's build too.

A branch's build brings its own CLI. Because it carries the same version number as the release it came after, the plugin replaces your installed CLI of that version with the branch's (see The bundled CLI below), and installing a release again puts the release's own CLI back the same way (or, for a newer release, as an update). A CLI newer than the build's, installed by hand, is never replaced.

Settings, the ignore list, the SteamGridDB key, the CLI's caches, your shortcuts and artwork live outside the plugin's directory and are untouched by an update. After an update Moonlight Sync moves to the end of Decky's plugin list; put it back where you want it in Decky's settings.

What is sent. The check is one unauthenticated request to GitHub's API for this repository's public list of releases; on a branch channel one more request fetches that branch's build.json from its build on GitHub. An install downloads the zip and its checksum file from this repository's releases on GitHub, by the plugin's backend. Nothing about your device or your library goes with any of them, and Decky reports nothing to its store for an install from a downloaded file. GitHub allows 60 API requests an hour per network; when it says the limit is spent, the Updates page says when to try again.

When it goes wrong. Every failure before Decky's dialog opens leaves the installed version as it was, working, and says why in a toast; nothing is removed until you confirm a zip that has already been checked.

What happened What you see What to do
The device was offline, or GitHub did not answer "Could not reach GitHub: …" Try again
GitHub is limiting requests from your network "GitHub is limiting requests from this network. Try again in …" Wait, then try again
The release has no zip or checksum file, or its checksums disagree "The release has no zip", "The release's checksum file could not be read", "The release's checksums disagree" Try again later; report it if it stays
A branch's build is being replaced right now "A new build is being published. Try again in a minute" Try again in a minute
The download did not match its checksum "The download did not match its checksum. Nothing was installed" Try again
The download is not a build of Moonlight Sync, or not the one asked for "The download is not a Moonlight Sync build. Nothing was installed" Report it
The download took too long "Timed out after … s" Try again
Another download is already going "An update is already being downloaded" Wait for it, or press Cancel
The branch has no build right now (it is being republished, or was deleted) "No build of main is published" Try again later, or return to Releases
Decky could not be asked "Decky did not accept the install. Update with install.sh from Desktop Mode" install.sh from Desktop Mode

When this Decky Loader is too old to install from a plugin (before v3.0.0), or its installer cannot be reached, the Updates page says so; update Decky Loader, or update with install.sh from Desktop Mode.

The bundled CLI

The zip carries bin/moonlight-steam-sync.pyz, the CLI built from this repo's cli/ in the same commit as the plugin; the two share one version (package.json's "version"). Every time the plugin loads it compares that copy with ~/.local/bin/moonlight-steam-sync:

  • missing there, or older → the bundled one is copied into place (atomically, ~/.local/bin is created if needed);
  • the same version but different bytes → the bundled one replaces it, the same way. A build of a branch carries the last release's version number, so this is how its CLI gets installed; it also replaces a CLI of that version you built or installed by hand;
  • the same version and the same bytes, or newer → left alone. A newer CLI you installed by hand stays; the plugin never installs an older one.

If ~/.local/bin/moonlight-steam-sync is a symlink (to a checkout, say), a replace, whether for an older version or for the same version with other bytes, puts the bundled file in place of the link itself; it never writes through the link, so the file it pointed to is left as it was.

The plugin always runs the copy in ~/.local/bin, through python3, so a shortcut made from Game Mode and one made from a terminal belong to the same tool. Settings → About shows both versions, the minimum the plugin needs (0.4.0) and any install error, and what the plugin itself was built from: Built from (main @ abc1234 · built … for a build of a branch, v0.12.0 @ abc1234 · built … for a release, "not recorded in this build" for a zip that does not say) and Commit, the whole commit. If the installed CLI is missing or older than 0.4.0 the panel shows a single row, "CLI not installed — see About" or "CLI too old (0.2.0, needs 0.4.0) — see About", and every CLI action is disabled; the settings pages that only touch the plugin's own files keep working. A zip packaged by hand without building the CLI first has no bundle; About then says "bundled CLI: none (this zip was packaged without the CLI)" and you can install the CLI on its own (see "Command-line tool only" below).

The Quick Access panel

  • Enable Sync, the on/off toggle at the top. Turn it off when the Deck travels away from its host: every Moonlight shortcut is hidden in the library (the visible ones and the Moonlight client entry too), the Stream buttons and the Streaming tab disappear, nothing syncs, the host is not probed, and the settings route shows the toggle alone until it is on again. Turning it on puts everything back as the last status and your settings say, and runs a controller-layout walk that fell due meanwhile. The toggle is unavailable while a sync runs; the run finishes on its own.
  • Host: a dropdown of your known hosts with the active one selected, shown only when you have more than one host. Under the label, a circle and the number of apps. The plugin never asks the PC anything on its own: every moonlight list wakes it (Moonlight's client sends a Wake-on-LAN packet before it even looks, see "Hosts" below), so the circle is grey and the count is the last listing's until you press Check, add a host or sync. After a check, a green circle and the count when moonlight list answered; a red circle when it did not, with the error and "last seen " (the last successful listing) on a line of its own under the dropdown. That line is there only while there is an error, and with a single host it is all the panel says about the host. A sync counts as a check too. Check and Wake show while the host is not known to be reachable, with one host or several; Wake sends the PC a Wake-on-LAN magic packet when its MAC address is known, and toasts "Give it a minute, then Check", since sending proves nothing about the PC. Choosing another host asks first ("Switch to OFFICE-PC? MY-GAMING-PC's tiles are parked, not removed"), then switches and syncs.
  • Sync now: the full sync. While it runs the panel shows a progress bar over the titles, the last five titles and where their images came from, the plan, and Stop (everything done so far is kept; sync again to resume).
  • The launch row: three icon buttons on one line. The line under them names the button that has focus and says what it does.
  • Open Moonlight (the moon): starts the Moonlight client itself, through a hidden "Moonlight" shortcut the first sync creates (until then it is disabled and the line reads "Sync once to enable").
  • Desktop (the monitor) and Steam Big Picture (the Steam logo): the two entries every Sunshine / Apollo host publishes by default get a button each, which launches that entry's synced shortcut. These buttons replace the two library tiles: every sync the plugin runs passes --hide-host-apps, so the CLI keeps the two shortcuts (Steam's controller layouts are per shortcut) but writes them hidden. On an existing install the first sync hides the two tiles in place: same appid, so their artwork and any controller layout you chose are untouched. A button only shows when the active host publishes the entry and it is not ignored (an ignored entry has no shortcut to launch); without one, the other icons widen to fill the row. Like the Stream button, they work while a game is already running too: Moonlight's own UI handles a stream that is already going. Their controller layouts can be changed from Steam's own controller settings while one of them streams, and they take the default layout like every other entry.
    • Getting the tiles back. There is no plugin setting for this. From a terminal (Desktop Mode or SSH), run the plugin's own sync without --hide-host-apps:

      cd ~/homebrew/settings/"Moonlight Sync"
      moonlight-steam-sync sync --owned-apps owned-apps.json \
        --ignore-file ignore.json --client-shortcut --park-unpublished

      The CLI sees two published, hidden shortcuts and shows them again (it restarts Steam to write, as a terminal sync always does). Keep the other flags: a bare sync --park-unpublished knows nothing of your owned games or the plugin's ignore list, so it would also turn Stream buttons back into visible tiles and re-add every title ignored on the Titles page. The exact command the plugin last ran is in ~/homebrew/logs/Moonlight Sync/moonlight-sync.log. The next sync from the plugin hides the two again, so this only lasts if you sync from the terminal from then on. A host whose two entries were renamed is not affected: only the names Desktop and Steam Big Picture (any case) are host apps.

  • The layout row, for the Moonlight entry. Layout opens Steam's controller configurator for the hidden Moonlight shortcut (left out on a Steam client that cannot open it). Make default makes that layout the default for every streaming entry (see "Controller layouts"), and is the one place a default is set: it asks you to confirm first. The line under the row names the current default ("Moonlight's layout · default for streams: …", or "no default for streams yet"). Both buttons are disabled until the first sync creates the shortcut, and work while a game is running.
  • Four counters from the last status: Stream buttons (hidden entries for games you own), Shortcuts, Unmatched, Ignored; and Last sync ("Today 2:02 PM · 2 added, 1 removed"; every time the plugin shows follows Steam's Settings → System → 24-hour clock). The two host apps count toward none of the first three.
  • Update to X, the last row, only while a newer release is offered (see "Updating"): it opens Settings → Updates.

On the very first run there is no host yet: the panel says "No host yet" and Add a host opens the Host page.

The Stream button

A title the host publishes that resolves to a game this Steam account owns gets no visible tile. Instead its real library page grows a Stream button (a row under the header, above Play / Install), with a note saying which host it streams from and, while a default controller layout is set, the state of its layout. Pressing it runs the hidden shortcut the sync created, so Steam owns the session: the overlay works, the Recent Games shelf shows it, and the layout picked for that hidden entry applies. The button appears only while the active host publishes the game (the last status says the entry is hidden, not parked and published); switch hosts and the other host's buttons go away until you switch back. A press works while a game is already running too: Moonlight's own UI handles a stream that is already going, and Steam handles the switch.

The button is added to the library page by patching Steam's /library/app/:appid route with Decky's own primitives, and it is the only thing the plugin changes on that page. Nothing is written to Steam's files by the plugin: the hidden shortcut is the CLI's, and the button just runs it.

Hidden shortcuts and the Streaming tab

The CLI marks the shortcut behind every Stream button (and the Moonlight client entry, Desktop / Steam Big Picture, and another host's parked titles) IsHidden in shortcuts.vdf. The current Steam client ignores that field: what is hidden lives in the client's own Hidden collection, which only the running client can change. So after every status (at load, after a sync, on the Titles page) the plugin brings the client in line, changing only what differs:

  • Hidden. Every entry the CLI calls hidden is hidden in the client, and every entry it calls visible is shown (which is what brings a parked tile back). Settings → Advanced → Hide Stream shortcuts (on by default) governs the Stream-button shortcuts only. Turn it off to have them under Non-Steam again, where a streamed game comes to the front of Home as its shortcut; the client entry, the two host apps and parked titles stay hidden either way. Because the CLI's word is final here, hide a Moonlight title with Ignore on the Titles page rather than Steam's own Hide this game, which the next sync would undo.
  • The Streaming tab. A tab of the library's own tab bar, after Non-Steam, with one tile for every title the active host can stream right now: the real Steam game when it has a Stream button, the Moonlight shortcut otherwise. It is drawn by the plugin from the client's own grid and written nowhere: it is not a collection, so it does not sync through Steam Cloud, and two devices synced to different hosts each see their own. (Earlier versions kept a real Streaming collection; its owned games synced to every machine, and two devices on different hosts kept removing each other's. On upgrade the plugin takes this device's games out of that collection and deletes it once it is empty.) Settings → Advanced → Streaming tab (on by default) turns it off.
  • The shortcut collection. With Hide Stream shortcuts off, the tab stays and a real Streaming collection comes alongside it, holding this device's Moonlight shortcuts only, never an owned game. It is found by its name; the plugin only ever adds its own entries and removes them again while it keeps the collection (that is, while Hide Stream shortcuts is off), so anything else in it (another device's shortcuts, a title you put there) stays, and it is deleted once nothing is left in it. Two devices under one account get the same appid for a shortcut of the same title, so a device with the tab alone leaves shortcut members where they are (hidden, so out of sight) rather than fight a device that keeps the collection; a parked title is left alone for the same reason. The Streaming tab toggle turned off takes every entry this device knows out. While another device still runs a version with the old collection, it keeps putting its owned games back and this one keeps taking them out; upgrade both.

Right after a sync's restart both wait (up to 90 s) for the client to load its shortcut list; anything still missing then is picked up the next time. On a client without these calls the two toggles are disabled and say so. With the panel's Enable Sync toggle off, every entry is hidden whatever the settings say, the tab is gone and the shortcut collection is emptied of this device's entries (and deleted once empty), until it is on again.

The one restart

Steam only reads shortcuts.vdf at startup, so a sync that changes it ends in one Steam restart. The CLI does all the work (listing, matching, every image) while Steam keeps running, then waits for Steam to exit. The plugin asks: "Sync finished: 2 added, 1 replaced — Restarting Steam in 5 s to show them", with Restart now and Later (B). The countdown length is a setting, 0 to 30 seconds (0 means no countdown, just the buttons; the ceiling is 30 because the CLI stops waiting for Steam after 60 s, and a longer countdown would race it), and it never starts while a game is running ("A game is running. Restart Steam when you're done.").

In Game Mode, asking Steam to shut down is the restart: the session brings it straight back, and the CLI writes the file in between. Later stops the waiting CLI without writing anything and leaves a Restart Steam to apply row in the panel; pressing it runs the sync again (no downloads: everything is cached) and restarts at once. A sync that only saved new artwork (nothing to write) offers the same restart ("New artwork needs a Steam restart to show"), and so does a stopped sync that saved images. The pending restart survives the plugin reloading.

Hosts

Settings → Host lists the hosts you added, each with its cached title count and when it was last seen, the active one marked:

  • Add host: type the name Moonlight knows the PC by and press Check and add. The plugin runs moonlight list against it as the pairing check (there is nothing on the PC to ask); it is added only when that works. The first host you add becomes the active one.

  • Switch: parks the current host's tiles (hidden, with their art and layouts kept) and syncs the other host. Switching back later is one listing, no downloads and one restart.

  • Forget: removes a host from the list (not the active one). Its parked tiles stay until you remove everything.

  • Wake-on-LAN: the panel's Wake button sends its magic packet to the MAC address in Moonlight's own host list (the Flatpak's Moonlight.conf, read, never written), which the client reads from the host each time it sees it online. There is nothing to enter here: a host Moonlight has no MAC for (a Sunshine host that reports none) has no Wake button; once the host reports one, open Moonlight with the PC on and the client picks it up. Moonlight itself has Wake PC in a host's menu but no command-line action for it, so the plugin sends the packet itself: to the broadcast address and to every address Moonlight knows for the host, on the usual Wake-on-LAN ports and the GameStream ones. The PC's firmware and network adapter still have to allow Wake-on-LAN (and the Deck has to be on the same network for a broadcast to reach it).

    One thing to know: Moonlight's command line sends that same packet by itself, on every list and stream, before it even looks whether the PC is up (found on a device on 2026-09-23 with a packet capture). So a PC that Moonlight knows the MAC of is woken by Check, by adding a host, by Sync now and by a Stream button, and by nothing else: the plugin runs no Moonlight command on its own, not when the panel opens, not at load, not when the Titles page opens.

The Titles page

The list button in the panel's header (or Settings → Titles) opens every title the active host publishes, sorted by name (or the newest first, see Recently added below), with what the next sync does with it. Each row has Steam's library capsule when the title is matched to a Steam game (a striped placeholder otherwise), the name, the match line (Balatro · Steam 2379780, Sea of Stars · SGDB 5322710, or no match) and one badge:

  • stream button: matched to a game this Steam account owns; a hidden shortcut named after the game, whose library page gets the Stream button.
  • shortcut: a visible shortcut with artwork, as today.
  • unmatched: a shortcut without a Steam or SteamGridDB match.
  • host app: Desktop or Steam Big Picture, a hidden shortcut the panel's button launches (see "The Quick Access panel"). It is listed under All only. Change match still works and only changes its artwork (every result reads art only: no match makes a host app a Stream button); Ignore removes the entry, and its panel button, on the next sync.
  • ignored: nothing is created for it.
  • duplicate: matched to the same owned game as another title (the line reads "same game as …"); the first title gets the hidden entry and this one gets no tile at all. It is almost always a wrong match: change it, or pin it to No match to give it its own tile.
  • parked: a title only another host publishes, hidden in place with its art kept. Parked titles are left out until you press Show parked, and then listed under All.

Chips flag what is worth a look: fuzzy (matched by a fuzzy title search, which never makes a Stream button), pinned (you chose the match), same game on OFFICE-PC as "…" (the other host publishes this game under a different name; align the names on the host side or pin one), and art refreshes on next sync (after a pin). The filters are All, Stream buttons, Shortcuts, Unmatched and Ignored; rows render 50 at a time with a "Show 50 more" row at the end, so a 500-title host stays quick to scroll with the D-pad. The page reads the listing the last sync cached ("N published by MY-GAMING-PC · listed "), never the host itself (a live moonlight list would wake the PC); Sync now is what refreshes it. A host that was never synced lists nothing, even when Check or adding it cached its list: the page says so and offers Sync now itself. While a sync is running it shows the same cached listing ("refreshes when the sync finishes") and re-lists on its own as soon as the run ends.

Recently added, beside the filters, lists the titles added last first, so the games your latest sync brought in are at the top; each row then says when it was added ("added today 2:02 PM"), and the titles of one sync are in name order among themselves. Press it again for the order by name. The page remembers the choice (the titles_recent_first setting). The plugin notes when it first sees a title, so the titles that were already there when this feature arrived have no time and come after the dated ones, by name; so do the rows without a shortcut (ignored, duplicate) and the host apps (Desktop, Steam Big Picture), which are never dated. A title that is removed and synced again later counts as added again. The time goes with the title's name, as its shortcut does: a title another host already has parked is known, so it is not new when the active host starts publishing it too.

Change match searches Steam's store and SteamGridDB (prefilled with the title's name; edit it and Search again) and shows the results as one list, the games this account owns first, then Steam's results before SteamGridDB's, each with what it would make of the title: becomes stream button for a game this account owns, shortcut otherwise (art only for every result on a host-app row). The current match is marked, and the last row is No match (a plain shortcut with art found by name on SteamGridDB). Pressing a row pins it; Cancel (B) changes nothing. Changing a match is disabled while a sync runs.

How a pin becomes a Stream button. A pin is written to the CLI's own match cache (how: "pinned", the same matches.json a terminal run uses) and nothing else changes yet ("Pinned; sync to apply"): no Steam restart per pin. On the next Sync now the CLI sees the pin, and a pin counts as an exact match, so a title pinned to a game you own becomes a stream button: its visible shortcut is replaced by a hidden one named after the Steam game, the art is fetched again from the new match, and the usual one restart shows the result. Pinning anything else, or No match, keeps the title a shortcut with art from the new match. Pins survive Re-fetch all art and Retry missing art; an [overrides] entry in config.toml still wins over a pin (the modal says so when one applies).

Titles stuck on "no match". The CLI remembers a title it found nothing for and does not look it up again for seven days, and the Titles page never looks a title up itself. So a first sync run before the SteamGridDB key was set (Steam's store search alone finds fewer titles) leaves those titles reading "no match" until the week passes, Retry missing art is on for the next sync, or the cache is reset. Settings → Advanced → Reset match cache deletes the CLI's whole match cache (~/.cache/moonlight-steam-sync/matches.json), pins included, after a confirmation; the next Sync now matches every title afresh. Nothing changes in Steam until that sync, a title that already has artwork keeps it, and the button is unavailable while a sync runs (a pin still being saved is refused the same way).

Ignore adds the title to the plugin's ignore list (ignore.json, passed to the CLI as --ignore-file) and Unignore takes it out; the next sync applies it. A title ignored in the CLI's config.toml reads "Ignored in config.toml" and can only be unignored there, since the plugin never writes that file.

Controller layouts

Steam keeps a controller layout per app, and everything the plugin manages is a stream: a streamed game runs as its own hidden shortcut, so the layout you picked for the real game does not apply to it by itself. The plugin therefore keeps one default controller layout that every entry it manages starts on, and each title stays customisable on its own.

  • Adopting a default. There is no layout list of the plugin's own (Steam has no API that returns a picked layout by name). Instead, set the layout up on the Moonlight entry from the Quick Access panel: Layout opens Steam's controller configurator for it, and Make default adopts the layout it has. The plugin reads that layout for the controller in use, asks you to confirm, and puts it on every entry that has no layout of its own: Stream buttons' hidden shortcuts, visible shortcuts, Desktop / Steam Big Picture and the Moonlight entry. A toast says how many titles took it and how many kept their own. Parked titles (another host's) get it when they come back.
  • What can be a default. A community layout or a personal layout you exported (both read back from Steam Input as workshop://…), or one of Steam's built-in templates (template://…). A layout you edited in place without exporting only exists for that one title (autosave://…): Make default refuses it and says so. In Steam's layout screen choose Export, select the exported copy on the Moonlight entry, then try again. With no layout chosen yet there is nothing to adopt either.
  • What is overwritten, and what never is. The plugin only ever changes a selection it made itself (it remembers, per shortcut, the last layout it set). A layout you chose yourself on a title, a layout you edited in place after the default landed, and the title you adopted the default from (the Moonlight entry) are left alone, by every Stream press, every sync and every change of the default. A title still on the plugin's earlier default moves to the new one; a title copied from its Steam game by an older version of the plugin counts as plugin-set and moves too. A hand-picked layout that happens to be the same as the default is still yours.
  • When it runs. On every press of a Stream button (before the launch; once the shortcut is on the default nothing is set again), once after a sync's Steam restart over every non-parked entry (as soon as Steam has loaded the shortcut list, polled every 2 s for up to 90 s; anything that never loads is recorded as unavailable and gets it on its next press), and at once when the default is set or cleared. Changing the default is not held back by a running game or a sync. While that walk is running, Make default answers "A layout walk is still running" and Clear is disabled; if the plugin has not loaded your titles yet (its status check failed), the change is kept and the toast says the layout is applied, or taken off, once they have loaded.
  • Clearing. Settings → Advanced → Default controller layout shows the current default and has Clear, which takes the plugin's layout off every title that still has it (they go back to Steam's default; titles whose layout you chose yourself are left alone) and stops applying one. This uses a Steam client call that has not been measured on a device yet; on a client without it, the default is still cleared and titles keep the layout they have (the toast says so).
  • What you see. While a default is set, the note next to a game's Stream button shows the last result for its entry: default layout, own layout (a choice of yours), Steam default, unavailable (no controller was connected, or the selection did not stick when read back) or picker opened. The results live in layouts.json in the plugin's settings directory; the plugin never writes Steam's controller config files, only asks Steam Input to select (or clear) a layout.
  • Which controller. Layouts are per controller. The Deck's built-in controller is found by type, never assumed to be index 0 (a paired pad can take that slot). The sanctioned way is Steam's own type string (controller_steamcontroller_neptune from ControllerStore.GetControllerTypeString); only on a client without that function does the plugin fall back to the enum value 4 (DECK_CONTROLLER_TYPE in src/lib/layouts.ts), which is still unverified on a Deck. When the client has the type string its answer is final: another pad is never taken for the Deck's because it shares an enum value. On a device with no built-in controller (a SteamOS box with a separate pad) the plugin uses the active controller, or the only connected one; with several pads and none active, or none connected, nothing is set (unavailable, and Make default asks you to connect a controller first).

The PR-0 device probes ran on 2026-09-20, on a SteamOS machine with a separate Steam Controller rather than a Deck. Probe V2 confirmed that a workshop:// layout published for one app, set on a shortcut through SetSelectedConfigForApp, reads back and sticks -- provided the call carries the fifth, selection-type argument Steam's own configurator passes (with four it returns normally and does nothing). Probe V1 found a community layout and an exported personal layout both read back as workshop://…, a layout edited in place as autosave:///… (a file path under one appid, which is why it is refused as a default), and an untouched game as template://… with bSelected: false -- a layout Steam offers, not one anybody chose, which the plugin therefore treats as no selection (a fresh shortcut itself reads default://<lowercased name>). Whether a template:// default sticks on other titles, and the name and arguments of the call that clears a selection, are still to be measured (DEVICE-CHECKLIST.md); a template:// that does not stick is recorded as unavailable per title, never as a wrong layout. A Deck itself is still untested. The fallback stays built in:

  • layout_strategy in settings.json ("copy", the default, or "picker"; no UI, edit the file by hand) switches the behaviour on a device without a rebuild. Under picker the plugin makes no Steam Input calls at all: a Stream press just launches, there is no walk after a sync's restart, the default layout is off (the Advanced field says so and Make default is not offered), and the panel's Layout (recorded as picker opened) is the plugin's only layout affordance.
  • The default lives in one place, DEFAULT_LAYOUT_STRATEGY in src/lib/layouts.ts. If a later client breaks the set, flipping that constant to "picker" and rewriting this section is the whole change.

SteamGridDB key

Settings → Artwork has the SteamGridDB API key field (get a key at steamgriddb.com/profile/preferences/api). The key is stored where the CLI itself looks for it, ~/.config/moonlight-steam-sync/sgdb-api-key (mode 0600), so the plugin and a terminal share one key. The CLI's precedence applies: [steamgriddb].api_key in config.toml wins (the field is then disabled and reads "set in config.toml"), then SGDB_API_KEY in the environment ("set in the environment"), then the key file. The plugin never writes config.toml, and it only ever shows the key's last four characters. Test runs a search to check the key; Remove deletes the key file and nothing else.

Get key from SteamGridDB… saves typing the 32 characters on a Deck. It is there with no key yet or with the key file above, not while config.toml or SGDB_API_KEY sets the key (a saved file would be ignored). It asks first:

Sign in to SteamGridDB with Steam? Moonlight Sync opens steamgriddb.com in Steam's browser and signs you in with your Steam account (SteamGridDB gets your public Steam ID, nothing else), then reads your API key from your SteamGridDB preferences and saves it on this device. Your key is never shown or sent anywhere else.

Continue opens SteamGridDB's API page in Steam's browser and the plugin drives it over Steam's own debugger port (the one Decky Loader itself uses): it follows Login via Steam, presses Steam's Sign In once, then reads the key. If Steam asks for your password or a Steam Guard code there, type it on the page and the plugin carries on. An account that has never made a key gets its Generate button pressed once; Revoke API Key, or anything else that would replace a key you already have, is never pressed. While it runs the field says where it is and Cancel stands in for Save; it gives up after three minutes. At the end the browser is closed, the key is saved to the key file and tested, and a toast shows its last four characters. Cancel is pressed on the Artwork page, so after it the plugin leaves the browser page open where it was rather than navigating anywhere. The key goes from the page to the file and nowhere else: not to the plugin's UI, its events or its log. If SteamGridDB's pages change shape the plugin says so and the typed field is the way in.

The same page has Retry missing art (look again for images that were missing last time) and Re-fetch all art, which needs a CLI whose art command accepts --commit and says "needs a newer CLI" otherwise.

Settings → Advanced has Default controller layout with its Clear button (see "Controller layouts"; the default itself is adopted from a title's row on the Titles page), Hide Stream shortcuts, Streaming tab, the restart countdown (0-30 s), Reset match cache (the CLI's matches.json, pins included, so the next sync matches every title afresh; see "Titles stuck on 'no match'" above) and Remove everything this plugin created (every shortcut, hidden entry and image the tool made; pins and ignored titles are kept; Steam restarts once; the layout records go with the entries).

Files

Everything the plugin writes lives in its own directories, never under Steam's:

  • ~/homebrew/settings/Moonlight Sync/: settings.json, ignore.json (the Titles page's ignore list, a sorted JSON list of names), owned-apps.json (the owned games, rewritten before every sync), pending.json (a restart that is still pending, whether the layout walk after a sync's restart is still due, and every host a sync has run for), layouts.json (the last layout result per hidden shortcut: {"version": 1, "entries": {"<shortcut appid>": {"real_appid", "result", "url", "when", "applied"}}}; real_appid is null for an entry with no Steam game behind it, such as Desktop / Steam Big Picture or the Moonlight client; applied is the last layout URL the plugin itself put on that shortcut, or null when the selection is one you made), added.json (when the plugin first saw each title, for the Titles page's Recently added order: {"version": 1, "titles": {"<Moonlight name>": "<time>"}}, the time null for a title that was there before the plugin kept track, and neither the Moonlight client nor a host app in it; deleting the file starts over, with every title undated).
  • ~/homebrew/logs/Moonlight Sync/moonlight-sync.log: every CLI call with its arguments, the CLI's own messages verbatim, and both CLI versions at startup.
  • ~/homebrew/data/Moonlight Sync/update/staged/: a plugin zip the plugin downloaded and checked before handing it to Decky's installer (Moonlight-Sync-<the first 12 digits of its SHA-256>.zip), and, only while a download is running, its .part file and the release's checksum file. The updater does not download through it yet. The plugin deletes a staged zip the first time it loads more than an hour after the download; deleting the whole directory at any time loses nothing.
  • ~/.local/bin/moonlight-steam-sync (the CLI install) and the SteamGridDB key file above.

settings.json also carries layout_strategy ("copy" or "picker"), which has no UI: it is the hand-editable switch described under "Controller layouts", for trying the fallback on a device; enabled (the panel's on/off toggle, true by default); update_check (Updates → Check for updates automatically, true by default; nothing about a check is kept on disk); update_channel (what the updater follows: "stable", the releases, by default, or "branch:<name>" for a branch's builds; a picker for it on the Updates page arrives in a later version, and a value that is not one of the two reads as "stable"); hide_stream_shortcuts / streaming_collection (the Streaming tab toggle: the key kept its name), the two Advanced toggles above; and default_layout (null, or {"url", "title", "when"}, the adopted default controller layout), which the plugin changes only through its own set_default_layout call, never through the other settings. A wake_macs key from before 0.11.0 (the Host page's entered Wake-on-LAN MACs) is left in place and read by nothing: Moonlight's host list is the one MAC source now.

A pin from the Titles page is written by the CLI itself (moonlight-steam-sync match … --defer-art) into its own match cache, ~/.cache/moonlight-steam-sync/matches.json, where a terminal run sees it too. That file is the one CLI file the plugin touches itself: Reset match cache deletes it whole (never edits it, and never while a sync or a pin is going); hosts/ beside it is left alone.

What the plugin never does: write shortcuts.vdf, anything under Steam's userdata/ or grid/, or controller config files (the CLI is the one writer); call Steam's live shortcut APIs (AddShortcut, RemoveShortcut, SetShortcutName, SetAppLaunchOptions, SetCustomArtworkForApp); write the CLI's config.toml; run as root; or install anything on the gaming PC. The one thing it changes in the running client is the hidden state of the CLI's own entries and the fallback Streaming collection (above), because no file the CLI could write does either; the Streaming tab is drawn, not stored.

Command-line tool only

The plugin is the recommended way to use this on a Deck, and it installs the CLI for you. To use the CLI on its own instead (from Desktop Mode, over SSH, or on a Linux machine without Decky), install just the CLI from the latest release:

curl -fsSL https://raw.githubusercontent.com/episode6/moonlight-steam-sync-decky/main/cli/install.sh | sh

It downloads the release's moonlight-steam-sync.pyz and its .sha256, verifies it and installs it as ~/.local/bin/moonlight-steam-sync (no root; Python 3.11+, which SteamOS ships). See cli/README.md for its options, configuration and every subcommand. The CLI used to live in its own repo, episode6/moonlight-steam-sync, now archived; its releases up to v0.4.0 stay there, and from the first release after the move it is released here, with the plugin's version.

Developing

Needs Node 20+ with pnpm 9, and Python 3.13 (what SteamOS ships; the CLI also supports 3.11). No Docker: the CLI zipapp is built from cli/src by scripts/build_cli.py and the plugin zip by scripts/package.py, which reproduces the Decky CLI's layout.

pnpm install
pnpm run build                 # dist/index.js
backend/entrypoint.sh          # cli/src -> backend/out/moonlight-steam-sync.pyz (strict)
python3 scripts/package.py     # out/Moonlight-Sync.zip (warns and skips bin/ without the CLI)

pnpm run typecheck && pnpm run lint && pnpm run test
python3 -m pip install -r requirements-dev.txt
python3 -m pip install --no-deps -e ./cli
ruff check . && python3 -m pytest && python3 -m pytest cli

scripts/build_cli.py refuses to build when the CLI's __version__ (cli/src/moonlight_steam_sync/__init__.py) is not package.json's "version": the two are bumped together.

A zip CI builds also carries Moonlight Sync/build.json, which says what it was built from (scripts/build_info.py: release and the tag, or branch and the branch's name, the commit and the run). A build made anywhere else, on your machine or on the Deck, carries one too: scripts/package.py reads the branch and the commit from the git checkout when the root has no build.json (the run is then null), and says so on stderr. So a zip you built from a clone of main names the commit the published build of main names, and with the Channel on main the Updates page offers a switch only when main has moved on. It also means the zip is a branch's build to the plugin, as a published one is: with the Channel left on Releases, the Updates page, the panel's last row and the toast at load offer Switch to X, the newest release, where a local build used to read as that release. Follow the branch you built (Settings → Updates → Channel) to be offered its builds instead. The checkout has to be that commit and nothing else: with uncommitted or untracked changes, a detached HEAD, a branch name with a character outside A-Z a-z 0-9 . _ / -, or no git checkout at all, the zip carries no build.json, and a zip without it is a release of package.json's version. package.py says on stderr which it was, with git's own message when git refused the checkout (a clone that belongs to another user, say).

Branch builds

Every push to main publishes a build of it as a prerelease tagged build-main (.github/workflows/builds.yml). Any other branch publishes one on request:

gh workflow run builds.yml --ref <branch>

and from then on with every push to it, as the prerelease build-<slug> (the branch's name with every character outside A-Z a-z 0-9 . _ - turned into -, cut to 80). Deleting the branch deletes its build. Builds are prereleases and are never "latest", so a release is what install.sh and the plugin's own updater still see; nothing in the plugin installs a build yet. A branch can only be built once builds.yml is on it, so a branch cut before the workflow reached main needs a rebase first. A branch whose name has a character outside A-Z a-z 0-9 . _ / - (a + or an @, say) cannot have a published build: build_info.py refuses the name and builds.yml fails. CI still passes for it; its zip just carries no build.json, with a notice saying so. Two branches whose names give the same slug (a/b and a-b) share one tag, and the first one asked holds it: the release's title names its branch, pushes to the other publish nothing, asking for the other fails naming the holder, and deleting the other leaves the build alone. Pull requests never publish a build.

Then copy out/Moonlight-Sync.zip to the Deck and install it as above. decky plugin build (Docker) still works through backend/Dockerfile and backend/entrypoint.sh, but it is only for a store submission. See AGENTS.md for the module map and the test harness.

License

MIT (see LICENSE). Scaffolded from the Decky plugin template (BSD 3-Clause); see THIRD_PARTY_NOTICES.md.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages