feat(shell): parsers for xrandr, ufw, trashinfo, archives and notifications - #9
Merged
Merged
Conversation
…ations
Groundwork for the desktop shell's next batch of apps. Nothing calls these
yet; they are here first because src/util is the only place in this extension
that can be unit-tested - everything under src/sys and src/apps imports
`vscode` and cannot be bundled outside the extension host.
parseXrandrOutputs reads rotation from before the bracket, because the
parenthesised list names every rotation on every line
parseUfwStatus splits columns on runs of two spaces, since "ALLOW IN"
and "Anywhere (v6)" both contain one
parseTrashInfo percent-decodes Path, and keeps an entry whose date is
missing rather than hiding a file the user can see
archiveKind matches longest suffix first, so ".tar.gz" stops being
read as ".gz"
notify helpers strip the markup subset the notification spec allows,
and pair the flat [key, label, ...] action array
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
Nothing on either image owned org.freedesktop.Notifications. On a normal Linux
desktop dunst or GNOME Shell owns it and every notify-send becomes a bubble;
here the call simply failed, silently, and the user never learned there was
anything to know - while the editor sat there with a perfectly good
notification UI going unused.
VsCodeOsCore now claims the name itself and turns each Notify into a VS Code
message, with the sender's action buttons attached. It is a real server rather
than an observer: dbus-monitor can watch traffic to a daemon but cannot answer
a method call, and the whole problem was that there was no daemon.
* A bell in the tray, left of now-playing, badged with the unread count and
tinted when something critical is waiting. Push-driven off the server's
change event, not the five-second tick.
* A notification centre as a ninth flyout card, with dismiss and clear-all.
Opening it marks everything read.
* GetCapabilities claims body, actions and persistence - deliberately not
body-markup, because a VS Code message is plain text and the markup is
stripped rather than shown as angle brackets.
* If another daemon already owns the name, we log and walk away. Two
notification servers is worse than none, and DO_NOT_QUEUE means we never
silently inherit the role later.
src/sys/usocket.ts is the one surprise. dbus-next's abstract-socket branch has
an unguarded require('usocket'), a native addon we do not ship - and
vscodeos-kiosk's dbus-launch fallback produces exactly that address form. So
the machines that needed the fallback would have been the ones where this quietly
failed to connect. node:net has handled Linux abstract sockets for years, so
esbuild aliases usocket to a twenty-line stub over it.
Verified against a real dbus-daemon on both a path and an abstract socket:
name acquisition, GetServerInformation, GetCapabilities, Notify, markup
stripping, urgency variants, ActionInvoked carrying the key rather than the
label, and NotificationClosed with the right reason for each of dismissed,
expired and closed-by-call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
sys/audio.ts has enumerated sources since it was written, and the flyout has been receiving them and drawing none of them - so a machine with a headset and a webcam had no way to say which one to record from, and the voice recorder took whatever PipeWire happened to make default. Adds setDefaultSource, plus gain and mute against @DEFAULT_AUDIO_SOURCE@, and an Input device section under the existing Output device one. Because the recorder records from @DEFAULT_SOURCE@, choosing here also chooses for it. Mic gain is capped at 100 where the speaker slider allows 150: pushing a microphone past its hardware level raises the noise floor along with the voice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
Display settings were a night-light toggle buried in a tray card; the keyboard
was not configurable at all; and the Updater was a whole app of its own for
four rows and a log. None of them had anywhere to live, so this gives them one:
a rail down the left and a pane on the right, with six sections.
Display every output xrandr reports, with resolution, refresh rate,
orientation and which screen is primary
Keyboard layout, applied to the session with setxkbmap and persisted with
localectl, plus key repeat delay and rate
Sound output and input device, each with its own level and mute
Storage (the next commit)
Updates the Updater, moved verbatim
About hostname, kernel, editor and shell versions, CPU, RAM, uptime and
the image build stamp
A display mode change is the one genuinely dangerous thing here. The kiosk sets
DontVTSwitch, so a mode the monitor cannot show leaves nothing on screen and no
console to escape to - a power cycle is the only way back. So the old mode is
captured first, the new one is applied, and it is put back unless the user
confirms within fifteen seconds.
Panes are fetched only when opened. `du` over a home directory and a pkexec
round trip for the package cache are not things to do because someone wanted to
change their keyboard layout.
The Updater keeps its behaviour exactly: same four probes, same pkexec of
vscodeos-update, same streamed log. It becomes UpdatesController, posting
through a callback the settings app supplies rather than owning a panel, and
its page becomes lib/updates.ts so it inlines into the settings bundle. Its
state stays on the host, so a running pacman -Syu survives switching panes.
`vscodeos.apps.updater` stays registered as an alias onto the Updates pane: it
is in the README, in the command palette and in muscle memory.
The launcher's old "Settings" tile, which opened VS Code's settings editor, is
renamed "Editor Settings" so there are not two tiles called Settings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
…ot owns Nothing in the extension knew about disk usage. On a 16 GB SD card that is the question people ask first, and the answer was "open a terminal and learn du". The Storage pane shows how full each mounted disk is, the largest folders in your home, and a checklist of things that are safe to throw away with a size against each. Capacity comes from fs.statfs over the mountpoints lsblk reports; the folder list comes from `du -x -d 2`, because a Node walk of a real home directory is tens of thousands of round trips and du is in `base` on both images. Rows whose parent is already listed are dropped, since a folder and its one big child say the same thing twice. The cleanup list is fixed in code. Nothing takes a path from the user and deletes it: every target is a constant in src/sys/storage.ts, so the worst a confused caller can do is empty a cache meant to be emptiable. Seven categories are the user's own - ~/.cache, thumbnails, editor and browser and npm caches, their own files in /tmp, and the Recycle Bin - and three need root. Those three go through rootfs-common/usr/local/bin/vscodeos-clean, built on exactly the same terms as vscodeos-update: one exact program named in 49-vscodeos.rules, a fixed vocabulary of words as its only arguments, no command line and no paths from the caller. It also answers `report`, printing "<id> <bytes>" so the pane can show real sizes for the privileged rows instead of guessing. A clean-up runs at most one pkexec, whatever mix of rows is ticked. paccache keeps the most recent version of each package, so a downgrade after a bad update is still possible. Verified the helper rejects an unknown verb, a path and a shell-injection attempt, and that `report` emits the format the pane parses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
Neither image shipped a packet filter. On x86 iptables-nft arrived only as a side effect of Docker; on the Pi there was nothing at all. Both images ship sshd, so a VS Code OS machine on someone else's network had nothing between it and that network, and the only way to change that was to install and learn ufw from a terminal - on a desktop that is an editor. The app shows the master switch, the two default policies, the numbered rule list with a delete button per rule, a form for adding one, and presets for SSH, HTTP and HTTPS. Turning the firewall on offers to allow SSH first when something is listening on 22 and no rule covers it: the default incoming policy is deny, and a Pi being administered over the network would otherwise go dark with no way back but a keyboard and a monitor. ufw needs root even to *read* its status, so unlike the network tray there is no cheap unprivileged probe and therefore no polling - the app refreshes when it opens and after each change. Everything goes through rootfs-common/usr/local/bin/vscodeos-firewall on the same terms as the other two helpers: one exact program in 49-vscodeos.rules, a fixed vocabulary, and a single argument checked against a pattern before ufw sees it. Application profile names are additionally checked against `ufw app list`, so the only non-numeric specs that get through are ones ufw already defined. Verified the helper accepts ports, ranges, protocols and multi-word profiles, and refuses shell metacharacters, command substitution, option-looking arguments, paths, unknown profiles and out-of-range ports. ufw is shipped *disabled* on both images. An image that silently starts dropping incoming connections on first boot would be a nasty surprise, so no multi-user.target.wants symlink; the app's toggle enables ufw and its unit together, which is what makes the setting survive a reboot. Also grants org.freedesktop.locale1.set-keyboard, so the keyboard pane can persist a layout without a third script of ours writing to /etc/X11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
The file explorer has always been able to put things in the trash - it calls
workspace.fs.delete with useTrash, which goes through Electron to the
freedesktop trash under ~/.local/share/Trash. Nothing ever read them back, so
"Move to trash" was a slower, more reassuring-sounding delete: the files were
there, on disk, taking up space, and unreachable without a terminal.
Two surfaces over one TrashService, so restoring in either redraws the other:
a Recycle Bin place in the Files app, listed always rather than only once
something has been deleted, and a view in the activity bar next to the Task
Manager for when it should be one click away.
The bin is a sentinel path the host intercepts, not a directory. Browsing
~/.local/share/Trash/files directly would show mangled names, no original
locations, no way to restore, and would happily let you rename things in it.
Inside the bin the Files app hides new-file, new-folder and paste, swaps the
size column for where the item came from, offers Restore and Delete
permanently, and makes double-click restore rather than open.
Three details that only show up on a real trash directory, all verified against
one:
* Paths in .trashinfo are percent-encoded, so "my%20folder%20(2)" has to come
back as "my folder (2)".
* Restoring must never overwrite whatever has taken the original name since -
it lands as "report (1).txt", the same rule pasting already followed.
* files/ and info/ can disagree. An entry with no .trashinfo is still listed,
because the user can see it is there, but restore reports it skipped rather
than guessing a destination; an orphaned .trashinfo is ignored.
uniqueName moves out of fileExplorer.ts and is shared, since restore needs
exactly the collision rule paste already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
zip, unzip and 7zip have always been on the images, but the Files app mapped
every archive to a disk glyph and handed it to vscode.open, which answers with
the binary-file notice. Double-clicking a download did nothing useful.
Archives now browse like folders, through a virtual path the host intercepts -
the same trick the Recycle Bin uses, so the page needs no idea that an archive
is not a directory. Opening a member extracts just that one to a temp file and
hands it to the editor. The context menu gains Extract here, Extract to… and
Compress, and the toolbar gains Extract everything while inside one.
bsdtar does all of it - zip, tar, every compression, 7z for reading - through
one interface, and needs no new package: libarchive is a hard dependency of
pacman, so it is on both images by construction. Relying on the 7zip package
would have left the Pi out, since that one is x86-only. unzip and tar are kept
behind it as a fallback, the way audio.ts keeps pactl behind wpctl, so the
extension still works off a VS Code OS machine.
Three things this had to get right:
* ".tar.gz" is not a ".gz". The old glyph lookup sliced at the last dot,
which also read ".bashrc" as an extension; archiveKind matches longest
suffix first and both bugs go with it.
* bsdtar and GNU tar print different verbose listings - ls-style columns
versus "owner/group", month-day versus ISO. Rather than counting columns,
which is what breaks on the other tool or on a file whose name starts with
a digit, each row is matched from the right and everything left over is the
name, spaces and all.
* An archive is untrusted input. Members named "../../etc/passwd" or
"/etc/shadow" are dropped from listings, and extraction never passes -P, so
the tools refuse them too.
Extraction goes into a folder named after the archive, so a tarball with no
top-level directory does not spray itself over the current folder, and reuses
the "(1)" collision rule paste and restore already share.
Archives are read-only: adding to an existing one is not something bsdtar can
do for zip, and half-supporting it would be worse than not offering it. The
page hides what would write, and the host refuses those messages outright for
virtual paths so a keyboard shortcut cannot reach fs.rm with an archive:// URL.
Verified against real zip and tar.gz archives: listing, folding to one level,
synthesising directories a zip never stored, extraction, single-member
extraction and compression, all with names containing spaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
xorg-setxkbmap was on neither package list, so the keyboard pane would have had nothing to change the running session's layout with. xorg-xset was x86-only, which had quietly cost the Pi half of energy saver - the DPMS blanking timeouts it re-enables - and would have cost it key repeat too. The skel settings.json gains the new vscodeos.* defaults, so the shipped machines start with the same values the manifest declares rather than falling back to them silently. READMEs: the desktop-shell list gains Notifications, the Recycle Bin, System Settings, Storage Sense and the Firewall, and the Files and Updater entries are rewritten. Two things there were about to become wrong rather than merely incomplete: the "Day-to-day" paragraph said polkit allows *one* program without a password, and the layout tree said the polkit rules cover "power and NetworkManager". There are three helpers now. extension/README.md gains a note on why the shell is the notification daemon rather than a listener - dbus-monitor can watch traffic to a daemon but cannot answer a method call, and the problem was that there was no daemon - and on why src/sys/usocket.ts exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for the desktop shell's next batch of apps. Nothing calls these
yet; they are here first because src/util is the only place in this extension
that can be unit-tested - everything under src/sys and src/apps imports
vscodeand cannot be bundled outside the extension host.parseXrandrOutputs reads rotation from before the bracket, because the
parenthesised list names every rotation on every line
parseUfwStatus splits columns on runs of two spaces, since "ALLOW IN"
and "Anywhere (v6)" both contain one
parseTrashInfo percent-decodes Path, and keeps an entry whose date is
missing rather than hiding a file the user can see
archiveKind matches longest suffix first, so ".tar.gz" stops being
read as ".gz"
notify helpers strip the markup subset the notification spec allows,
and pair the flat [key, label, ...] action array
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01HQFy6QAuuJLMgSm6uJwMYn