diff --git a/README.md b/README.md index b399914..dfa7bd6 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Two images are published for every release, sharing the same kiosk: | **Shell** | VsCodeOsCore, built into the editor | same | | **Browser** | Chromium | Chromium | | **Toolchain** | git, git-lfs, Node.js, Python, base-devel, Docker | git, Node.js, Python, base-devel | +| **Firewall** | `ufw`, installed and off until you turn it on | same | | **Boot** | UEFI (x64 and ia32) and legacy BIOS, one hybrid image | Pi firmware from a FAT partition | | **Getting it onto a machine** | live medium + `vscodeos-install` | flash the image; it *is* the system | @@ -63,9 +64,9 @@ rather than being installed from the Marketplace. Its source is in - **An all-apps button**, in the bottom-left corner — a searchable grid of every program on the machine, the way a start menu works. -- **A tray**, at the right end of the status bar. Left to right: now playing, - battery, volume, network, Bluetooth, the clock and date, and the power button - in the corner. Each one opens a card in the side bar. (VS Code has no API for +- **A tray**, at the right end of the status bar. Left to right: notifications, + now playing, battery, volume, network, Bluetooth, the clock and date, and the + power button in the corner. Each one opens a card in the side bar. (VS Code has no API for a popup anchored to a status bar item; the side bar is the closest thing that does not take the terminal panel away from you. `vscodeos.flyout.location` puts the cards back in the bottom panel if you prefer them there.) @@ -74,17 +75,30 @@ rather than being installed from the Marketplace. Its source is in - **Power settings**, on the battery — energy saver, a brightness slider whose moon button toggles night light, and the charge left. Nothing else: the radio switches live on the cards that own them. +- **Volume** — the output level and which speakers play it, plus microphone gain + and which microphone records. The voice recorder follows whatever is chosen + here. - **Network** — scan, connect with a password, switch between saved connections, and airplane mode. - **Bluetooth** — turn the adapter on, scan for devices, pair, connect and forget. The button hides itself when there is no adapter. +- **Notifications** — the shell *is* the machine's notification daemon. It owns + `org.freedesktop.Notifications`, so `notify-send` and any application that + posts a notification lands in the editor's own notification UI, action buttons + and all. A bell in the tray keeps a history. Nothing else on either image + claims that name, so without this those notifications simply vanished. - **Task Manager**, in the activity bar — processes with CPU and memory, per-core meters, load average, uptime and CPU temperature, sortable and filterable, with End task. - **Files** — a graphical file explorer with a places sidebar, grid and list views, rename, trash, copy and paste. **Everything opens in the editor**: text in the text editor, images in the built-in preview, video and audio in the - media player. + media player. **Archives browse like folders** — zip, tar and everything + compressed — with Extract here, Extract to… and Compress. +- **Recycle Bin** — in the Files app's places list and in the activity bar, + sharing one backend. Shows what you deleted, where it came from and when, with + Restore, Delete permanently and Empty. Before this, "Move to trash" put files + somewhere nothing could read them back from. - **Browser** — a real browser rendered *inside* an editor tab, with tabs, an address bar and history. It drives a headless Chromium and streams its picture back, which is the only way to show sites that refuse to be framed. "Open in @@ -93,8 +107,22 @@ rather than being installed from the Marketplace. Its source is in is in the folder. - **Music** — transport controls for whatever is playing, over MPRIS, plus one-click launchers for Spotify Web and YouTube Music. -- **Updater** — a GUI for the two update paths below, with live output and a - restart prompt when one is needed. +- **System Settings** — Display (resolution, refresh rate, orientation, which + screen is primary, with a fifteen-second revert in case the monitor cannot + show the mode), Keyboard (layout and key repeat), Sound (which speakers play + and which microphone records), Storage, Updates and About. +- **Storage Sense**, inside System Settings — how full each disk is, the largest + folders in your home, and a checklist of caches, temporary files and the + Recycle Bin with a size against each. The package cache, the systemd journal + and orphaned packages are there too, cleaned through a privileged helper. +- **Updates**, inside System Settings — a GUI for the two update paths below, + with live output and a restart prompt when one is needed. (It used to be an + app of its own; `vscodeos.apps.updater` still opens it.) +- **Firewall** — a GUI over `ufw`: the master switch, the default policies, the + rule list and presets for SSH, HTTP and HTTPS. Turning it on offers to allow + SSH first when something is listening on 22, because the default incoming + policy is deny and a machine being administered over the network would + otherwise go dark. **ufw ships disabled**; this is what turns it on. - **Apps** — Calculator, Paint, Screenshot and Voice Recorder. Pressing **Print Screen** jumps straight to a region capture. @@ -221,8 +249,8 @@ VSCODEOS_RESPAWN=1 # 0 = do not relaunch when VS Code exits ## Day-to-day -The **Updater** app does all of this with buttons — open it from the all-apps -button in the bottom-left corner. By hand: +The **Updates** pane of **System Settings** does all of this with buttons — open +it from the all-apps button in the bottom-left corner. By hand: ```bash sudo pacman -Syu # update the Arch base @@ -231,11 +259,15 @@ nmtui # join a Wi-Fi network (or use the tray) code ~/Projects/thing # open something in the running editor ``` -The Updater runs the same work through `/usr/local/bin/vscodeos-update`, which -it launches with `pkexec`. polkit is configured to allow that one program -without a password (`/etc/polkit-1/rules.d/49-vscodeos.rules`) because there is -no authentication agent in a session whose entire UI is the editor — a password -prompt would have nowhere to appear. +It runs the same work through `/usr/local/bin/vscodeos-update`, which it +launches with `pkexec`. There are three of these privileged helpers — +`vscodeos-update`, `vscodeos-clean` for Storage Sense and `vscodeos-firewall` +for the Firewall app — and polkit is configured to allow exactly those three +without a password (`/etc/polkit-1/rules.d/49-vscodeos.rules`), because there is +no authentication agent in a session whose entire UI is the editor and a +password prompt would have nowhere to appear. Each one takes a fixed vocabulary +of words and validates its arguments before running anything; none of them +accepts a command line. Extensions, settings sync and Marketplace sign-in all work normally; `gnome-keyring` is started by the session so credentials persist. @@ -330,12 +362,15 @@ rootfs-common/ the kiosk, shared by both images etc/passwd, group, shadow the kiosk account (uid 1000) etc/systemd/system/ autologin on tty1, enabled services etc/X11/xorg.conf.d/ kiosk hardening (DontVTSwitch, DontZap) - etc/polkit-1/rules.d/ power and NetworkManager without a password + etc/polkit-1/rules.d/ power, NetworkManager and three helpers, unprompted etc/udev/rules.d/ backlight writable by the `video` group etc/default/vscodeos kiosk settings etc/skel/ the kiosk user's home: .xinitrc, openbox rules, VS Code settings and keybindings usr/local/bin/vscodeos-kiosk the session supervisor + usr/local/bin/vscodeos-update the three pkexec'd helpers: updates, + usr/local/bin/vscodeos-clean disk clean-up, and the firewall. Each takes + usr/local/bin/vscodeos-firewall a fixed vocabulary, never a command line. usr/local/bin/vscodeos-update-code usr/local/bin/vscodeos-install-extensions diff --git a/archiso/packages.x86_64 b/archiso/packages.x86_64 index db07bb8..d846d56 100644 --- a/archiso/packages.x86_64 +++ b/archiso/packages.x86_64 @@ -67,6 +67,16 @@ ca-certificates # without bluez there is no bluetoothctl to ask in the first place. bluez bluez-utils +# Packet filter for the shell's Firewall app, which drives it through +# /usr/local/bin/vscodeos-firewall. ufw rather than firewalld because its status +# output parses cleanly and it needs no daemon of its own; it pulls in +# iptables-nft, which docker already brings in here anyway. Under 4 MiB +# installed between them, which the 2 GiB asset limit will not notice. +# +# Shipped *disabled*: these images run sshd, and an image that silently starts +# dropping incoming connections on first boot would be a nasty surprise. The +# app's toggle enables both ufw and its unit. +ufw # Web browser. # @@ -93,6 +103,10 @@ xorg-xinit xorg-xauth xorg-xrandr xorg-xset +# setxkbmap, for the keyboard pane of System Settings. It changes the layout of +# the running session; localectl writes /etc/X11/xorg.conf.d/00-keyboard.conf so +# the next one starts with it. +xorg-setxkbmap xorg-xhost xorg-xprop xorg-xkill diff --git a/archiso/profiledef.sh b/archiso/profiledef.sh index 4cbf298..616ea0b 100755 --- a/archiso/profiledef.sh +++ b/archiso/profiledef.sh @@ -26,10 +26,12 @@ file_permissions=( ["/usr/local/bin/vscodeos-kiosk"]="0:0:755" ["/usr/local/bin/vscodeos-update-code"]="0:0:755" ["/usr/local/bin/vscodeos-install-extensions"]="0:0:755" - # This one is load-bearing for security, not just for tidiness: polkit grants - # `pkexec /usr/local/bin/vscodeos-update` without a password, so a copy the - # kiosk user could write to would be a one-line path to root. + # These three are load-bearing for security, not just for tidiness: polkit + # grants `pkexec` on each of them without a password, so a copy the kiosk user + # could write to would be a one-line path to root. ["/usr/local/bin/vscodeos-update"]="0:0:755" + ["/usr/local/bin/vscodeos-clean"]="0:0:755" + ["/usr/local/bin/vscodeos-firewall"]="0:0:755" ["/usr/local/bin/vscodeos-screenshot"]="0:0:755" ["/usr/local/bin/code"]="0:0:755" ["/etc/sudoers.d/vscodeos"]="0:0:0440" diff --git a/extension/README.md b/extension/README.md index 2fc0e43..28f5782 100644 --- a/extension/README.md +++ b/extension/README.md @@ -13,21 +13,24 @@ accidentally uninstalled. | | | | --- | --- | | **Launcher** | An all-apps button in the bottom-left corner: a searchable grid of every program | -| **Tray** | Now-playing, battery, volume, network, Bluetooth, clock and date, and the power button, at the right end of the status bar | -| **Flyouts** | Apps, power, calendar, power settings, volume mixer, network picker, Bluetooth and music player | +| **Tray** | Notifications, now-playing, battery, volume, network, Bluetooth, clock and date, and the power button, at the right end of the status bar | +| **Flyouts** | Apps, power, calendar, power settings, volume mixer, network picker, Bluetooth, music player and notifications | +| **Notifications** | Serves `org.freedesktop.Notifications`, turning every desktop notification on the machine into an editor notification | | **Task Manager** | Processes with CPU/RAM, per-core meters, load, uptime and thermals, in the activity bar | -| **Files** | A graphical file explorer: places sidebar, grid/list, rename, trash, copy/paste. Everything opens in the editor | +| **Files** | A graphical file explorer: places sidebar, grid/list, rename, trash, copy/paste, and archives that browse like folders. Everything opens in the editor | +| **Recycle Bin** | Restore or permanently delete what the Files app trashed, from the places list or the activity bar | | **Browser** | A headless Chromium streamed into an editor tab, with tabs, an address bar and history | | **Media Player** | Video and audio in a tab, with a folder playlist | | **Music** | MPRIS transport for whatever is playing, plus launchers for Spotify Web and YouTube Music | -| **Updater** | pacman, VS Code and the shell itself, with live output | +| **System Settings** | Display, keyboard, sound devices, storage, updates and about, in one app | +| **Firewall** | A GUI over `ufw`: master switch, default policies, rules and presets | | **Apps** | Calculator, Paint, Screenshot and Voice Recorder | Every feature is behind a `vscodeos..enabled` setting, all defaulting to on. The all-apps button, or `VS Code OS: All Apps…` in the command palette, lists everything. -### Three things worth knowing +### Four things worth knowing **The flyouts are not popups.** VS Code has no API to anchor one to a status bar item, and the only floating-window route — moving an editor to an auxiliary @@ -42,8 +45,25 @@ rectangle on most of them. `puppeteer-core` drives a headless Chromium, CDP's events go back through `Input.dispatch*`. That costs an encode and a decode per frame, so `vscodeos.browser.frameRate` and `vscodeos.browser.quality` are settings — turn them down on a Pi — the stream stops when the tab is hidden, and -"Open in browser" is always there. `puppeteer-core` is the extension's one -runtime dependency and is bundled into `dist/extension.js` like everything else. +"Open in browser" is always there. `puppeteer-core` is bundled into +`dist/extension.js` like everything else. + +**The shell is the notification daemon, not a listener.** Nothing on either +image owns `org.freedesktop.Notifications`, so every `notify-send` on the +machine used to fail silently. `dbus-monitor` would not have helped: it can +watch traffic to a daemon but cannot answer a method call, and the problem was +that there was no daemon. So `src/sys/notifications.ts` claims the name over +`dbus-next` — the extension's second runtime dependency, bundled like the first +— implements `Notify`, `CloseNotification`, `GetCapabilities` and +`GetServerInformation`, and emits `NotificationClosed` and `ActionInvoked`. If +something else already holds the name, it logs and walks away. + +`src/sys/usocket.ts` is the odd corner. dbus-next's abstract-socket branch calls +`require('usocket')`, a native addon we do not ship, with no guard around it — +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. `node:net` has handled Linux abstract sockets for years, so +esbuild aliases `usocket` to a stub built on it. **Print Screen is bound by the window manager, not here.** On X11 the Print key never reaches Electron as a keydown, so a contributed keybinding cannot see it. @@ -85,9 +105,11 @@ src/ extension.ts activate(): wires everything, one DisposableStore sys/ the only code that touches the machine statusbar/ the tray, and the priority ladder that orders it - views/ flyout (side bar) and task manager (activity bar) providers - apps/ registry, file explorer, browser, media player, updater, - mini-apps, panel plumbing + views/ flyout (side bar), task manager and recycle bin (activity + bar) providers + apps/ registry, file explorer, browser, media player, firewall, + system settings (settings/updates.ts inside it), mini-apps, + panel plumbing webview/ HTML shell + the host↔webview message types media/ src/ one TypeScript entry point per page, shared code in src/lib @@ -156,3 +178,13 @@ These are VS Code and Electron limits, not missing work: authentication agent in the kiosk session, so `pkexec` has nothing to prompt with. End task on another user's process opens a terminal with `sudo kill -9 ` ready to run instead. +- **Anything else that needs root goes through a helper script.** For the same + reason: `pkexec` only works without a prompt for programs polkit has been told + about by exact path. Updates, Storage Sense's system clean-up and the whole of + the Firewall app run through `vscodeos-update`, `vscodeos-clean` and + `vscodeos-firewall` in `rootfs-common/usr/local/bin/`. Each takes a fixed + vocabulary of words and validates its arguments; none accepts a command line, + because each one is a password-free path to root. +- **Archives are read-only.** They browse like folders and extract, but nothing + can be added to an existing one — `bsdtar` cannot append to a zip, and + half-supporting it would be worse than not offering it. diff --git a/extension/esbuild.mjs b/extension/esbuild.mjs index 3c2e4ce..c7efbac 100644 --- a/extension/esbuild.mjs +++ b/extension/esbuild.mjs @@ -51,7 +51,19 @@ const configs = [ // bundle fails on a machine that never installed them. 'bufferutil', 'utf-8-validate', + // dbus-next reaches for `x11` to find a bus address from a window + // selection, which is the pre-systemd discovery route. sys/notifications.ts + // resolves the address itself and passes it in, so that branch is + // unreachable - but it is a bare `require`, which esbuild resolves at + // build time whether or not the code can run. + 'x11', ], + alias: { + // dbus-next's other optional dependency. Not externalised but + // *replaced*: see src/sys/usocket.ts for why a stub built on + // node:net is the right answer rather than an unresolved require. + usocket: join(root, 'src', 'sys', 'usocket.ts'), + }, }, { ...common, diff --git a/extension/media/css/vscodeos.css b/extension/media/css/vscodeos.css index fb43859..306f15f 100644 --- a/extension/media/css/vscodeos.css +++ b/extension/media/css/vscodeos.css @@ -377,6 +377,34 @@ input[type='range']::-webkit-slider-thumb { opacity: 0.8; } +/* A critical notification, marked the way the tray marks a low battery. */ +.list-row.urgent > span:first-child { + color: var(--vscode-notificationsErrorIcon-foreground, var(--vscode-errorForeground)); +} + +/* + * A notification's text is the whole point of the row, so it wraps rather than + * being cut off at one line the way a device name is. + */ +.list-row .list-name.wrap { + white-space: normal; + overflow: visible; +} + +/* A quiet text action in a section head: "Clear all", "Empty bin". */ +.link-button { + padding: 0; + font-size: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: var(--vscode-textLink-foreground); +} + +.link-button:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + .empty { padding: 18px 4px; text-align: center; @@ -1237,7 +1265,202 @@ audio { -webkit-user-drag: none; } -/* ------------------------------------------------------------- updater */ +/* ----------------------------------------------------- system settings */ + +/* The rail reuses .places, so it only needs to be a little wider: its rows + carry a subtitle where the file explorer's carry a name alone. */ +.settings-rail { + flex-basis: 216px; +} + +.settings-pane { + padding: 4px 18px 24px; +} + +.pane-head { + padding: 14px 0 6px; +} + +.pane-title { + font-size: 1.35em; + font-weight: 300; +} + +.pane-sub { + margin-top: 2px; + color: var(--vscodeos-muted); +} + +.pane-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.setting-group { + margin-top: 12px; + padding: 4px 12px; + border: 1px solid var(--vscodeos-card-border); + border-radius: var(--vscodeos-radius); + background: var(--vscodeos-card-bg); +} + +.setting-row { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 0; +} + +.setting-row + .setting-row { + border-top: 1px solid var(--vscodeos-card-border); +} + +.setting-label { + flex: 1; + min-width: 0; +} + +.setting-control { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + /* A mode list can be long; the label must not be squeezed to nothing. */ + max-width: 55%; +} + +.setting-control .slider-row { + min-width: 220px; +} + +.setting-select { + min-width: 150px; + max-width: 100%; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--vscode-dropdown-border, var(--vscodeos-card-border)); + background: var(--vscode-dropdown-background, var(--vscodeos-tile-bg)); + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); +} + +.setting-static { + color: var(--vscodeos-muted); + text-align: right; + overflow-wrap: anywhere; +} + +/* Storage */ + +.storage-mount + .storage-mount { + margin-top: 12px; +} + +.storage-mount { + padding: 8px 0; +} + +.storage-mount-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; +} + +.storage-mount-head .list-name { + flex: 1; +} + +.storage-size { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + color: var(--vscodeos-muted); +} + +/* A cleanup row is a label wrapping a checkbox, so the whole row is clickable. */ +.settings-pane label.list-row { + cursor: pointer; +} + +.settings-pane label.list-row input[type="checkbox"] { + flex: 0 0 auto; + margin: 0; +} + +.settings-pane .list-name .update-badge { + margin-left: 8px; +} + +/* ------------------------------------------------------------ recycle bin */ + +/* The bin shares the activity bar container with the Task Manager, so its rows + are the narrow kind: name over origin, with no columns. */ +.bin .toolbar { + gap: 6px; +} + +.bin-actions { + display: flex; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--vscodeos-card-border); +} + +.bin-actions .button { + flex: 1; + justify-content: center; +} + +.bin-count { + color: var(--vscodeos-muted); +} + +/* In the Files app's list view the size column carries the original location + instead, which is far longer than a byte count and has to be able to lose + its middle rather than push the date off the row. */ +.trash-origin { + direction: rtl; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + unicode-bidi: plaintext; +} + +/* --------------------------------------------------------------- firewall */ + +.firewall { + padding: 4px 18px 24px; +} + +/* The master switch is the first thing on the page and says the one thing that + matters, so it gets the accent border the rest of the groups do not. */ +.firewall-master { + border-color: var(--vscodeos-accent); +} + +.firewall-add { + display: flex; + gap: 8px; + align-items: center; + padding: 4px 0 8px; +} + +.firewall-add .app-search { + flex: 1; + min-width: 0; +} + +.firewall-presets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding-bottom: 8px; +} + +/* --------------------------------------------- updates (a settings pane) */ .update-rows { display: flex; diff --git a/extension/media/icons/updater.svg b/extension/media/icons/firewall.svg similarity index 63% rename from extension/media/icons/updater.svg rename to extension/media/icons/firewall.svg index 09ca560..dbbecf5 100644 --- a/extension/media/icons/updater.svg +++ b/extension/media/icons/firewall.svg @@ -1,3 +1,4 @@ - + + diff --git a/extension/media/icons/recyclebin.svg b/extension/media/icons/recyclebin.svg new file mode 100644 index 0000000..f155fb7 --- /dev/null +++ b/extension/media/icons/recyclebin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/extension/media/icons/settings.svg b/extension/media/icons/settings.svg new file mode 100644 index 0000000..fa495df --- /dev/null +++ b/extension/media/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/media/src/files.ts b/extension/media/src/files.ts index eee83c8..505fda0 100644 --- a/extension/media/src/files.ts +++ b/extension/media/src/files.ts @@ -2,6 +2,7 @@ import { append, clear, formatBytes, h, onMessage, post, root, vscode } from './lib/dom'; import { icon } from './lib/icons'; +import { archiveKind } from '../../src/util/archive'; import type { FileEntry, HostMessage, Place } from '../../src/webview/protocol'; interface Persisted { @@ -31,6 +32,41 @@ const banner = h('div', { class: 'error-banner', hidden: true }); const backButton = iconButton('chevronLeft', 'Back', () => go(-1)); const forwardButton = iconButton('chevronRight', 'Forward', () => go(1)); +/** + * The Recycle Bin is a place, not a directory, so it is browsed through a + * sentinel path the host intercepts. Everything that writes into the current + * folder - new file, new folder, paste, rename - is meaningless there, and the + * buttons hide rather than failing when pressed. + */ +const TRASH_PATH = 'trash://'; +const inTrash = (): boolean => currentPath === TRASH_PATH; + +/** "archive:///home/me/photos.zip!sub/dir" - the host owns the meaning. */ +const ARCHIVE_SCHEME = 'archive://'; +const inArchive = (): boolean => currentPath.startsWith(ARCHIVE_SCHEME); +/** True where the current folder cannot be written to: the bin, or an archive. */ +const readOnly = (): boolean => inTrash() || inArchive(); + +const newFolderButton = iconButton('plus', 'New folder', () => post({ type: 'newFolder', path: currentPath })); +const newFileButton = iconButton('file', 'New file', () => post({ type: 'newFile', path: currentPath })); +const emptyBinButton = iconButton('trash', 'Empty the Recycle Bin', () => post({ type: 'emptyTrash' })); +const extractAllButton = iconButton('archive', 'Extract everything', () => { + const file = archiveFile(); + if (file) { + post({ type: 'extract', paths: [file], chooseTarget: true }); + } +}); + +/** The archive's own path, out of the virtual path currently being browsed. */ +function archiveFile(): string | undefined { + if (!inArchive()) { + return undefined; + } + const rest = currentPath.slice(ARCHIVE_SCHEME.length); + const bang = rest.indexOf('!'); + return bang < 0 ? rest : rest.slice(0, bang); +} + clear(root()).append(h('div', { class: 'app' }, h('div', { class: 'toolbar' }, backButton, @@ -41,8 +77,10 @@ clear(root()).append(h('div', { class: 'app' }, }), iconButton('refresh', 'Refresh', () => post({ type: 'navigate', path: currentPath })), breadcrumb, - iconButton('plus', 'New folder', () => post({ type: 'newFolder', path: currentPath })), - iconButton('file', 'New file', () => post({ type: 'newFile', path: currentPath })), + newFolderButton, + newFileButton, + emptyBinButton, + extractAllButton, iconButton(view === 'grid' ? 'list' : 'grid', 'Switch view', () => { view = view === 'grid' ? 'list' : 'grid'; persist(); @@ -86,6 +124,13 @@ onMessage((message) => { backButton.disabled = historyIndex <= 0; forwardButton.disabled = historyIndex >= history.length - 1; + const trash = inTrash(); + newFolderButton.hidden = readOnly(); + newFileButton.hidden = readOnly(); + emptyBinButton.hidden = !trash; + emptyBinButton.disabled = entries.length === 0; + extractAllButton.hidden = !inArchive(); + renderPlaces(); renderBreadcrumb(); renderFiles(); @@ -126,6 +171,40 @@ function renderPlaces(): void { function renderBreadcrumb(): void { clear(breadcrumb); + + if (inTrash()) { + // "trash://" has no path segments to split on, and splitting it would + // produce a "trash:" crumb that navigates nowhere. + breadcrumb.append(h('span', { class: 'crumb' }, 'Recycle Bin')); + return; + } + + if (inArchive()) { + const file = archiveFile() ?? ''; + const inner = currentPath.slice(ARCHIVE_SCHEME.length + file.length + 1); + // The archive is a file, so its crumb leaves the archive rather than + // navigating deeper into a path that no longer exists. + breadcrumb.append(h('button', { + class: 'crumb', + on: { click: () => navigate(file.replace(/\/[^/]+$/, '') || '/') }, + }, h('span', { html: icon('archive', 14) }))); + breadcrumb.append(h('button', { + class: 'crumb', + on: { click: () => navigate(`${ARCHIVE_SCHEME}${file}!`) }, + }, file.slice(file.lastIndexOf('/') + 1))); + + let accumulated = ''; + for (const part of inner.split('/').filter(Boolean)) { + accumulated = accumulated ? `${accumulated}/${part}` : part; + const target = accumulated; + breadcrumb.append(h('button', { + class: 'crumb', + on: { click: () => navigate(`${ARCHIVE_SCHEME}${file}!${target}`) }, + }, part)); + } + return; + } + const parts = currentPath.split('/').filter(Boolean); breadcrumb.append(h('button', { class: 'crumb', on: { click: () => navigate('/') } }, '/')); let accumulated = ''; @@ -162,7 +241,11 @@ function renderFiles(): void { for (const entry of visible) { const glyph = entry.isDirectory ? 'folder' : glyphFor(entry.name); const activate = (): void => { - if (entry.isDirectory) { + if (inTrash()) { + // Nothing in the bin can be browsed or opened in place, so the + // obvious gesture does the obvious thing. + post({ type: 'restoreFromTrash', paths: [entry.path] }); + } else if (entry.isDirectory) { navigate(entry.path); } else { post({ type: 'openFile', path: entry.path }); @@ -182,7 +265,7 @@ function renderFiles(): void { }; const common = { - title: entry.path, + title: entry.originalPath ?? entry.path, tabIndex: 0, on: { click: select as (event: never) => void, @@ -191,8 +274,10 @@ function renderFiles(): void { if (event.key === 'Enter') { activate(); } else if (event.key === 'Delete') { - post({ type: 'delete', paths: [entry.path] }); - } else if (event.key === 'F2') { + post(inTrash() + ? { type: 'deleteFromTrash', paths: [entry.path] } + : { type: 'delete', paths: [entry.path] }); + } else if (event.key === 'F2' && !readOnly()) { post({ type: 'rename', path: entry.path }); } }) as (event: never) => void, @@ -220,7 +305,11 @@ function renderFiles(): void { }, h('span', { class: entry.isDirectory ? 'folder-icon' : '', html: icon(glyph, 17) }), h('span', { class: 'name' }, entry.name), - h('span', { class: 'size' }, entry.isDirectory ? '' : formatBytes(entry.size)), + // In the bin, where it came from and when it went say far more than + // a size and an mtime that is now the deletion time anyway. + inTrash() + ? h('span', { class: 'size trash-origin' }, entry.originalPath ?? 'Original location unknown') + : h('span', { class: 'size' }, entry.isDirectory ? '' : formatBytes(entry.size)), h('span', { class: 'date' }, entry.modified ? new Date(entry.modified).toLocaleString() : ''), )); } @@ -229,6 +318,15 @@ function renderFiles(): void { } function renderStatus(): void { + if (inTrash()) { + append(clear(status), + h('span', {}, entries.length === 0 + ? 'The Recycle Bin is empty' + : `${entries.length} item${entries.length === 1 ? '' : 's'} in the Recycle Bin`), + selection.size > 0 ? h('span', {}, `${selection.size} selected`) : null, + ); + return; + } const folders = entries.filter((e) => e.isDirectory && (showHidden || !e.hidden)).length; const files = entries.filter((e) => !e.isDirectory && (showHidden || !e.hidden)).length; append(clear(status), @@ -246,27 +344,62 @@ function showMenu(event: MouseEvent, entry: FileEntry): void { h('button', { class: 'list-row', on: { click: () => { menu.remove(); action(); } } }, h('span', { html: icon(glyph, 15) }), h('span', { class: 'list-name' }, label)); + const archives = paths.filter((candidate) => archiveKind(candidate) !== undefined); + + let items: HTMLElement[]; + if (inTrash()) { + items = [ + item('Restore', 'undo', () => post({ type: 'restoreFromTrash', paths })), + item('Delete permanently', 'trash', () => post({ type: 'deleteFromTrash', paths })), + item('Empty Recycle Bin', 'close', () => post({ type: 'emptyTrash' })), + ]; + } else if (inArchive()) { + // Nothing inside an archive can be renamed, moved or deleted in place. + const file = archiveFile(); + items = [ + item(entry.isDirectory ? 'Open' : 'Open a copy', 'open', () => + entry.isDirectory ? navigate(entry.path) : post({ type: 'openFile', path: entry.path })), + file + ? item('Extract everything…', 'archive', () => + post({ type: 'extract', paths: [file], chooseTarget: true })) + : null, + ].filter((node): node is HTMLElement => node !== null); + } else { + items = [ + item(entry.isDirectory ? 'Open' : 'Open in editor', 'open', () => + entry.isDirectory ? navigate(entry.path) : post({ type: 'openFile', path: entry.path })), + item('Open with default app', 'globe', () => post({ type: 'openExternal', path: entry.path })), + item('Reveal in sidebar', 'editor', () => post({ type: 'revealInSidebar', path: entry.path })), + archives.length > 0 + ? item('Extract here', 'archive', () => post({ type: 'extract', paths: archives, chooseTarget: false })) + : null, + archives.length > 0 + ? item('Extract to…', 'archive', () => post({ type: 'extract', paths: archives, chooseTarget: true })) + : null, + item(paths.length > 1 ? `Compress ${paths.length} items` : 'Compress', 'archive', () => + post({ type: 'compress', paths })), + item('Copy', 'file', () => post({ type: 'clipboard', paths, cut: false })), + item('Cut', 'file', () => post({ type: 'clipboard', paths, cut: true })), + item('Paste here', 'save', () => post({ type: 'paste', target: currentPath })), + item('Rename', 'editor', () => post({ type: 'rename', path: entry.path })), + item('Delete', 'trash', () => post({ type: 'delete', paths })), + ].filter((node): node is HTMLElement => node !== null); + } + + // Clamped to the menu's own height rather than a constant tuned to one + // length: the bin's menu is three rows and the ordinary one is eight. + const height = items.length * 30 + 12; const menu = h('div', { class: 'context-menu flyout', style: { position: 'fixed', left: `${Math.min(event.clientX, window.innerWidth - 220)}px`, - top: `${Math.min(event.clientY, window.innerHeight - 260)}px`, + top: `${Math.min(event.clientY, window.innerHeight - height)}px`, width: '210px', padding: '4px', zIndex: '30', }, - }, - item(entry.isDirectory ? 'Open' : 'Open in editor', 'open', () => - entry.isDirectory ? navigate(entry.path) : post({ type: 'openFile', path: entry.path })), - item('Open with default app', 'globe', () => post({ type: 'openExternal', path: entry.path })), - item('Reveal in sidebar', 'editor', () => post({ type: 'revealInSidebar', path: entry.path })), - item('Copy', 'file', () => post({ type: 'clipboard', paths, cut: false })), - item('Cut', 'file', () => post({ type: 'clipboard', paths, cut: true })), - item('Paste here', 'save', () => post({ type: 'paste', target: currentPath })), - item('Rename', 'editor', () => post({ type: 'rename', path: entry.path })), - item('Delete', 'trash', () => post({ type: 'delete', paths })), - ); + }, ...items); document.body.append(menu); const dismiss = (): void => { @@ -277,6 +410,11 @@ function showMenu(event: MouseEvent, entry: FileEntry): void { } function glyphFor(name: string): string { + // Checked first, because archiveKind understands the double extensions the + // last-dot rule below gets wrong - ".tar.gz" is not a ".gz". + if (archiveKind(name)) { + return 'archive'; + } const extension = name.slice(name.lastIndexOf('.')).toLowerCase(); if (['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'].includes(extension)) { return 'image'; @@ -287,15 +425,18 @@ function glyphFor(name: string): string { if (['.mp4', '.mkv', '.webm', '.mov', '.avi'].includes(extension)) { return 'video'; } - if (['.zip', '.gz', '.xz', '.bz2', '.7z', '.tar', '.zst'].includes(extension)) { - return 'disk'; - } return 'file'; } document.addEventListener('keydown', (event) => { if (event.key === 'Delete' && selection.size > 0) { - post({ type: 'delete', paths: [...selection] }); + if (inArchive()) { + // Members cannot be removed from an archive in place. + return; + } + post(inTrash() + ? { type: 'deleteFromTrash', paths: [...selection] } + : { type: 'delete', paths: [...selection] }); } else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && selection.size > 0) { post({ type: 'clipboard', paths: [...selection], cut: false }); } else if ((event.ctrlKey || event.metaKey) && event.key === 'x' && selection.size > 0) { diff --git a/extension/media/src/firewall.ts b/extension/media/src/firewall.ts new file mode 100644 index 0000000..349dd7a --- /dev/null +++ b/extension/media/src/firewall.ts @@ -0,0 +1,296 @@ +// The Firewall app's page. +// +// A master switch, the two default policies, the rule list, and a form for +// adding a rule. The log pane at the bottom shows what the helper actually ran, +// because "it did not work" is not a useful thing to tell someone about a +// firewall. + +import { clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { FirewallPolicy, FirewallState, HostMessage } from '../../src/webview/protocol'; + +let state: FirewallState | undefined; + +const body = h('div', { class: 'body firewall' }); +const logEl = h('pre', { class: 'update-log', hidden: true }); + +const refreshButton = h('button', { + class: 'button', + on: { click: () => post({ type: 'firewallRefresh' }) }, +}, h('span', { html: icon('refresh', 15) }), 'Refresh'); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('span', { html: icon('shield', 16) }), + h('span', {}, 'Firewall'), + h('span', { class: 'spacer' }), + refreshButton, + ), + body, +)); + +onMessage((message) => { + if (message.type === 'firewall') { + state = message.state; + render(); + return; + } + if (message.type === 'firewallBusy') { + if (state) { + state.busy = message.busy; + render(); + } + return; + } + if (message.type === 'firewallLog') { + logEl.hidden = false; + logEl.append(document.createTextNode(message.chunk)); + const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; + if (atBottom) { + logEl.scrollTop = logEl.scrollHeight; + } + } +}); + +post({ type: 'ready' }); + +function render(): void { + clear(body); + refreshButton.disabled = state?.busy ?? false; + + if (!state) { + body.append(h('div', { class: 'empty' }, 'Reading the firewall…')); + return; + } + if (!state.installed) { + body.append(h('div', { class: 'empty' }, + 'ufw is not installed. Install it with: sudo pacman -S ufw')); + return; + } + if (!state.canElevate) { + body.append(h('div', { class: 'empty' }, + 'pkexec is not installed, so the firewall cannot be read or changed from here.')); + return; + } + + const status = state.status; + const active = status?.active ?? false; + + body.append(masterSwitch(active)); + + if (!status) { + body.append(h('div', { class: 'error-banner' }, + 'The firewall status could not be read. The log below may say why.')); + body.append(logEl); + return; + } + + if (active && state.sshListening && !allowsSsh(status)) { + body.append(h('div', { class: 'error-banner' }, + 'This machine is accepting SSH connections but no rule allows them through. ' + + 'Anyone administering it over the network is already locked out.')); + } + + body.append(policyGroup(status.incoming, status.outgoing)); + body.append(rulesGroup(status)); + body.append(addRuleGroup()); + body.append(advancedGroup(status)); + body.append(logEl); +} + +function allowsSsh(status: NonNullable): boolean { + return status.rules.some( + (rule) => /^(22(\/tcp)?|OpenSSH)\b/i.test(rule.to) && rule.action.startsWith('ALLOW'), + ); +} + +function masterSwitch(active: boolean): HTMLElement { + return h('div', { class: 'setting-group firewall-master' }, + h('div', { class: 'setting-row' }, + h('div', { class: 'setting-label' }, + h('div', { class: 'pane-title' }, active ? 'Firewall is on' : 'Firewall is off'), + h('div', { class: 'list-sub' }, active + ? 'Incoming connections are filtered by the rules below.' + : 'Nothing is filtered. Every port this machine listens on is reachable.'), + ), + h('div', { class: 'setting-control' }, + h('button', { + class: `button${active ? '' : ' primary'}`, + disabled: state?.busy ?? false, + on: { click: () => post({ type: 'firewallToggle', enabled: !active }) }, + }, active ? 'Turn off' : 'Turn on'), + ), + ), + ); +} + +function policySelect(direction: 'incoming' | 'outgoing', current: FirewallPolicy | undefined): HTMLElement { + const el = h('select', { class: 'setting-select' }) as HTMLSelectElement; + const options: [FirewallPolicy, string][] = [ + ['allow', 'Allow'], + ['deny', 'Deny (drop silently)'], + ['reject', 'Reject (send a refusal)'], + ]; + for (const [value, label] of options) { + const option = h('option', { value }, label) as HTMLOptionElement; + option.selected = value === current; + el.append(option); + } + el.disabled = state?.busy ?? false; + el.addEventListener('change', () => + post({ type: 'firewallPolicy', direction, policy: el.value as FirewallPolicy })); + return el; +} + +function policyGroup(incoming?: FirewallPolicy, outgoing?: FirewallPolicy): HTMLElement { + return h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, 'Default policy'), + h('div', { class: 'setting-row' }, + h('div', { class: 'setting-label' }, + h('div', {}, 'Incoming'), + h('div', { class: 'list-sub' }, 'What happens to a connection no rule matches.'), + ), + h('div', { class: 'setting-control' }, policySelect('incoming', incoming)), + ), + h('div', { class: 'setting-row' }, + h('div', { class: 'setting-label' }, + h('div', {}, 'Outgoing'), + h('div', { class: 'list-sub' }, 'Denying this breaks most things; allow is the usual answer.'), + ), + h('div', { class: 'setting-control' }, policySelect('outgoing', outgoing)), + ), + ); +} + +function rulesGroup(status: NonNullable): HTMLElement { + const group = h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, `Rules (${status.rules.length})`), + ); + + if (status.rules.length === 0) { + group.append(h('div', { class: 'empty' }, + 'No rules. With a deny-incoming policy, nothing can reach this machine.')); + return group; + } + + const list = h('div', { class: 'list' }); + for (const rule of status.rules) { + const allow = rule.action.startsWith('ALLOW'); + list.append(h('div', { class: 'list-row' }, + h('span', { html: icon(allow ? 'check' : 'close', 16) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, rule.to), + h('div', { class: 'list-sub' }, `${rule.action} · from ${rule.from}`), + ), + rule.v6 ? h('span', { class: 'update-badge' }, 'IPv6') : null, + h('button', { + class: 'icon-button', + title: 'Delete this rule', + html: icon('trash', 15), + disabled: state?.busy ?? false, + on: { + click: () => post({ + type: 'firewallDelete', + number: rule.number, + label: `${rule.to} — ${rule.action} from ${rule.from}`, + }), + }, + }), + )); + } + group.append(list); + return group; +} + +const PRESETS: [string, string][] = [ + ['22/tcp', 'SSH'], + ['80/tcp', 'HTTP'], + ['443/tcp', 'HTTPS'], +]; + +function addRuleGroup(): HTMLElement { + const spec = h('input', { + class: 'app-search', + type: 'text', + placeholder: 'Port, range or profile — 22/tcp, 6000:6010/udp, OpenSSH', + }) as HTMLInputElement; + + const action = h('select', { class: 'setting-select' }) as HTMLSelectElement; + for (const [value, label] of [ + ['allow', 'Allow'], + ['deny', 'Deny'], + ['limit', 'Limit (rate-limited allow)'], + ] as [string, string][]) { + action.append(h('option', { value }, label)); + } + + const submit = (): void => { + const value = spec.value.trim(); + if (!value) { + return; + } + post({ type: 'firewallRule', action: action.value as 'allow' | 'deny' | 'limit', spec: value }); + spec.value = ''; + }; + + spec.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter') { + submit(); + } + }); + + return h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, 'Add a rule'), + h('div', { class: 'firewall-add' }, + action, + spec, + h('button', { + class: 'button primary', + disabled: state?.busy ?? false, + on: { click: submit }, + }, h('span', { html: icon('plus', 15) }), 'Add'), + ), + h('div', { class: 'firewall-presets' }, + h('span', { class: 'list-sub' }, 'Common:'), + ...PRESETS.map(([value, label]) => h('button', { + class: 'button', + disabled: state?.busy ?? false, + on: { click: () => post({ type: 'firewallRule', action: 'allow', spec: value }) }, + }, `Allow ${label}`)), + ), + ); +} + +function advancedGroup(status: NonNullable): HTMLElement { + const loggingOn = (status.logging ?? '').startsWith('on'); + return h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, 'Advanced'), + h('div', { class: 'setting-row' }, + h('div', { class: 'setting-label' }, + h('div', {}, 'Logging'), + h('div', { class: 'list-sub' }, + status.logging ? `Currently ${status.logging}.` : 'Blocked packets are recorded in the journal.'), + ), + h('div', { class: 'setting-control' }, + h('button', { + class: 'button', + disabled: state?.busy ?? false, + on: { click: () => post({ type: 'firewallLogging', enabled: !loggingOn }) }, + }, loggingOn ? 'Turn logging off' : 'Turn logging on'), + ), + ), + h('div', { class: 'setting-row' }, + h('div', { class: 'setting-label' }, + h('div', {}, 'Reset'), + h('div', { class: 'list-sub' }, 'Delete every rule and turn the firewall off.'), + ), + h('div', { class: 'setting-control' }, + h('button', { + class: 'button', + disabled: state?.busy ?? false, + on: { click: () => post({ type: 'firewallReset' }) }, + }, 'Reset firewall'), + ), + ), + ); +} diff --git a/extension/media/src/flyout.ts b/extension/media/src/flyout.ts index af0adfe..f451503 100644 --- a/extension/media/src/flyout.ts +++ b/extension/media/src/flyout.ts @@ -1,4 +1,4 @@ -// The eight tray flyouts, drawn as one card. +// The nine tray flyouts, drawn as one card. // // In the bottom panel the host is full width, so the card pins itself to the // bottom-right corner and reads as a flyout rising out of the tray item that was @@ -7,7 +7,14 @@ import { append, clear, h, onMessage, post, root, throttle, formatDuration } from './lib/dom'; import { icon, signalIcon } from './lib/icons'; -import type { AppEntry, BluetoothDevice, FlyoutKind, FlyoutState, HostMessage } from '../../src/webview/protocol'; +import type { + AppEntry, + BluetoothDevice, + FlyoutKind, + FlyoutState, + HostMessage, + NotificationRecord, +} from '../../src/webview/protocol'; let kind: FlyoutKind = 'apps'; let state: FlyoutState | undefined; @@ -62,7 +69,11 @@ setInterval(() => { }, 1000); function render(): void { - card.classList.toggle('wide', kind === 'network' || kind === 'music' || kind === 'apps' || kind === 'bluetooth'); + card.classList.toggle( + 'wide', + kind === 'network' || kind === 'music' || kind === 'apps' + || kind === 'bluetooth' || kind === 'notifications', + ); clear(card); switch (kind) { case 'apps': return renderApps(); @@ -72,6 +83,7 @@ function render(): void { case 'network': return renderNetwork(); case 'bluetooth': return renderBluetooth(); case 'music': return renderMusic(); + case 'notifications': return renderNotifications(); default: return renderPowerSettings(); } } @@ -327,6 +339,75 @@ function slider( ); } +// ----------------------------------------------------------- notifications + +/** "just now", "6 min ago", "14:32" - the resolution people actually want. */ +function whenLabel(at: number, now: number): string { + const seconds = Math.max(0, Math.round((now - at) / 1000)); + if (seconds < 45) { + return 'just now'; + } + if (seconds < 3600) { + return `${Math.round(seconds / 60)} min ago`; + } + const date = new Date(at); + if (seconds < 86400) { + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); +} + +function notificationRow(record: NotificationRecord, now: number): HTMLElement { + return h('div', { class: `list-row${record.urgency === 'critical' ? ' urgent' : ''}` }, + h('span', { html: icon(record.urgency === 'critical' ? 'warning' : 'bell', 18) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name wrap' }, record.text), + h('div', { class: 'list-sub' }, `${record.appName} · ${whenLabel(record.at, now)}`), + ), + h('button', { + class: 'icon-button', + title: 'Dismiss', + html: icon('close', 15), + on: { click: () => post({ type: 'dismissNotification', id: record.id }) }, + }), + ); +} + +function renderNotifications(): void { + const records = state?.notifications ?? []; + card.append( + h('div', { class: 'section-head' }, + h('span', {}, 'Notifications'), + records.length > 0 + ? h('button', { + class: 'link-button', + on: { click: () => post({ type: 'clearNotifications' }) }, + }, 'Clear all') + : null, + ), + ); + + if (state?.notificationsAvailable === false) { + // Another daemon holds the bus name, so nothing will ever land here and + // an empty list would look like a bug rather than a decision. + card.append(h('div', { class: 'empty' }, + 'Another notification daemon owns the desktop bus, so notifications go there instead.')); + return; + } + + if (records.length === 0) { + card.append(h('div', { class: 'empty' }, 'Nothing to catch up on.')); + return; + } + + const now = state?.now ?? Date.now(); + const list = h('div', { class: 'list' }); + for (const record of records) { + list.append(notificationRow(record, now)); + } + card.append(list); +} + // ------------------------------------------------------------------ volume function renderVolume(): void { @@ -364,6 +445,40 @@ function renderVolume(): void { )); } card.append(list); + + // The input half. It was already being fetched and thrown away; a machine + // with two microphones had no way at all to say which one to record from. + if (audio.sources.length === 0 && !audio.input) { + return; + } + card.append(h('div', { class: 'section-head' }, 'Input device')); + if (audio.input) { + card.append(slider( + audio.input.muted ? 'micMute' : 'mic', + audio.input.muted ? 0 : audio.input.volume, + 0, + 100, + (value) => post({ type: 'micVolume', value }), + () => post({ type: 'micMute' }), + audio.input.muted ? 'Unmute the microphone' : 'Mute the microphone', + )); + } + + const inputs = h('div', { class: 'list' }); + if (audio.sources.length === 0) { + inputs.append(h('div', { class: 'empty' }, 'No microphone found.')); + } + for (const source of audio.sources) { + inputs.append(h('button', { + class: `list-row${source.isDefault ? ' active' : ''}`, + on: { click: () => post({ type: 'source', id: source.id }) }, + }, + h('span', { html: icon('mic', 18) }), + h('span', { class: 'list-main' }, h('div', { class: 'list-name' }, source.name)), + source.isDefault ? h('span', { html: icon('check', 16) }) : null, + )); + } + card.append(inputs); } // ----------------------------------------------------------------- network diff --git a/extension/media/src/lib/icons.ts b/extension/media/src/lib/icons.ts index 997bbbb..d000ff7 100644 --- a/extension/media/src/lib/icons.ts +++ b/extension/media/src/lib/icons.ts @@ -48,6 +48,8 @@ const PATHS: Record = { grid: 'M4 4h7v7H4V4zM13 4h7v7h-7V4zM4 13h7v7H4v-7zM13 13h7v7h-7v-7z', list: 'M4 6h16M4 12h16M4 18h16', mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM6 11a6 6 0 0 0 12 0M12 17v4', + // The mic with a stroke through it, matching how volumeMute reads. + micMute: 'M12 3a3 3 0 0 1 3 3v5M9 8v4a3 3 0 0 0 4.9 2.3M6 11a6 6 0 0 0 9.3 5M18 11v1M12 17v4M4 3l16 18', stop: 'M6 6h12v12H6z', camera: 'M3 7h4l2-2h6l2 2h4v13H3V7zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', save: 'M4 4h12l4 4v12H4V4zM8 4v6h8V4M8 20v-6h8v6', @@ -69,6 +71,12 @@ const PATHS: Record = { brush: 'M15.5 3.5l5 5-8 8-5-5 8-8zM7.5 11.5L4 15c-1 1-1 4-1 5 1 0 4 0 5-1l3.5-3.5', tab: 'M3 6h7l2 2h9v10H3V6z', fullscreen: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', + bell: 'M18 15V10a6 6 0 0 0-12 0v5l-2 3h16l-2-3zM10 21h4', + shield: 'M12 2.5l8 3v6c0 5-3.4 8.9-8 10-4.6-1.1-8-5-8-10v-6l8-3z', + // A box with a lid band across it, which is how a zip reads at 18px. + archive: 'M3 6.5h18v4H3v-4zM4.5 10.5v9h15v-9M10 14h4', + keyboard: 'M2.5 6.5h19v11h-19v-11zM6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M6 14h12', + display: 'M3 4.5h18v11H3v-4.5zM8.5 20h7M12 15.5V20', }; /** Solid glyphs, drawn with a fill instead of a stroke. */ diff --git a/extension/media/src/updater.ts b/extension/media/src/lib/updates.ts similarity index 76% rename from extension/media/src/updater.ts rename to extension/media/src/lib/updates.ts index 4a379c6..0664067 100644 --- a/extension/media/src/updater.ts +++ b/extension/media/src/lib/updates.ts @@ -1,16 +1,23 @@ -// Updater. +// The Updates pane of System Settings. // // Four rows and a log. The log is the important half: `pacman -Syu` can take // minutes and can fail in ways only its own output explains, so it is streamed // verbatim rather than hidden behind a spinner. +// +// This lives under lib/ rather than being its own page, because esbuild treats +// every .ts directly in media/src as an entry point and this is now part of the +// settings bundle. Its state is module-level and survives switching to another +// settings pane and back, which is what keeps a running update's log intact. -import { clear, h, onMessage, post, root } from './lib/dom'; -import { icon } from './lib/icons'; -import type { HostMessage, UpdateItem, UpdateTarget } from '../../src/webview/protocol'; +import { clear, h, post } from './dom'; +import { icon } from './icons'; +import type { HostMessage, UpdateItem, UpdateTarget } from '../../../src/webview/protocol'; let items: UpdateItem[] = []; let running: UpdateTarget | undefined; let restartNeeded = false; +/** Set once the pane has been opened, so the host is only asked to check once. */ +let checked = false; const rowsEl = h('div', { class: 'update-rows' }); const logEl = h('pre', { class: 'update-log', hidden: true }); @@ -26,18 +33,6 @@ const allButton = h('button', { on: { click: () => post({ type: 'runUpdate', target: 'all' }) }, }, h('span', { html: icon('update', 15) }), 'Update everything'); -clear(root()).append(h('div', { class: 'app' }, - h('div', { class: 'toolbar' }, - h('span', { html: icon('update', 16) }), - h('span', {}, 'Updater'), - h('span', { class: 'spacer' }), - checkButton, - allButton, - ), - banner, - h('div', { class: 'body' }, rowsEl, logEl), -)); - function statusLabel(item: UpdateItem): { text: string; className: string } { switch (item.status) { case 'checking': return { text: 'Checking…', className: 'update-badge' }; @@ -47,7 +42,7 @@ function statusLabel(item: UpdateItem): { text: string; className: string } { } } -function render(): void { +function renderRows(): void { clear(rowsEl); for (const item of items) { const status = statusLabel(item); @@ -115,17 +110,18 @@ function appendLog(chunk: string): void { } } -onMessage((message) => { +/** True when the message was one of the updater's, so the caller can stop. */ +export function handleUpdateMessage(message: HostMessage): boolean { if (message.type === 'updateStatus') { items = message.items; running = message.running; - render(); - return; + renderRows(); + return true; } if (message.type === 'updateLog') { appendLog(message.chunk); - return; + return true; } if (message.type === 'updateDone') { @@ -138,8 +134,29 @@ onMessage((message) => { banner.hidden = true; appendLog('\n✓ finished\n'); } - render(); + renderRows(); + return true; } -}); -post({ type: 'ready' }); + return false; +} + +/** Draw the pane into a container the settings page owns. */ +export function renderUpdates(into: HTMLElement): void { + if (!checked) { + checked = true; + post({ type: 'checkUpdates' }); + } + renderRows(); + into.append( + h('div', { class: 'pane-head' }, + h('div', { class: 'pane-title' }, 'Updates'), + h('div', { class: 'pane-sub' }, + 'The Arch base, the editor itself and this shell are updated separately.'), + h('div', { class: 'pane-actions' }, checkButton, allButton), + ), + banner, + rowsEl, + logEl, + ); +} diff --git a/extension/media/src/recyclebin.ts b/extension/media/src/recyclebin.ts new file mode 100644 index 0000000..6f8c621 --- /dev/null +++ b/extension/media/src/recyclebin.ts @@ -0,0 +1,128 @@ +// The Recycle Bin view in the activity bar. +// +// Narrow by design - it shares its container with the Task Manager - so each +// row is the name, where it came from, and when. Selection is multi-select the +// way the file explorer's is, because restoring twenty things one at a time is +// not a feature. + +import { clear, formatBytes, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { FileEntry } from '../../src/webview/protocol'; + +interface TrashMessage { + type: 'trash'; + entries: (FileEntry & { originalPath?: string; deletedAt?: number })[]; +} + +let entries: TrashMessage['entries'] = []; +const selection = new Set(); + +const list = h('div', { class: 'list' }); +const emptyButton = h('button', { + class: 'button', + on: { click: () => post({ type: 'emptyTrash' }) }, +}, h('span', { html: icon('trash', 15) }), 'Empty'); + +const restoreButton = h('button', { + class: 'button primary', + on: { click: () => post({ type: 'restoreFromTrash', paths: [...selection] }) }, +}, 'Restore'); + +const deleteButton = h('button', { + class: 'button', + on: { click: () => post({ type: 'deleteFromTrash', paths: [...selection] }) }, +}, 'Delete'); + +const actions = h('div', { class: 'bin-actions', hidden: true }, restoreButton, deleteButton); + +clear(root()).append(h('div', { class: 'app bin' }, + h('div', { class: 'toolbar' }, + h('span', { html: icon('trash', 15) }), + h('span', { class: 'bin-count' }, ''), + h('span', { class: 'spacer' }), + emptyButton, + ), + actions, + h('div', { class: 'body' }, list), +)); + +const countLabel = document.querySelector('.bin-count') as HTMLElement; + +onMessage((message) => { + if (message.type !== 'trash') { + return; + } + entries = message.entries; + // Anything restored or deleted elsewhere should not stay selected here. + for (const name of [...selection]) { + if (!entries.some((entry) => entry.path === name)) { + selection.delete(name); + } + } + render(); +}); + +post({ type: 'ready' }); + +function whenLabel(at: number | undefined): string { + if (!at) { + return 'unknown date'; + } + const date = new Date(at); + const days = Math.floor((Date.now() - at) / 86400000); + if (days === 0) { + return `today, ${date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; + } + if (days === 1) { + return 'yesterday'; + } + return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function render(): void { + countLabel.textContent = entries.length === 0 + ? 'Recycle Bin' + : `${entries.length} item${entries.length === 1 ? '' : 's'}`; + emptyButton.disabled = entries.length === 0; + actions.hidden = selection.size === 0; + restoreButton.textContent = `Restore ${selection.size}`; + deleteButton.textContent = `Delete ${selection.size}`; + + clear(list); + if (entries.length === 0) { + list.append(h('div', { class: 'empty' }, 'The Recycle Bin is empty.')); + return; + } + + for (const entry of entries) { + const selected = selection.has(entry.path); + list.append(h('div', { + class: `list-row${selected ? ' active' : ''}`, + title: entry.originalPath ?? entry.name, + tabIndex: 0, + on: { + click: (event: MouseEvent) => { + if (!event.ctrlKey && !event.metaKey) { + selection.clear(); + } + if (selected) { + selection.delete(entry.path); + } else { + selection.add(entry.path); + } + render(); + }, + dblclick: () => post({ type: 'restoreFromTrash', paths: [entry.path] }), + }, + }, + h('span', { html: icon(entry.isDirectory ? 'folder' : 'file', 16) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, entry.name), + h('div', { class: 'list-sub' }, + entry.originalPath + ? `${entry.originalPath} · ${whenLabel(entry.deletedAt)}` + : `Original location unknown · ${formatBytes(entry.size)}`), + ), + )); + } +} diff --git a/extension/media/src/settings.ts b/extension/media/src/settings.ts new file mode 100644 index 0000000..b3fa3b5 --- /dev/null +++ b/extension/media/src/settings.ts @@ -0,0 +1,577 @@ +// System Settings, drawn as a rail and a pane. +// +// Every pane is rebuilt from whatever `settings` message last arrived. The one +// exception is Updates, which keeps its own module state in lib/updates.ts so a +// running `pacman -Syu` and its log survive a trip to another pane and back. + +import { append, clear, formatBytes, h, onMessage, post, root, throttle } from './lib/dom'; +import { icon } from './lib/icons'; +import { handleUpdateMessage, renderUpdates } from './lib/updates'; +import type { + AboutInfo, + CleanupCategory, + CleanupId, + DisplayOutput, + DisplaySettings, + HostMessage, + KeyboardState, + SettingsSection, + SettingsState, + AudioState, + StorageState, +} from '../../src/webview/protocol'; + +interface RailEntry { + id: SettingsSection; + title: string; + glyph: string; + sub: string; +} + +const RAIL: RailEntry[] = [ + { id: 'display', title: 'Display', glyph: 'display', sub: 'Resolution, rotation, night light' }, + { id: 'keyboard', title: 'Keyboard', glyph: 'keyboard', sub: 'Layout and key repeat' }, + { id: 'sound', title: 'Sound', glyph: 'volume', sub: 'Speakers and microphone' }, + { id: 'storage', title: 'Storage', glyph: 'disk', sub: 'Disk usage and clean-up' }, + { id: 'updates', title: 'Updates', glyph: 'update', sub: 'System, editor and shell' }, + { id: 'about', title: 'About', glyph: 'cpu', sub: 'What this machine is' }, +]; + +let state: SettingsState = { section: 'display' }; +let busy: string | undefined; +/** Cleanup rows the user has ticked, kept across the refresh a clean triggers. */ +const chosen = new Set(); + +const rail = h('div', { class: 'places settings-rail' }); +const pane = h('div', { class: 'files settings-pane' }); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('span', { html: icon('gear', 16) }), + h('span', {}, 'System Settings'), + ), + h('div', { class: 'body' }, h('div', { class: 'explorer' }, rail, pane)), +)); + +onMessage((message) => { + if (message.type === 'settings') { + state = message.state; + busy = undefined; + render(); + return; + } + if (message.type === 'settingsBusy') { + busy = message.label; + render(); + return; + } + // Updates owns its own DOM; it only needs redrawing when it is on screen. + if (handleUpdateMessage(message) && state.section === 'updates') { + render(); + } +}); + +post({ type: 'ready' }); + +// ------------------------------------------------------------------ chrome + +function render(): void { + renderRail(); + clear(pane); + + if (busy) { + pane.append(h('div', { class: 'empty' }, busy)); + return; + } + + switch (state.section) { + case 'display': return renderDisplay(state.display); + case 'keyboard': return renderKeyboard(state.keyboard); + case 'sound': return renderSound(state.audio); + case 'storage': return renderStorage(state.storage); + case 'updates': return renderUpdates(pane); + default: return renderAbout(state.about); + } +} + +function renderRail(): void { + clear(rail); + for (const entry of RAIL) { + rail.append(h('button', { + class: `list-row${entry.id === state.section ? ' active' : ''}`, + on: { click: () => post({ type: 'settingsSection', section: entry.id }) }, + }, + h('span', { html: icon(entry.glyph, 17) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, entry.title), + h('div', { class: 'list-sub' }, entry.sub), + ), + )); + } +} + +function paneHead(title: string, sub: string, ...actions: (Node | null)[]): HTMLElement { + return h('div', { class: 'pane-head' }, + h('div', { class: 'pane-title' }, title), + h('div', { class: 'pane-sub' }, sub), + actions.some(Boolean) ? append(h('div', { class: 'pane-actions' }), ...actions) : null, + ); +} + +function field(label: string, control: Node, hint?: string): HTMLElement { + return h('div', { class: 'setting-row' }, + h('div', { class: 'setting-label' }, + h('div', {}, label), + hint ? h('div', { class: 'list-sub' }, hint) : null, + ), + h('div', { class: 'setting-control' }, control), + ); +} + +function select(options: { value: string; label: string }[], current: string, onPick: (value: string) => void): HTMLElement { + const el = h('select', { class: 'setting-select' }) as HTMLSelectElement; + for (const option of options) { + const item = h('option', { value: option.value }, option.label) as HTMLOptionElement; + item.selected = option.value === current; + el.append(item); + } + el.addEventListener('change', () => onPick(el.value)); + return el; +} + +// ----------------------------------------------------------------- display + +function renderDisplay(settings: DisplaySettings | undefined): void { + pane.append(paneHead('Display', 'Resolution, refresh rate and orientation, per screen.')); + + if (!settings?.available) { + pane.append(h('div', { class: 'empty' }, + 'xrandr is not available, so the screen cannot be reconfigured from here.')); + return; + } + + const connected = settings.outputs.filter((o) => o.connected); + if (connected.length === 0) { + pane.append(h('div', { class: 'empty' }, 'No connected screens reported.')); + } + + for (const out of connected) { + pane.append(renderOutput(out, connected.length > 1)); + } + + const disconnected = settings.outputs.filter((o) => !o.connected); + if (disconnected.length > 0) { + pane.append( + h('div', { class: 'section-head' }, 'Not connected'), + h('div', { class: 'list-sub' }, disconnected.map((o) => o.name).join(', ')), + ); + } +} + +function renderOutput(out: DisplayOutput, showPrimary: boolean): HTMLElement { + const block = h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, + h('span', {}, out.name), + out.primary ? h('span', { class: 'update-badge current' }, 'Primary') : null, + ), + ); + + // One entry per size; the rate picker below chooses among that size's rates. + const sizes = out.modes.map((mode) => ({ + value: mode.size, + label: `${mode.size}${mode.preferred ? ' (recommended)' : ''}`, + })); + const current = out.currentMode ?? sizes[0]?.value ?? ''; + const currentMode = out.modes.find((m) => m.size === current); + + block.append(field( + 'Resolution', + select(sizes, current, (mode) => post({ type: 'setDisplayMode', output: out.name, mode })), + currentMode?.preferred ? 'This is the screen’s native resolution.' : undefined, + )); + + if (currentMode && currentMode.rates.length > 1) { + block.append(field( + 'Refresh rate', + select( + currentMode.rates.map((rate) => ({ value: String(rate), label: `${rate} Hz` })), + String(out.currentRate ?? currentMode.rates[0]), + (rate) => post({ type: 'setDisplayMode', output: out.name, mode: current, rate: Number(rate) }), + ), + )); + } + + block.append(field( + 'Orientation', + select( + [ + { value: 'normal', label: 'Landscape' }, + { value: 'left', label: 'Portrait (left)' }, + { value: 'right', label: 'Portrait (right)' }, + { value: 'inverted', label: 'Upside down' }, + ], + out.rotation, + (rotation) => post({ + type: 'setDisplayMode', + output: out.name, + mode: current, + rotation: rotation as DisplayOutput['rotation'], + }), + ), + )); + + if (showPrimary && !out.primary) { + block.append(field( + 'Primary screen', + h('button', { + class: 'button', + on: { + click: () => post({ type: 'setDisplayMode', output: out.name, mode: current, primary: true }), + }, + }, `Make ${out.name} primary`), + )); + } + + return block; +} + +// ---------------------------------------------------------------- keyboard + +function renderKeyboard(settings: KeyboardState | undefined): void { + pane.append(paneHead('Keyboard', 'The layout, and how fast a held key repeats.')); + + if (!settings?.available) { + pane.append(h('div', { class: 'empty' }, + 'Neither setxkbmap nor localectl is installed, so the layout cannot be changed from here.')); + return; + } + + const layouts = settings.layouts.length > 0 + ? settings.layouts + : [settings.current?.code ?? 'us']; + + pane.append(h('div', { class: 'setting-group' }, + field( + 'Layout', + select( + layouts.map((code) => ({ value: code, label: code })), + settings.current?.code ?? '', + (code) => post({ type: 'setKeyboardLayout', code }), + ), + settings.canPersist + ? 'Applies now and is written to /etc/X11 so it survives a reboot.' + : 'Applies to this session only: localectl is not installed.', + ), + settings.current?.variant + ? field('Variant', h('div', { class: 'setting-static' }, settings.current.variant)) + : null, + )); + + const repeat = settings.repeat; + const group = h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, 'Key repeat'), + ); + + const sendRepeat = throttle( + (next: { delay: number; rate: number }) => post({ type: 'setKeyRepeat', repeat: next }), + 200, + ); + + group.append( + field( + 'Delay before repeating', + rangeControl(repeat.delay, 100, 1000, 50, `${repeat.delay} ms`, + (delay) => sendRepeat({ delay, rate: repeat.rate })), + ), + field( + 'Repeat speed', + rangeControl(repeat.rate, 5, 60, 1, `${repeat.rate} / second`, + (rate) => sendRepeat({ delay: repeat.delay, rate })), + ), + h('div', { class: 'list-sub' }, + 'Key repeat applies to this session; the shell reapplies it at every login.'), + ); + pane.append(group); + + pane.append(h('div', { class: 'setting-group' }, + h('div', { class: 'section-head' }, 'Try it'), + h('input', { + class: 'app-search', + type: 'text', + placeholder: 'Type here to test the layout and repeat rate', + }), + )); +} + +function rangeControl( + value: number, + min: number, + max: number, + step: number, + label: string, + onInput: (value: number) => void, +): HTMLElement { + const readout = h('span', { class: 'slider-value' }, label); + const input = h('input', { + type: 'range', + min: String(min), + max: String(max), + step: String(step), + value: String(value), + on: { + input: (event: Event) => { + const next = Number((event.target as HTMLInputElement).value); + readout.textContent = label.replace(/^[\d.]+/, String(next)); + onInput(next); + }, + }, + }); + return h('div', { class: 'slider-row' }, input, readout); +} + +// ------------------------------------------------------------------- sound + +function renderSound(audio: AudioState | undefined): void { + pane.append(paneHead('Sound', 'Which speakers play, which microphone records.')); + + if (!audio?.available) { + pane.append(h('div', { class: 'empty' }, 'No audio server is running.')); + return; + } + + pane.append(deviceGroup( + 'Output', + audio.sinks, + 'volume', + audio.muted ? 0 : audio.volume, + audio.muted, + (value) => post({ type: 'volume', value }), + () => post({ type: 'mute' }), + (id) => post({ type: 'sink', id }), + 'No speakers reported.', + )); + + pane.append(deviceGroup( + 'Input', + audio.sources, + 'mic', + audio.input ? (audio.input.muted ? 0 : audio.input.volume) : undefined, + audio.input?.muted ?? false, + (value) => post({ type: 'micVolume', value }), + () => post({ type: 'micMute' }), + (id) => post({ type: 'source', id }), + 'No microphone found.', + )); + + pane.append(h('div', { class: 'list-sub' }, + 'The voice recorder records from whichever input is selected here.')); +} + +function deviceGroup( + title: string, + devices: { id: string; name: string; isDefault: boolean }[], + glyph: string, + level: number | undefined, + muted: boolean, + onLevel: (value: number) => void, + onMute: () => void, + onPick: (id: string) => void, + emptyText: string, +): HTMLElement { + const group = h('div', { class: 'setting-group' }, h('div', { class: 'section-head' }, title)); + + if (level !== undefined) { + const readout = h('span', { class: 'slider-value' }, muted ? 'Muted' : `${level}%`); + const send = throttle(onLevel, 120); + group.append(h('div', { class: 'slider-row' }, + h('button', { + class: 'icon-button', + title: muted ? 'Unmute' : 'Mute', + html: icon(muted ? (glyph === 'mic' ? 'micMute' : 'volumeMute') : glyph, 18), + on: { click: onMute }, + }), + h('input', { + type: 'range', + min: '0', + max: '100', + value: String(level), + on: { + input: (event: Event) => { + const next = Number((event.target as HTMLInputElement).value); + readout.textContent = `${next}%`; + send(next); + }, + }, + }), + readout, + )); + } + + const list = h('div', { class: 'list' }); + if (devices.length === 0) { + list.append(h('div', { class: 'empty' }, emptyText)); + } + for (const device of devices) { + list.append(h('button', { + class: `list-row${device.isDefault ? ' active' : ''}`, + on: { click: () => onPick(device.id) }, + }, + h('span', { html: icon(glyph, 18) }), + h('span', { class: 'list-main' }, h('div', { class: 'list-name' }, device.name)), + device.isDefault ? h('span', { html: icon('check', 16) }) : null, + )); + } + group.append(list); + return group; +} + +// ----------------------------------------------------------------- storage + +function renderStorage(storage: StorageState | undefined): void { + const selectable = (storage?.categories ?? []).filter((c) => c.available && c.bytes > 0); + const total = selectable + .filter((c) => chosen.has(c.id)) + .reduce((sum, c) => sum + c.bytes, 0); + + pane.append(paneHead( + 'Storage', + 'What is using the disk, and what can safely be thrown away.', + h('button', { + class: 'button', + on: { click: () => post({ type: 'settingsSection', section: 'storage' }) }, + }, h('span', { html: icon('refresh', 15) }), 'Rescan'), + chosen.size > 0 + ? h('button', { + class: 'button primary', + on: { click: () => post({ type: 'cleanStorage', ids: [...chosen] }) }, + }, `Clean up ${formatBytes(total)}`) + : null, + )); + + if (!storage) { + pane.append(h('div', { class: 'empty' }, 'Measuring…')); + return; + } + + pane.append(h('div', { class: 'section-head' }, 'Disks')); + const disks = h('div', { class: 'setting-group' }); + for (const mount of storage.mounts) { + const percent = mount.totalBytes > 0 ? (mount.usedBytes / mount.totalBytes) * 100 : 0; + disks.append(h('div', { class: 'storage-mount' }, + h('div', { class: 'storage-mount-head' }, + h('span', { html: icon('disk', 16) }), + h('span', { class: 'list-name' }, mount.label || mount.mountpoint), + h('span', { class: 'list-sub' }, + `${formatBytes(mount.freeBytes)} free of ${formatBytes(mount.totalBytes)}`), + ), + h('div', { class: `bar${percent >= 90 ? ' warn' : ''}` }, + h('span', { style: { width: `${Math.min(100, percent)}%` } }), + ), + )); + } + pane.append(disks); + + pane.append(h('div', { class: 'section-head' }, 'Clean up')); + const list = h('div', { class: 'list' }); + const cleanable = storage.categories.filter((c) => c.available); + if (cleanable.length === 0) { + list.append(h('div', { class: 'empty' }, 'Nothing measurable to clean.')); + } + for (const category of cleanable) { + list.append(cleanupRow(category)); + } + pane.append(list); + + if (!storage.canElevate) { + pane.append(h('div', { class: 'list-sub' }, + 'pkexec is not installed, so the package cache and system logs cannot be cleaned from here.')); + } + + if (storage.home.length > 0) { + pane.append(h('div', { class: 'section-head' }, 'Largest folders in your home')); + const folders = h('div', { class: 'list' }); + for (const entry of storage.home) { + folders.append(h('button', { + class: 'list-row', + title: entry.path, + on: { click: () => post({ type: 'revealPath', path: entry.path }) }, + }, + h('span', { html: icon('folder', 18) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, entry.name), + h('div', { class: 'list-sub' }, entry.path), + ), + h('span', { class: 'storage-size' }, formatBytes(entry.bytes)), + )); + } + pane.append(folders); + } +} + +function cleanupRow(category: CleanupCategory): HTMLElement { + const box = h('input', { + type: 'checkbox', + checked: chosen.has(category.id), + disabled: category.bytes === 0, + on: { + change: (event: Event) => { + if ((event.target as HTMLInputElement).checked) { + chosen.add(category.id); + } else { + chosen.delete(category.id); + } + render(); + }, + }, + }); + + return h('label', { class: 'list-row' }, + box, + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, + category.title, + category.privileged ? h('span', { class: 'update-badge' }, 'needs root') : null, + ), + h('div', { class: 'list-sub' }, category.description), + ), + h('span', { class: 'storage-size' }, category.bytes > 0 ? formatBytes(category.bytes) : '—'), + ); +} + +// ------------------------------------------------------------------- about + +function renderAbout(about: AboutInfo | undefined): void { + pane.append(paneHead('About', 'What this machine is running.')); + if (!about) { + return; + } + + const rows: [string, string | undefined][] = [ + ['Device name', about.hostname], + ['Visual Studio Code', about.codeVersion], + ['Desktop shell', `VsCodeOsCore ${about.shellVersion}`], + ['Kernel', `${about.kernel} (${about.architecture})`], + ['Processor', about.cpu], + ['Memory', about.memoryBytes ? formatBytes(about.memoryBytes) : undefined], + ['Uptime', formatUptime(about.uptimeSeconds)], + ['Image', about.build], + ]; + + const group = h('div', { class: 'setting-group' }); + for (const [label, value] of rows) { + if (value) { + group.append(field(label, h('div', { class: 'setting-static' }, value))); + } + } + pane.append(group); +} + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) { + return `${days} day${days === 1 ? '' : 's'}, ${hours} h ${minutes} min`; + } + return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`; +} diff --git a/extension/package-lock.json b/extension/package-lock.json index 0f66ecd..e65ebff 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "dbus-next": "^0.10.2", "puppeteer-core": "^25.5.0" }, "devDependencies": { @@ -463,6 +464,15 @@ "node": ">=18" } }, + "node_modules/@nornagon/put": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz", + "integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==", + "license": "MIT/X11", + "engines": { + "node": ">=0.3.0" + } + }, "node_modules/@puppeteer/browsers": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.1.0.tgz", @@ -508,6 +518,30 @@ "dev": true, "license": "MIT" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -532,6 +566,124 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", + "optional": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/chromium-bidi": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", @@ -579,18 +731,137 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT", + "optional": true + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dbus-next": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.10.2.tgz", + "integrity": "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==", + "license": "MIT", + "dependencies": { + "@nornagon/put": "0.0.8", + "event-stream": "3.3.4", + "hexy": "^0.2.10", + "jsbi": "^2.0.5", + "long": "^4.0.0", + "safe-buffer": "^5.1.1", + "xml2js": "^0.4.17" + }, + "optionalDependencies": { + "usocket": "^0.3.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/devtools-protocol": { "version": "0.0.1653615", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", "license": "BSD-3-Clause" }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -642,6 +913,179 @@ "node": ">=6" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "optional": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "optional": true + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -663,72 +1107,804 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/modern-tar": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", - "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "license": "MIT", - "engines": { - "node": ">=18.0.0" + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" } }, - "node_modules/puppeteer-core": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.5.0.tgz", - "integrity": "sha512-XPNT0dQJtphqQ4I29zxlG4IIPbg1iEHAQKWuQgtMJGXjACV77pZSmJvDi51IIIfd+DTKICcopJwUx4upVQ4XbA==", - "license": "Apache-2.0", + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, "dependencies": { - "@puppeteer/browsers": "3.1.0", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1653615", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=22.12.0" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", "license": "MIT", + "optional": true, "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "ajv": "^6.12.3", + "har-schema": "^2.0.0" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hexy": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", + "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", "license": "MIT", + "optional": true, "dependencies": { - "ansi-regex": "^6.2.2" + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=0.8", + "npm": ">=1.3.7" } }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, + "node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, + "node_modules/jsbi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz", + "integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", + "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.3", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "semver": "^7.3.2", + "tar": "^6.0.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT", + "optional": true + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-core": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.5.0.tgz", + "integrity": "sha512-XPNT0dQJtphqQ4I29zxlG4IIPbg1iEHAQKWuQgtMJGXjACV77pZSmJvDi51IIIfd+DTKICcopJwUx4upVQ4XbA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.1.0", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", + "optional": true + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", + "optional": true + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", "license": "MIT" }, @@ -753,12 +1929,156 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/usocket": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/usocket/-/usocket-0.3.0.tgz", + "integrity": "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.14.2", + "node-gyp": "^7.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", "license": "Apache-2.0" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -793,6 +2113,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, "node_modules/ws": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", @@ -814,6 +2141,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -823,6 +2172,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/yargs": { "version": "18.1.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", diff --git a/extension/package.json b/extension/package.json index f4e2452..6213bdd 100644 --- a/extension/package.json +++ b/extension/package.json @@ -71,6 +71,13 @@ "name": "Task Manager", "icon": "media/icons/system.svg", "when": "config.vscodeos.taskManager.enabled" + }, + { + "type": "webview", + "id": "vscodeos.recycleBin", + "name": "Recycle Bin", + "icon": "media/icons/recyclebin.svg", + "when": "config.vscodeos.recycleBin.enabled" } ], "vscodeos-flyout": [ @@ -85,7 +92,7 @@ "commands": [ { "command": "vscodeos.power.menu", - "title": "Power…", + "title": "Power\u2026", "category": "VS Code OS", "icon": "$(vscodeos-power)" }, @@ -211,7 +218,42 @@ }, { "command": "vscodeos.apps.updater", - "title": "Updater", + "title": "Updates", + "category": "VS Code OS" + }, + { + "command": "vscodeos.settings.open", + "title": "System Settings", + "category": "VS Code OS" + }, + { + "command": "vscodeos.settings.display", + "title": "Display Settings", + "category": "VS Code OS" + }, + { + "command": "vscodeos.settings.keyboard", + "title": "Keyboard Settings", + "category": "VS Code OS" + }, + { + "command": "vscodeos.settings.sound", + "title": "Sound Settings", + "category": "VS Code OS" + }, + { + "command": "vscodeos.settings.storage", + "title": "Storage Sense", + "category": "VS Code OS" + }, + { + "command": "vscodeos.recycleBin.open", + "title": "Recycle Bin", + "category": "VS Code OS" + }, + { + "command": "vscodeos.firewall.open", + "title": "Firewall", "category": "VS Code OS" }, { @@ -219,9 +261,19 @@ "title": "Accessibility Settings", "category": "VS Code OS" }, + { + "command": "vscodeos.notifications.show", + "title": "Show Notifications", + "category": "VS Code OS" + }, + { + "command": "vscodeos.notifications.clear", + "title": "Clear Notifications", + "category": "VS Code OS" + }, { "command": "vscodeos.apps.menu", - "title": "All Apps…", + "title": "All Apps\u2026", "category": "VS Code OS" } ], @@ -273,11 +325,47 @@ "default": true, "description": "Enable the graphical file explorer." }, + "vscodeos.recycleBin.enabled": { + "type": "boolean", + "default": true, + "description": "Show the Recycle Bin view in the activity bar. The Files app lists it as a place either way." + }, "vscodeos.music.enabled": { "type": "boolean", "default": true, "description": "Enable the music player and its status bar transport." }, + "vscodeos.notifications.enabled": { + "type": "boolean", + "default": true, + "description": "Serve org.freedesktop.Notifications, so notify-send and other desktop notifications appear as editor notifications. Turn off to leave the bus name to another daemon." + }, + "vscodeos.notifications.historyLimit": { + "type": "number", + "default": 50, + "minimum": 5, + "maximum": 500, + "description": "How many notifications the notification centre keeps." + }, + "vscodeos.keyboard.repeatDelay": { + "type": "number", + "default": 500, + "minimum": 100, + "maximum": 2000, + "description": "Milliseconds a key is held before it starts repeating. Reapplied at every login, because X forgets it with the session." + }, + "vscodeos.keyboard.repeatRate": { + "type": "number", + "default": 25, + "minimum": 1, + "maximum": 110, + "description": "Key repeats per second once a key has started repeating." + }, + "vscodeos.firewall.enabled": { + "type": "boolean", + "default": true, + "description": "Enable the Firewall app, which drives ufw through a privileged helper." + }, "vscodeos.browser.command": { "type": "string", "default": "", @@ -352,6 +440,7 @@ "typescript": "^5.7.0" }, "dependencies": { + "dbus-next": "^0.10.2", "puppeteer-core": "^25.5.0" } } diff --git a/extension/src/apps/fileExplorer.ts b/extension/src/apps/fileExplorer.ts index 68a56d0..ace87a8 100644 --- a/extension/src/apps/fileExplorer.ts +++ b/extension/src/apps/fileExplorer.ts @@ -15,19 +15,60 @@ import * as vscode from 'vscode'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as archive from '../sys/archive'; import { openWithDefaultApp } from '../sys/browser'; import { output } from '../sys/exec'; +import { archiveBaseName, archiveKind, isMultiFileArchive } from '../util/archive'; +import { TRASH_PATH, uniqueName } from '../sys/trash'; +import type { TrashService } from '../sys/trash'; import { render, webviewOptions } from '../webview/html'; import { mediaKind } from '../util/media'; import type { FileEntry, Place, WebviewMessage } from '../webview/protocol'; import { log } from '../log'; +/** + * The virtual path an archive is browsed at: the archive's own path, a bang, + * then the directory inside it. The same trick the Recycle Bin uses - the host + * intercepts it, so the page needs no idea that an archive is not a folder. + */ +const ARCHIVE_SCHEME = 'archive://'; + +function archiveLocation(target: string): { file: string; inner: string } | undefined { + if (!target.startsWith(ARCHIVE_SCHEME)) { + return undefined; + } + const rest = target.slice(ARCHIVE_SCHEME.length); + const bang = rest.indexOf('!'); + return bang < 0 + ? { file: rest, inner: '' } + : { file: rest.slice(0, bang), inner: rest.slice(bang + 1) }; +} + +function archivePath(file: string, inner: string): string { + return `${ARCHIVE_SCHEME}${file}!${inner}`; +} + +/** Messages whose paths must be real files on disk. */ +const WRITES_TO_DISK: ReadonlySet = new Set([ + 'newFolder', 'newFile', 'rename', 'delete', 'paste', 'clipboard', +]); + export class FileExplorer { private panel: vscode.WebviewPanel | undefined; private currentPath = os.homedir(); private clipboard: { paths: string[]; cut: boolean } | undefined; - constructor(private readonly context: vscode.ExtensionContext) {} + constructor( + private readonly context: vscode.ExtensionContext, + private readonly trash: TrashService, + ) { + // Restoring something from the activity bar view has to show up here too. + this.trash.on('change', () => { + if (this.currentPath === TRASH_PATH) { + void this.list(TRASH_PATH); + } + }); + } async open(startPath?: string): Promise { if (startPath) { @@ -56,6 +97,17 @@ export class FileExplorer { } private async list(target: string, error?: string): Promise { + if (target === TRASH_PATH) { + await this.listTrash(error); + return; + } + + const inside = archiveLocation(target); + if (inside) { + await this.listArchive(inside.file, inside.inner, error); + return; + } + let entries: FileEntry[] = []; try { const names = await fs.readdir(target, { withFileTypes: true }); @@ -94,8 +146,68 @@ export class FileExplorer { this.post({ type: 'files', path: target, entries, places: await places(), error }); } + /** + * The bin is not a directory listing. Names inside files/ are mangled to + * avoid collisions, so what gets shown is the original name, with where it + * came from and when it went in place of the usual size and date. + */ + private async listTrash(error?: string): Promise { + this.currentPath = TRASH_PATH; + const items = await this.trash.list(); + const entries: FileEntry[] = items.map((item) => ({ + name: item.originalPath ? path.basename(item.originalPath) : item.name, + // The key the host needs back for restore and delete, not a real path. + path: item.name, + isDirectory: item.isDirectory, + isSymlink: false, + size: item.size, + modified: item.deletedAt ?? 0, + hidden: false, + originalPath: item.originalPath, + deletedAt: item.deletedAt, + })); + this.post({ type: 'files', path: TRASH_PATH, entries, places: await places(), error }); + } + + /** + * One level of an archive. Read-only: adding to an existing archive is not + * something bsdtar can do for zip, and half-supporting it would be worse + * than not offering it. + */ + private async listArchive(file: string, inner: string, error?: string): Promise { + this.currentPath = archivePath(file, inner); + const items = await archive.listDirectory(file, inner); + const entries: FileEntry[] = items.map((item) => ({ + name: path.posix.basename(item.path), + path: item.isDirectory ? archivePath(file, item.path) : archivePath(file, item.path), + isDirectory: item.isDirectory, + isSymlink: false, + size: item.size, + modified: 0, + hidden: path.posix.basename(item.path).startsWith('.'), + })); + this.post({ + type: 'files', + path: this.currentPath, + entries, + places: await places(), + error: error ?? (entries.length === 0 && !inner ? 'This archive is empty or could not be read.' : undefined), + }); + } + private async handle(message: WebviewMessage): Promise { try { + // The Recycle Bin and archive listings are virtual: their entry + // paths are keys, not files. The page hides the actions that would + // write to them, but a keyboard shortcut must not reach fs.rm with + // "archive://..." either. + if (WRITES_TO_DISK.has(message.type) && this.currentPath.startsWith(ARCHIVE_SCHEME)) { + void vscode.window.showInformationMessage( + 'An archive is read-only here. Extract it first to change what is inside.', + ); + return; + } + switch (message.type) { case 'ready': await this.list(this.currentPath); @@ -106,11 +218,34 @@ export class FileExplorer { return; case 'openFile': { + if (this.currentPath === TRASH_PATH) { + // Opening something in the bin would edit a file that is + // meant to be deleted; restore it first. + void vscode.window.showInformationMessage( + 'Restore this item before opening it.', + ); + return; + } + const inside = archiveLocation(message.path); + if (inside) { + await this.openArchiveMember(inside.file, inside.inner); + return; + } + const stat = await fs.stat(message.path); if (stat.isDirectory()) { await this.list(message.path); return; } + + const kind = archiveKind(message.path); + if (kind && isMultiFileArchive(kind) && archive.isAvailable()) { + // Browse it rather than handing the editor a wall of + // binary, which is what vscode.open would show. + await this.list(archivePath(message.path, '')); + return; + } + if (mediaKind(message.path)) { await vscode.commands.executeCommand('vscodeos.apps.player', message.path); return; @@ -119,6 +254,14 @@ export class FileExplorer { return; } + case 'extract': + await this.extract(message.paths, message.chooseTarget); + return; + + case 'compress': + await this.compress(message.paths); + return; + case 'openExternal': if (!openWithDefaultApp(message.path)) { void vscode.window.showWarningMessage('xdg-open is not available.'); @@ -163,6 +306,10 @@ export class FileExplorer { } case 'delete': { + if (this.currentPath === TRASH_PATH) { + await this.deleteFromTrash(message.paths); + return; + } const label = message.paths.length === 1 ? `"${path.basename(message.paths[0])}"` : `${message.paths.length} items`; @@ -186,10 +333,49 @@ export class FileExplorer { await fs.rm(target, { recursive: true, force: true }); } } + if (choice === 'Move to trash') { + this.trash.notifyChanged(); + } await this.list(this.currentPath); return; } + case 'restoreFromTrash': { + const { restored, skipped } = await this.trash.restore(message.paths); + if (skipped.length > 0) { + void vscode.window.showWarningMessage( + `${skipped.length} item${skipped.length === 1 ? '' : 's'} could not be restored: ` + + 'the original location is unknown.', + ); + } else if (restored > 0) { + void vscode.window.showInformationMessage( + `Restored ${restored} item${restored === 1 ? '' : 's'}.`, + ); + } + await this.list(TRASH_PATH); + return; + } + + case 'deleteFromTrash': + await this.deleteFromTrash(message.paths); + return; + + case 'emptyTrash': { + if (await this.trash.isEmpty()) { + return; + } + const choice = await vscode.window.showWarningMessage( + 'Empty the Recycle Bin?', + { modal: true, detail: 'Everything in it is deleted for good.' }, + 'Empty Recycle Bin', + ); + if (choice === 'Empty Recycle Bin') { + await this.trash.empty(); + } + await this.list(TRASH_PATH); + return; + } + case 'clipboard': this.clipboard = { paths: message.paths, cut: message.cut }; return; @@ -224,6 +410,117 @@ export class FileExplorer { } } + /** Pull one member out to a temp file so the editor has a real path to open. */ + private async openArchiveMember(file: string, member: string): Promise { + const extracted = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Extracting ${path.posix.basename(member)}…` }, + () => archive.extractOne(file, member), + ); + if (!extracted) { + void vscode.window.showErrorMessage(`Could not extract ${member} from the archive.`); + return; + } + if (mediaKind(extracted)) { + await vscode.commands.executeCommand('vscodeos.apps.player', extracted); + return; + } + await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(extracted)); + } + + /** + * Unpack archives next to themselves, into a folder named after the archive + * so a tarball with no top-level directory does not spray its contents over + * whatever was already there. + */ + private async extract(paths: string[], chooseTarget: boolean): Promise { + const archives = paths.filter((file) => archiveKind(file) !== undefined); + if (archives.length === 0) { + void vscode.window.showInformationMessage('Nothing selected that looks like an archive.'); + return; + } + + let base: string | undefined; + if (chooseTarget) { + const picked = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: vscode.Uri.file(path.dirname(archives[0])), + openLabel: 'Extract here', + }); + base = picked?.[0]?.fsPath; + if (!base) { + return; + } + } + + const channel = this.progress(); + for (const file of archives) { + const destination = await uniqueName( + path.join(base ?? path.dirname(file), archiveBaseName(file)), + ); + await fs.mkdir(destination, { recursive: true }); + const result = await archive.extract(file, destination, channel); + if (!result.ok) { + void vscode.window.showErrorMessage(`Could not extract ${path.basename(file)}: ${result.message}`); + } + } + await this.list(this.currentPath); + } + + private async compress(paths: string[]): Promise { + if (paths.length === 0) { + return; + } + const suggested = paths.length === 1 + ? `${path.basename(paths[0]).replace(/\.[^.]+$/, '')}.zip` + : `${path.basename(path.dirname(paths[0])) || 'archive'}.zip`; + + const name = await vscode.window.showInputBox({ + prompt: 'Name for the new archive', + value: suggested, + valueSelection: [0, suggested.lastIndexOf('.')], + }); + if (!name) { + return; + } + if (!archiveKind(name)) { + void vscode.window.showErrorMessage( + `"${name}" has no archive extension. Try .zip, .tar.gz or .tar.xz.`, + ); + return; + } + + const destination = await uniqueName(path.join(path.dirname(paths[0]), name)); + const result = await archive.compress(paths, destination, this.progress()); + if (!result.ok) { + void vscode.window.showErrorMessage(`Could not create the archive: ${result.message}`); + } + await this.list(this.currentPath); + } + + /** + * Archive work streams into the output channel rather than a log pane: the + * Files app has nowhere to put one, and the interesting case is a failure + * the user then wants to read. + */ + private progress(): (chunk: string) => void { + return (chunk) => log.info(chunk.trimEnd()); + } + + private async deleteFromTrash(names: string[]): Promise { + const label = names.length === 1 ? 'this item' : `${names.length} items`; + const choice = await vscode.window.showWarningMessage( + `Permanently delete ${label}?`, + { modal: true, detail: 'This cannot be undone.' }, + 'Delete permanently', + ); + if (choice === 'Delete permanently') { + await this.trash.remove(names); + } + await this.list(TRASH_PATH); + } + dispose(): void { this.panel?.dispose(); } @@ -253,6 +550,9 @@ async function places(): Promise { } } existing.push({ name: 'Filesystem', path: '/', icon: 'disk' }); + // Always listed, even when empty: a Recycle Bin that appears only once you + // have deleted something is a Recycle Bin nobody finds. + existing.push({ name: 'Recycle Bin', path: TRASH_PATH, icon: 'trash' }); // Removable media, straight from lsblk rather than by guessing at /run/media. const mounts = await output('lsblk', ['-nrpo', 'MOUNTPOINT,LABEL,RM'], 4000); @@ -268,24 +568,3 @@ async function places(): Promise { } return existing; } - -/** "report.txt" -> "report (1).txt" when pasting into the same folder. */ -async function uniqueName(target: string): Promise { - try { - await fs.access(target); - } catch { - return target; - } - const directory = path.dirname(target); - const extension = path.extname(target); - const base = path.basename(target, extension); - for (let i = 1; i < 1000; i++) { - const candidate = path.join(directory, `${base} (${i})${extension}`); - try { - await fs.access(candidate); - } catch { - return candidate; - } - } - return `${target}.${Date.now()}`; -} diff --git a/extension/src/apps/firewall.ts b/extension/src/apps/firewall.ts new file mode 100644 index 0000000..3710353 --- /dev/null +++ b/extension/src/apps/firewall.ts @@ -0,0 +1,176 @@ +// The Firewall app. +// +// A GUI over ufw. It exists because the images ship sshd and, until now, no +// packet filter at all - 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. +// +// Every operation is privileged (see src/sys/firewall.ts), so this refreshes on +// open and after each change rather than polling. + +import * as vscode from 'vscode'; +import * as firewall from '../sys/firewall'; +import { AppPanels } from './panels'; +import type { AppOptions } from './panels'; +import type { FirewallState, HostMessage, WebviewMessage } from '../webview/protocol'; +import { log } from '../log'; + +const PANEL: AppOptions = { + id: 'firewall', + title: 'Firewall', + script: 'firewall', + icon: 'firewall', +}; + +export class Firewall { + private busy = false; + + constructor(private readonly panels: AppPanels) {} + + open(): void { + const existing = this.panels.get(PANEL.id) !== undefined; + this.panels.open({ ...PANEL, onMessage: (message) => this.handle(message) }); + if (existing) { + void this.refresh(); + } + } + + private post(message: HostMessage): void { + void this.panels.get(PANEL.id)?.webview.postMessage(message); + } + + private async handle(message: WebviewMessage): Promise { + try { + switch (message.type) { + case 'ready': + case 'firewallRefresh': + await this.refresh(); + return; + + case 'firewallToggle': + await this.toggle(message.enabled); + return; + + case 'firewallPolicy': + await this.change( + [message.direction === 'incoming' ? 'default-incoming' : 'default-outgoing', message.policy], + ); + return; + + case 'firewallRule': + if (!firewall.isValidSpec(message.spec)) { + void vscode.window.showErrorMessage( + `"${message.spec}" is not a port, a port range or an application profile.`, + ); + return; + } + await this.change([message.action, message.spec]); + return; + + case 'firewallDelete': { + const choice = await vscode.window.showWarningMessage( + `Delete firewall rule ${message.number}?`, + { modal: true, detail: message.label }, + 'Delete rule', + ); + if (choice === 'Delete rule') { + await this.change(['delete', String(message.number)]); + } + return; + } + + case 'firewallLogging': + await this.change(['logging', message.enabled ? 'on' : 'off']); + return; + + case 'firewallReset': { + const choice = await vscode.window.showWarningMessage( + 'Reset the firewall?', + { + modal: true, + detail: 'Every rule is deleted and the firewall is turned off. This cannot be undone.', + }, + 'Reset everything', + ); + if (choice === 'Reset everything') { + await this.change(['reset']); + } + return; + } + + default: + return; + } + } catch (error) { + log.error('firewall', error); + void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error)); + } + } + + /** + * Turning the firewall on is the one action that can cut the machine off + * from whoever is using it. The default incoming policy is deny, so a Pi + * being administered over SSH goes dark the moment this is switched on + * unless port 22 is already allowed. + */ + private async toggle(enabled: boolean): Promise { + if (enabled) { + const status = await firewall.getStatus(); + const allowsSsh = (status?.rules ?? []).some( + (rule) => /^(22(\/tcp)?|OpenSSH)\b/i.test(rule.to) && rule.action.startsWith('ALLOW'), + ); + if (!allowsSsh && (await firewall.sshIsListening())) { + const choice = await vscode.window.showWarningMessage( + 'Turning the firewall on will block incoming SSH.', + { + modal: true, + detail: + 'This machine is accepting SSH connections and no rule allows them through. ' + + 'If you are working on it remotely you will be disconnected and will not be able to reconnect.', + }, + 'Allow SSH, then turn on', + 'Turn on anyway', + ); + if (!choice) { + return; + } + if (choice === 'Allow SSH, then turn on') { + await this.change(['allow', '22/tcp'], false); + } + } + } + await this.change([enabled ? 'enable' : 'disable']); + } + + private async change(args: string[], refresh = true): Promise { + if (this.busy) { + return; + } + this.busy = true; + this.post({ type: 'firewallBusy', busy: true }); + try { + const result = await firewall.apply(args, (chunk) => this.post({ type: 'firewallLog', chunk })); + if (!result.ok) { + void vscode.window.showErrorMessage(result.message ?? 'The firewall could not be changed.'); + } + } finally { + this.busy = false; + } + if (refresh) { + await this.refresh(); + } + } + + private async refresh(): Promise { + const state: FirewallState = { + installed: firewall.isAvailable(), + canElevate: firewall.canElevate(), + busy: this.busy, + }; + if (state.installed && state.canElevate) { + state.status = await firewall.getStatus(); + state.sshListening = await firewall.sshIsListening(); + } + this.post({ type: 'firewall', state }); + } +} diff --git a/extension/src/apps/registry.ts b/extension/src/apps/registry.ts index bc94f8c..0302a2c 100644 --- a/extension/src/apps/registry.ts +++ b/extension/src/apps/registry.ts @@ -91,12 +91,25 @@ export const APPS: AppDescriptor[] = [ enabled: apps, }, { - id: 'updater', - title: 'Updater', - description: 'Update the system, VS Code and the shell', - icon: 'update', - command: 'vscodeos.apps.updater', - keywords: ['upgrade', 'pacman', 'patch', 'version'], + id: 'firewall', + title: 'Firewall', + description: 'Control what can reach this machine', + icon: 'shield', + command: 'vscodeos.firewall.open', + keywords: ['ufw', 'ports', 'security', 'network', 'block', 'allow'], + enabled: (config) => config.get('firewall.enabled', true), + }, + { + id: 'systemSettings', + title: 'System Settings', + description: 'Display, keyboard, sound, storage and updates', + icon: 'gear', + command: 'vscodeos.settings.open', + keywords: [ + 'control panel', 'preferences', 'display', 'resolution', 'monitor', + 'keyboard', 'layout', 'sound', 'audio', 'microphone', 'storage', + 'disk', 'clean', 'update', 'upgrade', 'pacman', 'version', + ], }, { id: 'taskManager', @@ -116,12 +129,12 @@ export const APPS: AppDescriptor[] = [ keywords: ['bash', 'console', 'command line'], }, { - id: 'settings', - title: 'Settings', - description: 'Editor and desktop settings', - icon: 'gear', + id: 'editorSettings', + title: 'Editor Settings', + description: "VS Code's own settings editor", + icon: 'editor', command: 'workbench.action.openSettings', - keywords: ['preferences', 'options', 'configure'], + keywords: ['preferences', 'options', 'configure', 'json', 'vscode'], }, { id: 'accessibility', diff --git a/extension/src/apps/updater.ts b/extension/src/apps/settings/updates.ts similarity index 90% rename from extension/src/apps/updater.ts rename to extension/src/apps/settings/updates.ts index ad9f640..705b9dd 100644 --- a/extension/src/apps/updater.ts +++ b/extension/src/apps/settings/updates.ts @@ -1,8 +1,8 @@ -// The Updater. +// The Updates pane of System Settings. // // VS Code OS is updated from two places that know nothing about each other: // pacman, for the Arch base, and a tarball from Microsoft, for the editor. Both -// were terminal-only until now, which on a machine whose whole UI is the editor +// were terminal-only once, which on a machine whose whole UI is the editor // meant "read the README and type two commands". // // Checking is unprivileged on purpose. `checkupdates` computes pending package @@ -15,42 +15,35 @@ import * as vscode from 'vscode'; import { promises as fs } from 'node:fs'; import * as https from 'node:https'; import * as os from 'node:os'; -import { run, start, which } from '../sys/exec'; -import { parsePendingUpdates } from '../util/parse'; -import { codeUpdateUrl } from '../util/url'; -import { runPowerAction } from '../views/flyout'; -import { AppPanels } from './panels'; -import type { AppOptions } from './panels'; -import type { HostMessage, UpdateItem, UpdateTarget, WebviewMessage } from '../webview/protocol'; -import { log } from '../log'; - -const PANEL: AppOptions = { - id: 'updater', - title: 'Updater', - script: 'updater', - icon: 'updater', -}; +import { run, start, which } from '../../sys/exec'; +import { parsePendingUpdates } from '../../util/parse'; +import { codeUpdateUrl } from '../../util/url'; +import { runPowerAction } from '../../views/flyout'; +import type { HostMessage, UpdateItem, UpdateTarget, WebviewMessage } from '../../webview/protocol'; +import { log } from '../../log'; const CODE_PREFIX = '/opt/visual-studio-code'; const UPDATE_HELPER = '/usr/local/bin/vscodeos-update'; -export class Updater { +/** + * The updater's own state lives on the host rather than in the page, so closing + * System Settings in the middle of a `pacman -Syu` and reopening it picks the + * running update back up instead of losing it. + */ +export class UpdatesController { private running: UpdateTarget | undefined; private restartNeeded = false; - constructor(private readonly panels: AppPanels) {} + /** `post` is supplied by whoever owns the panel; see apps/systemSettings.ts. */ + constructor(private readonly post: (message: HostMessage) => void) {} - open(): void { - this.panels.open({ ...PANEL, onMessage: (message) => this.handle(message) }); + /** True while an update is applying, which the settings rail shows. */ + get isRunning(): boolean { + return this.running !== undefined; } - private post(message: HostMessage): void { - void this.panels.get(PANEL.id)?.webview.postMessage(message); - } - - private async handle(message: WebviewMessage): Promise { + async handle(message: WebviewMessage): Promise { switch (message.type) { - case 'ready': case 'checkUpdates': await this.check(); return; @@ -74,7 +67,7 @@ export class Updater { // ----------------------------------------------------------------- check - private async check(): Promise { + async check(): Promise { this.post({ type: 'updateStatus', items: this.skeleton().map((item) => ({ ...item, status: 'checking' as const })), diff --git a/extension/src/apps/systemSettings.ts b/extension/src/apps/systemSettings.ts new file mode 100644 index 0000000..28858be --- /dev/null +++ b/extension/src/apps/systemSettings.ts @@ -0,0 +1,337 @@ +// System Settings. +// +// The desktop's control panel: the screen, the keyboard, the sound devices, the +// disk, updates and what this machine actually is. It exists because those +// settings had nowhere to live - display control was a night-light toggle 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. +// +// One webview with a rail down the left, rather than six apps. Every pane is +// asked for only when it is 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. + +import * as vscode from 'vscode'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as audio from '../sys/audio'; +import * as backlight from '../sys/backlight'; +import * as display from '../sys/display'; +import * as keyboard from '../sys/keyboard'; +import * as storage from '../sys/storage'; +import { start, which } from '../sys/exec'; +import { AppPanels } from './panels'; +import type { AppOptions } from './panels'; +import { UpdatesController } from './settings/updates'; +import type { + AboutInfo, + CleanupId, + HostMessage, + SettingsSection, + SettingsState, + WebviewMessage, +} from '../webview/protocol'; +import { log } from '../log'; + +const PANEL: AppOptions = { + id: 'settings', + title: 'System Settings', + script: 'settings', + icon: 'settings', +}; + +/** How long a new display mode is on trial before it is put back. */ +const MODE_REVERT_MS = 15000; + +export class SystemSettings { + private section: SettingsSection = 'display'; + private readonly updates = new UpdatesController((message) => this.post(message)); + + constructor(private readonly panels: AppPanels) {} + + open(section?: SettingsSection): void { + if (section) { + this.section = section; + } + const existing = this.panels.get(PANEL.id) !== undefined; + this.panels.open({ ...PANEL, onMessage: (message) => this.handle(message) }); + if (existing) { + // Already open on another pane: jump it, since the page will not + // send a fresh `ready`. + this.post({ type: 'settings', state: { section: this.section } }); + void this.refresh(); + } + } + + private post(message: HostMessage): void { + void this.panels.get(PANEL.id)?.webview.postMessage(message); + } + + private async handle(message: WebviewMessage): Promise { + try { + switch (message.type) { + case 'ready': + await this.refresh(); + return; + + case 'settingsSection': + this.section = message.section; + await this.refresh(); + return; + + case 'setDisplayMode': + await this.changeMode(message); + return; + + case 'setKeyboardLayout': + await this.changeLayout(message.code, message.variant); + return; + + case 'setKeyRepeat': + await keyboard.setRepeat(message.repeat); + await vscode.workspace.getConfiguration('vscodeos').update( + 'keyboard.repeatDelay', + message.repeat.delay, + vscode.ConfigurationTarget.Global, + ); + await vscode.workspace.getConfiguration('vscodeos').update( + 'keyboard.repeatRate', + message.repeat.rate, + vscode.ConfigurationTarget.Global, + ); + await this.refresh(); + return; + + // Sound: the same messages the volume flyout sends, so the two + // stay in step without a second set of handlers. + case 'volume': + await audio.setVolume(message.value); + await this.refresh(); + return; + case 'mute': + await audio.toggleMute(); + await this.refresh(); + return; + case 'sink': + await audio.setDefaultSink(message.id); + await this.refresh(); + return; + case 'source': + await audio.setDefaultSource(message.id); + await this.refresh(); + return; + case 'micVolume': + await audio.setInputVolume(message.value); + await this.refresh(); + return; + case 'micMute': + await audio.toggleInputMute(); + await this.refresh(); + return; + + case 'cleanStorage': + await this.clean(message.ids); + return; + + case 'revealPath': + await vscode.commands.executeCommand('vscodeos.files.open', message.path); + return; + + case 'checkUpdates': + case 'runUpdate': + case 'restart': + await this.updates.handle(message); + return; + + default: + return; + } + } catch (error) { + log.error('system settings', error); + void vscode.window.showErrorMessage( + error instanceof Error ? error.message : String(error), + ); + } + } + + // ---------------------------------------------------------------- reading + + private async refresh(): Promise { + const state: SettingsState = { section: this.section }; + + switch (this.section) { + case 'display': { + const brightness = await backlight.getState(); + state.display = { + available: display.isAvailable(), + outputs: await display.listOutputs(), + nightLight: display.isNightLightOn(), + energySaver: display.isEnergySaverOn(), + brightness: brightness.available && brightness.writable ? brightness.percent : undefined, + }; + break; + } + case 'keyboard': + state.keyboard = await keyboard.getState(this.repeatSetting()); + break; + case 'sound': + state.audio = await audio.getState(); + break; + case 'storage': + this.post({ type: 'settingsBusy', label: 'Measuring…' }); + state.storage = await storage.getState(); + break; + case 'about': + state.about = await this.about(); + break; + default: + break; + } + + this.post({ type: 'settings', state }); + } + + private repeatSetting(): keyboard.RepeatRate { + const config = vscode.workspace.getConfiguration('vscodeos'); + return { + delay: config.get('keyboard.repeatDelay', keyboard.DEFAULT_REPEAT.delay), + rate: config.get('keyboard.repeatRate', keyboard.DEFAULT_REPEAT.rate), + }; + } + + private async about(): Promise { + const [build, code] = await Promise.all([ + readText('/usr/share/vscodeos/build-info'), + readText('/opt/visual-studio-code/resources/app/package.json'), + ]); + let codeVersion: string | undefined; + try { + codeVersion = code ? (JSON.parse(code) as { version?: string }).version : undefined; + } catch { + codeVersion = undefined; + } + + const extension = vscode.extensions.getExtension('vscodeos.vscodeos-core'); + return { + hostname: os.hostname(), + kernel: os.release(), + architecture: os.arch(), + build: build?.trim() || undefined, + codeVersion, + shellVersion: String(extension?.packageJSON?.version ?? 'unknown'), + cpu: os.cpus()[0]?.model, + memoryBytes: os.totalmem(), + uptimeSeconds: os.uptime(), + }; + } + + // ---------------------------------------------------------------- writing + + /** + * Change a display mode, then ask whether it worked. + * + * The kiosk sets DontVTSwitch, so a mode the monitor cannot show leaves no + * console to escape to and no way back short of a power cycle. The old mode + * is captured first and put back if the confirmation does not arrive, which + * is the only thing that makes this safe to offer at all. + */ + private async changeMode(request: { + output: string; + mode: string; + rate?: number; + rotation?: display.Rotation; + primary?: boolean; + }): Promise { + const before = (await display.listOutputs()).find((o) => o.name === request.output); + const result = await display.applyMode(request); + if (!result.ok) { + void vscode.window.showErrorMessage(result.message ?? 'The display mode could not be changed.'); + await this.refresh(); + return; + } + + const keep = 'Keep these settings'; + const choice = await Promise.race([ + vscode.window.showWarningMessage( + `${request.output} is now ${request.mode}. Keep it?`, + { modal: true, detail: `Reverting in ${MODE_REVERT_MS / 1000} seconds if you do not answer.` }, + keep, + ), + new Promise((resolve) => setTimeout(() => resolve(undefined), MODE_REVERT_MS)), + ]); + + if (choice !== keep && before) { + await display.restoreOutput(before); + } + await this.refresh(); + } + + private async changeLayout(code: string, variant?: string): Promise { + const result = await keyboard.setLayout({ code, variant }); + if (!result.ok) { + void vscode.window.showErrorMessage(result.message ?? 'The keyboard layout could not be changed.'); + } else if (!result.persisted) { + void vscode.window.showWarningMessage( + 'The layout is active now but will not survive a reboot: localectl is not available.', + ); + } + await this.refresh(); + } + + /** + * Clean up. The unprivileged categories are done here; the three that need + * root go through one pkexec of vscodeos-clean with the ids as its + * arguments, so the user answers at most one authorisation. + */ + private async clean(ids: CleanupId[]): Promise { + if (ids.length === 0) { + return; + } + const privileged = ids.filter((id) => storage.isPrivileged(id)); + const plain = ids.filter((id) => !storage.isPrivileged(id)); + + this.post({ type: 'settingsBusy', label: 'Cleaning up…' }); + + for (const id of plain) { + await storage.cleanUser(id); + } + + if (privileged.length > 0) { + if (!which('pkexec')) { + void vscode.window.showErrorMessage('pkexec is not installed, so system files cannot be cleaned.'); + } else { + await this.runCleanHelper(privileged); + } + } + + await this.refresh(); + } + + private runCleanHelper(ids: CleanupId[]): Promise { + return new Promise((resolve) => { + this.post({ type: 'updateLog', chunk: `$ pkexec ${storage.cleanHelper} ${ids.join(' ')}\n` }); + const child = start('pkexec', [storage.cleanHelper, ...ids]); + const feed = (chunk: Buffer | string): void => this.post({ type: 'updateLog', chunk: String(chunk) }); + child.stdout?.on('data', feed); + child.stderr?.on('data', feed); + child.on('error', (error) => this.post({ type: 'updateLog', chunk: `\n${String(error)}\n` })); + child.on('close', (code) => { + if (code !== 0) { + log.error(`vscodeos-clean exited with ${String(code)}`); + void vscode.window.showErrorMessage( + `Cleaning system files failed (vscodeos-clean exited with ${code ?? 'unknown'}).`, + ); + } + resolve(); + }); + }); + } +} + +async function readText(file: string): Promise { + try { + return await fs.readFile(file, 'utf8'); + } catch { + return undefined; + } +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 919272e..83b84cd 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -13,13 +13,18 @@ import { Browser } from './apps/browser'; import { FileExplorer } from './apps/fileExplorer'; import { MediaPlayer } from './apps/mediaPlayer'; import { MiniApps } from './apps/miniApps'; -import { Updater } from './apps/updater'; +import { Firewall } from './apps/firewall'; +import { SystemSettings } from './apps/systemSettings'; import { StatusBar } from './statusbar'; import { FlyoutProvider, runPowerAction } from './views/flyout'; +import { RecycleBinProvider } from './views/recycleBin'; import { TaskManagerProvider } from './views/taskManager'; import { MprisMonitor } from './sys/mpris'; +import { NotificationServer } from './sys/notifications'; import * as audio from './sys/audio'; import * as display from './sys/display'; +import * as keyboard from './sys/keyboard'; +import { TrashService } from './sys/trash'; import * as mpris from './sys/mpris'; import { log } from './log'; @@ -37,12 +42,25 @@ export function activate(context: vscode.ExtensionContext): void { music.startWatching(); } + // --- notifications ----------------------------------------------------- + // + // Started early: nothing else owns org.freedesktop.Notifications on either + // image, and until this claims it every notify-send on the machine fails. + const notifications = new NotificationServer( + String(context.extension.packageJSON.version ?? '1.0.0'), + config().get('notifications.historyLimit', 50), + ); + context.subscriptions.push({ dispose: () => notifications.dispose() }); + if (config().get('notifications.enabled', true)) { + void notifications.start(); + } + // --- the tray ---------------------------------------------------------- // Contributed to both the side bar and the bottom panel under a `when` on // vscodeos.flyout.location, so exactly one of these ever resolves. Both are // registered because the setting can change without a reload. - const flyout = new FlyoutProvider(context, music); + const flyout = new FlyoutProvider(context, music, notifications); for (const viewId of [FlyoutProvider.sidebarViewId, FlyoutProvider.panelViewId]) { context.subscriptions.push( vscode.window.registerWebviewViewProvider(viewId, flyout, { @@ -52,7 +70,7 @@ export function activate(context: vscode.ExtensionContext): void { } if (config().get('statusBar.enabled', true)) { - const statusBar = new StatusBar(music); + const statusBar = new StatusBar(music, notifications); statusBar.start(); context.subscriptions.push(statusBar); } @@ -67,14 +85,29 @@ export function activate(context: vscode.ExtensionContext): void { taskManager, ); + // --- recycle bin ------------------------------------------------------- + // + // One service behind both surfaces - this view and the Files app's places + // list - so restoring in either redraws the other. + + const trash = new TrashService(); + const recycleBin = new RecycleBinProvider(context, trash); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(RecycleBinProvider.viewId, recycleBin, { + webviewOptions: { retainContextWhenHidden: true }, + }), + recycleBin, + ); + // --- apps -------------------------------------------------------------- const panels = new AppPanels(context); - const files = new FileExplorer(context); + const files = new FileExplorer(context, trash); const apps = new MiniApps(panels); const player = new MediaPlayer(panels); const browser = new Browser(panels); - const updater = new Updater(panels); + const settings = new SystemSettings(panels); + const firewall = new Firewall(panels); context.subscriptions.push( panels, apps, @@ -83,6 +116,14 @@ export function activate(context: vscode.ExtensionContext): void { ); void apps.ensureDirectories(); + // X forgets the key repeat rate with the session, and there is no + // system-wide place to put it, so the setting is the record and this is + // what makes it stick across logins. + void keyboard.setRepeat({ + delay: config().get('keyboard.repeatDelay', keyboard.DEFAULT_REPEAT.delay), + rate: config().get('keyboard.repeatRate', keyboard.DEFAULT_REPEAT.rate), + }); + // --- commands ---------------------------------------------------------- const register = (command: string, handler: (...args: never[]) => unknown): void => { @@ -102,6 +143,8 @@ export function activate(context: vscode.ExtensionContext): void { register('vscodeos.bluetooth.show', () => flyout.show('bluetooth')); register('vscodeos.music.show', () => flyout.show('music')); register('vscodeos.apps.menu', () => flyout.show('apps')); + register('vscodeos.notifications.show', () => flyout.show('notifications')); + register('vscodeos.notifications.clear', () => notifications.clear()); register('vscodeos.volume.up', () => audio.step(5)); register('vscodeos.volume.down', () => audio.step(-5)); @@ -114,11 +157,11 @@ export function activate(context: vscode.ExtensionContext): void { register('vscodeos.settings.accessibility', () => vscode.commands.executeCommand('workbench.action.openSettings', '@tag:accessibility')); - register('vscodeos.files.open', () => { + register('vscodeos.files.open', (startPath?: string) => { if (!config().get('files.enabled', true)) { return vscode.window.showInformationMessage('The file explorer is disabled in settings.'); } - return files.open(); + return files.open(typeof startPath === 'string' ? startPath : undefined); }); register('vscodeos.browser.open', (url?: string) => browser.open(typeof url === 'string' ? url : undefined)); @@ -138,7 +181,24 @@ export function activate(context: vscode.ExtensionContext): void { register('vscodeos.apps.screenshotRegion', guard(() => apps.captureRegion())); register('vscodeos.apps.recorder', guard(() => apps.recorderApp())); register('vscodeos.apps.player', guard((path?: string) => player.open(path))); - register('vscodeos.apps.updater', () => updater.open()); + // Kept as an alias rather than removed: it is in the README, in muscle + // memory and in the command palette, and it still opens the same four rows. + register('vscodeos.apps.updater', () => settings.open('updates')); + register('vscodeos.settings.open', () => settings.open()); + register('vscodeos.settings.display', () => settings.open('display')); + register('vscodeos.settings.keyboard', () => settings.open('keyboard')); + register('vscodeos.settings.sound', () => settings.open('sound')); + register('vscodeos.settings.storage', () => settings.open('storage')); + + register('vscodeos.recycleBin.open', () => + vscode.commands.executeCommand(`${RecycleBinProvider.viewId}.focus`)); + + register('vscodeos.firewall.open', () => { + if (!config().get('firewall.enabled', true)) { + return vscode.window.showInformationMessage('The firewall app is disabled in settings.'); + } + return firewall.open(); + }); // --- the Print key ----------------------------------------------------- diff --git a/extension/src/statusbar/index.ts b/extension/src/statusbar/index.ts index 6c25173..bdf5106 100644 --- a/extension/src/statusbar/index.ts +++ b/extension/src/statusbar/index.ts @@ -18,10 +18,12 @@ import * as batterySys from '../sys/battery'; import * as bluetoothSys from '../sys/bluetooth'; import * as networkSys from '../sys/network'; import { MprisMonitor, type NowPlaying } from '../sys/mpris'; +import type { NotificationServer } from '../sys/notifications'; import { formatDate, formatTime } from '../util/format'; /** Left to right, as the tray is read. */ const PRIORITY = { + notifications: -25, media: -50, battery: -100, volume: -200, @@ -52,14 +54,24 @@ export class StatusBar implements vscode.Disposable { private readonly networkItem: vscode.StatusBarItem; private readonly bluetoothItem: vscode.StatusBarItem; private readonly mediaItem: vscode.StatusBarItem; + private readonly notificationItem: vscode.StatusBarItem; private clockTimer: NodeJS.Timeout | undefined; private slowTimer: NodeJS.Timeout | undefined; - constructor(private readonly music: MprisMonitor) { + constructor( + private readonly music: MprisMonitor, + private readonly notifications: NotificationServer, + ) { // The first argument is the item's *id*, which has to be unique - the // battery tile and the power button are both "power" to a user, but they // cannot share one here. + this.notificationItem = this.create( + 'notifications', + PRIORITY.notifications, + 'vscodeos.notifications.show', + 'Notifications', + ); this.mediaItem = this.create('media', PRIORITY.media, 'vscodeos.music.show', 'Music'); this.batteryItem = this.create('battery', PRIORITY.battery, 'vscodeos.power.settings', 'Battery'); this.volumeItem = this.create('volume', PRIORITY.volume, 'vscodeos.volume.show', 'Volume'); @@ -86,6 +98,7 @@ export class StatusBar implements vscode.Disposable { this.power.tooltip = new vscode.MarkdownString('**Power** — sleep, restart or shut down'); this.music.on('change', (state: NowPlaying | undefined) => this.renderMedia(state)); + this.notifications.on('change', () => this.renderNotifications()); } private create( @@ -117,6 +130,7 @@ export class StatusBar implements vscode.Disposable { this.slowTimer = setInterval(() => void this.tickSlow(), SLOW_TICK_MS); this.renderMedia(this.music.current); + this.renderNotifications(); } private tickClock(): void { @@ -235,6 +249,35 @@ export class StatusBar implements vscode.Disposable { this.bluetoothItem.show(); } + /** + * Push-driven, not polled: the server emits `change` on every arrival, and + * a notification the user has to wait five seconds to see counted is not + * much of a notification. + */ + private renderNotifications(): void { + if (!this.notifications.running) { + // Nothing owns the bus name, so nothing will ever arrive; an empty + // bell would be a button that does nothing. + this.notificationItem.hide(); + return; + } + + const { unread, records } = this.notifications; + this.notificationItem.text = unread > 0 ? `$(bell-dot) ${unread}` : '$(bell)'; + this.notificationItem.backgroundColor = records.some((r) => !r.read && r.urgency === 'critical') + ? new vscode.ThemeColor('statusBarItem.warningBackground') + : undefined; + this.notificationItem.tooltip = new vscode.MarkdownString([ + unread > 0 ? `**${unread} new notification${unread === 1 ? '' : 's'}**` : '**Notifications**', + records.length === 0 + ? 'Nothing yet' + : records.slice(0, 3).map((r) => truncate(r.text, 48)).join('\n\n'), + '', + '_Click for the notification centre_', + ].join('\n\n')); + this.notificationItem.show(); + } + private renderMedia(state: NowPlaying | undefined): void { const enabled = vscode.workspace.getConfiguration('vscodeos').get('music.enabled', true); if (!enabled || !state || state.status === 'Stopped') { diff --git a/extension/src/sys/archive.ts b/extension/src/sys/archive.ts new file mode 100644 index 0000000..b4ea0dc --- /dev/null +++ b/extension/src/sys/archive.ts @@ -0,0 +1,205 @@ +// Reading, extracting and building archives. +// +// bsdtar does all of it - zip, tar, every compression, and 7z for reading - +// through one interface, and it needs no new package: libarchive is a hard +// dependency of pacman, so it is on both images by construction. The 7zip +// package is x86-only, and relying on it would have left the Pi without. +// +// unzip and tar are kept as a fallback, the way audio.ts keeps pactl behind +// wpctl, so the extension still works on a machine that has neither libarchive +// nor VS Code OS. +// +// Everything here treats archive contents as hostile. Member names come from a +// file someone downloaded, and "../../etc/passwd" is an old and real attack, so +// extraction never uses -P and listings are filtered before they are shown. + +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { run, start, which } from './exec'; +import { archiveKind, entriesInDirectory, parseArchiveListing, parseUnzipListing } from '../util/archive'; +import type { ArchiveEntry } from '../util/archive'; +import { log } from '../log'; + +export type { ArchiveEntry } from '../util/archive'; + +export function isAvailable(): boolean { + return which('bsdtar') !== undefined || which('unzip') !== undefined || which('tar') !== undefined; +} + +function tool(file: string): 'bsdtar' | 'unzip' | 'tar' | undefined { + if (which('bsdtar')) { + return 'bsdtar'; + } + // Without bsdtar the two families need different tools, and neither can do + // the other's job: GNU tar cannot read a zip, and unzip cannot read a tar. + const kind = archiveKind(file); + if (kind === 'zip') { + return which('unzip') ? 'unzip' : undefined; + } + return which('tar') ? 'tar' : undefined; +} + +export async function list(file: string): Promise { + const chosen = tool(file); + if (!chosen) { + return []; + } + + if (chosen === 'unzip') { + const result = await run('unzip', ['-l', '--', file], { timeout: 60000 }); + return parseUnzipListing(result.stdout); + } + + // `--` so a file called "-C" is a file and not an option. + const result = await run(chosen, ['-tvf', file], { timeout: 60000 }); + if (!result.ok && result.stdout.trim() === '') { + log.debug(`${chosen} -tvf ${file}: ${result.stderr.trim().split('\n')[0] ?? 'failed'}`); + return []; + } + return parseArchiveListing(result.stdout); +} + +/** One level of an archive, folded the way a file explorer shows a folder. */ +export async function listDirectory(file: string, directory: string): Promise { + return entriesInDirectory(await list(file), directory); +} + +export interface ArchiveResult { + ok: boolean; + message?: string; +} + +/** + * Unpack an archive into a directory, streaming progress. + * + * Deliberately no `-P`: without it bsdtar and GNU tar both refuse absolute + * paths and `..` components, which is the protection that matters most here. + */ +export function extract( + file: string, + destination: string, + onLog: (chunk: string) => void, +): Promise { + const chosen = tool(file); + if (!chosen) { + return Promise.resolve({ ok: false, message: 'No archive tool is installed.' }); + } + + const [command, args] = chosen === 'unzip' + ? ['unzip', ['-o', '--', file, '-d', destination]] + : [chosen, ['-x', '-v', '-f', file, '-C', destination]]; + + return new Promise((resolve) => { + onLog(`$ ${command} ${(args as string[]).join(' ')}\n`); + const child = start(command as string, args as string[]); + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => onLog(String(chunk))); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += String(chunk); + // bsdtar writes its verbose file list to stderr, so this is progress + // and not necessarily trouble. + onLog(String(chunk)); + }); + child.on('error', (error) => { + stderr += error.message; + onLog(`\n${String(error)}\n`); + }); + child.on('close', (code) => { + resolve(code === 0 + ? { ok: true } + : { ok: false, message: stderr.trim().split('\n').pop() || `${command} exited with ${code ?? '?'}.` }); + }); + }); +} + +/** + * Build an archive from a list of paths. + * + * They are named relative to a common parent so the archive holds "photos/a.jpg" + * rather than "/home/vscodeos/Pictures/photos/a.jpg", which is what every other + * archiver does and what an extraction expects. + */ +export function compress( + sources: string[], + destination: string, + onLog: (chunk: string) => void, +): Promise { + if (sources.length === 0) { + return Promise.resolve({ ok: false, message: 'Nothing selected.' }); + } + const parent = path.dirname(sources[0]); + const names = sources.map((source) => path.basename(source)); + + if (which('bsdtar')) { + // -a picks the format from the destination's extension. + return spawnArchive('bsdtar', ['-a', '-c', '-v', '-f', destination, '-C', parent, ...names], onLog); + } + if (archiveKind(destination) === 'zip' && which('zip')) { + return spawnArchive('zip', ['-r', '-q', destination, ...names], onLog, parent); + } + if (which('tar')) { + return spawnArchive('tar', ['-c', '-z', '-v', '-f', destination, '-C', parent, ...names], onLog); + } + return Promise.resolve({ ok: false, message: 'No archive tool is installed.' }); +} + +function spawnArchive( + command: string, + args: string[], + onLog: (chunk: string) => void, + cwd?: string, +): Promise { + return new Promise((resolve) => { + onLog(`$ ${command} ${args.join(' ')}\n`); + // `zip` has no equivalent of tar's -C, so it is the one case that needs + // a working directory rather than a flag. + const child = start(command, args, { cwd }); + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => onLog(String(chunk))); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += String(chunk); + onLog(String(chunk)); + }); + child.on('error', (error) => { + stderr += error.message; + onLog(`\n${String(error)}\n`); + }); + child.on('close', (code) => { + resolve(code === 0 + ? { ok: true } + : { ok: false, message: stderr.trim().split('\n').pop() || `${command} exited with ${code ?? '?'}.` }); + }); + }); +} + +/** + * Pull one member out to a temporary directory so the editor can open it. + * + * The member is written under a per-archive temp directory, which is why the + * caller gets back a real path: a webview cannot be handed a path inside a zip. + */ +export async function extractOne(file: string, member: string): Promise { + const chosen = tool(file); + if (!chosen) { + return undefined; + } + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'vscodeos-archive-')); + + const result = chosen === 'unzip' + ? await run('unzip', ['-o', '--', file, member, '-d', scratch], { timeout: 60000 }) + : await run(chosen, ['-x', '-f', file, '-C', scratch, '--', member], { timeout: 60000 }); + + if (!result.ok) { + log.debug(`extracting ${member}: ${result.stderr.trim().split('\n')[0] ?? 'failed'}`); + return undefined; + } + + const extracted = path.join(scratch, member); + try { + await fs.access(extracted); + return extracted; + } catch { + return undefined; + } +} diff --git a/extension/src/sys/audio.ts b/extension/src/sys/audio.ts index b8ddd66..b6f090d 100644 --- a/extension/src/sys/audio.ts +++ b/extension/src/sys/audio.ts @@ -1,4 +1,4 @@ -// Volume, mute and device selection. +// Volume, mute and device selection, for the speakers and the microphone alike. // // wpctl (wireplumber) is the native control surface for the PipeWire stack both // images ship. pactl is kept as a fallback so the extension is also usable on a @@ -18,9 +18,12 @@ export interface AudioState { muted: boolean; sinks: AudioDevice[]; sources: AudioDevice[]; + /** The default microphone's gain and mute, absent when there is no input. */ + input?: VolumeLevel; } const SINK = '@DEFAULT_AUDIO_SINK@'; +const SOURCE = '@DEFAULT_AUDIO_SOURCE@'; function backend(): 'wpctl' | 'pactl' | undefined { if (which('wpctl')) { @@ -42,25 +45,28 @@ export async function getState(): Promise { const text = await output('wpctl', ['get-volume', SINK]); // "Volume: 0.65" or "Volume: 0.65 [MUTED]" const match = text ? /Volume:\s*([\d.]+)/.exec(text) : undefined; - const { sinks, sources } = await readWpctlDevices(); + const [{ sinks, sources }, input] = await Promise.all([readWpctlDevices(), getInputLevel()]); return { available: true, volume: match ? Math.round(Number(match[1]) * 100) : 0, muted: text !== undefined && text.includes('[MUTED]'), sinks, sources, + input: input.available ? input : undefined, }; } const volumeText = await output('pactl', ['get-sink-volume', '@DEFAULT_SINK@']); const muteText = await output('pactl', ['get-sink-mute', '@DEFAULT_SINK@']); const percent = volumeText ? /(\d+)%/.exec(volumeText) : undefined; + const input = await getInputLevel(); return { available: true, volume: percent ? Number(percent[1]) : 0, muted: muteText?.includes('yes') ?? false, sinks: await readPactlDevices('sinks'), sources: await readPactlDevices('sources'), + input: input.available ? input : undefined, }; } @@ -132,6 +138,68 @@ export async function setDefaultSink(id: string): Promise { } } +/** + * Pick the microphone. + * + * `wpctl set-default` takes any node id, so it is the same call as for a sink - + * which is why the ids from readWpctlDevices must not be tidied into something + * prettier. pactl needs the other verb, and there the id is a device name + * rather than an index; see readPactlDevices. + * + * This also decides which microphone the voice recorder uses, since that + * records from @DEFAULT_SOURCE@. + */ +export async function setDefaultSource(id: string): Promise { + if (which('wpctl')) { + await run('wpctl', ['set-default', id]); + } else if (which('pactl')) { + await run('pactl', ['set-default-source', id]); + } +} + +/** Microphone gain and mute: the input-side mirror of getVolume. */ +export async function getInputLevel(): Promise { + if (which('wpctl')) { + const text = await output('wpctl', ['get-volume', SOURCE], 4000); + const match = text ? /Volume:\s*([\d.]+)/.exec(text) : undefined; + return { + available: text !== undefined, + volume: match ? Math.round(Number(match[1]) * 100) : 0, + muted: text !== undefined && text.includes('[MUTED]'), + }; + } + if (which('pactl')) { + const volumeText = await output('pactl', ['get-source-volume', '@DEFAULT_SOURCE@'], 4000); + const muteText = await output('pactl', ['get-source-mute', '@DEFAULT_SOURCE@'], 4000); + const percent = volumeText ? /(\d+)%/.exec(volumeText) : undefined; + return { + available: volumeText !== undefined, + volume: percent ? Number(percent[1]) : 0, + muted: muteText?.includes('yes') ?? false, + }; + } + return { available: false, volume: 0, muted: true }; +} + +export async function setInputVolume(percent: number): Promise { + // Capped at 100 rather than the sink's 150: pushing a microphone past its + // hardware level raises the noise floor along with the voice. + const clamped = Math.max(0, Math.min(100, Math.round(percent))); + if (which('wpctl')) { + await run('wpctl', ['set-volume', SOURCE, `${clamped / 100}`]); + } else if (which('pactl')) { + await run('pactl', ['set-source-volume', '@DEFAULT_SOURCE@', `${clamped}%`]); + } +} + +export async function toggleInputMute(): Promise { + if (which('wpctl')) { + await run('wpctl', ['set-mute', SOURCE, 'toggle']); + } else if (which('pactl')) { + await run('pactl', ['set-source-mute', '@DEFAULT_SOURCE@', 'toggle']); + } +} + /** * `wpctl status` prints a tree; the sections we want are "Sinks:" and "Sources:" * under Audio, whose rows look like `│ * 47. Built-in Audio [vol: 0.65]`. diff --git a/extension/src/sys/display.ts b/extension/src/sys/display.ts index 9728c44..9927b89 100644 --- a/extension/src/sys/display.ts +++ b/extension/src/sys/display.ts @@ -1,4 +1,4 @@ -// Night light and energy saver. +// Outputs, modes, night light and energy saver. // // Both are done with what the images already have. Night light is an xrandr // gamma ramp on every connected output - the same thing redshift does, without @@ -8,8 +8,12 @@ // dims the backlight. import { output, run, which } from './exec'; +import { parseXrandrOutputs } from '../util/parse'; +import type { DisplayOutput, Rotation } from '../util/parse'; import * as backlight from './backlight'; +export type { DisplayOutput, DisplayMode, Rotation } from '../util/parse'; + /** Warm ramp, roughly 3500 K. Red is left alone; green and blue come down. */ const NIGHT_GAMMA = '1.0:0.82:0.65'; const DAY_GAMMA = '1.0:1.0:1.0'; @@ -19,23 +23,94 @@ let energySaverOn = false; let brightnessBeforeSaving: number | undefined; export function supportsNightLight(): boolean { - return which('xrandr') !== undefined && !!process.env.DISPLAY; + return isAvailable(); } export function isNightLightOn(): boolean { return nightLightOn; } -async function connectedOutputs(): Promise { - const text = await output('xrandr', ['--query'], 5000); - if (!text) { +export function isAvailable(): boolean { + return which('xrandr') !== undefined && !!process.env.DISPLAY; +} + +/** Every output xrandr knows about, with its modes. */ +export async function listOutputs(): Promise { + if (!isAvailable()) { return []; } - return text - .split('\n') - .filter((line) => / connected/.test(line)) - .map((line) => line.split(' ')[0]) - .filter(Boolean); + return parseXrandrOutputs(await output('xrandr', ['--query'], 5000)); +} + +async function connectedOutputs(): Promise { + return (await listOutputs()).filter((o) => o.connected).map((o) => o.name); +} + +export interface ModeChange { + output: string; + mode: string; + rate?: number; + rotation?: Rotation; + primary?: boolean; +} + +/** + * Apply a mode. Every argument is checked against what xrandr just reported + * rather than trusted: these strings arrive from a webview, and `run` refuses + * `shell: true`, but a bogus mode name still costs a blanked screen. + */ +export async function applyMode(change: ModeChange): Promise<{ ok: boolean; message?: string }> { + if (!isAvailable()) { + return { ok: false, message: 'xrandr is not available.' }; + } + const outputs = await listOutputs(); + const target = outputs.find((o) => o.name === change.output && o.connected); + if (!target) { + return { ok: false, message: `No connected output called ${change.output}.` }; + } + const mode = target.modes.find((m) => m.size === change.mode); + if (!mode) { + return { ok: false, message: `${change.output} does not offer ${change.mode}.` }; + } + + const args = ['--output', target.name, '--mode', mode.size]; + if (change.rate !== undefined && mode.rates.includes(change.rate)) { + args.push('--rate', String(change.rate)); + } + if (change.rotation) { + args.push('--rotate', change.rotation); + } + if (change.primary) { + args.push('--primary'); + } + + const result = await run('xrandr', args, { timeout: 15000 }); + return result.ok + ? { ok: true } + : { ok: false, message: result.stderr.trim().split('\n')[0] || 'xrandr refused the mode.' }; +} + +/** + * Put an output back exactly as it was. + * + * This is what makes a mode change safe to offer at all. The kiosk sets + * DontVTSwitch, so there is no console to escape to: a mode the monitor cannot + * display leaves the machine with nothing on screen and no way to undo it short + * of a power cycle. The settings app applies a change, asks whether it worked, + * and calls this when the answer does not arrive. + */ +export async function restoreOutput(previous: DisplayOutput): Promise { + if (!isAvailable() || !previous.currentMode) { + return; + } + const args = ['--output', previous.name, '--mode', previous.currentMode, '--rotate', previous.rotation]; + if (previous.currentRate !== undefined) { + args.push('--rate', String(previous.currentRate)); + } + if (previous.primary) { + args.push('--primary'); + } + await run('xrandr', args, { timeout: 15000 }); } export async function setNightLight(on: boolean): Promise { diff --git a/extension/src/sys/exec.ts b/extension/src/sys/exec.ts index f435696..7bc802c 100644 --- a/extension/src/sys/exec.ts +++ b/extension/src/sys/exec.ts @@ -56,9 +56,16 @@ export async function output(command: string, args: string[] = [], timeout?: num return result.ok ? result.stdout.trim() : undefined; } -/** Start a long-lived process and hand back the handle (recorder, playerctl --follow). */ -export function start(command: string, args: string[] = []): ChildProcess { - return spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); +/** + * Start a long-lived process and hand back the handle (recorder, + * playerctl --follow, pkexec of a helper). + * + * `cwd` exists for the handful of tools with no equivalent of tar's -C; it is + * still a real directory handed to spawn, not a shell doing a `cd`, so the + * no-shell rule at the top of this file holds. + */ +export function start(command: string, args: string[] = [], options: { cwd?: string } = {}): ChildProcess { + return spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], cwd: options.cwd }); } const whichCache = new Map(); diff --git a/extension/src/sys/firewall.ts b/extension/src/sys/firewall.ts new file mode 100644 index 0000000..084a774 --- /dev/null +++ b/extension/src/sys/firewall.ts @@ -0,0 +1,128 @@ +// The firewall, driven through one privileged helper. +// +// ufw needs root even to read its status - the rules are in /etc/ufw and the +// live ruleset is in the kernel - so there is no cheap unprivileged probe to +// poll with, the way the network tray polls nmcli. Everything goes through +// `pkexec /usr/local/bin/vscodeos-firewall`, which polkit answers without a +// prompt, and the app refreshes on open and after each change instead of on a +// timer. +// +// The helper is where the validation lives: it takes a fixed vocabulary and one +// checked argument. This module still refuses obvious nonsense before spawning +// anything, because a round trip through pkexec to be told "no" is a waste. + +import { run, start, which } from './exec'; +import { parseUfwStatus } from '../util/parse'; +import type { FirewallStatus } from '../util/parse'; +import { log } from '../log'; + +export type { FirewallStatus, FirewallRule, FirewallPolicy } from '../util/parse'; + +const HELPER = '/usr/local/bin/vscodeos-firewall'; + +/** A port, a range, either with a protocol, or an application profile name. */ +const PORT = /^\d{1,5}(:\d{1,5})?(\/(tcp|udp))?$/; +const PROFILE = /^[A-Za-z][A-Za-z0-9 ._+-]{0,63}$/; + +export function isAvailable(): boolean { + return which('ufw') !== undefined; +} + +export function canElevate(): boolean { + return which('pkexec') !== undefined; +} + +export function isValidSpec(spec: string): boolean { + if (PORT.test(spec)) { + return spec + .split('/')[0] + .split(':') + .every((part) => { + const port = Number(part); + return port >= 1 && port <= 65535; + }); + } + return PROFILE.test(spec); +} + +export interface FirewallResult { + ok: boolean; + message?: string; +} + +async function helper(args: string[], timeout = 60000): Promise<{ ok: boolean; stdout: string; stderr: string }> { + if (!canElevate()) { + return { ok: false, stdout: '', stderr: 'pkexec is not installed.' }; + } + const result = await run('pkexec', [HELPER, ...args], { timeout }); + return { ok: result.ok, stdout: result.stdout, stderr: result.stderr }; +} + +export async function getStatus(): Promise { + const result = await helper(['status'], 30000); + if (!result.ok) { + log.debug(`firewall status failed: ${result.stderr.trim()}`); + return undefined; + } + return parseUfwStatus(result.stdout); +} + +/** + * Run a change and stream its output, the way the updater streams pacman's. + * `onLog` sees the helper's own `==>` lines, so the app can show what happened + * rather than only whether it worked. + */ +export function apply(args: string[], onLog: (chunk: string) => void): Promise { + if (!canElevate()) { + return Promise.resolve({ ok: false, message: 'pkexec is not installed.' }); + } + return new Promise((resolve) => { + onLog(`$ pkexec ${HELPER} ${args.join(' ')}\n`); + const child = start('pkexec', [HELPER, ...args]); + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => onLog(String(chunk))); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += String(chunk); + onLog(String(chunk)); + }); + child.on('error', (error) => { + onLog(`\n${String(error)}\n`); + stderr += error.message; + }); + child.on('close', (code) => { + if (code === 0) { + resolve({ ok: true }); + return; + } + log.error(`vscodeos-firewall ${args[0]} exited with ${String(code)}`); + resolve({ + ok: false, + message: firstError(stderr) || `vscodeos-firewall exited with status ${code ?? 'unknown'}.`, + }); + }); + }); +} + +function firstError(text: string): string { + const line = text.trim().split('\n').find((l) => l.startsWith('error: ')); + return line ? line.slice('error: '.length) : text.trim().split('\n')[0] ?? ''; +} + +/** + * Whether anything is listening for incoming SSH. + * + * Used to warn before incoming traffic is denied: `openssh` is on both images, + * and someone administering a Pi over the network can lock themselves out of it + * with two clicks and no way back except a keyboard and a monitor. + */ +export async function sshIsListening(): Promise { + if (which('ss')) { + const result = await run('ss', ['-Hltn', 'sport = :22'], { timeout: 5000 }); + return result.ok && result.stdout.trim() !== ''; + } + if (which('systemctl')) { + const result = await run('systemctl', ['is-active', 'sshd.service'], { timeout: 5000 }); + return result.stdout.trim() === 'active'; + } + return false; +} diff --git a/extension/src/sys/keyboard.ts b/extension/src/sys/keyboard.ts new file mode 100644 index 0000000..2b951cf --- /dev/null +++ b/extension/src/sys/keyboard.ts @@ -0,0 +1,164 @@ +// Keyboard layout and key repeat. +// +// Two halves that are easy to confuse. `setxkbmap` changes the layout of the +// running X session and forgets it the moment the session ends; `localectl +// set-x11-keymap` writes /etc/X11/xorg.conf.d/00-keyboard.conf, which is what +// the next session reads. A settings pane has to do both, or the change either +// does not take effect now or does not survive a reboot. +// +// Persisting is privileged, but not through a helper script: localectl talks to +// systemd-localed over D-Bus, and polkit answers +// org.freedesktop.locale1.set-keyboard for this session +// (rootfs-common/etc/polkit-1/rules.d/49-vscodeos.rules). That keeps a +// password-free path to root down to one named systemd action rather than +// another program of ours. + +import { output, run, which } from './exec'; + +export interface KeyboardLayout { + /** The xkb code: "us", "gb", "de". */ + code: string; + /** Optional variant: "intl", "dvorak", "colemak". */ + variant?: string; +} + +export interface KeyboardState { + /** False when neither setxkbmap nor localectl is installed. */ + available: boolean; + /** True when the layout can be made to stick across reboots. */ + canPersist: boolean; + current?: KeyboardLayout; + /** Every layout code the X keyboard data offers, sorted. */ + layouts: string[]; + repeat: RepeatRate; +} + +export interface RepeatRate { + /** Milliseconds held before a key starts repeating. */ + delay: number; + /** Repeats per second once it starts. */ + rate: number; +} + +export const DEFAULT_REPEAT: RepeatRate = { delay: 500, rate: 25 }; + +export function isAvailable(): boolean { + return which('setxkbmap') !== undefined || which('localectl') !== undefined; +} + +export function canPersist(): boolean { + return which('localectl') !== undefined; +} + +/** + * `setxkbmap -query` is the honest answer for the running session: + * + * rules: evdev + * model: pc105 + * layout: gb + * variant: intl + */ +async function currentLayout(): Promise { + const text = which('setxkbmap') ? await output('setxkbmap', ['-query'], 4000) : undefined; + if (text) { + const layout = /^layout:\s*(\S+)/m.exec(text); + const variant = /^variant:\s*(\S+)/m.exec(text); + if (layout) { + // A comma-separated list means several layouts are loaded; the first + // is the active one and the rest are switched to with a hotkey we do + // not configure, so only the first is shown. + return { code: layout[1].split(',')[0], variant: variant?.[1].split(',')[0] }; + } + } + + // No X, or no setxkbmap: fall back to what the next session will use. + const status = await output('localectl', ['status'], 4000); + const layout = status ? /X11 Layout:\s*(\S+)/.exec(status) : undefined; + const variant = status ? /X11 Variant:\s*(\S+)/.exec(status) : undefined; + return layout ? { code: layout[1].split(',')[0], variant: variant?.[1].split(',')[0] } : undefined; +} + +export async function listLayouts(): Promise { + const text = await output('localectl', ['list-x11-keymap-layouts'], 8000); + return (text ?? '').split('\n').map((line) => line.trim()).filter(Boolean); +} + +export async function listVariants(layout: string): Promise { + if (!/^[a-z0-9_-]{1,32}$/i.test(layout)) { + return []; + } + const text = await output('localectl', ['list-x11-keymap-variants', layout], 8000); + return (text ?? '').split('\n').map((line) => line.trim()).filter(Boolean); +} + +export async function getState(repeat: RepeatRate): Promise { + if (!isAvailable()) { + return { available: false, canPersist: false, layouts: [], repeat }; + } + const [current, layouts] = await Promise.all([currentLayout(), listLayouts()]); + return { available: true, canPersist: canPersist(), current, layouts, repeat }; +} + +/** xkb codes are short and alphanumeric; anything else never reaches a command. */ +function validLayout(layout: KeyboardLayout): boolean { + return /^[a-z0-9_-]{1,32}$/i.test(layout.code) + && (!layout.variant || /^[a-z0-9_-]{1,32}$/i.test(layout.variant)); +} + +export interface KeyboardResult { + ok: boolean; + message?: string; + /** True when the change will survive a reboot as well as applying now. */ + persisted?: boolean; +} + +export async function setLayout(layout: KeyboardLayout): Promise { + if (!validLayout(layout)) { + return { ok: false, message: 'That is not a valid keyboard layout name.' }; + } + + let applied = false; + if (which('setxkbmap') && process.env.DISPLAY) { + const args = ['-layout', layout.code, ...(layout.variant ? ['-variant', layout.variant] : [])]; + const result = await run('setxkbmap', args, { timeout: 8000 }); + if (!result.ok) { + return { ok: false, message: result.stderr.trim().split('\n')[0] || 'setxkbmap refused the layout.' }; + } + applied = true; + } + + let persisted = false; + if (canPersist()) { + // Model is left as the empty string so localed keeps whatever the + // installer wrote; only the layout and variant are ours to set. + const result = await run( + 'localectl', + ['set-x11-keymap', layout.code, '', layout.variant ?? ''], + { timeout: 15000 }, + ); + persisted = result.ok; + if (!result.ok && !applied) { + return { + ok: false, + message: result.stderr.trim().split('\n')[0] || 'localectl could not set the keymap.', + }; + } + } + + return { ok: applied || persisted, persisted }; +} + +/** + * `xset r rate `. Session-only - there is no system-wide place + * for it - so the settings app stores the numbers and reapplies them on + * activation. + */ +export async function setRepeat(repeat: RepeatRate): Promise { + if (!which('xset') || !process.env.DISPLAY) { + return false; + } + const delay = Math.max(100, Math.min(2000, Math.round(repeat.delay))); + const rate = Math.max(1, Math.min(110, Math.round(repeat.rate))); + const result = await run('xset', ['r', 'rate', String(delay), String(rate)], { timeout: 5000 }); + return result.ok; +} diff --git a/extension/src/sys/notifications.ts b/extension/src/sys/notifications.ts new file mode 100644 index 0000000..0eba9b3 --- /dev/null +++ b/extension/src/sys/notifications.ts @@ -0,0 +1,388 @@ +// The desktop's notification daemon, which is the editor. +// +// On a normal Linux desktop something owns the `org.freedesktop.Notifications` +// bus name - dunst, mako, GNOME Shell - and every `notify-send`, every finished +// download and every calendar reminder becomes a bubble in the corner. VS Code +// OS ships no such daemon, so until now all of that went nowhere: the call +// failed, silently, and the user never learned there was anything to know. +// +// The editor already has a notification UI, so this owns the name itself and +// turns each `Notify` call into a VS Code message. That makes VsCodeOsCore a +// real notification server rather than an observer, which is why it speaks the +// protocol properly - `dbus-monitor` can watch traffic to a daemon but cannot +// answer a method call, and the whole point here is that there is no daemon. +// +// The one rule: if something else already owns the name, walk away. Two servers +// fighting over it is worse than none. + +import * as vscode from 'vscode'; +import { EventEmitter } from 'node:events'; +import { existsSync } from 'node:fs'; +import * as dbus from 'dbus-next'; +import { notificationText, parseActions, urgencyOf } from '../util/notify'; +import type { Urgency } from '../util/notify'; +import { log } from '../log'; + +const BUS_NAME = 'org.freedesktop.Notifications'; +const OBJECT_PATH = '/org/freedesktop/Notifications'; + +/** Reasons in `NotificationClosed`, from the spec. */ +const CLOSED_EXPIRED = 1; +const CLOSED_DISMISSED = 2; +const CLOSED_BY_CALL = 3; + +export interface NotificationRecord { + id: number; + /** The sending application's own name for itself; may be empty. */ + appName: string; + summary: string; + body: string; + /** The one line that was shown, summary and body already flattened. */ + text: string; + urgency: Urgency; + /** Milliseconds since the epoch. */ + at: number; + read: boolean; +} + +/** + * The exported D-Bus object. + * + * Split from `NotificationServer` because `Interface` subclasses have to call + * `configureMembers` at class-definition time, and every method on them is part + * of the wire protocol - there is nowhere to keep the extension's own state. + */ +class NotificationsInterface extends dbus.interface.Interface { + constructor( + private readonly onNotify: ( + appName: string, + replacesId: number, + summary: string, + body: string, + actions: string[], + hints: Record, + expireTimeout: number, + ) => number, + private readonly onClose: (id: number) => void, + private readonly version: string, + ) { + super(BUS_NAME); + } + + Notify( + appName: string, + replacesId: number, + _appIcon: string, + summary: string, + body: string, + actions: string[], + hints: Record, + expireTimeout: number, + ): number { + return this.onNotify(appName, replacesId, summary, body, actions, hints, expireTimeout); + } + + CloseNotification(id: number): void { + this.onClose(id); + } + + /** + * Deliberately not `body-markup` or `body-hyperlinks`: a VS Code message is + * plain text, so claiming to render markup would leave senders emitting + * angle brackets the user then has to read. `persistence` is honest - the + * notification centre card keeps them after the toast is gone. + */ + GetCapabilities(): string[] { + return ['body', 'actions', 'persistence']; + } + + GetServerInformation(): string[] { + return ['VsCodeOsCore', 'VS Code OS', this.version, '1.2']; + } + + NotificationClosed(id: number, reason: number): number[] { + return [id, reason]; + } + + ActionInvoked(id: number, actionKey: string): [number, string] { + return [id, actionKey]; + } +} + +NotificationsInterface.configureMembers({ + methods: { + Notify: { inSignature: 'susssasa{sv}i', outSignature: 'u' }, + CloseNotification: { inSignature: 'u', outSignature: '' }, + GetCapabilities: { inSignature: '', outSignature: 'as' }, + GetServerInformation: { inSignature: '', outSignature: 'ssss' }, + }, + signals: { + NotificationClosed: { signature: 'uu' }, + ActionInvoked: { signature: 'us' }, + }, +}); + +/** + * Where the session bus is, resolved here rather than left to dbus-next. + * + * Its own search ends at an X11 window-selection lookup, which needs the `x11` + * package that is not installed - so an unset variable would surface as a + * module-not-found rather than as "there is no bus". Both of the two ways a VS + * Code OS session gets a bus are covered: logind exports the variable, and + * vscodeos-kiosk's `dbus-launch` fallback exports it too. + */ +function sessionBusAddress(): string | undefined { + const configured = process.env.DBUS_SESSION_BUS_ADDRESS; + if (configured) { + return configured; + } + // The systemd user bus, in case the variable was lost between the login + // shell and the extension host. + const runtime = process.env.XDG_RUNTIME_DIR; + return runtime && existsSync(`${runtime}/bus`) ? `unix:path=${runtime}/bus` : undefined; +} + +/** One notification that is still on screen, so a later call can close it. */ +interface Live { + record: NotificationRecord; + /** Resolves the shown message early when the sender closes it first. */ + close: (reason: number) => void; + timer?: NodeJS.Timeout; +} + +export class NotificationServer extends EventEmitter { + private bus: dbus.MessageBus | undefined; + private iface: NotificationsInterface | undefined; + private readonly live = new Map(); + private history: NotificationRecord[] = []; + private nextId = 1; + + constructor(private readonly version: string, private readonly historyLimit = 50) { + super(); + } + + get records(): NotificationRecord[] { + return this.history; + } + + get unread(): number { + return this.history.filter((record) => !record.read).length; + } + + get running(): boolean { + return this.iface !== undefined; + } + + /** + * Claim the bus name and export the object. + * + * `DO_NOT_QUEUE` matters: without it a losing request sits in a queue and we + * would silently become the daemon the moment a real one exited, halfway + * through that other daemon's lifetime. Better to lose once and stay lost. + */ + async start(): Promise { + if (this.bus) { + return; + } + const address = sessionBusAddress(); + if (!address) { + log.info('notifications: no session bus to connect to, not starting'); + return; + } + + try { + const bus = dbus.sessionBus({ busAddress: address }); + this.bus = bus; + bus.on('error', (error: Error) => log.debug(`notification bus: ${error.message}`)); + + const reply = await bus.requestName(BUS_NAME, dbus.NameFlag.DO_NOT_QUEUE); + if (reply !== dbus.RequestNameReply.PRIMARY_OWNER && reply !== dbus.RequestNameReply.ALREADY_OWNER) { + log.info(`notifications: ${BUS_NAME} is owned by another daemon, leaving it alone`); + bus.disconnect(); + this.bus = undefined; + return; + } + + this.iface = new NotificationsInterface( + (appName, replacesId, summary, body, actions, hints, expireTimeout) => + this.notify(appName, replacesId, summary, body, actions, hints, expireTimeout), + (id) => this.closeFromCaller(id), + this.version, + ); + bus.export(OBJECT_PATH, this.iface); + log.info(`notifications: serving ${BUS_NAME}`); + } catch (error) { + log.error('notifications: could not take the bus name', error); + this.bus?.disconnect(); + this.bus = undefined; + this.iface = undefined; + } + } + + // -------------------------------------------------------------- incoming + + private notify( + appName: string, + replacesId: number, + summary: string, + body: string, + actions: string[], + hints: Record, + expireTimeout: number, + ): number { + // A non-zero replaces_id means "this supersedes that one"; the spec says + // the id is reused, so the old message is retired first. + const id = replacesId > 0 ? replacesId : this.nextId++; + if (replacesId > 0) { + this.retire(replacesId, CLOSED_BY_CALL); + this.nextId = Math.max(this.nextId, replacesId + 1); + } + + const record: NotificationRecord = { + id, + appName: appName || 'Application', + summary, + body, + text: notificationText(summary, body), + urgency: urgencyOf(hints), + at: Date.now(), + read: false, + }; + + this.remember(record); + void this.present(record, parseActions(actions), expireTimeout); + return id; + } + + private async present( + record: NotificationRecord, + actions: { key: string; label: string }[], + expireTimeout: number, + ): Promise { + let settled = false; + const finish = (reason: number): void => { + if (settled) { + return; + } + settled = true; + this.retire(record.id, reason); + }; + + const early = new Promise((resolve) => { + this.live.set(record.id, { + record, + close: (reason) => { + finish(reason); + resolve(undefined); + }, + // A positive expire_timeout is in milliseconds. VS Code owns how + // long its own toast stays up, so this only decides when the + // sender is told the notification lapsed. + timer: expireTimeout > 0 + ? setTimeout(() => { + finish(CLOSED_EXPIRED); + resolve(undefined); + }, expireTimeout) + : undefined, + }); + }); + + const show = record.urgency === 'critical' + ? vscode.window.showWarningMessage(record.text, ...actions.map((a) => a.label)) + : vscode.window.showInformationMessage(record.text, ...actions.map((a) => a.label)); + + const choice = await Promise.race([show, early]); + record.read = true; + + if (choice) { + const action = actions.find((a) => a.label === choice); + if (action) { + this.emitSignal(() => this.iface?.ActionInvoked(record.id, action.key)); + } + } + finish(CLOSED_DISMISSED); + this.emit('change'); + } + + private closeFromCaller(id: number): void { + const entry = this.live.get(id); + if (entry) { + entry.close(CLOSED_BY_CALL); + } + } + + /** Drop a live notification and tell the sender why it went. */ + private retire(id: number, reason: number): void { + const entry = this.live.get(id); + if (!entry) { + return; + } + if (entry.timer) { + clearTimeout(entry.timer); + } + this.live.delete(id); + this.emitSignal(() => this.iface?.NotificationClosed(id, reason)); + } + + /** + * A signal that cannot be delivered must not take the daemon down with it - + * the sender may well have exited between the call and the reply. + */ + private emitSignal(emit: () => void): void { + try { + emit(); + } catch (error) { + log.debug(`notification signal failed: ${String(error)}`); + } + } + + // --------------------------------------------------------------- history + + private remember(record: NotificationRecord): void { + // A replacement takes the old one's place rather than sitting next to + // it. Ids are reused by design when replaces_id is set, and two rows + // sharing one id would mean dismissing either removed both. + this.history = [record, ...this.history.filter((old) => old.id !== record.id)] + .slice(0, Math.max(1, this.historyLimit)); + this.emit('change'); + } + + markAllRead(): void { + if (this.unread === 0) { + return; + } + this.history = this.history.map((record) => ({ ...record, read: true })); + this.emit('change'); + } + + dismiss(id: number): void { + this.history = this.history.filter((record) => record.id !== id); + this.live.get(id)?.close(CLOSED_DISMISSED); + this.emit('change'); + } + + clear(): void { + this.history = []; + this.emit('change'); + } + + dispose(): void { + for (const entry of [...this.live.values()]) { + if (entry.timer) { + clearTimeout(entry.timer); + } + } + this.live.clear(); + this.removeAllListeners(); + if (this.bus && this.iface) { + try { + this.bus.unexport(OBJECT_PATH, this.iface); + } catch { + /* the bus may already be gone */ + } + } + this.bus?.disconnect(); + this.bus = undefined; + this.iface = undefined; + } +} diff --git a/extension/src/sys/storage.ts b/extension/src/sys/storage.ts new file mode 100644 index 0000000..70cf7e1 --- /dev/null +++ b/extension/src/sys/storage.ts @@ -0,0 +1,391 @@ +// Storage Sense: what is using the disk, and what is safe to delete. +// +// Two questions, answered by two different mechanisms. "How full is each disk" +// is `fs.statfs` over the mountpoints lsblk reports. "What inside my home is +// big" is `du -x -d 2`, because walking a home directory in Node means tens of +// thousands of round trips and `du` is in `base` on both images. +// +// The cleanup half is deliberately a fixed list. Nothing here takes a path from +// the user and deletes it: every target is a constant in this file, so the worst +// a confused caller can do is empty a cache that is meant to be emptiable. The +// three that need root go through /usr/local/bin/vscodeos-clean, which takes one +// word from a fixed vocabulary - the same shape as the updater's helper. + +import { promises as fs, statfs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { output, run, which } from './exec'; +import { log } from '../log'; + +const CLEAN_HELPER = '/usr/local/bin/vscodeos-clean'; + +export interface MountUsage { + mountpoint: string; + label?: string; + totalBytes: number; + freeBytes: number; + usedBytes: number; + removable: boolean; +} + +export interface DirectoryUsage { + path: string; + name: string; + bytes: number; +} + +export type CleanupId = + | 'user-cache' + | 'thumbnails' + | 'code-cache' + | 'chromium-cache' + | 'npm-cache' + | 'tmp' + | 'trash' + | 'pacman-cache' + | 'journal' + | 'orphans'; + +export interface CleanupCategory { + id: CleanupId; + title: string; + description: string; + bytes: number; + /** Needs root, so it goes through pkexec and the helper. */ + privileged: boolean; + /** False when the tool it needs is not installed on this machine. */ + available: boolean; +} + +export interface StorageState { + mounts: MountUsage[]; + home: DirectoryUsage[]; + categories: CleanupCategory[]; + /** True when the privileged rows can actually be acted on. */ + canElevate: boolean; +} + +/** Where each unprivileged category lives. Constants, never user input. */ +function userTargets(): Record { + const home = os.homedir(); + return { + 'user-cache': [path.join(home, '.cache')], + thumbnails: [path.join(home, '.cache', 'thumbnails')], + 'code-cache': [ + path.join(home, '.config', 'Code', 'Cache'), + path.join(home, '.config', 'Code', 'CachedData'), + path.join(home, '.config', 'Code', 'Code Cache'), + path.join(home, '.config', 'Code', 'GPUCache'), + path.join(home, '.config', 'Code', 'logs'), + ], + 'chromium-cache': [path.join(home, '.cache', 'chromium')], + 'npm-cache': [path.join(home, '.npm', '_cacache')], + trash: [path.join(home, '.local', 'share', 'Trash')], + }; +} + +// ------------------------------------------------------------------- reading + +export async function mounts(): Promise { + const text = await output('lsblk', ['-nrpo', 'MOUNTPOINT,LABEL,RM'], 5000); + const seen = new Set(); + const found: MountUsage[] = []; + + for (const line of (text ?? '').split('\n')) { + const [mountpoint, label, removable] = line.split(' '); + if (!mountpoint || mountpoint === '[SWAP]' || seen.has(mountpoint)) { + continue; + } + seen.add(mountpoint); + const usage = await usageOf(mountpoint); + if (usage) { + found.push({ + mountpoint, + label: label || undefined, + removable: removable === '1', + ...usage, + }); + } + } + + // A machine whose lsblk says nothing useful - a container, an overlay root - + // should still see how full "/" is. + if (found.length === 0) { + const usage = await usageOf('/'); + if (usage) { + found.push({ mountpoint: '/', removable: false, ...usage }); + } + } + return found; +} + +function usageOf(mountpoint: string): Promise<{ totalBytes: number; freeBytes: number; usedBytes: number } | undefined> { + return new Promise((resolve) => { + statfs(mountpoint, (error, stats) => { + if (error || !stats || stats.blocks === 0) { + resolve(undefined); + return; + } + const total = Number(stats.blocks) * Number(stats.bsize); + // bavail, not bfree: the reserved blocks are not space anyone can use. + const free = Number(stats.bavail) * Number(stats.bsize); + resolve({ totalBytes: total, freeBytes: free, usedBytes: total - free }); + }); + }); +} + +/** + * The biggest things under a directory, two levels down. + * + * `-x` keeps it on one filesystem, so a mounted USB stick under the home + * directory is not counted twice, and `du` is left to do the walking because a + * Node recursion over a real home directory is thousands of stat calls. + */ +export async function largestDirectories(root = os.homedir(), limit = 12): Promise { + if (!which('du')) { + return []; + } + const result = await run('du', ['-x', '-d', '2', '-B1', root], { timeout: 60000 }); + // du exits non-zero when it could not read something, which is routine and + // does not invalidate the lines it did produce. + const entries: DirectoryUsage[] = []; + for (const line of result.stdout.split('\n')) { + const tab = line.indexOf('\t'); + if (tab < 1) { + continue; + } + const bytes = Number(line.slice(0, tab)); + const full = line.slice(tab + 1); + if (!Number.isFinite(bytes) || full === root) { + continue; + } + entries.push({ path: full, name: path.relative(root, full) || path.basename(full), bytes }); + } + + // Keep only the deepest interesting rows: a parent and its one big child say + // the same thing twice, and the child is the more useful of the two. + entries.sort((a, b) => b.bytes - a.bytes); + const kept: DirectoryUsage[] = []; + for (const entry of entries) { + if (kept.some((k) => entry.path.startsWith(`${k.path}${path.sep}`))) { + continue; + } + kept.push(entry); + if (kept.length >= limit) { + break; + } + } + return kept; +} + +async function sizeOf(targets: string[]): Promise { + if (!which('du')) { + return 0; + } + let total = 0; + for (const target of targets) { + const result = await run('du', ['-sx', '-B1', target], { timeout: 30000 }); + const bytes = Number(result.stdout.split('\t')[0]); + if (Number.isFinite(bytes)) { + total += bytes; + } + } + return total; +} + +/** Files directly in /tmp that this user owns. Nothing recursive, nothing else's. */ +async function tmpSize(): Promise { + let total = 0; + try { + const uid = os.userInfo().uid; + for (const entry of await fs.readdir('/tmp')) { + const full = path.join('/tmp', entry); + try { + const stat = await fs.lstat(full); + if (stat.uid === uid) { + total += stat.size; + } + } catch { + /* vanished between readdir and lstat, or not ours to stat */ + } + } + } catch (error) { + log.debug(`storage: could not measure /tmp: ${String(error)}`); + } + return total; +} + +/** + * What the helper reports for the privileged categories. + * `vscodeos-clean report` prints " " lines. + */ +async function privilegedSizes(): Promise> { + if (!which('pkexec')) { + return {}; + } + const result = await run('pkexec', [CLEAN_HELPER, 'report'], { timeout: 60000 }); + const sizes: Record = {}; + for (const line of result.stdout.split('\n')) { + const [id, bytes] = line.trim().split(/\s+/); + if (id && Number.isFinite(Number(bytes))) { + sizes[id] = Number(bytes); + } + } + return sizes; +} + +export async function getState(): Promise { + const targets = userTargets(); + const canElevate = which('pkexec') !== undefined; + + const [mountList, home, sizes, tmpBytes, privileged] = await Promise.all([ + mounts(), + largestDirectories(), + Promise.all( + Object.entries(targets).map(async ([id, paths]) => [id, await sizeOf(paths)] as const), + ), + tmpSize(), + canElevate ? privilegedSizes() : Promise.resolve({} as Record), + ]); + + const userBytes = Object.fromEntries(sizes); + const categories: CleanupCategory[] = [ + { + id: 'user-cache', + title: 'Application cache', + description: '~/.cache — rebuilt on demand by whatever put it there', + bytes: userBytes['user-cache'] ?? 0, + privileged: false, + available: true, + }, + { + id: 'thumbnails', + title: 'Thumbnails', + description: 'Preview images for files you have browsed', + bytes: userBytes.thumbnails ?? 0, + privileged: false, + available: true, + }, + { + id: 'code-cache', + title: 'Editor cache', + description: "VS Code's own caches and logs, not your settings", + bytes: userBytes['code-cache'] ?? 0, + privileged: false, + available: true, + }, + { + id: 'chromium-cache', + title: 'Browser cache', + description: 'Chromium page cache; history and logins are untouched', + bytes: userBytes['chromium-cache'] ?? 0, + privileged: false, + available: true, + }, + { + id: 'npm-cache', + title: 'npm cache', + description: 'Downloaded packages, re-fetched when next needed', + bytes: userBytes['npm-cache'] ?? 0, + privileged: false, + available: true, + }, + { + id: 'tmp', + title: 'Temporary files', + description: 'Your own files in /tmp', + bytes: tmpBytes, + privileged: false, + available: true, + }, + { + id: 'trash', + title: 'Recycle Bin', + description: 'Everything you have deleted and not yet restored', + bytes: userBytes.trash ?? 0, + privileged: false, + available: true, + }, + { + id: 'pacman-cache', + title: 'Package cache', + description: 'Downloaded packages, keeping the most recent of each', + bytes: privileged['pacman-cache'] ?? 0, + privileged: true, + available: canElevate, + }, + { + id: 'journal', + title: 'System logs', + description: 'Trims the systemd journal to 50 MB', + bytes: privileged.journal ?? 0, + privileged: true, + available: canElevate, + }, + { + id: 'orphans', + title: 'Orphaned packages', + description: 'Packages pulled in as dependencies and no longer needed', + bytes: privileged.orphans ?? 0, + privileged: true, + available: canElevate, + }, + ]; + + return { mounts: mountList, home, categories, canElevate }; +} + +// ------------------------------------------------------------------ cleaning + +/** Empty a directory without removing the directory itself. */ +async function emptyDirectory(target: string): Promise { + let names: string[]; + try { + names = await fs.readdir(target); + } catch { + return; // never existed, which is the same as already clean + } + for (const name of names) { + await fs.rm(path.join(target, name), { recursive: true, force: true }).catch((error: unknown) => { + log.debug(`storage: could not remove ${name}: ${String(error)}`); + }); + } +} + +/** + * Run one unprivileged cleanup. Returns false for the privileged ids, which the + * caller has to send through the helper instead. + */ +export async function cleanUser(id: CleanupId): Promise { + const targets = userTargets(); + + if (id === 'tmp') { + const uid = os.userInfo().uid; + for (const entry of await fs.readdir('/tmp').catch(() => [] as string[])) { + const full = path.join('/tmp', entry); + try { + if ((await fs.lstat(full)).uid === uid) { + await fs.rm(full, { recursive: true, force: true }); + } + } catch { + /* not ours, or gone already */ + } + } + return true; + } + + const paths = targets[id]; + if (!paths) { + return false; + } + for (const target of paths) { + await emptyDirectory(target); + } + return true; +} + +export function isPrivileged(id: CleanupId): boolean { + return id === 'pacman-cache' || id === 'journal' || id === 'orphans'; +} + +export const cleanHelper = CLEAN_HELPER; diff --git a/extension/src/sys/trash.ts b/extension/src/sys/trash.ts new file mode 100644 index 0000000..fc91afe --- /dev/null +++ b/extension/src/sys/trash.ts @@ -0,0 +1,194 @@ +// The Recycle Bin. +// +// The file explorer has always been able to put things in the trash - it calls +// `vscode.workspace.fs.delete({ useTrash: true })`, which goes through Electron +// to the freedesktop trash under ~/.local/share/Trash. Nothing has ever read +// them back, so "Move to trash" has been a slower, more reassuring-sounding +// delete. +// +// The spec is simple: files/ holds the thing, info/.trashinfo holds +// where it came from and when it went. Restoring is moving one back and +// deleting the other. The two directories can disagree - a crash between the +// two writes, or someone tidying by hand - so an entry with no info file is +// still listed, just without an original location, and an info file with no +// file is dropped. + +import { EventEmitter } from 'node:events'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { parseTrashInfo } from '../util/parse'; +import { log } from '../log'; + +/** + * The virtual path the Recycle Bin lives at. + * + * A sentinel rather than the real directory: browsing ~/.local/share/Trash/files + * shows mangled names, no original locations and no way to restore anything, and + * the file explorer would happily let you rename things in it. + */ +export const TRASH_PATH = 'trash://'; + +export interface TrashEntry { + /** Name inside files/, which is also the key for restore and remove. */ + name: string; + /** Absolute path in the trash, for stat and rm. */ + path: string; + /** Where it came from, absent when the .trashinfo is missing or unreadable. */ + originalPath?: string; + deletedAt?: number; + isDirectory: boolean; + size: number; +} + +function trashRoot(): string { + const dataHome = process.env.XDG_DATA_HOME; + return dataHome + ? path.join(dataHome, 'Trash') + : path.join(os.homedir(), '.local', 'share', 'Trash'); +} + +export class TrashService extends EventEmitter { + private get filesDir(): string { + return path.join(trashRoot(), 'files'); + } + + private get infoDir(): string { + return path.join(trashRoot(), 'info'); + } + + async list(): Promise { + let names: string[]; + try { + names = await fs.readdir(this.filesDir); + } catch { + // No trash directory means nothing has ever been deleted. + return []; + } + + const entries = await Promise.all(names.map((name) => this.describe(name))); + return entries + .filter((entry): entry is TrashEntry => entry !== undefined) + .sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0)); + } + + private async describe(name: string): Promise { + const full = path.join(this.filesDir, name); + let isDirectory = false; + let size = 0; + try { + // lstat, not stat: a trashed symlink should be listed as what it is + // rather than followed to something outside the bin. + const stat = await fs.lstat(full); + isDirectory = stat.isDirectory(); + size = stat.size; + } catch { + return undefined; + } + + let info; + try { + info = parseTrashInfo(await fs.readFile(path.join(this.infoDir, `${name}.trashinfo`), 'utf8')); + } catch { + info = undefined; + } + + return { name, path: full, originalPath: info?.path, deletedAt: info?.deletedAt, isDirectory, size }; + } + + async isEmpty(): Promise { + try { + return (await fs.readdir(this.filesDir)).length === 0; + } catch { + return true; + } + } + + /** + * Put things back where they came from. + * + * An entry whose original location is gone, or whose .trashinfo was lost, + * cannot be restored automatically - there is nowhere to put it - so the + * caller is told which ones were skipped rather than having them silently + * vanish or land somewhere arbitrary. + */ + async restore(names: string[]): Promise<{ restored: number; skipped: string[] }> { + const skipped: string[] = []; + let restored = 0; + + for (const name of names) { + const entry = await this.describe(name); + if (!entry?.originalPath) { + skipped.push(name); + continue; + } + try { + await fs.mkdir(path.dirname(entry.originalPath), { recursive: true }); + const destination = await uniqueName(entry.originalPath); + await fs.rename(entry.path, destination); + await fs.rm(path.join(this.infoDir, `${name}.trashinfo`), { force: true }); + restored += 1; + } catch (error) { + log.error(`could not restore ${name}`, error); + skipped.push(name); + } + } + + this.emit('change'); + return { restored, skipped }; + } + + async remove(names: string[]): Promise { + for (const name of names) { + await fs.rm(path.join(this.filesDir, name), { recursive: true, force: true }); + await fs.rm(path.join(this.infoDir, `${name}.trashinfo`), { force: true }); + } + this.emit('change'); + } + + async empty(): Promise { + for (const directory of [this.filesDir, this.infoDir]) { + let names: string[]; + try { + names = await fs.readdir(directory); + } catch { + continue; + } + for (const name of names) { + await fs.rm(path.join(directory, name), { recursive: true, force: true }).catch((error: unknown) => { + log.debug(`could not empty ${name}: ${String(error)}`); + }); + } + } + this.emit('change'); + } + + /** Something outside this module put a file in the bin; redraw both views. */ + notifyChanged(): void { + this.emit('change'); + } +} + +/** + * "report.txt" -> "report (1).txt" when the original location is occupied + * again. Restoring must never overwrite whatever took the old name. + */ +export async function uniqueName(target: string): Promise { + try { + await fs.access(target); + } catch { + return target; + } + const directory = path.dirname(target); + const extension = path.extname(target); + const base = path.basename(target, extension); + for (let i = 1; i < 1000; i++) { + const candidate = path.join(directory, `${base} (${i})${extension}`); + try { + await fs.access(candidate); + } catch { + return candidate; + } + } + return `${target}.${Date.now()}`; +} diff --git a/extension/src/sys/usocket.ts b/extension/src/sys/usocket.ts new file mode 100644 index 0000000..8373702 --- /dev/null +++ b/extension/src/sys/usocket.ts @@ -0,0 +1,39 @@ +// A stand-in for the `usocket` package, which dbus-next reaches for and which +// is not installed here. +// +// usocket is a native addon whose reason to exist is file-descriptor passing +// over a Unix socket, plus abstract-namespace addresses. We need neither: fd +// passing is only used when a caller asks for `negotiateUnixFd`, which nothing +// here does, and Node's own `net` has supported Linux abstract sockets - a path +// beginning with a NUL - for years. +// +// This matters because of how the session bus gets started. When logind is +// driving the session the address is `unix:path=/run/user//bus` and +// dbus-next's fallback would have been fine; but vscodeos-kiosk falls back to +// `dbus-launch` when the login session provided no bus, and dbus-launch hands +// back `unix:abstract=/tmp/dbus-XXXXXX`. dbus-next's abstract branch calls +// `require('usocket')` with no guard around it, so on exactly the machines that +// needed the fallback the notification server would have failed to connect. +// +// esbuild.mjs aliases `usocket` to this file, so dbus-next gets a working +// USocket for both address forms and the extension ships no native code. + +import * as net from 'node:net'; + +export interface USocketOptions { + /** A filesystem path, or one prefixed with NUL for the abstract namespace. */ + path: string; +} + +export class USocket extends net.Socket { + /** + * dbus-next assigns this after construction and branches on it: false means + * "write a plain Buffer", which is the only thing this shim supports. + */ + supportsUnixFd = false; + + constructor(options: USocketOptions) { + super(); + this.connect({ path: options.path }); + } +} diff --git a/extension/src/util/archive.ts b/extension/src/util/archive.ts new file mode 100644 index 0000000..b0f331f --- /dev/null +++ b/extension/src/util/archive.ts @@ -0,0 +1,255 @@ +// What counts as an archive. +// +// Shared by the file explorer, which routes these into the archive browser +// instead of to `vscode.open`, and by sys/archive.ts, which picks a destination +// name when compressing. Kept here rather than in either so it can be tested, +// and so the two cannot disagree about what a ".tar.gz" is. +// +// The double extensions are the whole reason this is not a one-line Set lookup: +// `name.slice(name.lastIndexOf('.'))` reads ".tar.gz" as ".gz", which is a +// different format with a different `bsdtar` invocation and a different +// stripped-down name once it is unpacked. + +export type ArchiveKind = + | 'zip' + | 'tar' + | 'tar.gz' + | 'tar.bz2' + | 'tar.xz' + | 'tar.zst' + | '7z' + | 'rar' + | 'gz' + | 'bz2' + | 'xz' + | 'zst'; + +/** Longest suffix first: ".tar.gz" has to win over ".gz". */ +const SUFFIXES: [string, ArchiveKind][] = [ + ['.tar.gz', 'tar.gz'], + ['.tar.bz2', 'tar.bz2'], + ['.tar.xz', 'tar.xz'], + ['.tar.zst', 'tar.zst'], + ['.tgz', 'tar.gz'], + ['.tbz2', 'tar.bz2'], + ['.tbz', 'tar.bz2'], + ['.txz', 'tar.xz'], + ['.tzst', 'tar.zst'], + ['.zip', 'zip'], + ['.jar', 'zip'], + ['.whl', 'zip'], + ['.tar', 'tar'], + ['.7z', '7z'], + ['.rar', 'rar'], + ['.gz', 'gz'], + ['.bz2', 'bz2'], + ['.xz', 'xz'], + ['.zst', 'zst'], +]; + +/** + * The archive format a name implies, or undefined when it implies none. + * + * A leading dot is a hidden file and not an extension, so ".gz" on its own is + * not an archive - the same rule util/media.ts applies. + */ +export function archiveKind(file: string): ArchiveKind | undefined { + const name = file.slice(file.lastIndexOf('/') + 1).toLowerCase(); + for (const [suffix, kind] of SUFFIXES) { + // `> 0` rather than `>= 0`: ".zip" as a whole name is a hidden file. + if (name.length > suffix.length && name.endsWith(suffix)) { + return kind; + } + } + return undefined; +} + +/** + * True when the format holds several files rather than one compressed stream. + * + * A plain ".gz" is a single file with its name shortened, so browsing it as a + * directory would show exactly one entry and an "extract here" is the only + * sensible action. + */ +export function isMultiFileArchive(kind: ArchiveKind): boolean { + return kind !== 'gz' && kind !== 'bz2' && kind !== 'xz' && kind !== 'zst'; +} + +export interface ArchiveEntry { + /** Path inside the archive, normalised: no leading "./", no trailing slash. */ + path: string; + isDirectory: boolean; + /** Uncompressed size; 0 when the listing did not say. */ + size: number; +} + +/** + * A verbose archive listing, from whichever tool produced it. + * + * bsdtar is what both images have, and it prints an ls-style row: + * + * -rw-r--r-- 0 dan dan 6 Aug 7 12:00 sub/b.txt + * + * GNU tar packs owner and group into one column and uses ISO dates: + * + * -rw-r--r-- root/root 6 2026-08-07 19:51 ./sub/b.txt + * + * Rather than count columns - which is what makes this kind of parser break on + * the other tool, or on a file whose name starts with a digit - each row is + * matched from the right: a size, a date, a time or a year, then everything + * left over is the name, spaces and all. A row that does not match at all is + * still kept, using the whole line as a name with an unknown size, because + * hiding a file that is genuinely in the archive is worse than showing it + * without its size. + */ +export function parseArchiveListing(text: string | undefined): ArchiveEntry[] { + const entries: ArchiveEntry[] = []; + const seen = new Set(); + + for (const raw of (text ?? '').split('\n')) { + const line = raw.trimEnd(); + if (!line.trim()) { + continue; + } + + // Only rows that begin with a permission string are entries; anything + // else is a header or a summary line. + const permissions = /^([-dlbcpsD][rwxsStT-]{9}[.+@]?)\s+(.*)$/.exec(line); + if (!permissions) { + continue; + } + + const rest = permissions[2]; + const tail = /\s(\d+)\s+(?:\d{4}-\d{2}-\d{2}|\w{3}\s+\d{1,2})\s+(?:[\d:]{4,8}|\d{4})\s+(.+)$/.exec(rest); + + // Only the size and the name are captured; the date alternatives are + // non-capturing, so the name is group 2 rather than group 3. + const name = tail ? tail[2] : rest.split(/\s+/).slice(-1)[0] ?? ''; + const size = tail ? Number(tail[1]) : 0; + const normalised = normaliseEntryPath(name); + if (!normalised || seen.has(normalised)) { + continue; + } + seen.add(normalised); + + entries.push({ + path: normalised, + // Trust the permission bit over the trailing slash: a directory + // stored without one still has to browse like a directory. + isDirectory: permissions[1].startsWith('d') || name.endsWith('/'), + size: Number.isFinite(size) ? size : 0, + }); + } + + return entries; +} + +/** + * `unzip -l`, the fallback when there is no bsdtar: + * + * --------- ---------- ----- ---- + * 6 2026-08-07 19:51 sub/b.txt + */ +export function parseUnzipListing(text: string | undefined): ArchiveEntry[] { + const entries: ArchiveEntry[] = []; + const seen = new Set(); + + for (const raw of (text ?? '').split('\n')) { + const match = /^\s*(\d+)\s+\d{4}-\d{2}-\d{2}\s+[\d:]+\s+(.+?)\s*$/.exec(raw); + if (!match) { + continue; + } + const normalised = normaliseEntryPath(match[2]); + if (!normalised || seen.has(normalised)) { + continue; + } + seen.add(normalised); + entries.push({ + path: normalised, + // unzip has no permission column here, so the trailing slash is the + // only signal - which is exactly what the zip format itself uses. + isDirectory: match[2].endsWith('/'), + size: Number(match[1]), + }); + } + + return entries; +} + +/** + * Strip the "./" tar puts on everything and the trailing slash directories + * carry, and refuse anything that would escape the extraction directory. + * + * An archive is untrusted input: entries named "../../etc/passwd" or "/etc/shadow" + * are a real and old attack. Extraction itself is done with bsdtar's `-P` + * absent, which already refuses these, but they must not be *displayed* as + * though they were ordinary members either. + */ +function normaliseEntryPath(name: string): string | undefined { + let path = name.trim(); + while (path.startsWith('./')) { + path = path.slice(2); + } + path = path.replace(/\/+$/, ''); + if (!path || path === '.') { + return undefined; + } + if (path.startsWith('/') || path.split('/').includes('..')) { + return undefined; + } + return path; +} + +/** + * The entries directly inside one directory of an archive, folded to a single + * level the way a file explorer shows a folder. + * + * Archives are flat lists, and a directory may have no entry of its own - a zip + * built from a file list often has "docs/a.txt" and nothing for "docs" - so + * intermediate directories are synthesised from the paths that mention them. + */ +export function entriesInDirectory(entries: ArchiveEntry[], directory: string): ArchiveEntry[] { + const prefix = directory ? `${directory}/` : ''; + const direct = new Map(); + + for (const entry of entries) { + if (!entry.path.startsWith(prefix) || entry.path === directory) { + continue; + } + const remainder = entry.path.slice(prefix.length); + const slash = remainder.indexOf('/'); + + if (slash < 0) { + direct.set(remainder, { ...entry, path: entry.path }); + continue; + } + + // Deeper than this level: contribute the directory it implies. + const name = remainder.slice(0, slash); + if (!direct.has(name)) { + direct.set(name, { path: `${prefix}${name}`, isDirectory: true, size: 0 }); + } + } + + return [...direct.values()].sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }); + }); +} + +/** + * The name an archive unpacks to, used to offer a destination folder. + * "photos.tar.gz" -> "photos", "notes.txt.gz" -> "notes.txt". + */ +export function archiveBaseName(file: string): string { + const name = file.slice(file.lastIndexOf('/') + 1); + const lower = name.toLowerCase(); + for (const [suffix] of SUFFIXES) { + if (lower.length > suffix.length && lower.endsWith(suffix)) { + return name.slice(0, name.length - suffix.length); + } + } + return name; +} diff --git a/extension/src/util/notify.ts b/extension/src/util/notify.ts new file mode 100644 index 0000000..70bba47 --- /dev/null +++ b/extension/src/util/notify.ts @@ -0,0 +1,111 @@ +// Turning a freedesktop notification into something VS Code can show. +// +// The bus hands over three things the editor has no idea what to do with: a +// body that may contain a small subset of HTML, an action list that is a flat +// array of alternating keys and labels, and a hints dictionary whose `urgency` +// decides whether this is an information message or a warning. All three are +// pure data manipulation, so they live here where they can be tested without a +// session bus. + +export type Urgency = 'low' | 'normal' | 'critical'; + +/** The markup the spec allows in a body, none of which VS Code renders. */ +const TAGS = /<\/?(?:b|i|u|a|img)(?:\s[^>]*)?\/?>/gi; + +const ENTITIES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', +}; + +/** + * Flatten a notification body to plain text. + * + * The Desktop Notifications spec allows ` ` in a body and + * says a server that does not render them should strip them - which is exactly + * our case, because a VS Code message is plain text and would otherwise show + * the angle brackets. Entities are unescaped afterwards so an escaped ampersand + * does not survive as "&". + */ +export function stripNotificationMarkup(body: string): string { + return body + .replace(//gi, '\n') + .replace(TAGS, '') + .replace(/&[a-z]+;|&#\d+;/gi, (entity) => { + const known = ENTITIES[entity.toLowerCase()]; + if (known !== undefined) { + return known; + } + const numeric = /^&#(\d+);$/.exec(entity); + return numeric ? String.fromCodePoint(Number(numeric[1])) : entity; + }) + .replace(/[ \t]*\n[ \t]*/g, '\n') + .trim(); +} + +export interface NotificationAction { + key: string; + label: string; +} + +/** + * `Notify`'s actions argument is a flat [key, label, key, label, …] array. + * + * The key "default" is the activation that happens when the notification body + * itself is clicked, not a button, so it is dropped - there is nothing to click + * in a VS Code message. A trailing key with no label is discarded rather than + * shown with an empty caption. + */ +export function parseActions(actions: readonly string[]): NotificationAction[] { + const parsed: NotificationAction[] = []; + for (let i = 0; i + 1 < actions.length; i += 2) { + const key = actions[i]; + const label = actions[i + 1]; + if (key === 'default' || !label) { + continue; + } + parsed.push({ key, label }); + } + return parsed; +} + +/** + * The `urgency` hint, which is a byte: 0 low, 1 normal, 2 critical. + * + * The hints dictionary has signature `a{sv}`, so every value arrives wrapped in + * a dbus-next `Variant` - hence the `.value` unwrap, done by shape rather than + * by importing the class so this stays testable without a session bus. Senders + * are not obliged to use a byte, and some use a string, so anything that is not + * recognisably 0 or 2 is treated as normal. + */ +export function urgencyOf(hints: Record | undefined): Urgency { + const raw = hints?.urgency; + const unwrapped = raw !== null && typeof raw === 'object' && 'value' in raw + ? (raw as { value: unknown }).value + : raw; + const level = Number(unwrapped); + if (level === 2) { + return 'critical'; + } + return level === 0 ? 'low' : 'normal'; +} + +/** + * The single line a VS Code message shows. + * + * An em dash rather than a newline: `showInformationMessage` collapses + * whitespace, so a two-line message would run together anyway and this at least + * says where the summary stops. + */ +export function notificationText(summary: string, body: string): string { + const head = stripNotificationMarkup(summary); + const rest = stripNotificationMarkup(body).replace(/\s*\n\s*/g, ' '); + if (!rest) { + return head || 'Notification'; + } + return head ? `${head} — ${rest}` : rest; +} diff --git a/extension/src/util/parse.ts b/extension/src/util/parse.ts index 8c18966..9fa4c68 100644 --- a/extension/src/util/parse.ts +++ b/extension/src/util/parse.ts @@ -55,3 +55,254 @@ export function parsePendingUpdates(stdout: string, limit = 12): PendingUpdates : lines, }; } + +export type Rotation = 'normal' | 'left' | 'right' | 'inverted'; + +export interface DisplayMode { + /** "1920x1080", the form xrandr --mode wants back. */ + size: string; + width: number; + height: number; + /** Refresh rates offered for this size, best first as xrandr prints them. */ + rates: number[]; + /** The mode the output is running now. */ + current: boolean; + /** The monitor's own preferred mode, which is usually the panel's native one. */ + preferred: boolean; +} + +export interface DisplayOutput { + name: string; + connected: boolean; + primary: boolean; + rotation: Rotation; + /** Position and size of the current mode, absent when the output is off. */ + geometry?: { width: number; height: number; x: number; y: number }; + currentMode?: string; + currentRate?: number; + modes: DisplayMode[]; +} + +const ROTATIONS: Rotation[] = ['left', 'right', 'inverted']; + +/** + * `xrandr --query`: + * + * eDP-1 connected primary 1920x1080+0+0 (normal left inverted …) 344mm x 194mm + * 1920x1080 60.02*+ 59.97 59.93 + * 1680x1050 59.95 + * HDMI-1 disconnected (normal left inverted right x axis y axis) + * + * Two things make this fiddly. The parenthesised list always contains the words + * "left", "right" and "inverted" whether or not the output is rotated, so the + * rotation has to be read from the text *before* the bracket. And the flags on a + * refresh rate are positional: `*` marks the active mode and `+` the preferred + * one, and they can appear together, apart, or with a space between the rate and + * the plus. + */ +export function parseXrandrOutputs(text: string | undefined): DisplayOutput[] { + const outputs: DisplayOutput[] = []; + let current: DisplayOutput | undefined; + + for (const line of (text ?? '').split('\n')) { + const header = /^(\S+)\s+(connected|disconnected)\b(.*?)(?:\(|$)/.exec(line); + if (header) { + const flags = header[3]; + const geometry = /(\d+)x(\d+)\+(-?\d+)\+(-?\d+)/.exec(flags); + current = { + name: header[1], + connected: header[2] === 'connected', + primary: /\bprimary\b/.test(flags), + rotation: ROTATIONS.find((r) => new RegExp(`\\b${r}\\b`).test(flags)) ?? 'normal', + geometry: geometry + ? { + width: Number(geometry[1]), + height: Number(geometry[2]), + x: Number(geometry[3]), + y: Number(geometry[4]), + } + : undefined, + modes: [], + }; + outputs.push(current); + continue; + } + + // A mode line is indented; anything else at column 0 ends the output. + const mode = /^\s+(\d+)x(\d+)i?\s+(.*\S)\s*$/.exec(line); + if (!mode || !current) { + continue; + } + + const rates: number[] = []; + let isCurrent = false; + let isPreferred = false; + for (const match of mode[3].matchAll(/([\d.]+)\s*([*+]*)/g)) { + rates.push(Number(match[1])); + if (match[2].includes('*')) { + isCurrent = true; + } + if (match[2].includes('+')) { + isPreferred = true; + } + } + + const size = `${mode[1]}x${mode[2]}`; + current.modes.push({ + size, + width: Number(mode[1]), + height: Number(mode[2]), + rates, + current: isCurrent, + preferred: isPreferred, + }); + if (isCurrent) { + current.currentMode = size; + // The starred rate, not the first one: a mode can offer several. + const starred = /([\d.]+)\s*\*/.exec(mode[3]); + current.currentRate = starred ? Number(starred[1]) : rates[0]; + } + } + + return outputs; +} + +export type FirewallPolicy = 'allow' | 'deny' | 'reject'; + +export interface FirewallRule { + number: number; + to: string; + action: string; + from: string; + /** ufw lists the IPv6 half of each rule separately, tagged "(v6)". */ + v6: boolean; +} + +export interface FirewallStatus { + active: boolean; + logging?: string; + incoming?: FirewallPolicy; + outgoing?: FirewallPolicy; + routed?: FirewallPolicy; + rules: FirewallRule[]; +} + +const POLICIES: FirewallPolicy[] = ['allow', 'deny', 'reject']; + +function policy(word: string | undefined): FirewallPolicy | undefined { + return POLICIES.find((p) => p === word?.toLowerCase()); +} + +/** + * `ufw status numbered verbose`: + * + * Status: active + * Logging: on (low) + * Default: deny (incoming), allow (outgoing), disabled (routed) + * + * To Action From + * -- ------ ---- + * [ 1] 22/tcp ALLOW IN Anywhere + * [ 2] 22/tcp (v6) ALLOW IN Anywhere (v6) + * + * An inactive firewall prints "Status: inactive" and nothing else, so an empty + * rule list there means "unknown", not "no rules" - the rules are still on disk. + * The columns are separated by runs of spaces and every field can contain a + * single space ("ALLOW IN", "Anywhere (v6)"), so they are split on two-or-more. + */ +export function parseUfwStatus(text: string | undefined): FirewallStatus { + const status: FirewallStatus = { active: false, rules: [] }; + + for (const raw of (text ?? '').split('\n')) { + const line = raw.trimEnd(); + + const state = /^Status:\s*(\w+)/i.exec(line); + if (state) { + status.active = state[1].toLowerCase() === 'active'; + continue; + } + + const logging = /^Logging:\s*(.+)$/i.exec(line); + if (logging) { + status.logging = logging[1].trim(); + continue; + } + + if (/^Default:/i.test(line)) { + for (const match of line.matchAll(/(\w+)\s*\((incoming|outgoing|routed)\)/gi)) { + const value = policy(match[1]); + const where = match[2].toLowerCase(); + if (where === 'incoming') { + status.incoming = value; + } else if (where === 'outgoing') { + status.outgoing = value; + } else { + status.routed = value; + } + } + continue; + } + + const rule = /^\[\s*(\d+)\]\s+(.+?)\s{2,}(\S+(?:\s+(?:IN|OUT|FWD))?)\s{2,}(.+?)\s*$/.exec(line); + if (rule) { + const to = rule[2].trim(); + status.rules.push({ + number: Number(rule[1]), + to, + action: rule[3].replace(/\s+/g, ' ').trim(), + from: rule[4].trim(), + v6: /\(v6\)/.test(to), + }); + } + } + + return status; +} + +export interface TrashInfo { + /** Absolute path the file came from, percent-decoded. */ + path: string; + /** Milliseconds since the epoch, or undefined when the date is missing or bad. */ + deletedAt?: number; +} + +/** + * A `.trashinfo` file, per the freedesktop trash spec: + * + * [Trash Info] + * Path=/home/vscodeos/Documents/report%20final.txt + * DeletionDate=2026-08-07T12:34:56 + * + * `Path` is percent-encoded and `DeletionDate` is local time with no offset, + * which is what `new Date` assumes for a bare date-time. A file with no readable + * Path is not restorable and the caller drops it. + */ +export function parseTrashInfo(text: string | undefined): TrashInfo | undefined { + let path: string | undefined; + let deletedAt: number | undefined; + + for (const raw of (text ?? '').split('\n')) { + const line = raw.trim(); + const equals = line.indexOf('='); + if (equals < 1) { + continue; + } + const key = line.slice(0, equals).trim().toLowerCase(); + const value = line.slice(equals + 1).trim(); + + if (key === 'path' && path === undefined) { + try { + path = decodeURIComponent(value); + } catch { + // A malformed escape is still a name; showing it beats hiding + // the entry, and restoring it will simply fail loudly. + path = value; + } + } else if (key === 'deletiondate' && deletedAt === undefined) { + const parsed = Date.parse(value); + deletedAt = Number.isNaN(parsed) ? undefined : parsed; + } + } + + return path ? { path, deletedAt } : undefined; +} diff --git a/extension/src/views/flyout.ts b/extension/src/views/flyout.ts index 1db4319..ce5b098 100644 --- a/extension/src/views/flyout.ts +++ b/extension/src/views/flyout.ts @@ -1,5 +1,5 @@ // The flyouts: apps, power, calendar, power settings, volume, network, -// bluetooth and music. +// bluetooth, music and notifications. // // A VS Code extension cannot draw a popup anchored to a status bar item - there // is no such API, and the only floating-window route (moving an editor to an @@ -8,7 +8,7 @@ // in the bottom panel. The side bar is the default: the bottom panel is where // the terminal lives, and clicking the clock should not close it. // -// One provider serves all eight cards, because a container can only hold one +// One provider serves all nine cards, because a container can only hold one // view without splitting the space between them, and because they share their // whole refresh loop. @@ -22,6 +22,7 @@ import * as mpris from '../sys/mpris'; import * as network from '../sys/network'; import * as power from '../sys/power'; import { MprisMonitor } from '../sys/mpris'; +import type { NotificationServer } from '../sys/notifications'; import { availableApps } from '../apps/registry'; import { render, webviewOptions } from '../webview/html'; import type { FlyoutKind, FlyoutState, HostMessage, WebviewMessage } from '../webview/protocol'; @@ -36,6 +37,7 @@ const TITLES: Record = { network: 'Network', bluetooth: 'Bluetooth', music: 'Music', + notifications: 'Notifications', }; const REFRESH_MS = 2000; @@ -64,6 +66,7 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { constructor( private readonly context: vscode.ExtensionContext, private readonly music: MprisMonitor, + private readonly notifications: NotificationServer, ) {} private get location(): FlyoutLocation { @@ -204,6 +207,12 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { if (this.kind === 'power') { state.canSuspend = power.canSuspend(); } + if (this.kind === 'notifications') { + state.notifications = this.notifications.records; + state.notificationsAvailable = this.notifications.running; + // Opening the card is reading them; the tray badge clears with it. + this.notifications.markAllRead(); + } this.post({ type: 'state', state }); } catch (error) { @@ -239,6 +248,21 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { await this.refresh(); return; + case 'source': + await audio.setDefaultSource(message.id); + await this.refresh(); + return; + + case 'micVolume': + await audio.setInputVolume(message.value); + await this.refresh(); + return; + + case 'micMute': + await audio.toggleInputMute(); + await this.refresh(); + return; + case 'brightness': await backlight.setPercent(message.value); return; @@ -380,6 +404,16 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { return; } + case 'dismissNotification': + this.notifications.dismiss(message.id); + await this.refresh(); + return; + + case 'clearNotifications': + this.notifications.clear(); + await this.refresh(); + return; + case 'command': await vscode.commands.executeCommand(message.command); return; diff --git a/extension/src/views/recycleBin.ts b/extension/src/views/recycleBin.ts new file mode 100644 index 0000000..5c25bf5 --- /dev/null +++ b/extension/src/views/recycleBin.ts @@ -0,0 +1,122 @@ +// The Recycle Bin, in the activity bar. +// +// The same TrashService the Files app browses, in a view that is one click away +// rather than three. Restoring here redraws the Files app and vice versa, +// because both listen to the service's `change` event. +// +// Like the Task Manager, this only reads the trash while it is actually +// visible: a hidden view re-listing a bin full of files on every change would +// be work nobody asked for. + +import * as vscode from 'vscode'; +import { render, webviewOptions } from '../webview/html'; +import type { TrashService } from '../sys/trash'; +import type { WebviewMessage } from '../webview/protocol'; +import { log } from '../log'; + +export class RecycleBinProvider implements vscode.WebviewViewProvider, vscode.Disposable { + static readonly viewId = 'vscodeos.recycleBin'; + + private view: vscode.WebviewView | undefined; + private readonly subscription: () => void; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly trash: TrashService, + ) { + const onChange = (): void => void this.refresh(); + this.trash.on('change', onChange); + this.subscription = () => this.trash.off('change', onChange); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = webviewOptions(this.context); + view.webview.html = render(view.webview, this.context, { + title: 'Recycle Bin', + script: 'recyclebin', + }); + + view.webview.onDidReceiveMessage((message: WebviewMessage) => void this.handle(message)); + view.onDidChangeVisibility(() => { + if (view.visible) { + void this.refresh(); + } + }); + view.onDidDispose(() => (this.view = undefined)); + } + + private async refresh(): Promise { + if (!this.view?.visible) { + return; + } + try { + const entries = await this.trash.list(); + void this.view.webview.postMessage({ type: 'trash', entries }); + } catch (error) { + log.error('recycle bin', error); + } + } + + private async handle(message: WebviewMessage): Promise { + switch (message.type) { + case 'ready': + await this.refresh(); + return; + + case 'restoreFromTrash': { + const { restored, skipped } = await this.trash.restore(message.paths); + if (skipped.length > 0) { + void vscode.window.showWarningMessage( + `${skipped.length} item${skipped.length === 1 ? '' : 's'} could not be restored: ` + + 'the original location is unknown.', + ); + } else if (restored > 0) { + void vscode.window.showInformationMessage( + `Restored ${restored} item${restored === 1 ? '' : 's'}.`, + ); + } + return; + } + + case 'deleteFromTrash': { + const label = message.paths.length === 1 ? 'this item' : `${message.paths.length} items`; + const choice = await vscode.window.showWarningMessage( + `Permanently delete ${label}?`, + { modal: true, detail: 'This cannot be undone.' }, + 'Delete permanently', + ); + if (choice === 'Delete permanently') { + await this.trash.remove(message.paths); + } + return; + } + + case 'emptyTrash': { + if (await this.trash.isEmpty()) { + return; + } + const choice = await vscode.window.showWarningMessage( + 'Empty the Recycle Bin?', + { modal: true, detail: 'Everything in it is deleted for good.' }, + 'Empty Recycle Bin', + ); + if (choice === 'Empty Recycle Bin') { + await this.trash.empty(); + } + return; + } + + case 'command': + await vscode.commands.executeCommand(message.command); + return; + + default: + return; + } + } + + dispose(): void { + this.subscription(); + } +} diff --git a/extension/src/webview/protocol.ts b/extension/src/webview/protocol.ts index 7c55e2b..3fda2f5 100644 --- a/extension/src/webview/protocol.ts +++ b/extension/src/webview/protocol.ts @@ -8,9 +8,14 @@ import type { AudioState } from '../sys/audio'; import type { BatteryState } from '../sys/battery'; import type { BluetoothState } from '../sys/bluetooth'; import type { NetworkState } from '../sys/network'; +import type { DisplayOutput, Rotation } from '../sys/display'; +import type { FirewallPolicy, FirewallStatus } from '../sys/firewall'; +import type { KeyboardState, RepeatRate } from '../sys/keyboard'; import type { NowPlaying } from '../sys/mpris'; +import type { NotificationRecord } from '../sys/notifications'; import type { PowerAction } from '../sys/power'; import type { ProcessInfo, SystemInfo } from '../sys/procfs'; +import type { CleanupId, StorageState } from '../sys/storage'; // Re-exported so the webview bundles can type their own copies of the payloads // without reaching into src/sys, which is host-only code. @@ -18,10 +23,17 @@ export type { AudioState, AudioDevice } from '../sys/audio'; export type { BatteryState } from '../sys/battery'; export type { BluetoothState, BluetoothDevice } from '../sys/bluetooth'; export type { NetworkState, AccessPoint, Connection } from '../sys/network'; +export type { DisplayOutput, DisplayMode, Rotation } from '../sys/display'; +export type { FirewallStatus, FirewallRule, FirewallPolicy } from '../sys/firewall'; +export type { KeyboardState, KeyboardLayout, RepeatRate } from '../sys/keyboard'; export type { NowPlaying } from '../sys/mpris'; +export type { NotificationRecord } from '../sys/notifications'; export type { ProcessInfo, SystemInfo } from '../sys/procfs'; +export type { StorageState, MountUsage, DirectoryUsage, CleanupCategory, CleanupId } from '../sys/storage'; -export type FlyoutKind = 'apps' | 'power' | 'powersettings' | 'calendar' | 'volume' | 'network' | 'bluetooth' | 'music'; +export type FlyoutKind = + | 'apps' | 'power' | 'powersettings' | 'calendar' + | 'volume' | 'network' | 'bluetooth' | 'music' | 'notifications'; /** * One entry in the app launcher. Deliberately plain data: the registry that @@ -56,6 +68,56 @@ export interface FlyoutState { players?: string[]; mprisAvailable?: boolean; canSuspend?: boolean; + notifications?: NotificationRecord[]; + /** False when another daemon owns the bus name, so nothing will ever arrive. */ + notificationsAvailable?: boolean; +} + +export interface FirewallState { + /** False when ufw is not on this machine at all. */ + installed: boolean; + /** False when there is no pkexec, so nothing privileged can be run. */ + canElevate: boolean; + busy: boolean; + /** Absent when the status could not be read. */ + status?: FirewallStatus; + /** Whether anything is listening on port 22, for the lock-yourself-out warning. */ + sshListening?: boolean; +} + +/** The panes of the System Settings app, in rail order. */ +export type SettingsSection = 'display' | 'keyboard' | 'sound' | 'storage' | 'updates' | 'about'; + +export interface DisplaySettings { + /** False when there is no xrandr or no X display to talk to. */ + available: boolean; + outputs: DisplayOutput[]; + nightLight: boolean; + energySaver: boolean; + /** Backlight percentage, absent on a machine with no backlight. */ + brightness?: number; +} + +export interface AboutInfo { + hostname: string; + kernel: string; + architecture: string; + /** Contents of /usr/share/vscodeos/build-info, when the image wrote one. */ + build?: string; + codeVersion?: string; + shellVersion: string; + cpu?: string; + memoryBytes?: number; + uptimeSeconds: number; +} + +export interface SettingsState { + section: SettingsSection; + display?: DisplaySettings; + keyboard?: KeyboardState; + audio?: AudioState; + storage?: StorageState; + about?: AboutInfo; } /** One row in the updater. */ @@ -117,7 +179,14 @@ export type HostMessage = | { type: 'browserFrame'; data: string; width: number; height: number } | { type: 'browserState'; state: BrowserState } | { type: 'browserError'; message: string; fatal?: boolean } - // updater + // firewall + | { type: 'firewall'; state: FirewallState } + | { type: 'firewallLog'; chunk: string } + | { type: 'firewallBusy'; busy: boolean } + // system settings + | { type: 'settings'; state: SettingsState } + | { type: 'settingsBusy'; label?: string } + // updater, which is the Updates pane of the settings app | { type: 'updateStatus'; items: UpdateItem[]; running?: UpdateTarget } | { type: 'updateLog'; chunk: string } | { type: 'updateDone'; ok: boolean; needsRestart: boolean; message?: string }; @@ -129,6 +198,9 @@ export type WebviewMessage = | { type: 'volume'; value: number } | { type: 'mute' } | { type: 'sink'; id: string } + | { type: 'source'; id: string } + | { type: 'micVolume'; value: number } + | { type: 'micMute' } | { type: 'brightness'; value: number } | { type: 'wifi'; enabled: boolean } | { type: 'scan' } @@ -148,6 +220,9 @@ export type WebviewMessage = | { type: 'launchMusic'; service: 'spotify' | 'ytmusic' } | { type: 'command'; command: string } | { type: 'closeFlyout' } + // notifications + | { type: 'dismissNotification'; id: number } + | { type: 'clearNotifications' } // task manager | { type: 'endTask'; pid: number; name: string } | { type: 'endTaskAsRoot'; pid: number; name: string } @@ -160,6 +235,11 @@ export type WebviewMessage = | { type: 'newFile'; path: string } | { type: 'rename'; path: string } | { type: 'delete'; paths: string[] } + | { type: 'restoreFromTrash'; paths: string[] } + | { type: 'deleteFromTrash'; paths: string[] } + | { type: 'emptyTrash' } + | { type: 'extract'; paths: string[]; chooseTarget: boolean } + | { type: 'compress'; paths: string[] } | { type: 'clipboard'; paths: string[]; cut: boolean } | { type: 'paste'; target: string } | { type: 'revealInSidebar'; path: string } @@ -184,7 +264,22 @@ export type WebviewMessage = | { type: 'browserInput'; input: BrowserInput } | { type: 'browserTab'; action: 'new' | 'close' | 'select'; id?: string } | { type: 'browserExternal' } - // updater + // firewall + | { type: 'firewallRefresh' } + | { type: 'firewallToggle'; enabled: boolean } + | { type: 'firewallPolicy'; direction: 'incoming' | 'outgoing'; policy: FirewallPolicy } + | { type: 'firewallRule'; action: 'allow' | 'deny' | 'limit'; spec: string } + | { type: 'firewallDelete'; number: number; label: string } + | { type: 'firewallLogging'; enabled: boolean } + | { type: 'firewallReset' } + // system settings + | { type: 'settingsSection'; section: SettingsSection } + | { type: 'cleanStorage'; ids: CleanupId[] } + | { type: 'revealPath'; path: string } + | { type: 'setDisplayMode'; output: string; mode: string; rate?: number; rotation?: Rotation; primary?: boolean } + | { type: 'setKeyboardLayout'; code: string; variant?: string } + | { type: 'setKeyRepeat'; repeat: RepeatRate } + // updater, which is the Updates pane of the settings app | { type: 'checkUpdates' } | { type: 'runUpdate'; target: UpdateTarget } | { type: 'restart'; mode: 'editor' | 'reboot' }; @@ -197,6 +292,10 @@ export interface FileEntry { size: number; modified: number; hidden: boolean; + /** Recycle Bin only: where the item was before it was deleted. */ + originalPath?: string; + /** Recycle Bin only: when it was deleted, in milliseconds. */ + deletedAt?: number; } export interface Place { diff --git a/extension/test/index.test.ts b/extension/test/index.test.ts index 6407475..e0d7e4c 100644 --- a/extension/test/index.test.ts +++ b/extension/test/index.test.ts @@ -15,8 +15,23 @@ import { describe, it } from 'node:test'; import { evaluate, isError } from '../media/src/lib/calc'; import { expandHome, formatBytes, formatDate, formatDuration, formatElapsed, formatTime } from '../src/util/format'; +import { + archiveBaseName, + archiveKind, + entriesInDirectory, + isMultiFileArchive, + parseArchiveListing, + parseUnzipListing, +} from '../src/util/archive'; import { mediaFilterExtensions, mediaKind } from '../src/util/media'; -import { parseBluetoothDevices, parsePendingUpdates } from '../src/util/parse'; +import { notificationText, parseActions, stripNotificationMarkup, urgencyOf } from '../src/util/notify'; +import { + parseBluetoothDevices, + parsePendingUpdates, + parseTrashInfo, + parseUfwStatus, + parseXrandrOutputs, +} from '../src/util/parse'; import { codeUpdateUrl, normaliseAddress } from '../src/util/url'; describe('calculator', () => { @@ -264,3 +279,353 @@ describe('VS Code update endpoint', () => { assert.ok(codeUpdateUrl('x64').endsWith(`/${'0'.repeat(40)}`)); }); }); + +describe('archive classification', () => { + const cases: [string, string | undefined][] = [ + ['photos.zip', 'zip'], + ['backup.tar', 'tar'], + // The whole point: the last dot is the wrong place to split. + ['photos.tar.gz', 'tar.gz'], + ['photos.tar.xz', 'tar.xz'], + ['photos.tar.bz2', 'tar.bz2'], + ['photos.tar.zst', 'tar.zst'], + ['photos.tgz', 'tar.gz'], + ['notes.txt.gz', 'gz'], + ['app.jar', 'zip'], + ['thing.7z', '7z'], + ['ARCHIVE.ZIP', 'zip'], + ['/home/vscodeos/Downloads/linux-6.12.tar.xz', 'tar.xz'], + ['report.txt', undefined], + ['noextension', undefined], + // A leading dot is a hidden file, not an extension. + ['.gz', undefined], + ['.bashrc', undefined], + ]; + for (const [name, expected] of cases) { + it(`${name} -> ${expected ?? 'not an archive'}`, () => { + assert.equal(archiveKind(name), expected); + }); + } + + it('knows which formats hold more than one file', () => { + assert.equal(isMultiFileArchive('zip'), true); + assert.equal(isMultiFileArchive('tar.gz'), true); + assert.equal(isMultiFileArchive('gz'), false); + assert.equal(isMultiFileArchive('xz'), false); + }); + + it('strips the whole suffix when naming a destination', () => { + assert.equal(archiveBaseName('photos.tar.gz'), 'photos'); + assert.equal(archiveBaseName('notes.txt.gz'), 'notes.txt'); + assert.equal(archiveBaseName('/tmp/a/b.zip'), 'b'); + assert.equal(archiveBaseName('plain'), 'plain'); + }); +}); + +describe('archive listings', () => { + // Captured from GNU tar, which packs owner and group into one column. + const gnuTar = [ + 'drwxr-xr-x root/root 0 2026-08-07 19:51 ./', + 'drwxr-xr-x root/root 0 2026-08-07 19:51 ./dir with space/', + '-rw-r--r-- root/root 2 2026-08-07 19:51 ./dir with space/c d.txt', + 'drwxr-xr-x root/root 0 2026-08-07 19:51 ./sub/', + '-rw-r--r-- root/root 6 2026-08-07 19:51 ./sub/b.txt', + '-rw-r--r-- root/root 6 2026-08-07 19:51 ./a.txt', + ].join('\n'); + + // bsdtar, which is what both images actually have: ls-style columns, a + // month-and-day date, and a year instead of a time for anything old. + const bsdTar = [ + 'drwxr-xr-x 0 dan dan 0 Aug 7 19:51 sub/', + '-rw-r--r-- 0 dan dan 6 Aug 7 19:51 sub/b.txt', + '-rw-r--r-- 0 dan dan 142 Jan 14 2024 old notes.txt', + '-rw-r--r-- 0 dan dan 6 Aug 7 19:51 a.txt', + ].join('\n'); + + it('reads GNU tar rows, stripping the leading ./', () => { + const entries = parseArchiveListing(gnuTar); + assert.deepEqual(entries.map((e) => e.path), [ + 'dir with space', 'dir with space/c d.txt', 'sub', 'sub/b.txt', 'a.txt', + ]); + }); + + it('reads bsdtar rows, including a year in place of a time', () => { + const entries = parseArchiveListing(bsdTar); + assert.deepEqual(entries.map((e) => e.path), ['sub', 'sub/b.txt', 'old notes.txt', 'a.txt']); + assert.equal(entries[2].size, 142); + }); + + it('keeps names containing spaces intact', () => { + const entries = parseArchiveListing(gnuTar); + const entry = entries.find((e) => e.path.endsWith('c d.txt')); + assert.equal(entry?.path, 'dir with space/c d.txt'); + assert.equal(entry?.size, 2); + }); + + it('marks directories from the permission bit and the trailing slash alike', () => { + const entries = parseArchiveListing(gnuTar); + assert.equal(entries.find((e) => e.path === 'sub')?.isDirectory, true); + assert.equal(entries.find((e) => e.path === 'sub/b.txt')?.isDirectory, false); + }); + + it('drops entries that would escape the extraction directory', () => { + const hostile = [ + '-rw-r--r-- root/root 1 2026-08-07 19:51 ../../etc/passwd', + '-rw-r--r-- root/root 1 2026-08-07 19:51 /etc/shadow', + '-rw-r--r-- root/root 1 2026-08-07 19:51 ok.txt', + ].join('\n'); + assert.deepEqual(parseArchiveListing(hostile).map((e) => e.path), ['ok.txt']); + }); + + it('ignores headers, summaries and empty output', () => { + assert.deepEqual(parseArchiveListing('Archive: t.zip\ntotal 4'), []); + assert.deepEqual(parseArchiveListing(undefined), []); + assert.deepEqual(parseArchiveListing(''), []); + }); + + it('reads unzip -l, headers and footers and all', () => { + // Captured from unzip -l. + const listing = [ + 'Archive: t.zip', + ' Length Date Time Name', + '--------- ---------- ----- ----', + ' 0 2026-08-07 19:51 dir with space/', + ' 2 2026-08-07 19:51 dir with space/c d.txt', + ' 6 2026-08-07 19:51 a.txt', + '--------- -------', + ' 14 5 files', + ].join('\n'); + const entries = parseUnzipListing(listing); + assert.deepEqual(entries.map((e) => e.path), ['dir with space', 'dir with space/c d.txt', 'a.txt']); + assert.equal(entries[0].isDirectory, true); + assert.equal(entries[2].size, 6); + }); + + it('folds a flat listing down to one directory level', () => { + const entries = parseArchiveListing(gnuTar); + assert.deepEqual(entriesInDirectory(entries, '').map((e) => e.path), ['dir with space', 'sub', 'a.txt']); + assert.deepEqual(entriesInDirectory(entries, 'sub').map((e) => e.path), ['sub/b.txt']); + }); + + it('invents directories the archive never stored', () => { + // A zip built from a file list has no entry for "docs" at all. + const sparse = parseArchiveListing([ + '-rw-r--r-- root/root 1 2026-08-07 19:51 docs/deep/a.txt', + '-rw-r--r-- root/root 1 2026-08-07 19:51 docs/b.txt', + ].join('\n')); + const top = entriesInDirectory(sparse, ''); + assert.deepEqual(top.map((e) => e.path), ['docs']); + assert.equal(top[0].isDirectory, true); + assert.deepEqual(entriesInDirectory(sparse, 'docs').map((e) => e.path), ['docs/deep', 'docs/b.txt']); + }); +}); + +describe('notification payloads', () => { + it('strips the markup subset the spec allows in a body', () => { + assert.equal(stripNotificationMarkup('Build finished'), 'Build finished'); + assert.equal(stripNotificationMarkup('see the log'), 'see the log'); + assert.equal(stripNotificationMarkup('one
two'), 'one\ntwo'); + assert.equal(stripNotificationMarkup('ydone'), 'done'); + }); + + it('unescapes entities so an ampersand is an ampersand', () => { + assert.equal(stripNotificationMarkup('rock & roll'), 'rock & roll'); + assert.equal(stripNotificationMarkup('<not a tag>'), ''); + assert.equal(stripNotificationMarkup('AB'), 'AB'); + // Something unrecognised is left exactly as it came. + assert.equal(stripNotificationMarkup('&zzz;'), '&zzz;'); + }); + + it('pairs the flat action array into keys and labels', () => { + assert.deepEqual(parseActions(['reply', 'Reply', 'archive', 'Archive']), [ + { key: 'reply', label: 'Reply' }, + { key: 'archive', label: 'Archive' }, + ]); + }); + + it('drops the default action, which is a click target and not a button', () => { + assert.deepEqual(parseActions(['default', 'Open', 'reply', 'Reply']), [ + { key: 'reply', label: 'Reply' }, + ]); + }); + + it('survives a malformed action list', () => { + assert.deepEqual(parseActions([]), []); + assert.deepEqual(parseActions(['orphan']), []); + assert.deepEqual(parseActions(['a', '', 'b', 'B']), [{ key: 'b', label: 'B' }]); + }); + + it('reads urgency, defaulting anything odd to normal', () => { + assert.equal(urgencyOf({ urgency: 0 }), 'low'); + assert.equal(urgencyOf({ urgency: 1 }), 'normal'); + assert.equal(urgencyOf({ urgency: 2 }), 'critical'); + assert.equal(urgencyOf({}), 'normal'); + assert.equal(urgencyOf(undefined), 'normal'); + assert.equal(urgencyOf({ urgency: 'loud' }), 'normal'); + }); + + it('unwraps the variant the hints dictionary actually delivers', () => { + // a{sv} means every hint arrives as a Variant, not a bare number. + assert.equal(urgencyOf({ urgency: { signature: 'y', value: 2 } }), 'critical'); + assert.equal(urgencyOf({ urgency: { signature: 'y', value: 0 } }), 'low'); + }); + + it('joins summary and body into one line', () => { + assert.equal(notificationText('Backup', 'finished in 3s'), 'Backup — finished in 3s'); + assert.equal(notificationText('Backup', ''), 'Backup'); + assert.equal(notificationText('', 'orphan body'), 'orphan body'); + assert.equal(notificationText('', ''), 'Notification'); + assert.equal(notificationText('Sync', 'line one\nline two'), 'Sync — line one line two'); + }); +}); + +describe('xrandr output', () => { + const query = [ + 'Screen 0: minimum 320 x 200, current 4480 x 1440, maximum 16384 x 16384', + 'eDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 344mm x 194mm', + ' 1920x1080 60.02*+ 59.97 59.93 ', + ' 1680x1050 59.95 59.88 ', + 'DP-1 connected 2560x1440+1920+0 left (normal left inverted right x axis y axis) 597mm x 336mm', + ' 2560x1440 59.95*+', + 'HDMI-1 disconnected (normal left inverted right x axis y axis)', + ].join('\n'); + + it('finds every output and its connection state', () => { + const outputs = parseXrandrOutputs(query); + assert.deepEqual(outputs.map((o) => o.name), ['eDP-1', 'DP-1', 'HDMI-1']); + assert.deepEqual(outputs.map((o) => o.connected), [true, true, false]); + }); + + it('marks the primary output and only that one', () => { + const outputs = parseXrandrOutputs(query); + assert.deepEqual(outputs.map((o) => o.primary), [true, false, false]); + }); + + it('reads rotation from before the bracket, not from inside it', () => { + // The parenthesised list names every rotation on every line, so a naive + // search finds "left" on an output that is not rotated at all. + const outputs = parseXrandrOutputs(query); + assert.equal(outputs[0].rotation, 'normal'); + assert.equal(outputs[1].rotation, 'left'); + assert.equal(outputs[2].rotation, 'normal'); + }); + + it('reads geometry and the active mode', () => { + const [edp, dp] = parseXrandrOutputs(query); + assert.deepEqual(edp.geometry, { width: 1920, height: 1080, x: 0, y: 0 }); + assert.deepEqual(dp.geometry, { width: 2560, height: 1440, x: 1920, y: 0 }); + assert.equal(edp.currentMode, '1920x1080'); + assert.equal(edp.currentRate, 60.02); + }); + + it('collects every mode with its rates and flags', () => { + const [edp] = parseXrandrOutputs(query); + assert.equal(edp.modes.length, 2); + assert.deepEqual(edp.modes[0], { + size: '1920x1080', + width: 1920, + height: 1080, + rates: [60.02, 59.97, 59.93], + current: true, + preferred: true, + }); + assert.equal(edp.modes[1].current, false); + assert.equal(edp.modes[1].preferred, false); + }); + + it('leaves a disconnected output with no modes and no geometry', () => { + const hdmi = parseXrandrOutputs(query)[2]; + assert.deepEqual(hdmi.modes, []); + assert.equal(hdmi.geometry, undefined); + }); + + it('returns nothing when xrandr could not run', () => { + assert.deepEqual(parseXrandrOutputs(undefined), []); + assert.deepEqual(parseXrandrOutputs(''), []); + }); +}); + +describe('ufw status', () => { + const verbose = [ + 'Status: active', + 'Logging: on (low)', + 'Default: deny (incoming), allow (outgoing), disabled (routed)', + 'New profiles: skip', + '', + 'To Action From', + '-- ------ ----', + '[ 1] 22/tcp ALLOW IN Anywhere', + '[ 2] 80,443/tcp ALLOW IN Anywhere', + '[ 3] 22/tcp (v6) ALLOW IN Anywhere (v6)', + '[ 4] 3306/tcp DENY IN 192.168.1.0/24', + ].join('\n'); + + it('reads the state and the default policies', () => { + const status = parseUfwStatus(verbose); + assert.equal(status.active, true); + assert.equal(status.logging, 'on (low)'); + assert.equal(status.incoming, 'deny'); + assert.equal(status.outgoing, 'allow'); + // "disabled" is not a policy word, so routed stays unknown. + assert.equal(status.routed, undefined); + }); + + it('splits the columns even though every field can contain a space', () => { + const status = parseUfwStatus(verbose); + assert.equal(status.rules.length, 4); + assert.deepEqual(status.rules[0], { + number: 1, to: '22/tcp', action: 'ALLOW IN', from: 'Anywhere', v6: false, + }); + assert.deepEqual(status.rules[3], { + number: 4, to: '3306/tcp', action: 'DENY IN', from: '192.168.1.0/24', v6: false, + }); + }); + + it('tags the IPv6 half of a rule', () => { + const status = parseUfwStatus(verbose); + assert.deepEqual(status.rules.map((r) => r.v6), [false, false, true, false]); + }); + + it('reports an inactive firewall with no rules', () => { + const status = parseUfwStatus('Status: inactive\n'); + assert.equal(status.active, false); + assert.deepEqual(status.rules, []); + }); + + it('survives no output at all', () => { + assert.deepEqual(parseUfwStatus(undefined), { active: false, rules: [] }); + }); +}); + +describe('trashinfo files', () => { + it('decodes the original path', () => { + const info = parseTrashInfo([ + '[Trash Info]', + 'Path=/home/vscodeos/Documents/report%20final%20(2).txt', + 'DeletionDate=2026-08-07T12:34:56', + ].join('\n')); + assert.equal(info?.path, '/home/vscodeos/Documents/report final (2).txt'); + assert.equal(new Date(info?.deletedAt ?? 0).getFullYear(), 2026); + }); + + it('tolerates CRLF and odd spacing', () => { + const info = parseTrashInfo('[Trash Info]\r\nPath = /tmp/a.txt \r\nDeletionDate =2026-01-02T03:04:05\r\n'); + assert.equal(info?.path, '/tmp/a.txt'); + assert.ok(info?.deletedAt); + }); + + it('keeps the entry when the date is missing or unparseable', () => { + assert.deepEqual(parseTrashInfo('Path=/tmp/a.txt'), { path: '/tmp/a.txt', deletedAt: undefined }); + assert.equal(parseTrashInfo('Path=/tmp/a.txt\nDeletionDate=never')?.deletedAt, undefined); + }); + + it('gives up on an entry with no path, which cannot be restored', () => { + assert.equal(parseTrashInfo('[Trash Info]\nDeletionDate=2026-08-07T12:34:56'), undefined); + assert.equal(parseTrashInfo(''), undefined); + assert.equal(parseTrashInfo(undefined), undefined); + }); + + it('keeps a malformed escape rather than dropping the file', () => { + assert.equal(parseTrashInfo('Path=/tmp/100%.txt')?.path, '/tmp/100%.txt'); + }); +}); diff --git a/rootfs-common/etc/polkit-1/rules.d/49-vscodeos.rules b/rootfs-common/etc/polkit-1/rules.d/49-vscodeos.rules index c40f9a9..8b10126 100644 --- a/rootfs-common/etc/polkit-1/rules.d/49-vscodeos.rules +++ b/rootfs-common/etc/polkit-1/rules.d/49-vscodeos.rules @@ -58,5 +58,32 @@ polkit.addRule(function (action, subject) { return polkit.Result.YES; } + /* Storage Sense, which runs `pkexec /usr/local/bin/vscodeos-clean` to trim + the package cache and the journal and to remove orphaned packages. Same + terms as vscodeos-update: one exact program, a fixed vocabulary of words + as its only arguments, no user-supplied command line and no paths. */ + if (action.id === "org.freedesktop.policykit.exec" + && action.lookup("program") === "/usr/local/bin/vscodeos-clean") { + return polkit.Result.YES; + } + + /* The Firewall app, which runs `pkexec /usr/local/bin/vscodeos-firewall`. + ufw needs root even to read its status, so this is on the read path too + and not only on writes. That script takes a fixed vocabulary and one + argument it checks against a pattern - and, for application profiles, + against `ufw app list` - before ufw ever sees it. */ + if (action.id === "org.freedesktop.policykit.exec" + && action.lookup("program") === "/usr/local/bin/vscodeos-firewall") { + return polkit.Result.YES; + } + + /* The keyboard pane of System Settings, which persists a layout with + `localectl set-x11-keymap`. A named systemd action rather than a program + of ours: localed validates the keymap itself, and the alternative would + have been a third script writing to /etc/X11. */ + if (action.id === "org.freedesktop.locale1.set-keyboard") { + return polkit.Result.YES; + } + return polkit.Result.NOT_HANDLED; }); diff --git a/rootfs-common/etc/skel/.config/Code/User/settings.json b/rootfs-common/etc/skel/.config/Code/User/settings.json index 410247d..fcb33e9 100644 --- a/rootfs-common/etc/skel/.config/Code/User/settings.json +++ b/rootfs-common/etc/skel/.config/Code/User/settings.json @@ -49,7 +49,21 @@ "vscodeos.clock.dateFormat": "dd/MM/yyyy", "vscodeos.taskManager.enabled": true, "vscodeos.files.enabled": true, + "vscodeos.recycleBin.enabled": true, "vscodeos.music.enabled": true, "vscodeos.apps.enabled": true, - "vscodeos.power.confirm": true + "vscodeos.firewall.enabled": true, + "vscodeos.power.confirm": true, + + // The shell serves org.freedesktop.Notifications, so notify-send and any + // application's notifications become editor notifications. Nothing else on + // either image claims that bus name; turn this off to leave it to a daemon + // you install yourself. + "vscodeos.notifications.enabled": true, + "vscodeos.notifications.historyLimit": 50, + + // X forgets the key repeat rate with the session, so the shell reapplies + // these at every login. + "vscodeos.keyboard.repeatDelay": 500, + "vscodeos.keyboard.repeatRate": 25 } diff --git a/rootfs-common/usr/local/bin/vscodeos-clean b/rootfs-common/usr/local/bin/vscodeos-clean new file mode 100755 index 0000000..46b52d8 --- /dev/null +++ b/rootfs-common/usr/local/bin/vscodeos-clean @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Reclaim disk space that only root can reclaim. The privileged half of Storage +# Sense in System Settings. +# +# Run through pkexec, which polkit answers without a prompt for exactly this +# program (etc/polkit-1/rules.d/49-vscodeos.rules). sudo is not an option: +# vscodeos-install tightens sudoers to ask for a password, and there is no +# polkit authentication agent in a session whose entire UI is the editor, so a +# password prompt has nowhere to appear and would simply hang. +# +# Usage: vscodeos-clean report +# vscodeos-clean [more...] +# +# report print " " for each target, so the GUI can show +# sizes without guessing. Reads only; changes nothing. +# pacman-cache drop cached packages, keeping the most recent of each +# journal trim the systemd journal to 50 MB +# orphans remove packages pulled in as dependencies and no longer needed +# +# Every argument is matched against that fixed list before anything runs. This +# is a password-free path to root, so it takes words, never a command line, and +# never a path from the caller. +# +# Output is plain and line-buffered: the GUI streams it into a log pane, so +# progress bars and colour would only arrive as noise. + +set -Eeuo pipefail + +readonly PACMAN_CACHE=/var/cache/pacman/pkg +readonly JOURNAL_KEEP=50M + +msg() { printf '==> %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +(( EUID == 0 )) || die "this must run as root (Storage Sense uses pkexec)" + +(( $# > 0 )) || die "usage: vscodeos-clean " + +for action in "$@"; do + case "${action}" in + report|pacman-cache|journal|orphans) ;; + *) die "unknown action '${action}'" ;; + esac +done + +# Bytes used by a path, or 0 when it is not there. Never fails the script: a +# machine with no journal directory is a machine with nothing to report. +size_of() { + local path="$1" + [[ -e "${path}" ]] || { printf '0\n'; return; } + du -sx -B1 "${path}" 2>/dev/null | cut -f1 || printf '0\n' +} + +# What `paccache -rk1` would remove, without removing it. paccache prints +# "finished dry run: N candidates (disk space saved: X MiB)", which is a string +# meant for people, so the candidate files are measured directly instead. +pacman_cache_reclaimable() { + command -v paccache >/dev/null || { printf '0\n'; return; } + local total=0 file + while IFS= read -r file; do + [[ -f "${file}" ]] || continue + total=$(( total + $(stat -c %s "${file}" 2>/dev/null || printf 0) )) + done < <(paccache -dvk1 2>/dev/null | grep -E '^/' || true) + printf '%s\n' "${total}" +} + +journal_reclaimable() { + command -v journalctl >/dev/null || { printf '0\n'; return; } + local used + used=$(size_of /var/log/journal) + # Only what is above the ceiling can actually be freed. + local keep=$(( 50 * 1024 * 1024 )) + if (( used > keep )); then + printf '%s\n' "$(( used - keep ))" + else + printf '0\n' + fi +} + +orphans_reclaimable() { + command -v pacman >/dev/null || { printf '0\n'; return; } + local total=0 line + # `pacman -Qtdq` lists orphans; -Qi gives each one's installed size in bytes. + while IFS= read -r line; do + [[ -n "${line}" ]] || continue + total=$(( total + line )) + done < <(pacman -Qtdq 2>/dev/null | xargs -r pacman -Qi 2>/dev/null \ + | awk -F': *' '/^Installed Size/ { print $2 }' \ + | awk '{ + unit = $2; value = $1; + if (unit ~ /^K/) value *= 1024; + else if (unit ~ /^M/) value *= 1024 * 1024; + else if (unit ~ /^G/) value *= 1024 * 1024 * 1024; + printf "%d\n", value; + }' || true) + printf '%s\n' "${total}" +} + +do_report() { + printf 'pacman-cache %s\n' "$(pacman_cache_reclaimable)" + printf 'journal %s\n' "$(journal_reclaimable)" + printf 'orphans %s\n' "$(orphans_reclaimable)" +} + +clean_pacman_cache() { + command -v paccache >/dev/null || die "paccache is missing (pacman-contrib)" + msg "trimming the package cache in ${PACMAN_CACHE}" + # -k1 keeps the most recent version of each package, so a downgrade after a + # bad update is still possible. -u would also drop packages that are no + # longer installed, which is the same argument in reverse; keep it simple. + paccache -rk1 + msg "package cache trimmed" +} + +clean_journal() { + command -v journalctl >/dev/null || die "journalctl is missing" + msg "trimming the systemd journal to ${JOURNAL_KEEP}" + journalctl --vacuum-size="${JOURNAL_KEEP}" + msg "journal trimmed" +} + +clean_orphans() { + command -v pacman >/dev/null || die "pacman is missing" + local orphans + # `|| true`: pacman exits 1 when there are no orphans, which is not an error. + orphans=$(pacman -Qtdq 2>/dev/null || true) + if [[ -z "${orphans}" ]]; then + msg "no orphaned packages" + return + fi + msg "removing orphaned packages:" + printf '%s\n' "${orphans}" + # --noconfirm because nothing can answer a prompt here. + printf '%s\n' "${orphans}" | xargs -r pacman -Rns --noconfirm --noprogressbar + msg "orphans removed" +} + +for action in "$@"; do + case "${action}" in + report) do_report ;; + pacman-cache) clean_pacman_cache ;; + journal) clean_journal ;; + orphans) clean_orphans ;; + esac +done + +# `report` is the one action with machine-readable output; saying "done" after +# it would leave the caller parsing a line that is not a size. +if [[ " $* " != *" report "* ]]; then + msg "done" +fi diff --git a/rootfs-common/usr/local/bin/vscodeos-firewall b/rootfs-common/usr/local/bin/vscodeos-firewall new file mode 100755 index 0000000..c5b6271 --- /dev/null +++ b/rootfs-common/usr/local/bin/vscodeos-firewall @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# The firewall, behind one privileged entry point. Drives ufw for the Firewall +# app in the desktop shell. +# +# Run through pkexec, which polkit answers without a prompt for exactly this +# program (etc/polkit-1/rules.d/49-vscodeos.rules). sudo is not an option: +# vscodeos-install tightens sudoers to ask for a password, and there is no +# polkit authentication agent in a session whose entire UI is the editor, so a +# password prompt has nowhere to appear and would simply hang. +# +# ufw needs root even to *read* its status - the rules live in /etc/ufw and the +# live ruleset is in the kernel - so every operation comes through here, and the +# app refreshes on open and after a change rather than polling. +# +# Usage: +# vscodeos-firewall status +# vscodeos-firewall enable | disable | reset +# vscodeos-firewall default-incoming +# vscodeos-firewall default-outgoing +# vscodeos-firewall allow|deny|limit +# vscodeos-firewall delete +# vscodeos-firewall logging +# +# is a port, a port range or an application profile, and is validated +# against a pattern - and, for profiles, against `ufw app list` - before it +# reaches ufw. This is a password-free path to root; it takes a fixed vocabulary +# and a checked argument, never a command line. +# +# Output is plain and line-buffered: the GUI streams it into a log pane. + +set -Eeuo pipefail + +msg() { printf '==> %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +(( EUID == 0 )) || die "this must run as root (the Firewall app uses pkexec)" + +command -v ufw >/dev/null || die "ufw is not installed" + +readonly ACTION="${1:-}" +readonly ARGUMENT="${2:-}" + +# A port ("22"), a range ("6000:6010"), either with a protocol ("22/tcp"), or an +# application profile name. Anything else never reaches ufw. +readonly PORT_RE='^[0-9]{1,5}(:[0-9]{1,5})?(/(tcp|udp))?$' +readonly PROFILE_RE='^[A-Za-z][A-Za-z0-9 ._+-]{0,63}$' + +port_in_range() { + local value + for value in $(printf '%s\n' "${1%%/*}" | tr ':' ' '); do + (( value >= 1 && value <= 65535 )) || return 1 + done + return 0 +} + +# True when the name is one ufw itself offers, which is the only way a +# non-numeric spec is accepted. +known_profile() { + ufw app list 2>/dev/null | tail -n +2 | sed 's/^[[:space:]]*//' | grep -qxF "$1" +} + +require_spec() { + [[ -n "${ARGUMENT}" ]] || die "'${ACTION}' needs a port or an application profile" + + if [[ "${ARGUMENT}" =~ ${PORT_RE} ]]; then + port_in_range "${ARGUMENT}" || die "port out of range in '${ARGUMENT}'" + return + fi + + if [[ "${ARGUMENT}" =~ ${PROFILE_RE} ]] && known_profile "${ARGUMENT}"; then + return + fi + + die "'${ARGUMENT}' is not a port, a port range or a known application profile" +} + +require_policy() { + case "${ARGUMENT}" in + allow|deny|reject) ;; + *) die "policy must be allow, deny or reject" ;; + esac +} + +case "${ACTION}" in + status) + # numbered so the app can offer a delete button per rule; verbose for the + # default policies and the logging level. + ufw status numbered verbose + ;; + + enable) + msg "enabling the firewall" + # --force skips the "this may disrupt existing ssh connections" prompt, + # which nothing here can answer. The app warns about that itself, where + # there is a human to read it. + ufw --force enable + # ufw enable only writes ENABLED=yes; the unit is what restores the + # rules at boot, so without this the firewall would be off again after a + # restart and the app would be lying. + systemctl enable ufw.service >/dev/null 2>&1 || true + msg "firewall enabled" + ;; + + disable) + msg "disabling the firewall" + ufw disable + systemctl disable ufw.service >/dev/null 2>&1 || true + msg "firewall disabled" + ;; + + reset) + msg "resetting every rule" + ufw --force reset + systemctl disable ufw.service >/dev/null 2>&1 || true + msg "firewall reset; it is now disabled with no rules" + ;; + + default-incoming) + require_policy + msg "default policy for incoming traffic: ${ARGUMENT}" + ufw default "${ARGUMENT}" incoming + ;; + + default-outgoing) + require_policy + msg "default policy for outgoing traffic: ${ARGUMENT}" + ufw default "${ARGUMENT}" outgoing + ;; + + allow|deny|limit) + require_spec + msg "${ACTION} ${ARGUMENT}" + ufw "${ACTION}" "${ARGUMENT}" + ;; + + delete) + [[ "${ARGUMENT}" =~ ^[0-9]{1,3}$ ]] || die "delete takes a rule number" + msg "deleting rule ${ARGUMENT}" + ufw --force delete "${ARGUMENT}" + ;; + + logging) + case "${ARGUMENT}" in + on|off) ;; + *) die "logging takes on or off" ;; + esac + msg "logging ${ARGUMENT}" + ufw logging "${ARGUMENT}" + ;; + + *) + die "usage: vscodeos-firewall " + ;; +esac diff --git a/rpi/packages.aarch64 b/rpi/packages.aarch64 index 95c3ae6..4108ff0 100644 --- a/rpi/packages.aarch64 +++ b/rpi/packages.aarch64 @@ -34,6 +34,10 @@ curl ca-certificates bluez bluez-utils +# Packet filter for the shell's Firewall app; see the note in +# archiso/packages.x86_64. Shipped disabled - this image runs sshd too, and a Pi +# is usually the machine being administered over the network. +ufw # Web browser for the desktop shell's browser launcher and its music player. # Microsoft publishes no ARM64 Linux build of Edge, so Chromium is the only @@ -57,7 +61,12 @@ xorg-server xorg-xinit xorg-xauth xorg-xrandr +# xset was missing here, which quietly cost the Pi half of energy saver: the +# DPMS blanking timeouts it re-enables, and the key repeat rate the keyboard +# pane sets. xorg-xset +# setxkbmap, for the keyboard pane of System Settings. +xorg-setxkbmap xorg-xhost xorg-xprop xorg-xkill