Decky 0.3.0
Desktop app 0.3.0 · Deck firmware 0.1.5 (updated)
Installed Decky updates itself: use the updates pill in the title bar, or Settings → Updates. New installs: run the Decky-Setup installer. The deck's firmware updates from the same place, with the deck on USB.
Desktop
- Keep the profiles panel still, and let it wear the theme's scrollbar (cccc15a)
Arming the bin put a warning above the list, which pushed the list down
under the pointer; it goes to the notice at the foot of the window instead,
and a row keeps its height while its name is being typed. The rule that
separated the list from the buttons hung below them, separating nothing - it
sits above them now, and in the import view under the last line of what the
profile holds, with the name field clear of it.
Scrollbars are Decky's everywhere rather than Windows' own: one rule, so a
scrolling area added later cannot arrive in the wrong colours.
The UI check walks the import view now - what a profile holds, the widgets
this Decky has retired, what its keys would run, and the name it will be
kept under - and asserts the list does not move when the bin is armed. - Name a profile as it is imported, and never keep two of the same name (eaae396)
Importing your own profile back left two called Default. The inspector now
has the name it will be kept under, filled in with the one it came with and
yours to change before you press the button; and a name another profile
already has takes a number, whatever it was asked for. - Make a hover felt, and a delete take two presses (37a37c2)
The rows in Pages and Profiles, their icon buttons, the panel close buttons,
the editor's tabs and the icon grid had no hover at all, and the dock's was
four percent of a warm white - so the pointer said nothing. They lift now,
each over 0.15s, and the bin reddens where it means it.
Deleting a profile takes two presses: the bin arms, a tick confirms, and it
disarms itself after four seconds. While it is armed the panel says what
goes with it - the scripts the profile brought. - Let Windows open a .deckyprofile with Decky, and keep it that way (e55ea0e)
The installer registers the file type, but nothing kept it right afterwards:
an in-app update, a Decky that moved, or a copy run from elsewhere would
leave it pointing at where Decky used to be. A packaged Decky now writes the
four HKCU\Software\Classes keys on start, and only the ones that do not
already point at itself - no administrator, and nothing written when it is
already right.
The icon comes from the branding through build-icons.mjs, which now builds
both icons in one pass, and ships unpacked: inside the asar Windows could
never have read it. - Keep several profiles, and carry one whole in a .deckyprofile (6aa1d09)
Each profile is a folder of its own under profiles/ - its pages, what its
widgets counted, and the apps it talks to - with the one setup an older
Decky kept adopted as the first. What belongs to the deck rather than a
setup, its pairing and its firmware, stays where it was, so switching never
unpairs anything. Switching reloads what a profile owns in place and lets
the deck start a fresh session, so two profiles' pages never sit in its
memory at once.
A .deckyprofile carries a whole setup: its pages, the apps' settings without
their passwords, and the scripts its keys run, gzipped and encrypted with a
key Decky carries - which keeps the file out of a text editor rather than
out of anyone's hands, and says so where it matters. Importing adds a
profile, never replaces the one in use: the files land in that profile's own
folder and its keys are rewritten to point there.
Nothing is written before it is seen. One inspector serves both importing a
file and switching to a profile of your own: its pages, its widgets - the
ones this Decky no longer has named and counted - and every program and
script a key would run, which is the part worth reading in someone else's
profile. - Have each kind of action and widget say what files it keeps (c4f46c5)
An exported profile has to carry the scripts its keys run, and a list of
what to carry would be wrong the first time someone added a kind and forgot.
Instead every kind says for itself, beside where it is already declared:
ACTION_FILES gives the paths in an action and the action with them moved,
and whether the file travels at all - a script does, a program is wherever
it is installed. WIDGET_FILES does the same for widgets, which keep none
today.
Both are mapped over every kind, so a new action or widget does not compile
until it has an answer. - Give the page you are looking at the deck's last room for a live picture (28deffb)
The widget keys that kept going blank were the deck running out of PSRAM:
ERR live memory, forty times in deck.log, from the moment a microphone key
was added to a profile of 8 pages and 25 widgets. A key picture is ~29 KB,
butlive_room()asks for the 768 KB floor plus a whole page in one piece
before it will allocate one, so live pictures stop long before memory is
full - and the app, told nothing it could act on, kept sending.
The firmware now takes the live pictures of the oldest page copy that is not
on screen rather than refusing, and the desktop does the same from its side
onERR live memory, leaving hidden pages alone for a minute afterwards.
Neither needs the other: the deck's own eviction shows up as a base-CRC
mismatch, which already sends the whole key again. - Add a speed test key, over Speedtest.net's own servers (cddc48e)
Tap it and it measures the line: their config for where you are, their
server list for what is near, the five nearest pinged and the quickest kept,
then ten seconds down and ten seconds up. Tap again to stop. It ends on both
speeds with the ping, and rests there until it is tapped again.
While it hunts for a server the key is a ring the deck turns itself; while
it measures it is a dial like the volume's, following the speed on a
logarithmic scale and eased towards each reading, with the megabits in the
middle.
Decky's own client for their published endpoints, not Ookla's code or
binary: speedtest-net@1 is 2019-era with old transitive dependencies, and
its v2 downloads a CLI that does not survive packaging. Bytes are counted as
they stream over six connections down and four up, the first 1.5 s dropped
for slow start, so no correction factor is needed.
Their mirrors are asked in their own client's order, c.speedtest.net first:
the www ones hand out a short list of whatever, and a run from Istanbul
picked Liechtenstein at 46 ms. With c. first the same line finds Istanbul at
7 ms. A server that answers a ping but serves nothing is dropped for the
next best, and a dropped connection costs that stream its file, not the run. - Let a key's icon size be set back to 100% (24a2577)
The size slider means "no setting" by 100%, so it hands the editor an
appearance with no iconSize at all - and the key editor merged that with the
key it had, where a field left out cannot win. The size sprang back to what
it was, and the next step over 100 skipped it: 95, then 105.
Appearance fields are now replaced together (withAppearance), so leaving one
out takes it away, as removing an image already did by hand. The UI check
fails if the slider ever springs back off 100. - Say in deck.log why a widget key's picture is not reaching the deck (2b8c4b4)
A key that stops showing what it should leaves no trace today: the send is
refused, skipped as unchanged, or never made, and all three look the same
from outside. Each page load now logs the copy it made, and a widget key
logs what became of its pictures - at most a line every ten seconds, or a
minute for one that keeps drawing the picture the deck is thought to hold -
until it is sending again.
Also replays the reported case in a test: a key edited on a page of live
widgets, and a widget key added afterwards, both keep their pictures on the
deck. It passes, so whatever is wrong is not in this path. - Drop the icon/text spacing setting (1f5a3a7)
The gap between a key's icon and its label is now what the setting's
default gave (a fifteenth of the key), so nothing looks different; the
slider, the saved labelGap and its validation are gone. Old profiles that
carry one still load: the field is simply left behind. - Pick a widget's design on the key itself (2a1d931)
A hold on a clock, a die or a level offers the looks it can take, the way
Discord's microphone key offers its devices: the key draws each design over
a dot for each, a tap moves to the next, and three quiet seconds keep the
one showing in the profile.
Each kind names its designs, so a new one needs nothing more. Dice held to
roll, which a tap already did, so its hold is free for this; and the deck
stops turning a key while a design is picked, so the preview shows. - Keep a key's picture in the middle when it has a label (292b5e7)
A label used to move what the key shows: the icon and the label were
centred together, so the icon rode up, and a widget's caption pushed the
widget down the key. Now the label sits at the foot of the key and the
icon stays in the middle, shrinking only as far as the label and the
spacing under it need; a widget's caption leaves as much room at the
foot as it takes at the top, so the widget is a little smaller and stays
where it was. - Notice a deck that went, instead of waiting on a link that is gone (59d1e57)
A cable pulled and plugged back in while Decky was loading left it looking
connected: the unplug had fallen back to Wi-Fi, the replug took the deck's
power, and a write to a peer that is gone waits on TCP's own timeouts -
minutes - with every deck command queued behind it, the ping that would
have noticed included. Meanwhile the deck came back from its card with the
pages but no live pictures, and the app went on believing it held them, so
widget keys stayed blank while the window drew them.
Nothing here asks the deck anything more often; it all watches this PC:- the socket goes when the deck ends it, and a write to one that is gone is
refused rather than throwing EPIPE at whoever asked; - a write over Wi-Fi that takes more than 3 seconds counts as the link gone;
- a ping still waiting while the deck has said nothing for 8 seconds
(DeckLink.silentFor) means the queue is stuck on a dead link: the deck
counts as gone and closing the link frees everything waiting; - the port watch notices a port go, not only appear, and this deck's own
cable going means it is gone at once; - a patch that fails because the deck went is no longer an error: the window
draws the key again once it is back; - HELLO drops every live picture on the deck, so the app forgets its record
of them there too - it forgot only the overlays before.
- the socket goes when the deck ends it, and a write to one that is gone is
- Grey out an app's keys while it is out of reach, and go back where you came from (dd5e0bd)
A key that controls an app it cannot reach now looks disabled, on the
deck and in the window: its icon grey and struck through, its label
grey, an image greyed - the look its app's widgets already had. Only its
OFF picture is drawn so, since an app out of reach reports no state and
its toggles show OFF; the deck takes ON pictures whole, 29 KB each, and
redrawing those on every change of an app's reach froze it for seconds.
A key that cycles through options is its own thing now (choice.ts, the
renderer's choice face): the option chosen, a dot for each, a tap moving
to the next. Discord's microphone and output switchers are the first
two, and another key can be one.
Back returns to the page it was opened from - a page reached from
several goes back where you were - and to the page it was created under
when that is not known or is gone. Pictures drawn for the profile as it
was, still waiting behind another upload when it is saved again, are
answered as stale and drawn again, instead of being refused as an error. - Let a key size its icon, and add the logos Lucide lacks (fbf35bc)
Appearance has an Icon size slider (40-200%), for any key that shows an
icon and for a widget that draws the key's own - a voice channel with no
one in it. The picker also searches about sixty brands' logos (Simple
Icons, CC0) that Lucide has none of: Discord, OBS Studio, Twitch,
Spotify, Steam, GitHub. A logo is drawn solid in the key's colour, a
little smaller than an outline icon so it weighs the same.
A widget's view can now say that it draws the key's icon, and add its
own looks to the Appearance tab - a voice key's layout, and whether it
shows the channel's name. Any library icon draws on a widget's canvas at
once (key-icon-canvas.ts), so a widget's first picture has it too. - Add Discord's keys, through StreamKit (bf69d89)
Discord gives RPC to an unapproved application only for its owner, so
Decky asks as Discord StreamKit Overlay, whose permission any user can
give: Authorize once, and the token lasts seven days, kept encrypted.
This is unofficial, and Discord can end it.
Its controls are toggle keys whose ON and OFF follow Discord, however
they were switched: mute, deafen, camera, screen share, noise
suppression, echo cancellation, automatic gain and leave call. Deafened
counts as muted, as Discord counts it. The window never says ON and OFF
for them; each control names its two states (Unmuted and Muted, Not in a
call and In a call). Camera and screen share come from Windows' own
record, which Discord's RPC does not report, and starting a share brings
Discord to the front as it was left - a discord:// link would take it
to Home.
Its widgets: a voice channel or the call you are in, showing who is
there as avatars with a green ring on whoever speaks; the microphone and
output Discord uses, a tap moving to the next; and how many
notifications came since the last tap, a tap opening that conversation.
Discord lists no devices for a moment after it starts, so a switcher
waits rather than showing a device that is not there. - Show Decky on Discord, when its owner wants it (fcd196a)
Settings has a Show on Discord switch, off until it is turned on. On,
friends see "Watching Decky": a line for what is happening - the deck at
hand, a key being edited, OBS recording or streaming, an update
installing, the deck unplugged - the presses on the deck today, and for
how long.
It goes over Discord's own pipe as Decky's application, with no sign-in
and no secret, and nothing leaves the PC but what Discord shows. A card
goes at most every four seconds and only when it changed; Discord
closed, it is tried again every fifteen. On and today's presses are kept
in presence.json. - Let the deck move a ring's arc itself, and keep its loop free (53d3f95)
A ring drawn a second at a time steps; over USB every frame would be a
key upload. The deck moves the arc now (SWEEP, sweep=1 in ID): the app
sends where the ring is, where it goes and by when, in 32 bytes, and the
deck draws each frame between its own blocks. Sliding text works the
same way, so both are KeyOverlay subclasses on a page, cloned into a new
version and sent after the picture they belong to.
Two things blocked the loop while that ran: waiting for the panel's
vertical blank, and waiting for a key to finish drawing. The vertical
blank is stamped by its interrupt now (Bus_RGB, panel::last_vsync_us),
so nothing waits on it, and draw_key gives way while it waits, drawing
overlay frames meanwhile. DISPLAY_STATE says how many overlay frames
were drawn and the longest gap between them.
The keys' own countdowns still step: a whole second at a time, so a
picture drawn again for another reason is the same picture and never
travels. Only OBS's five-second ring sweeps, as the user asked. - Load everything at boot, not at the first press (96d37bc)
Every page's widgets and the looks the deck turns go up while the deck
still shows its progress, so no page opens on a placeholder or waits for
a look. The hotkey helper starts with Decky rather than on the first
press, which used to wait for it. - Add OBS Studio as the first app Decky talks to (404337c)
An app is declared once (shared/integrations/): its name, its settings,
what is said of each standing - not installed, closed, its API off, its
password refused, connected - and what its card's button does then. The
window draws its page and its connection card from that, so a new app
needs no screens of its own; main gives it a service and a row in
IntegrationServices.
OBS speaks obs-websocket 5. Its Recording and Streaming keys follow the
events OBS sends, never a status asked for again, since answers can come
back out of order and one did leave a stopped recording counting. A
touch counts five seconds down before OBS is asked, and a second touch
calls it off. The link is kept while any OBS key exists, retried while
OBS is closed, and Open OBS starts the copy the finder found.
A key can also be an app's control ({ kind: "app" }), which the runner
leaves to the workspace. - Make the volume and microphone one kind, and taps a table (ee95c4f)
They were near copies of each other. Both are one kind now, made for a
flow: shared/widgets/level.ts, the renderer's kinds/level/, and main's
SoundAction. The microphone shows as a level like the volume, with its
own dial and mute.
What a press does is a table too. tap-runs.ts counts taps and holds and
gives the widget's action the gesture it knows (a tap, a double, a
triple, a hold), so a widget that tells them apart waits for the next
tap and one that does not acts at once. WidgetReadings holds what
widgets read - pings, feeds - and follows the profile in one place. - Split the app's main process into parts, and widgets into kinds (fc5aece)
main/index.ts only builds and wires the parts, each in its folder: app/,
deck/, pages/, widgets/, profile/, updates/, ipc/, logging/.
Each widget type is a kind (shared/widgets/: its settings, defaults,
validation, what a press does to it), a view (renderer widgets/kinds/:
its drawing, settings, sample readings, deck look) and, when a press
does more than change its state, an action (main/widgets/actions/).
Each has a registry, typed so a type missing from one does not compile.
Step and action kinds, and the serial link's kinds of line, are tables
too.
Comments name the firmware's new files. - Add live widget keys, and keep edits fast (f85f834)
Thirteen widgets: clock, timer, pomodoro, countdown, counter, ping, note,
now playing, volume, microphone, system, crypto and dice. The app draws
them and patches them onto the deck as they change. Sound and prices
arrive as events (Core Audio callbacks, Binance's public stream), not
polls, so mute and volume show at once. Keys the deck can animate are
handed to it: drums, the volume dial, the die, and song titles too long
for their key, which slide.
Loading brings every page's widgets up to date before the app opens, and
the Disconnected screen says the deck is starting meanwhile. An edit sends
only the keys that changed, as patches: changing a widget's style on a
page of eight live widgets went from 10.4 s to 0.66 s.
The app records deck restarts and failed uploads in deck.log, and says
when the deck's USB adapter has stopped answering and needs a replug.
Firmware
- Give the page you are looking at the deck's last room for a live picture (28deffb)
The widget keys that kept going blank were the deck running out of PSRAM:
ERR live memory, forty times in deck.log, from the moment a microphone key
was added to a profile of 8 pages and 25 widgets. A key picture is ~29 KB,
butlive_room()asks for the 768 KB floor plus a whole page in one piece
before it will allocate one, so live pictures stop long before memory is
full - and the app, told nothing it could act on, kept sending.
The firmware now takes the live pictures of the oldest page copy that is not
on screen rather than refusing, and the desktop does the same from its side
onERR live memory, leaving hidden pages alone for a minute afterwards.
Neither needs the other: the deck's own eviction shows up as a base-CRC
mismatch, which already sends the whole key again. - Let the deck move a ring's arc itself, and keep its loop free (53d3f95)
A ring drawn a second at a time steps; over USB every frame would be a
key upload. The deck moves the arc now (SWEEP, sweep=1 in ID): the app
sends where the ring is, where it goes and by when, in 32 bytes, and the
deck draws each frame between its own blocks. Sliding text works the
same way, so both are KeyOverlay subclasses on a page, cloned into a new
version and sent after the picture they belong to.
Two things blocked the loop while that ran: waiting for the panel's
vertical blank, and waiting for a key to finish drawing. The vertical
blank is stamped by its interrupt now (Bus_RGB, panel::last_vsync_us),
so nothing waits on it, and draw_key gives way while it waits, drawing
overlay frames meanwhile. DISPLAY_STATE says how many overlay frames
were drawn and the longest gap between them.
The keys' own countdowns still step: a whole second at a time, so a
picture drawn again for another reason is the same picture and never
travels. Only OBS's five-second ring sweeps, as the user asked. - Split the firmware into folders and classes (5adc41d)
One Deck owns every part; main.cpp only makes it and runs it. Each area
is a folder: deck/, protocol/, pages/, display/, input/, keys/ (drum/,
dial/, die/, text/), storage/, network/, security/, common/.
Commands go through a router to the CommandSet that knows them, each a
table of commands and the methods that parse them. Animated keys are a
Look subclass (what the desktop sends, from a version table) and a
KeyAnimation (how it moves and draws). The Wi-Fi commands and reset
reasons are tables too.
tools/golden_run.py drives every command the desktop uses and compares
the replies and two screen captures with a recorded run: identical
before and after. The unused AnimatedGIF library is gone. - Draw and animate widget keys on the deck (ca85ef7)
Widget pictures arrive as patches (LIVE) into live pictures the deck keeps
apart from each page's own. A page's own pictures therefore always match
its signature and the card's copy, and a new version of a page needs only
its changed keys, which come as patches too (PATCH): a whole page is 22 KB
instead of 311.
The deck turns some keys by itself, at the panel's rate: picker drums for
countdowns and lists, the volume dial (an arc with its number, a bar
without), and a die drawn in 3D that is thrown, bounces off the key's edges
and lands wherever it stops, each face as likely. It slides text too long
for its key along (SLIDE), takes every page's widgets while it loads
(warm=1), takes uploads in 2 KB blocks (BLOCK), and says why it last
restarted (reset=).