Skip to content

feat(shell): parsers for xrandr, ufw, trashinfo, archives and notifications - #9

Merged
danisss9 merged 9 commits into
mainfrom
claude/os-notifications-system-settings-bxsle9
Aug 7, 2026
Merged

danisss9 merged 9 commits into
mainfrom
claude/os-notifications-system-settings-bxsle9

Conversation

@danisss9

@danisss9 danisss9 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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

claude added 9 commits August 7, 2026 19:18
…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
@danisss9
danisss9 merged commit 64c4ca8 into main Aug 7, 2026
5 checks passed
@danisss9
danisss9 deleted the claude/os-notifications-system-settings-bxsle9 branch August 7, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants