From 0e32a7d0121163127a7e74768fd97cf5f936f128 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:54:31 +0000 Subject: [PATCH 1/3] Add VsCodeOsCore: a desktop shell built into the editor VS Code OS boots straight into VS Code with no desktop environment, which until now meant there was no way to shut the machine down, see the time, change the volume, join a network, look at what is running, or open a file that is not text. Everything had to go through the integrated terminal. This adds extension/, a TypeScript VS Code extension that supplies the missing desktop: * a tray at the right end of the status bar - now playing, battery, volume, network, clock and date, and the power button in the corner; * flyouts for power, calendar, quick settings, the volume mixer, the network picker and the music player; * a Task Manager in the activity bar, with processes, per-core CPU, memory, load, uptime and thermals, read from /proc; * a graphical file explorer; * an MPRIS music player with launchers for Spotify Web and YouTube Music; * a browser launcher, and Calculator, Notepad, Paint, Screenshot and Voice Recorder. The shell ships as a *built-in* extension rather than a Marketplace one. Both builds stage it at /usr/share/vscodeos/extensions and vscodeos-install-extensions copies it into VS Code's own resources/app/extensions, which is a plain directory scan with no extensions.json and no engine check - so an editor update can never decide the desktop is incompatible and disable it. That update does replace the whole app tree, so vscodeos-update-code re-runs the installer afterwards. The bundle is architecture-neutral JavaScript, so CI builds it once in a new `extension` job and hands both image jobs the same artifact through VSCODEOS_EXTENSION_PREBUILT. The release job now filters its artifact download to VSCodeOS-*, so the bundle is not published as a release asset. Packages added to both images: chromium (Edge is AUR-only on Arch and has no ARM64 Linux build at all, so it could never have shipped on the Pi; the launcher still prefers microsoft-edge-stable when it is installed), plus playerctl, scrot, bluez and bluez-utils. Roughly 200 MiB on the ISO against ~400 MiB of headroom under the 2 GiB release-asset limit. Two grants make the shell work without a polkit agent, which the kiosk session has nowhere to draw: 49-vscodeos.rules for power and NetworkManager, and a udev rule making the backlight writable by the `video` group. The status bar setting is load-bearing: the notifications bell registers at a priority no extension can outrank, so the skel settings move notifications to the top right to free the bottom-right corner for the power button. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MQheV7d9dNqz8WSxo43cLk --- .github/workflows/build-iso.yml | 51 +- .gitignore | 7 + README.md | 93 +- archiso/packages.x86_64 | 24 + archiso/profiledef.sh | 1 + extension/README.md | 111 ++ extension/esbuild.mjs | 62 ++ extension/media/css/vscodeos.css | 989 ++++++++++++++++++ extension/media/icons/calculator.svg | 4 + extension/media/icons/files.svg | 3 + extension/media/icons/notepad.svg | 3 + extension/media/icons/paint.svg | 4 + extension/media/icons/recorder.svg | 3 + extension/media/icons/screenshot.svg | 3 + extension/media/icons/system.svg | 5 + extension/media/src/calculator.ts | 326 ++++++ extension/media/src/files.ts | 306 ++++++ extension/media/src/flyout.ts | 529 ++++++++++ extension/media/src/lib/dom.ts | 160 +++ extension/media/src/lib/icons.ts | 103 ++ extension/media/src/notepad.ts | 80 ++ extension/media/src/paint.ts | 364 +++++++ extension/media/src/recorder.ts | 112 ++ extension/media/src/screenshot.ts | 84 ++ extension/media/src/taskmanager.ts | 243 +++++ extension/package-lock.json | 544 ++++++++++ extension/package.json | 294 ++++++ extension/src/apps/fileExplorer.ts | 296 ++++++ extension/src/apps/miniApps.ts | 361 +++++++ extension/src/apps/panels.ts | 77 ++ extension/src/extension.ts | 151 +++ extension/src/log.ts | 34 + extension/src/statusbar/index.ts | 209 ++++ extension/src/sys/audio.ts | 186 ++++ extension/src/sys/backlight.ts | 77 ++ extension/src/sys/battery.ts | 135 +++ extension/src/sys/bluetooth.ts | 70 ++ extension/src/sys/browser.ts | 98 ++ extension/src/sys/display.ts | 103 ++ extension/src/sys/exec.ts | 105 ++ extension/src/sys/mpris.ts | 155 +++ extension/src/sys/network.ts | 215 ++++ extension/src/sys/power.ts | 53 + extension/src/sys/procfs.ts | 350 +++++++ extension/src/sys/recorder.ts | 115 ++ extension/src/sys/screenshot.ts | 66 ++ extension/src/util/format.ts | 83 ++ extension/src/views/flyout.ts | 338 ++++++ extension/src/views/taskManager.ts | 151 +++ extension/src/webview/html.ts | 88 ++ extension/src/webview/protocol.ts | 129 +++ extension/tsconfig.json | 19 + .../etc/polkit-1/rules.d/49-vscodeos.rules | 47 + .../etc/skel/.config/Code/User/settings.json | 18 +- .../udev/rules.d/90-vscodeos-backlight.rules | 16 + .../usr/local/bin/vscodeos-install-extensions | 84 ++ .../usr/local/bin/vscodeos-update-code | 8 + rpi/build-image.sh | 15 + rpi/packages.aarch64 | 12 + scripts/build-extension.sh | 123 +++ scripts/build-iso.sh | 23 + 61 files changed, 8508 insertions(+), 10 deletions(-) create mode 100644 extension/README.md create mode 100644 extension/esbuild.mjs create mode 100644 extension/media/css/vscodeos.css create mode 100644 extension/media/icons/calculator.svg create mode 100644 extension/media/icons/files.svg create mode 100644 extension/media/icons/notepad.svg create mode 100644 extension/media/icons/paint.svg create mode 100644 extension/media/icons/recorder.svg create mode 100644 extension/media/icons/screenshot.svg create mode 100644 extension/media/icons/system.svg create mode 100644 extension/media/src/calculator.ts create mode 100644 extension/media/src/files.ts create mode 100644 extension/media/src/flyout.ts create mode 100644 extension/media/src/lib/dom.ts create mode 100644 extension/media/src/lib/icons.ts create mode 100644 extension/media/src/notepad.ts create mode 100644 extension/media/src/paint.ts create mode 100644 extension/media/src/recorder.ts create mode 100644 extension/media/src/screenshot.ts create mode 100644 extension/media/src/taskmanager.ts create mode 100644 extension/package-lock.json create mode 100644 extension/package.json create mode 100644 extension/src/apps/fileExplorer.ts create mode 100644 extension/src/apps/miniApps.ts create mode 100644 extension/src/apps/panels.ts create mode 100644 extension/src/extension.ts create mode 100644 extension/src/log.ts create mode 100644 extension/src/statusbar/index.ts create mode 100644 extension/src/sys/audio.ts create mode 100644 extension/src/sys/backlight.ts create mode 100644 extension/src/sys/battery.ts create mode 100644 extension/src/sys/bluetooth.ts create mode 100644 extension/src/sys/browser.ts create mode 100644 extension/src/sys/display.ts create mode 100644 extension/src/sys/exec.ts create mode 100644 extension/src/sys/mpris.ts create mode 100644 extension/src/sys/network.ts create mode 100644 extension/src/sys/power.ts create mode 100644 extension/src/sys/procfs.ts create mode 100644 extension/src/sys/recorder.ts create mode 100644 extension/src/sys/screenshot.ts create mode 100644 extension/src/util/format.ts create mode 100644 extension/src/views/flyout.ts create mode 100644 extension/src/views/taskManager.ts create mode 100644 extension/src/webview/html.ts create mode 100644 extension/src/webview/protocol.ts create mode 100644 extension/tsconfig.json create mode 100644 rootfs-common/etc/polkit-1/rules.d/49-vscodeos.rules create mode 100644 rootfs-common/etc/udev/rules.d/90-vscodeos-backlight.rules create mode 100755 rootfs-common/usr/local/bin/vscodeos-install-extensions create mode 100755 scripts/build-extension.sh diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 54a6765..f4a9190 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -50,15 +50,50 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" echo "Building VS Code OS ${version}" + # The desktop shell is architecture-neutral JavaScript, so it is built once and + # both image jobs consume the same artifact. Building it inside each image job + # would mean two Node toolchains - pacman's in an Arch container, and + # setup-node's on an arm64 Ubuntu runner - producing the same bytes twice. + extension: + name: Build VsCodeOsCore + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Typecheck + run: npm ci --prefix extension --no-audit --no-fund && npm run --prefix extension typecheck + + - name: Build + run: ./scripts/build-extension.sh -o out/extension + + - uses: actions/upload-artifact@v7 + with: + name: vscodeos-core-extension + path: out/extension + retention-days: 14 + x86_64: name: x86-64 ISO - needs: version + needs: [version, extension] if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["both","x86_64"]'), github.event.inputs.targets) runs-on: ubuntu-latest timeout-minutes: 120 steps: - uses: actions/checkout@v7 + - name: Fetch the prebuilt extension + uses: actions/download-artifact@v8 + with: + name: vscodeos-core-extension + path: extension-dist + - name: Free up disk space run: | echo "before:"; df -h / /mnt | tail -2 @@ -90,6 +125,7 @@ jobs: -w /build \ -e ISO_VERSION="${BUILD_VERSION}" \ -e VSCODE_VERSION="${VSCODE_VERSION}" \ + -e VSCODEOS_EXTENSION_PREBUILT=/build/extension-dist \ archlinux:latest \ bash -euo pipefail -c ' # The keyring goes first and on its own: a base image older than @@ -156,7 +192,7 @@ jobs: raspberrypi: name: Raspberry Pi image - needs: version + needs: [version, extension] if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["both","raspberrypi"]'), github.event.inputs.targets) # A native aarch64 runner means the build can chroot into the image # directly instead of emulating every command through qemu. @@ -165,6 +201,12 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Fetch the prebuilt extension + uses: actions/download-artifact@v8 + with: + name: vscodeos-core-extension + path: extension-dist + - name: Install image tooling run: | sudo apt-get update -qq @@ -176,6 +218,7 @@ jobs: env: BUILD_VERSION: ${{ needs.version.outputs.version }} VSCODE_VERSION: ${{ github.event.inputs.vscode_version || 'latest' }} + VSCODEOS_EXTENSION_PREBUILT: ${{ github.workspace }}/extension-dist run: sudo -E ./rpi/build-image.sh -v "${BUILD_VERSION}" -o "${PWD}/out" -w /mnt/vscodeos-rpi - name: Summarise @@ -219,6 +262,10 @@ jobs: steps: - uses: actions/download-artifact@v8 with: + # Images only. Without the filter the extension bundle would be + # downloaded too, and `gh release create artifacts/*` below would + # publish it as a release asset. + pattern: VSCodeOS-* path: artifacts merge-multiple: true diff --git a/.gitignore b/.gitignore index adf5225..df33758 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ # pacman sync databases, dropped in the working tree by a build run from here *.db +# Extension build output. The sources are committed; the bundles are not. +/extension/node_modules/ +/extension/dist/ +/extension/out/ +/extension/media/dist/ +/extension-dist/ + # Editor / OS noise .DS_Store *.swp diff --git a/README.md b/README.md index 14c5431..ba2914d 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ A Linux distribution whose entire user interface is Visual Studio Code. It is a minimal Arch Linux base with the official Microsoft build of VS Code -layered on top. There is no desktop environment, no taskbar and no application -menu: the machine boots, logs in and puts the editor on screen fullscreen, and -that is the whole system. Everything else — package management, networking, -git, compilers — is reached through the editor's integrated terminal. +layered on top. There is no desktop environment and no application menu: the +machine boots, logs in and puts the editor on screen fullscreen, and that is the +whole system. What a desktop would normally give you — a tray with a clock and a +power button, a task manager, a file manager, a browser, a handful of small apps +— is supplied by **VsCodeOsCore**, an extension built into the editor itself. ``` power on @@ -30,6 +31,8 @@ Two images are published for every release, sharing the same kiosk: | **Base** | Arch Linux, built with `archiso` | Arch Linux ARM | | **Editor** | Official VS Code, `linux-x64` | Official VS Code, `linux-arm64` | | **Session** | Xorg + Openbox kiosk — no panel, no launcher, no desktop | same | +| **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 | | **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 | @@ -51,6 +54,48 @@ and Arch Linux ARM repositories as they stand on the day of the release, so each release is a current system rather than a frozen one. The `.packages.txt` file published next to each image lists exactly which versions it shipped. +### The desktop shell + +The editor has no menu bar and no window controls, so everything a desktop needs +lives in **VsCodeOsCore** — a VS Code extension that ships *inside* the editor +rather than being installed from the Marketplace. Its source is in +[`extension/`](extension/), and it adds: + +- **A tray**, at the right end of the status bar. Left to right: now playing, + battery, volume, network, the clock and date, and the power button in the + corner. Each one opens a flyout in the bottom panel — a Windows-style card + that rises directly above the item you clicked. +- **Power** — sleep, restart, shut down and log out, with a confirmation. +- **Calendar** — a month grid with today highlighted, on the clock. +- **Quick settings** — Wi-Fi, Bluetooth, airplane mode, energy saver, night + light and accessibility, plus brightness and volume sliders. Tiles hide + themselves when the hardware is not there, rather than showing a dead switch. +- **Network** — scan, connect with a password, and switch between saved + connections. +- **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. Text opens in the editor; everything else + goes to `xdg-open`. +- **Music** — transport controls for whatever is playing, over MPRIS, plus + one-click launchers that open Spotify Web and YouTube Music as their own + browser windows. +- **Apps** — Calculator, Notepad, Paint, Screenshot and Voice Recorder. `VS Code + OS: All Apps…` in the command palette (**Ctrl** + **Shift** + **P**) lists + everything. + +Two honest limits, both imposed by VS Code rather than by this project: +**Spotify audio cannot play inside the editor** (VS Code's Electron ships no +Widevine, so the Web Playback SDK cannot work — which is exactly why the player +controls a real browser window instead), and **Microsoft Edge is not on either +image** (it is AUR-only on Arch, and Microsoft publishes no ARM64 Linux build at +all, so the Pi could never have matched). The browser launcher prefers +`microsoft-edge-stable` if you install it yourself, and falls back to Chromium. + +Every part of the shell can be turned off individually in settings under +`vscodeos.*`; [`extension/README.md`](extension/README.md) has the details. + ### Supported Raspberry Pi models 64-bit boards only: **Pi 5, Pi 4, Pi 400, CM4, Pi 3/3+ and Zero 2 W**. A 4 GB @@ -154,13 +199,21 @@ VSCODEOS_RESPAWN=1 # 0 = do not relaunch when VS Code exits ```bash sudo pacman -Syu # update the Arch base sudo vscodeos-update-code # update VS Code itself (it is not a pacman package) -nmtui # join a Wi-Fi network +nmtui # join a Wi-Fi network (or use the tray) code ~/Projects/thing # open something in the running editor ``` Extensions, settings sync and Marketplace sign-in all work normally; `gnome-keyring` is started by the session so credentials persist. +`vscodeos-update-code` replaces the whole editor tree, which is where the shell +lives, so it reinstalls VsCodeOsCore afterwards. To do that by hand — say after +unpacking a VS Code build yourself: + +```bash +sudo vscodeos-install-extensions --force +``` + ## Building the images ### With GitHub Actions @@ -185,13 +238,18 @@ release. ### Locally +Both builds compile the desktop shell first, so a local build needs **Node.js** +on the machine doing the building — `pacman -S nodejs npm`, or whatever your +distribution calls it. The container one-liner below installs it itself. Pass +`VSCODEOS_SKIP_EXTENSION=1` to leave the shell out and build the bare kiosk. + **x86-64 ISO** — needs Docker (or an Arch host with `archiso`) and roughly 20 GB of free disk space: ```bash docker run --rm --privileged --pull always -v "$PWD:/build" -w /build archlinux:latest \ bash -c 'pacman -Sy --noconfirm --needed archlinux-keyring && - pacman -Syu --noconfirm --needed archiso git grub && + pacman -Syu --noconfirm --needed archiso git grub nodejs npm && ./scripts/build-iso.sh -v 1.0.0' ``` @@ -203,7 +261,7 @@ qemu-system-x86_64 -m 4G -enable-kvm -cdrom out/VSCodeOS-1.0.0-x86_64.iso laptop, an ARM VM), because the build chroots into the image it is assembling: ```bash -sudo apt-get install -y libarchive-tools dosfstools e2fsprogs xz-utils util-linux +sudo apt-get install -y libarchive-tools dosfstools e2fsprogs xz-utils util-linux nodejs npm sudo ./rpi/build-image.sh -v 1.0.0 ``` @@ -223,15 +281,23 @@ stack — when it finishes. ## How the repository is laid out ``` +extension/ VsCodeOsCore, the desktop shell + src/ extension host: sys/, statusbar/, views/, apps/ + media/ one webview bundle per page, plus the stylesheet + (see extension/README.md) + 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/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-code + usr/local/bin/vscodeos-install-extensions archiso/ x86-64 only profiledef.sh archiso profile: image name, boot modes @@ -251,6 +317,7 @@ rpi/ Raspberry Pi only scripts/ build-iso.sh assembles the profile and runs mkarchiso fetch-vscode.sh downloads and stages VS Code (x64 or arm64) + build-extension.sh bundles VsCodeOsCore for both images pkg-versions.sh summarises a build's package manifest .github/workflows/build-iso.yml tag -> both images -> one GitHub release ``` @@ -260,6 +327,18 @@ Details worth knowing if you are modifying it: - **The kiosk is shared, the plumbing is not.** Everything in `rootfs-common/` is copied into both images; anything architecture-specific lives in `archiso/airootfs/` or `rpi/overlay/`. +- **The shell is a built-in extension, not a Marketplace one.** Both builds stage + it at `/usr/share/vscodeos/extensions/`, then `vscodeos-install-extensions` + copies it into `/opt/visual-studio-code/resources/app/extensions/`. That + directory is a plain scan — no `extensions.json`, no version check — so a + VS Code update can never decide the desktop is incompatible and disable it. It + does, however, replace the whole tree, which is why `vscodeos-update-code` + re-runs the installer. +- **The extension is built once, for both images.** It is architecture-neutral + JavaScript, so CI has a separate `extension` job and passes the result to both + image jobs via `VSCODEOS_EXTENSION_PREBUILT`. Local builds compile it on the + spot and cache the result; `VSCODEOS_SKIP_EXTENSION=1` leaves it out when you + are only iterating on the OS. - **Boot menus are not vendored.** `build-iso.sh` copies `syslinux/`, `grub/` and `efiboot/` out of the `archiso` package installed in the build environment and rebrands the labels, so the boot configuration always matches diff --git a/archiso/packages.x86_64 b/archiso/packages.x86_64 index bdf5900..1a3a924 100644 --- a/archiso/packages.x86_64 +++ b/archiso/packages.x86_64 @@ -58,6 +58,23 @@ openssh wget curl ca-certificates +# The quick-settings Bluetooth tile hides itself when there is no adapter, but +# without bluez there is no bluetoothctl to ask in the first place. +bluez +bluez-utils + +# Web browser. +# +# Chromium rather than Microsoft Edge: Edge is AUR-only on Arch, so neither +# build's `pacman -S` flow can install it, and Microsoft publishes no ARM64 +# Linux build at all - the Pi image could never have matched. VsCodeOsCore +# prefers microsoft-edge-stable at run time, so installing it from the AUR is +# enough to make every launcher in the shell use it instead. +# +# This is the single largest thing on this list after the kernel and VS Code +# itself (~600 MiB installed, ~180 MiB once squashfs has had it), so it is the +# first candidate to drop if the ISO ever creeps back over the 2 GiB limit. +chromium # Filesystems / storage helpers gvfs @@ -89,6 +106,9 @@ vulkan-radeon vulkan-swrast xdg-utils xdg-user-dirs +# Screen capture for the shell's screenshot tool. Nothing inside a VS Code +# webview can read the screen, so this has to be a real X client. +scrot # Fonts ttf-dejavu @@ -105,6 +125,10 @@ pipewire-alsa pipewire-pulse wireplumber alsa-utils +# MPRIS transport control for the shell's music player. Chromium exports MPRIS +# for whatever is playing in it, which is how Spotify Web and YouTube Music end +# up controllable from the status bar. +playerctl # Input / laptop bits libinput diff --git a/archiso/profiledef.sh b/archiso/profiledef.sh index 21affe4..ee64cd2 100755 --- a/archiso/profiledef.sh +++ b/archiso/profiledef.sh @@ -25,6 +25,7 @@ file_permissions=( ["/usr/local/bin/vscodeos-install"]="0:0:755" ["/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" ["/usr/local/bin/code"]="0:0:755" ["/etc/sudoers.d/vscodeos"]="0:0:0440" ) diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..54e9318 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,111 @@ +# VsCodeOsCore + +The VS Code OS desktop shell. VS Code OS has no desktop environment — the editor +*is* the user interface — so this extension supplies the parts of a desktop the +editor does not have. + +It ships as a **built-in extension** in both images (see +[Packaging](#packaging)), so it is present on first boot and cannot be +accidentally uninstalled. + +## What it adds + +| | | +| --- | --- | +| **Tray** | Power button, clock and date, battery, volume, network and now-playing, at the right end of the status bar | +| **Flyouts** | Power, calendar, quick settings, volume mixer, network picker and music player | +| **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, open with `xdg-open` | +| **Music** | MPRIS transport for whatever is playing, plus launchers for Spotify Web and YouTube Music | +| **Browser** | Launches Edge → Chromium → Firefox, whichever is installed | +| **Apps** | Calculator, Notepad, Paint, Screenshot and Voice Recorder | + +Every feature is behind a `vscodeos..enabled` setting, all defaulting to +on. `VS Code OS: All Apps…` in the command palette lists everything. + +## Developing + +```bash +cd extension +npm install +npm run watch # esbuild in watch mode +``` + +Then press F5 in VS Code to launch an Extension Development Host. Most +of it works on any Linux desktop; the `sys/*` modules degrade to a hidden status +bar item when the binary they need is missing, so a machine without `nmcli` or +`wpctl` just shows fewer tray items rather than erroring. + +```bash +npm run typecheck # tsc --noEmit, also run in CI +npm run package # production bundles +``` + +## Layout + +``` +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 (panel) and task manager (activity bar) providers + apps/ file explorer, 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 + css/ one stylesheet, all colours from --vscode-* variables + icons/ container and panel icons +``` + +`src/webview/protocol.ts` is imported by both sides, so a change to a message +shape is a compile error in the webview that consumes it. + +## Packaging + +`scripts/build-extension.sh` (in the repo root) produces the tree that ships: + +``` +/usr/share/vscodeos/extensions/vscodeos-core/ +``` + +and `vscodeos-install-extensions` copies it into + +``` +/opt/visual-studio-code/resources/app/extensions/vscodeos-core/ +``` + +That folder is VS Code's built-in extension directory. It is scanned as a plain +directory — no `extensions.json`, no marketplace metadata — and built-ins skip +the engine version check entirely, so a VS Code update can never mark the shell +incompatible. The trade is that `vscodeos-update-code` replaces the whole app +tree, so it re-runs `vscodeos-install-extensions --force` afterwards. + +Being a built-in has one hard consequence: **stable API only.** Proposed APIs +for built-in extensions are gated on Microsoft's `product.json`, which we do not +control. + +## Things this cannot do, and why + +These are VS Code and Electron limits, not missing work: + +- **No popup anchored to a status bar item.** There is no such API. The flyouts + are a webview view in the bottom panel, which is why they open above the + status bar; the panel's height is workbench layout state with no API, so a + flyout opens at whatever height the panel was last left at. +- **The power button is only rightmost because of a setting.** The notifications + bell registers at `NEGATIVE_INFINITY` and extension priorities are clamped to + `-Number.MAX_VALUE`, so no extension can outrank it. The images ship + `"workbench.notifications.position": "top-right"`, which removes the bell from + the status bar. Without it the power button sits second from the right. +- **No microphone in a webview.** VS Code's Electron main process omits `media` + from the permissions it grants webviews, so `getUserMedia({audio:true})` is + denied outright. The recorder is a `pw-record` subprocess with a webview UI. +- **Spotify audio cannot play inside VS Code.** Stock Electron ships no Widevine + CDM, so the Web Playback SDK cannot decrypt anything, and `open.spotify.com` + refuses to be framed. This is why the music player controls real players over + MPRIS and launches the services as browser app windows — Chromium exports + MPRIS, so playback there is fully controllable from the tray. +- **Root-owned processes cannot be ended directly.** There is no polkit + 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. diff --git a/extension/esbuild.mjs b/extension/esbuild.mjs new file mode 100644 index 0000000..497b6ce --- /dev/null +++ b/extension/esbuild.mjs @@ -0,0 +1,62 @@ +// Bundler for VsCodeOsCore. +// +// Two very different targets come out of one config: +// +// src/extension.ts -> dist/extension.js Node/CJS, runs in the extension host +// media/src/*.ts -> media/dist/*.js browser/IIFE, runs inside each webview +// +// Nothing is left in node_modules: the shipped extension is package.json, dist/ +// and media/, which keeps the payload that goes into every image small. + +import * as esbuild from 'esbuild'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = dirname(fileURLToPath(import.meta.url)); +const production = process.argv.includes('--production'); +const watch = process.argv.includes('--watch'); + +// One entry point per page. Shared code lives in media/src/lib and is inlined +// into each bundle rather than emitted as a script of its own. +const webviewEntries = readdirSync(join(root, 'media', 'src'), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) + .map((entry) => join(root, 'media', 'src', entry.name)); + +/** @type {import('esbuild').BuildOptions} */ +const common = { + bundle: true, + minify: production, + sourcemap: production ? false : 'inline', + logLevel: 'info', +}; + +/** @type {import('esbuild').BuildOptions[]} */ +const configs = [ + { + ...common, + entryPoints: [join(root, 'src', 'extension.ts')], + outfile: join(root, 'dist', 'extension.js'), + platform: 'node', + target: 'node18', + format: 'cjs', + // Supplied by the extension host at run time, never bundled. + external: ['vscode'], + }, + { + ...common, + entryPoints: webviewEntries, + outdir: join(root, 'media', 'dist'), + platform: 'browser', + target: 'es2021', + format: 'iife', + }, +]; + +if (watch) { + const contexts = await Promise.all(configs.map((c) => esbuild.context(c))); + await Promise.all(contexts.map((c) => c.watch())); + console.log('watching…'); +} else { + await Promise.all(configs.map((c) => esbuild.build(c))); +} diff --git a/extension/media/css/vscodeos.css b/extension/media/css/vscodeos.css new file mode 100644 index 0000000..8025b13 --- /dev/null +++ b/extension/media/css/vscodeos.css @@ -0,0 +1,989 @@ +/* VS Code OS shell styling. + * + * Every colour comes from a --vscode-* variable, so the shell follows whatever + * theme the user picks instead of shipping its own palette. The flyouts are laid + * out as a card pinned to the bottom-right of the panel, so they read as rising + * out of the tray item that opened them. */ + +:root { + --vscodeos-radius: 8px; + --vscodeos-gap: 8px; + --vscodeos-card-bg: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + --vscodeos-card-border: var(--vscode-widget-border, var(--vscode-editorWidget-border, transparent)); + --vscodeos-tile-bg: var(--vscode-button-secondaryBackground, rgba(127, 127, 127, 0.14)); + --vscodeos-tile-fg: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + --vscodeos-accent: var(--vscode-button-background, #0078d4); + --vscodeos-accent-fg: var(--vscode-button-foreground, #ffffff); + --vscodeos-muted: var(--vscode-descriptionForeground); +} + +* { + box-sizing: border-box; +} + +body.vscodeos { + margin: 0; + padding: 0; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size, 13px); + color: var(--vscode-foreground); + background: transparent; + -webkit-font-smoothing: antialiased; +} + +.icon { + flex: 0 0 auto; + vertical-align: middle; +} + +button { + font-family: inherit; + font-size: inherit; + color: inherit; + border: none; + background: none; + cursor: pointer; +} + +button:focus-visible, +[tabindex]:focus-visible, +input:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +input[type='text'], +input[type='password'], +input[type='search'], +textarea, +select { + font-family: inherit; + font-size: inherit; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + padding: 4px 8px; +} + +/* ---------------------------------------------------------------- flyouts */ + +.flyout-host { + display: flex; + justify-content: flex-end; + align-items: flex-end; + min-height: 100%; + padding: 10px 14px; +} + +.flyout { + width: min(400px, 100%); + max-height: 100%; + overflow-y: auto; + padding: 14px; + border-radius: var(--vscodeos-radius); + border: 1px solid var(--vscodeos-card-border); + background: var(--vscodeos-card-bg); + box-shadow: 0 8px 26px rgba(0, 0, 0, 0.34); + animation: flyout-in 130ms ease-out; +} + +.flyout.wide { + width: min(560px, 100%); +} + +@keyframes flyout-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .flyout { animation: none; } +} + +.flyout-title { + font-size: 1.05em; + font-weight: 600; + margin: 0 0 10px; +} + +.flyout-note { + color: var(--vscodeos-muted); + font-size: 0.92em; + margin: 8px 2px 0; +} + +/* Quick-settings grid, three across, exactly like the Windows tray. */ +.tiles { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--vscodeos-gap); +} + +.tile { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-height: 74px; + padding: 10px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + text-align: left; + transition: background 80ms ease; +} + +.tile:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.tile.on { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.tile.on:hover { + background: var(--vscode-button-hoverBackground, var(--vscodeos-accent)); +} + +.tile-label { + font-size: 0.86em; + line-height: 1.25; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.tile-sub { + opacity: 0.75; + font-size: 0.94em; +} + +.slider-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} + +.slider-row .icon { + opacity: 0.85; +} + +.slider-value { + min-width: 3.2em; + text-align: right; + color: var(--vscodeos-muted); + font-variant-numeric: tabular-nums; +} + +input[type='range'] { + flex: 1; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--vscode-scrollbarSlider-background, rgba(127, 127, 127, 0.4)); + cursor: pointer; +} + +input[type='range']::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--vscodeos-accent); + border: 2px solid var(--vscodeos-card-bg); +} + +/* Power flyout */ +.power-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--vscodeos-gap); +} + +.power-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 18px 10px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + font-size: 0.95em; +} + +.power-button:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.power-button.danger:hover { + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); +} + +/* Calendar */ +.calendar-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.calendar-title { + font-weight: 600; +} + +.calendar-nav { + display: flex; + gap: 2px; +} + +.calendar-nav button, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + color: var(--vscode-foreground); +} + +.calendar-nav button:hover, +.icon-button:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.calendar-grid .dow { + text-align: center; + font-size: 0.82em; + color: var(--vscodeos-muted); + padding: 4px 0; +} + +.calendar-day { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.92em; + font-variant-numeric: tabular-nums; +} + +.calendar-day:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.calendar-day.other-month { + color: var(--vscodeos-muted); + opacity: 0.55; +} + +.calendar-day.today { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); + font-weight: 600; +} + +.calendar-clock { + text-align: center; + font-size: 1.9em; + font-weight: 300; + font-variant-numeric: tabular-nums; + margin-bottom: 2px; +} + +.calendar-date { + text-align: center; + color: var(--vscodeos-muted); + margin-bottom: 12px; +} + +/* Lists (networks, bluetooth devices, sinks, recordings) */ +.list { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; +} + +.list-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 5px; + text-align: left; +} + +.list-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.list-row.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.list-main { + flex: 1; + min-width: 0; +} + +.list-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-sub { + font-size: 0.85em; + color: var(--vscodeos-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-row.active .list-sub { + color: inherit; + opacity: 0.8; +} + +.empty { + padding: 18px 4px; + text-align: center; + color: var(--vscodeos-muted); +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 14px 2px 4px; + font-size: 0.82em; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vscodeos-muted); +} + +/* Now playing */ +.now-playing { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.album-art { + width: 62px; + height: 62px; + border-radius: 5px; + object-fit: cover; + background: var(--vscodeos-tile-bg); + flex: 0 0 auto; +} + +.transport { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin: 8px 0 4px; +} + +.transport button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + color: var(--vscode-foreground); +} + +.transport button:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.transport .primary { + width: 42px; + height: 42px; + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.progress-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82em; + color: var(--vscodeos-muted); + font-variant-numeric: tabular-nums; +} + +/* ----------------------------------------------------------- app chrome */ + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +.toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--vscode-panel-border, transparent); + flex: 0 0 auto; + flex-wrap: wrap; +} + +.toolbar .spacer { + flex: 1; +} + +.button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 4px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + white-space: nowrap; +} + +.button:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.button.primary { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.button.primary:hover { + background: var(--vscode-button-hoverBackground, var(--vscodeos-accent)); +} + +.button:disabled { + opacity: 0.45; + cursor: default; +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.status { + flex: 0 0 auto; + padding: 5px 10px; + border-top: 1px solid var(--vscode-panel-border, transparent); + color: var(--vscodeos-muted); + font-size: 0.88em; + display: flex; + gap: 14px; +} + +.error-banner { + margin: 8px 10px; + padding: 8px 10px; + border-radius: 4px; + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + border: 1px solid var(--vscode-inputValidation-errorBorder, transparent); +} + +/* ------------------------------------------------------- task manager */ + +.meters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + padding: 10px; +} + +.meter { + padding: 10px 12px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); +} + +.meter-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.meter-value { + margin-left: auto; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.meter-sub { + font-size: 0.85em; + color: var(--vscodeos-muted); + margin-top: 6px; +} + +.bar { + height: 5px; + border-radius: 3px; + background: var(--vscode-scrollbarSlider-background, rgba(127, 127, 127, 0.3)); + overflow: hidden; +} + +.bar > span { + display: block; + height: 100%; + background: var(--vscodeos-accent); + transition: width 200ms linear; +} + +.bar.warn > span { + background: var(--vscode-charts-red, #f14c4c); +} + +.cores { + display: flex; + gap: 3px; + margin-top: 8px; + height: 26px; + align-items: flex-end; +} + +.cores > span { + flex: 1; + background: var(--vscodeos-accent); + border-radius: 1px 1px 0 0; + min-height: 2px; + transition: height 200ms linear; +} + +table.processes { + width: 100%; + border-collapse: collapse; + font-size: 0.92em; +} + +table.processes th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + font-weight: 600; + padding: 6px 10px; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.3)); + cursor: pointer; + white-space: nowrap; +} + +table.processes th:hover { + color: var(--vscode-textLink-foreground); +} + +table.processes td { + padding: 4px 10px; + border-bottom: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.12)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 320px; +} + +table.processes tbody tr:hover { + background: var(--vscode-list-hoverBackground); +} + +table.processes tbody tr.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.hot { + color: var(--vscode-charts-red, #f14c4c); + font-weight: 600; +} + +/* ------------------------------------------------------ file explorer */ + +.breadcrumb { + display: flex; + align-items: center; + gap: 2px; + flex-wrap: wrap; + flex: 1; + min-width: 0; +} + +.crumb { + padding: 3px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.crumb:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + +.explorer { + display: flex; + height: 100%; + min-height: 0; +} + +.places { + flex: 0 0 190px; + overflow-y: auto; + padding: 8px; + border-right: 1px solid var(--vscode-panel-border, transparent); +} + +.files { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 8px; +} + +.files.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); + gap: 4px; + align-content: start; +} + +.file-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 10px 6px; + border-radius: 5px; + text-align: center; +} + +.file-tile .name { + font-size: 0.85em; + line-height: 1.25; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.file-tile:hover, +.file-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.file-tile.selected, +.file-row.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.file-row { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 8px; + border-radius: 4px; +} + +.file-row .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-row .size, +.file-row .date { + color: var(--vscodeos-muted); + font-size: 0.86em; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.file-row .size { + width: 78px; + text-align: right; +} + +.file-row .date { + width: 150px; + text-align: right; +} + +.folder-icon { + color: var(--vscode-charts-blue, #75beff); +} + +/* --------------------------------------------------------- calculator */ + +.calc { + max-width: 340px; + margin: 0 auto; + padding: 14px; +} + +.calc-display { + padding: 14px 12px; + border-radius: 6px; + background: var(--vscodeos-tile-bg); + text-align: right; + margin-bottom: 12px; +} + +.calc-expression { + min-height: 1.3em; + color: var(--vscodeos-muted); + font-size: 0.92em; + word-break: break-all; +} + +.calc-result { + font-size: 2.1em; + font-weight: 300; + font-variant-numeric: tabular-nums; + word-break: break-all; +} + +.calc-keys { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.calc-keys.scientific { + grid-template-columns: repeat(5, 1fr); +} + +.key { + padding: 14px 0; + border-radius: 5px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + font-size: 1.05em; +} + +.key:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.key.op { + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.16)); +} + +.key.equals { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.key.span-2 { + grid-column: span 2; +} + +.calc-history { + margin-top: 14px; + max-height: 160px; + overflow-y: auto; + font-size: 0.9em; +} + +.calc-history div { + padding: 4px 2px; + color: var(--vscodeos-muted); + text-align: right; + border-bottom: 1px solid var(--vscode-panel-border, rgba(127, 127, 127, 0.12)); +} + +/* -------------------------------------------------------------- paint */ + +.paint-wrap { + display: flex; + height: 100%; + min-height: 0; +} + +.paint-tools { + flex: 0 0 62px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + border-right: 1px solid var(--vscode-panel-border, transparent); + overflow-y: auto; +} + +.tool { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + border-radius: 5px; + background: var(--vscodeos-tile-bg); +} + +.tool:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.tool.active { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.canvas-area { + flex: 1; + min-width: 0; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: var(--vscode-editor-background); +} + +#canvas { + background: #ffffff; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); + cursor: crosshair; + touch-action: none; + max-width: 100%; +} + +.swatches { + display: grid; + grid-template-columns: repeat(8, 18px); + gap: 3px; +} + +.swatch { + width: 18px; + height: 18px; + border-radius: 3px; + border: 1px solid rgba(127, 127, 127, 0.4); + padding: 0; +} + +.swatch.active { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +/* ------------------------------------------------ notepad / screenshot */ + +#notepad-text { + width: 100%; + height: 100%; + border: none; + border-radius: 0; + resize: none; + padding: 12px 14px; + font-family: var(--vscode-editor-font-family); + font-size: var(--vscode-editor-font-size, 14px); + line-height: 1.5; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +#notepad-text:focus { + outline: none; +} + +.shot-preview, +.paint-preview { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 0; +} + +.shot-preview img { + max-width: 100%; + max-height: 68vh; + border-radius: 4px; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3); +} + +.field { + display: flex; + align-items: center; + gap: 6px; +} + +.field label { + color: var(--vscodeos-muted); + font-size: 0.9em; +} + +/* ----------------------------------------------------------- recorder */ + +.record-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 28px 16px 18px; +} + +.record-button { + width: 84px; + height: 84px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-charts-red, #f14c4c); + color: #ffffff; +} + +.record-button.recording { + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(241, 76, 76, 0.5); } + 50% { box-shadow: 0 0 0 14px rgba(241, 76, 76, 0); } +} + +@media (prefers-reduced-motion: reduce) { + .record-button.recording { animation: none; } +} + +.record-time { + font-size: 2em; + font-weight: 300; + font-variant-numeric: tabular-nums; +} + +audio { + width: 100%; +} diff --git a/extension/media/icons/calculator.svg b/extension/media/icons/calculator.svg new file mode 100644 index 0000000..cd95347 --- /dev/null +++ b/extension/media/icons/calculator.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/media/icons/files.svg b/extension/media/icons/files.svg new file mode 100644 index 0000000..acc264a --- /dev/null +++ b/extension/media/icons/files.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/notepad.svg b/extension/media/icons/notepad.svg new file mode 100644 index 0000000..89f59e4 --- /dev/null +++ b/extension/media/icons/notepad.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/paint.svg b/extension/media/icons/paint.svg new file mode 100644 index 0000000..c87a70b --- /dev/null +++ b/extension/media/icons/paint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/media/icons/recorder.svg b/extension/media/icons/recorder.svg new file mode 100644 index 0000000..b9e29d6 --- /dev/null +++ b/extension/media/icons/recorder.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/screenshot.svg b/extension/media/icons/screenshot.svg new file mode 100644 index 0000000..f467426 --- /dev/null +++ b/extension/media/icons/screenshot.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/media/icons/system.svg b/extension/media/icons/system.svg new file mode 100644 index 0000000..9a8e7f2 --- /dev/null +++ b/extension/media/icons/system.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extension/media/src/calculator.ts b/extension/media/src/calculator.ts new file mode 100644 index 0000000..ae60782 --- /dev/null +++ b/extension/media/src/calculator.ts @@ -0,0 +1,326 @@ +// Calculator: standard and scientific, keyboard-driven. +// +// The expression is evaluated by a small shunting-yard parser rather than by +// handing a string to the JS engine - a webview that eval()s whatever is in a +// text field is exactly the thing a CSP is there to prevent. + +import { clear, h, post, root, vscode } from './lib/dom'; +import { icon } from './lib/icons'; + +interface Persisted { + scientific: boolean; + history: string[]; +} + +const saved = vscode.getState() ?? { scientific: false, history: [] }; +let scientific = saved.scientific; +let history: string[] = saved.history; +let expression = ''; +let result = '0'; + +const expressionEl = h('div', { class: 'calc-expression' }); +const resultEl = h('div', { class: 'calc-result' }, '0'); +const keysEl = h('div', { class: 'calc-keys' }); +const historyEl = h('div', { class: 'calc-history' }); + +const modeButton = h('button', { + class: 'button', + on: { + click: () => { + scientific = !scientific; + persist(); + renderKeys(); + modeButton.replaceChildren(scientific ? 'Standard' : 'Scientific'); + }, + }, +}, scientific ? 'Standard' : 'Scientific'); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('span', { html: icon('grid', 16) }), + h('span', {}, 'Calculator'), + h('span', { class: 'spacer' }), + modeButton, + h('button', { + class: 'button', + on: { click: () => { history = []; persist(); renderHistory(); } }, + }, 'Clear history'), + ), + h('div', { class: 'body' }, + h('div', { class: 'calc' }, + h('div', { class: 'calc-display' }, expressionEl, resultEl), + keysEl, + historyEl, + ), + ), +)); + +const STANDARD: [string, string?][] = [ + ['C'], ['⌫'], ['%'], ['÷'], + ['7'], ['8'], ['9'], ['×'], + ['4'], ['5'], ['6'], ['−'], + ['1'], ['2'], ['3'], ['+'], + ['±'], ['0'], ['.'], ['='], +]; + +const SCIENTIFIC: [string, string?][] = [ + ['C'], ['⌫'], ['('], [')'], ['÷'], + ['sin'], ['cos'], ['tan'], ['^'], ['×'], + ['ln'], ['log'], ['√'], ['%'], ['−'], + ['7'], ['8'], ['9'], ['π'], ['+'], + ['4'], ['5'], ['6'], ['e'], ['='], + ['1'], ['2'], ['3'], ['0'], ['.'], +]; + +function renderKeys(): void { + keysEl.className = `calc-keys${scientific ? ' scientific' : ''}`; + clear(keysEl); + for (const [label] of scientific ? SCIENTIFIC : STANDARD) { + const isOperator = ['÷', '×', '−', '+', '%', '^', '(', ')'].includes(label); + const isFunction = ['sin', 'cos', 'tan', 'ln', 'log', '√', 'π', 'e'].includes(label); + keysEl.append(h('button', { + class: `key${label === '=' ? ' equals' : isOperator || isFunction ? ' op' : ''}`, + on: { click: () => press(label) }, + }, label)); + } +} + +function renderHistory(): void { + clear(historyEl); + for (const entry of history.slice(-12).reverse()) { + historyEl.append(h('div', {}, entry)); + } +} + +function persist(): void { + vscode.setState({ scientific, history }); +} + +function press(key: string): void { + switch (key) { + case 'C': + expression = ''; + result = '0'; + break; + case '⌫': + expression = expression.slice(0, -1); + break; + case '=': { + if (!expression) { + break; + } + const value = evaluate(expression); + result = value; + if (!value.startsWith('Error')) { + history = [...history, `${expression} = ${value}`].slice(-50); + persist(); + renderHistory(); + expression = value; + } + break; + } + case '±': + expression = expression.startsWith('-') ? expression.slice(1) : `-${expression}`; + break; + case 'π': + expression += 'π'; + break; + case 'e': + expression += 'e'; + break; + case 'sin': case 'cos': case 'tan': case 'ln': case 'log': case '√': + expression += `${key}(`; + break; + default: + expression += key; + } + + if (key !== '=' && expression) { + const preview = evaluate(expression); + result = preview.startsWith('Error') ? result : preview; + } + draw(); +} + +function draw(): void { + expressionEl.textContent = expression || ' '; + resultEl.textContent = result; +} + +// --------------------------------------------------------------- evaluation + +type Token = { kind: 'number'; value: number } | { kind: 'op' | 'fn' | 'paren'; value: string }; + +const PRECEDENCE: Record = { '+': 1, '−': 1, '×': 2, '÷': 2, '%': 2, '^': 3 }; +const FUNCTIONS: Record number> = { + sin: (v) => Math.sin(v), + cos: (v) => Math.cos(v), + tan: (v) => Math.tan(v), + ln: (v) => Math.log(v), + log: (v) => Math.log10(v), + '√': (v) => Math.sqrt(v), +}; + +function tokenize(input: string): Token[] | undefined { + const tokens: Token[] = []; + let i = 0; + while (i < input.length) { + const char = input[i]; + if (char === ' ') { + i++; + } else if (/[\d.]/.test(char)) { + let number = ''; + while (i < input.length && /[\d.]/.test(input[i])) { + number += input[i++]; + } + const value = Number(number); + if (!Number.isFinite(value)) { + return undefined; + } + tokens.push({ kind: 'number', value }); + } else if (char === 'π') { + tokens.push({ kind: 'number', value: Math.PI }); + i++; + } else if (char === 'e') { + tokens.push({ kind: 'number', value: Math.E }); + i++; + } else if (char === '(' || char === ')') { + tokens.push({ kind: 'paren', value: char }); + i++; + } else if (char in PRECEDENCE) { + // A leading minus, or one straight after another operator, is a sign. + const previous = tokens[tokens.length - 1]; + if (char === '−' && (!previous || previous.kind === 'op' || (previous.kind === 'paren' && previous.value === '('))) { + tokens.push({ kind: 'number', value: 0 }); + } + tokens.push({ kind: 'op', value: char }); + i++; + } else if (char === '-') { + tokens.push({ kind: 'op', value: '−' }); + i++; + } else { + const name = Object.keys(FUNCTIONS).find((fn) => input.startsWith(fn, i)); + if (!name) { + return undefined; + } + tokens.push({ kind: 'fn', value: name }); + i += name.length; + } + } + return tokens; +} + +/** Shunting-yard to RPN, then a stack evaluation. No eval(), no Function(). */ +function evaluate(input: string): string { + const tokens = tokenize(input); + if (!tokens || tokens.length === 0) { + return 'Error'; + } + + const output: Token[] = []; + const operators: Token[] = []; + + for (const token of tokens) { + if (token.kind === 'number') { + output.push(token); + } else if (token.kind === 'fn') { + operators.push(token); + } else if (token.kind === 'op') { + while (operators.length) { + const top = operators[operators.length - 1]; + const higher = top.kind === 'fn' + || (top.kind === 'op' && PRECEDENCE[top.value] >= PRECEDENCE[token.value] && token.value !== '^'); + if (top.kind === 'paren' || !higher) { + break; + } + output.push(operators.pop() as Token); + } + operators.push(token); + } else if (token.value === '(') { + operators.push(token); + } else { + let matched = false; + while (operators.length) { + const top = operators.pop() as Token; + if (top.kind === 'paren' && top.value === '(') { + matched = true; + break; + } + output.push(top); + } + if (!matched) { + return 'Error'; + } + const top = operators[operators.length - 1]; + if (top?.kind === 'fn') { + output.push(operators.pop() as Token); + } + } + } + while (operators.length) { + const top = operators.pop() as Token; + if (top.kind === 'paren') { + return 'Error'; + } + output.push(top); + } + + const stack: number[] = []; + for (const token of output) { + if (token.kind === 'number') { + stack.push(token.value); + } else if (token.kind === 'fn') { + const value = stack.pop(); + if (value === undefined) { + return 'Error'; + } + stack.push(FUNCTIONS[token.value](value)); + } else { + const right = stack.pop(); + const left = stack.pop(); + if (right === undefined || left === undefined) { + return 'Error'; + } + switch (token.value) { + case '+': stack.push(left + right); break; + case '−': stack.push(left - right); break; + case '×': stack.push(left * right); break; + case '÷': stack.push(right === 0 ? NaN : left / right); break; + case '%': stack.push(right === 0 ? NaN : left % right); break; + case '^': stack.push(left ** right); break; + default: return 'Error'; + } + } + } + + const value = stack.pop(); + if (value === undefined || stack.length > 0 || !Number.isFinite(value)) { + return value !== undefined && !Number.isFinite(value) ? 'Error: undefined' : 'Error'; + } + // Kill the float noise 0.1+0.2 produces without truncating real precision. + return String(Number(value.toPrecision(12))); +} + +// ----------------------------------------------------------------- keyboard + +const KEY_MAP: Record = { + '/': '÷', '*': '×', '-': '−', '+': '+', '%': '%', '^': '^', + Enter: '=', '=': '=', Backspace: '⌫', Escape: 'C', Delete: 'C', + '(': '(', ')': ')', '.': '.', +}; + +document.addEventListener('keydown', (event) => { + if (/^\d$/.test(event.key)) { + press(event.key); + } else if (event.key in KEY_MAP) { + press(KEY_MAP[event.key]); + } else { + return; + } + event.preventDefault(); +}); + +renderKeys(); +renderHistory(); +draw(); +post({ type: 'ready' }); diff --git a/extension/media/src/files.ts b/extension/media/src/files.ts new file mode 100644 index 0000000..eee83c8 --- /dev/null +++ b/extension/media/src/files.ts @@ -0,0 +1,306 @@ +// File explorer front-end: places sidebar, breadcrumb, grid/list view. + +import { append, clear, formatBytes, h, onMessage, post, root, vscode } from './lib/dom'; +import { icon } from './lib/icons'; +import type { FileEntry, HostMessage, Place } from '../../src/webview/protocol'; + +interface Persisted { + view: 'grid' | 'list'; + showHidden: boolean; + sort: 'name' | 'size' | 'modified'; +} + +const saved = vscode.getState() ?? { view: 'grid', showHidden: false, sort: 'name' }; +let view = saved.view; +let showHidden = saved.showHidden; +let sort = saved.sort; + +let currentPath = '/'; +let entries: FileEntry[] = []; +let places: Place[] = []; +let selection = new Set(); +const history: string[] = []; +let historyIndex = -1; + +const breadcrumb = h('div', { class: 'breadcrumb' }); +const placesPane = h('div', { class: 'places' }); +const filesPane = h('div', { class: 'files' }); +const status = h('div', { class: 'status' }); +const banner = h('div', { class: 'error-banner', hidden: true }); + +const backButton = iconButton('chevronLeft', 'Back', () => go(-1)); +const forwardButton = iconButton('chevronRight', 'Forward', () => go(1)); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + backButton, + forwardButton, + iconButton('chevronUp', 'Up one level', () => { + const parent = currentPath.replace(/\/[^/]+\/?$/, '') || '/'; + navigate(parent); + }), + 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 })), + iconButton(view === 'grid' ? 'list' : 'grid', 'Switch view', () => { + view = view === 'grid' ? 'list' : 'grid'; + persist(); + renderFiles(); + }), + iconButton('search', showHidden ? 'Hide hidden files' : 'Show hidden files', () => { + showHidden = !showHidden; + persist(); + renderFiles(); + }), + ), + banner, + h('div', { class: 'body' }, h('div', { class: 'explorer' }, placesPane, filesPane)), + status, +)); + +function iconButton(glyph: string, title: string, onClick: () => void): HTMLButtonElement { + return h('button', { class: 'icon-button', title, html: icon(glyph, 16), on: { click: onClick } }); +} + +onMessage((message) => { + if (message.type !== 'files') { + return; + } + currentPath = message.path; + entries = message.entries; + places = message.places; + selection = new Set(); + + banner.hidden = !message.error; + if (message.error) { + clear(banner).append(h('span', { html: icon('warning', 16) }), ` ${message.error}`); + } + + // Only record a genuine move, so Back does not walk through refreshes. + if (history[historyIndex] !== currentPath) { + history.splice(historyIndex + 1); + history.push(currentPath); + historyIndex = history.length - 1; + } + backButton.disabled = historyIndex <= 0; + forwardButton.disabled = historyIndex >= history.length - 1; + + renderPlaces(); + renderBreadcrumb(); + renderFiles(); +}); + +post({ type: 'ready' }); + +function persist(): void { + vscode.setState({ view, showHidden, sort }); +} + +function navigate(path: string): void { + post({ type: 'navigate', path }); +} + +function go(delta: number): void { + const next = historyIndex + delta; + if (next < 0 || next >= history.length) { + return; + } + historyIndex = next; + // Do not re-record: the message handler sees the same entry it is on. + post({ type: 'navigate', path: history[next] }); +} + +function renderPlaces(): void { + clear(placesPane); + for (const place of places) { + placesPane.append(h('button', { + class: `list-row${place.path === currentPath ? ' active' : ''}`, + on: { click: () => navigate(place.path) }, + }, + h('span', { html: icon(place.icon, 16) }), + h('span', { class: 'list-name' }, place.name), + )); + } +} + +function renderBreadcrumb(): void { + clear(breadcrumb); + const parts = currentPath.split('/').filter(Boolean); + breadcrumb.append(h('button', { class: 'crumb', on: { click: () => navigate('/') } }, '/')); + let accumulated = ''; + for (const part of parts) { + accumulated += `/${part}`; + const target = accumulated; + breadcrumb.append(h('button', { class: 'crumb', on: { click: () => navigate(target) } }, part)); + } +} + +function renderFiles(): void { + const visible = entries + .filter((entry) => showHidden || !entry.hidden) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + if (sort === 'size') { + return b.size - a.size; + } + if (sort === 'modified') { + return b.modified - a.modified; + } + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + }); + + filesPane.className = `files${view === 'grid' ? ' grid' : ''}`; + clear(filesPane); + + if (visible.length === 0) { + filesPane.append(h('div', { class: 'empty' }, 'This folder is empty.')); + } + + for (const entry of visible) { + const glyph = entry.isDirectory ? 'folder' : glyphFor(entry.name); + const activate = (): void => { + if (entry.isDirectory) { + navigate(entry.path); + } else { + post({ type: 'openFile', path: entry.path }); + } + }; + const select = (event: MouseEvent): void => { + if (!event.ctrlKey && !event.metaKey) { + selection.clear(); + } + if (selection.has(entry.path)) { + selection.delete(entry.path); + } else { + selection.add(entry.path); + } + renderFiles(); + renderStatus(); + }; + + const common = { + title: entry.path, + tabIndex: 0, + on: { + click: select as (event: never) => void, + dblclick: activate, + keydown: ((event: KeyboardEvent) => { + if (event.key === 'Enter') { + activate(); + } else if (event.key === 'Delete') { + post({ type: 'delete', paths: [entry.path] }); + } else if (event.key === 'F2') { + post({ type: 'rename', path: entry.path }); + } + }) as (event: never) => void, + contextmenu: ((event: MouseEvent) => { + event.preventDefault(); + selection = new Set([entry.path]); + renderFiles(); + showMenu(event, entry); + }) as (event: never) => void, + }, + }; + + if (view === 'grid') { + filesPane.append(h('div', { + ...common, + class: `file-tile${selection.has(entry.path) ? ' selected' : ''}`, + }, + h('span', { class: entry.isDirectory ? 'folder-icon' : '', html: icon(glyph, 30) }), + h('span', { class: 'name' }, entry.name), + )); + } else { + filesPane.append(h('div', { + ...common, + class: `file-row${selection.has(entry.path) ? ' selected' : ''}`, + }, + 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)), + h('span', { class: 'date' }, entry.modified ? new Date(entry.modified).toLocaleString() : ''), + )); + } + } + renderStatus(); +} + +function renderStatus(): void { + 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), + h('span', {}, `${folders} folders, ${files} files`), + selection.size > 0 ? h('span', {}, `${selection.size} selected`) : null, + ); +} + +/** A small context menu, positioned at the pointer. */ +function showMenu(event: MouseEvent, entry: FileEntry): void { + document.querySelector('.context-menu')?.remove(); + + const paths = selection.size > 0 ? [...selection] : [entry.path]; + const item = (label: string, glyph: string, action: () => void): HTMLElement => + h('button', { class: 'list-row', on: { click: () => { menu.remove(); action(); } } }, + h('span', { html: icon(glyph, 15) }), h('span', { class: 'list-name' }, label)); + + 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`, + 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 })), + ); + + document.body.append(menu); + const dismiss = (): void => { + menu.remove(); + document.removeEventListener('click', dismiss); + }; + setTimeout(() => document.addEventListener('click', dismiss), 0); +} + +function glyphFor(name: string): string { + const extension = name.slice(name.lastIndexOf('.')).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'].includes(extension)) { + return 'image'; + } + if (['.mp3', '.wav', '.flac', '.ogg', '.m4a', '.opus'].includes(extension)) { + return 'music'; + } + 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] }); + } 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) { + post({ type: 'clipboard', paths: [...selection], cut: true }); + } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { + post({ type: 'paste', target: currentPath }); + } +}); diff --git a/extension/media/src/flyout.ts b/extension/media/src/flyout.ts new file mode 100644 index 0000000..5bb474f --- /dev/null +++ b/extension/media/src/flyout.ts @@ -0,0 +1,529 @@ +// The six tray flyouts, drawn as one right-anchored card. +// +// The panel is full width, so the card pins itself to the bottom-right corner: +// that is what makes it read as a flyout rising out of the tray item that was +// clicked, rather than as a docked panel. + +import { append, clear, h, onMessage, post, root, throttle, formatDuration } from './lib/dom'; +import { icon, signalIcon } from './lib/icons'; +import type { FlyoutKind, FlyoutState, HostMessage } from '../../src/webview/protocol'; + +let kind: FlyoutKind = 'quicksettings'; +let state: FlyoutState | undefined; +let busy: string | undefined; +/** Month the calendar is showing; the clock keeps ticking regardless. */ +let calendarMonth = new Date(); + +const host = h('div', { class: 'flyout-host' }); +const card = h('div', { class: 'flyout' }); +host.append(card); +clear(root()).append(host); + +onMessage((message) => { + switch (message.type) { + case 'flyout': + if (message.kind !== kind) { + kind = message.kind; + calendarMonth = new Date(); + busy = undefined; + } + render(); + return; + case 'state': + state = message.state; + busy = undefined; + render(); + return; + case 'scanning': + busy = 'Scanning for networks…'; + render(); + return; + case 'busy': + busy = message.label; + render(); + return; + default: + return; + } +}); + +post({ type: 'ready' }); + +// The clock in the calendar card has to tick on its own; the host only pushes +// state every couple of seconds. +setInterval(() => { + if (kind === 'calendar') { + render(); + } +}, 1000); + +function render(): void { + card.classList.toggle('wide', kind === 'network' || kind === 'music'); + clear(card); + switch (kind) { + case 'power': return renderPower(); + case 'calendar': return renderCalendar(); + case 'volume': return renderVolume(); + case 'network': return renderNetwork(); + case 'music': return renderMusic(); + default: return renderQuickSettings(); + } +} + +function title(text: string): HTMLElement { + return h('h2', { class: 'flyout-title' }, text); +} + +// ------------------------------------------------------------------- power + +type PowerName = 'poweroff' | 'reboot' | 'suspend' | 'logout'; + +function renderPower(): void { + const button = (name: PowerName, label: string, glyph: string, danger = false): HTMLElement => + h('button', { + class: `power-button${danger ? ' danger' : ''}`, + on: { click: () => post({ type: 'power', action: name }) }, + }, h('span', { html: icon(glyph, 26) }), label); + + append(card, + title('Power'), + h('div', { class: 'power-grid' }, + button('poweroff', 'Shut down', 'power', true), + button('reboot', 'Restart', 'restart'), + state?.canSuspend !== false ? button('suspend', 'Sleep', 'sleep') : null, + button('logout', 'Log out', 'logout'), + ), + state?.battery?.present + ? h('p', { class: 'flyout-note' }, + `Battery ${state.battery.level}% · ${state.battery.charging ? 'charging' : state.battery.status.toLowerCase()}`) + : null, + ); +} + +// ---------------------------------------------------------------- calendar + +function renderCalendar(): void { + const now = new Date(); + const shown = calendarMonth; + const first = new Date(shown.getFullYear(), shown.getMonth(), 1); + // Monday-first, matching the reference and most of Europe. + const offset = (first.getDay() + 6) % 7; + const daysInMonth = new Date(shown.getFullYear(), shown.getMonth() + 1, 0).getDate(); + const daysInPrevious = new Date(shown.getFullYear(), shown.getMonth(), 0).getDate(); + + const grid = h('div', { class: 'calendar-grid' }); + for (const day of ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']) { + grid.append(h('div', { class: 'dow' }, day)); + } + + const cell = (day: number, otherMonth: boolean, date: Date): HTMLElement => { + const isToday = date.toDateString() === now.toDateString(); + return h('button', { + class: `calendar-day${otherMonth ? ' other-month' : ''}${isToday ? ' today' : ''}`, + title: date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), + }, String(day)); + }; + + for (let i = offset - 1; i >= 0; i--) { + const day = daysInPrevious - i; + grid.append(cell(day, true, new Date(shown.getFullYear(), shown.getMonth() - 1, day))); + } + for (let day = 1; day <= daysInMonth; day++) { + grid.append(cell(day, false, new Date(shown.getFullYear(), shown.getMonth(), day))); + } + // Fill the last row so the grid never reflows between months. + const used = offset + daysInMonth; + for (let day = 1; used + day - 1 < Math.ceil(used / 7) * 7; day++) { + grid.append(cell(day, true, new Date(shown.getFullYear(), shown.getMonth() + 1, day))); + } + + const step = (months: number) => () => { + calendarMonth = new Date(shown.getFullYear(), shown.getMonth() + months, 1); + render(); + }; + + card.append( + h('div', { class: 'calendar-clock' }, now.toLocaleTimeString()), + h('div', { class: 'calendar-date' }, + now.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })), + h('div', { class: 'calendar-head' }, + h('button', { + class: 'calendar-title icon-button', + style: { width: 'auto', padding: '0 8px' }, + title: 'Back to this month', + on: { click: () => { calendarMonth = new Date(); render(); } }, + }, shown.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })), + h('div', { class: 'calendar-nav' }, + h('button', { title: 'Previous month', html: icon('chevronUp', 16), on: { click: step(-1) } }), + h('button', { title: 'Next month', html: icon('chevronDown', 16), on: { click: step(1) } }), + ), + ), + grid, + ); +} + +// ----------------------------------------------------------- quick settings + +function renderQuickSettings(): void { + const tiles = h('div', { class: 'tiles' }); + + const tile = (options: { + on: boolean; + glyph: string; + label: string; + sub?: string; + onClick: () => void; + disabled?: boolean; + }): HTMLElement => + h('button', { + class: `tile${options.on ? ' on' : ''}`, + disabled: options.disabled, + on: { click: options.onClick }, + }, + h('span', { html: options.glyph }), + h('span', { class: 'tile-label' }, + options.label, + options.sub ? h('div', { class: 'tile-sub' }, options.sub) : null, + )); + + const network = state?.network; + if (network?.wifiHardware) { + const active = network.active.find((c) => c.type.includes('wireless')); + tiles.append(tile({ + on: network.wifiEnabled, + // A real signal strength only exists per access point, and the tile + // has no room for one - so it says on/off rather than inventing bars. + glyph: icon(network.wifiEnabled ? 'wifi' : 'wifiOff', 20), + label: 'Wi-Fi', + sub: network.wifiEnabled ? (active?.name ?? 'Not connected') : 'Off', + onClick: () => post({ type: 'wifi', enabled: !network.wifiEnabled }), + })); + } + + if (state?.bluetooth?.available) { + const connected = state.bluetooth.devices.find((d) => d.connected); + tiles.append(tile({ + on: state.bluetooth.powered, + glyph: icon('bluetooth', 20), + label: 'Bluetooth', + sub: state.bluetooth.powered ? (connected?.name ?? 'Not connected') : 'Off', + onClick: () => post({ type: 'bluetooth', enabled: !state?.bluetooth?.powered }), + })); + } + + tiles.append(tile({ + on: state?.airplaneMode ?? false, + glyph: icon('airplane', 20), + label: 'Airplane mode', + onClick: () => post({ type: 'airplane', enabled: !state?.airplaneMode }), + })); + + tiles.append(tile({ + on: state?.energySaver ?? false, + glyph: icon('battery', 20), + label: 'Energy saver', + onClick: () => post({ type: 'energySaver', enabled: !state?.energySaver }), + })); + + tiles.append(tile({ + on: state?.nightLight ?? false, + glyph: icon('moon', 20), + label: 'Night light', + onClick: () => post({ type: 'nightLight', enabled: !state?.nightLight }), + })); + + tiles.append(tile({ + on: false, + glyph: icon('accessibility', 20), + label: 'Accessibility', + onClick: () => post({ type: 'accessibility' }), + })); + + card.append(title('Quick settings'), tiles); + + if (state?.brightness !== undefined) { + card.append(slider('sun', state.brightness, 1, 100, (value) => post({ type: 'brightness', value }))); + } + + if (state?.audio?.available) { + const audio = state.audio; + card.append(slider( + audio.muted ? 'volumeMute' : 'volumeHigh', + audio.muted ? 0 : audio.volume, + 0, + 100, + (value) => post({ type: 'volume', value }), + () => post({ type: 'mute' }), + )); + } + + const battery = state?.battery; + if (battery) { + card.append(h('p', { class: 'flyout-note' }, + battery.present + ? `Battery ${battery.level}% · ${battery.charging ? 'charging' : battery.onAc ? 'plugged in' : 'on battery'}` + : 'Running on mains power')); + } +} + +function slider( + glyph: string, + value: number, + min: number, + max: number, + onInput: (value: number) => void, + onIconClick?: () => void, +): HTMLElement { + const readout = h('span', { class: 'slider-value' }, `${Math.round(value)}%`); + const send = throttle(onInput, 120); + return h('div', { class: 'slider-row' }, + h(onIconClick ? 'button' : 'span', { + class: onIconClick ? 'icon-button' : '', + html: icon(glyph, 18), + on: onIconClick ? { click: onIconClick } : {}, + }), + h('input', { + type: 'range', + min, + max, + value, + on: { + input: (event: Event) => { + const next = Number((event.target as HTMLInputElement).value); + readout.textContent = `${next}%`; + send(next); + }, + }, + }), + readout, + ); +} + +// ------------------------------------------------------------------ volume + +function renderVolume(): void { + const audio = state?.audio; + if (!audio?.available) { + card.append(title('Volume'), h('div', { class: 'empty' }, 'No audio device found.')); + return; + } + + card.append( + title('Volume'), + slider( + audio.muted ? 'volumeMute' : 'volumeHigh', + audio.muted ? 0 : audio.volume, + 0, + 100, + (value) => post({ type: 'volume', value }), + () => post({ type: 'mute' }), + ), + h('div', { class: 'section-head' }, 'Output device'), + ); + + const list = h('div', { class: 'list' }); + if (audio.sinks.length === 0) { + list.append(h('div', { class: 'empty' }, 'No outputs reported.')); + } + for (const sink of audio.sinks) { + list.append(h('button', { + class: `list-row${sink.isDefault ? ' active' : ''}`, + on: { click: () => post({ type: 'sink', id: sink.id }) }, + }, + h('span', { html: icon('volume', 18) }), + h('span', { class: 'list-main' }, h('div', { class: 'list-name' }, sink.name)), + sink.isDefault ? h('span', { html: icon('check', 16) }) : null, + )); + } + card.append(list); +} + +// ----------------------------------------------------------------- network + +function renderNetwork(): void { + const network = state?.network; + card.append(title('Network')); + + if (!network?.available) { + card.append(h('div', { class: 'empty' }, 'NetworkManager is not running.')); + return; + } + + const list = h('div', { class: 'list' }); + + for (const connection of network.active.filter((c) => !c.type.includes('wireless'))) { + list.append(h('div', { class: 'list-row active' }, + h('span', { html: icon('ethernet', 18) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, connection.name), + h('div', { class: 'list-sub' }, `${connection.type} · ${connection.device}`), + ), + h('button', { + class: 'icon-button', + title: 'Disconnect', + html: icon('close', 15), + on: { click: () => post({ type: 'disconnect', name: connection.name }) }, + }), + )); + } + + card.append( + h('div', { class: 'section-head' }, + h('span', {}, network.wifiHardware ? 'Wi-Fi' : 'Connections'), + h('span', { style: { display: 'flex', gap: '2px' } }, + network.wifiHardware + ? h('button', { + class: 'icon-button', + title: network.wifiEnabled ? 'Turn Wi-Fi off' : 'Turn Wi-Fi on', + html: icon(network.wifiEnabled ? 'wifi' : 'wifiOff', 15), + on: { click: () => post({ type: 'wifi', enabled: !network.wifiEnabled }) }, + }) + : null, + network.wifiHardware && network.wifiEnabled + ? h('button', { + class: 'icon-button', + title: 'Scan again', + html: icon('refresh', 15), + on: { click: () => post({ type: 'scan' }) }, + }) + : null, + ), + ), + list, + ); + + if (busy) { + card.append(h('div', { class: 'empty' }, busy)); + return; + } + + if (!network.wifiHardware) { + card.append(h('p', { class: 'flyout-note' }, 'No wireless adapter on this machine.')); + return; + } + if (!network.wifiEnabled) { + card.append(h('div', { class: 'empty' }, 'Wi-Fi is off.')); + return; + } + if (network.accessPoints.length === 0) { + card.append(h('div', { class: 'empty' }, 'No networks in range.')); + return; + } + + const wifiList = h('div', { class: 'list' }); + for (const point of network.accessPoints) { + const secured = point.security !== '' && point.security !== '--'; + wifiList.append(h('button', { + class: `list-row${point.inUse ? ' active' : ''}`, + on: { + click: () => { + if (point.inUse) { + post({ type: 'disconnect', name: point.ssid }); + } else { + post({ type: 'connect', ssid: point.ssid, secured, known: point.known }); + } + }, + }, + }, + h('span', { html: signalIcon(point.signal, 18) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, point.ssid), + h('div', { class: 'list-sub' }, + point.inUse ? 'Connected' : point.known ? 'Saved' : secured ? point.security : 'Open'), + ), + secured ? h('span', { html: icon('lock', 14), style: { opacity: '0.7' } }) : null, + )); + } + card.append(wifiList); +} + +// ------------------------------------------------------------------- music + +function renderMusic(): void { + card.append(title('Music')); + + const launchers = h('div', { class: 'tiles', style: { gridTemplateColumns: 'repeat(2, 1fr)' } }, + h('button', { + class: 'tile', + on: { click: () => post({ type: 'launchMusic', service: 'spotify' }) }, + }, h('span', { html: icon('music', 20) }), h('span', { class: 'tile-label' }, 'Spotify Web')), + h('button', { + class: 'tile', + on: { click: () => post({ type: 'launchMusic', service: 'ytmusic' }) }, + }, h('span', { html: icon('play', 20) }), h('span', { class: 'tile-label' }, 'YouTube Music')), + ); + card.append(launchers); + + if (!state?.mprisAvailable) { + card.append(h('p', { class: 'flyout-note' }, + 'Install playerctl to control playback from here: sudo pacman -S playerctl')); + return; + } + + const playing = state.nowPlaying; + card.append(h('div', { class: 'section-head' }, 'Now playing')); + + if (!playing) { + card.append(h('div', { class: 'empty' }, + 'Nothing is playing. Open one of the services above and press play.')); + return; + } + + card.append(h('div', { class: 'now-playing' }, + playing.artUrl + ? h('img', { class: 'album-art', src: playing.artUrl, alt: '' }) + : h('div', { class: 'album-art', style: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, html: icon('music', 26) }), + h('div', { class: 'list-main' }, + h('div', { class: 'list-name', style: { fontWeight: '600' } }, playing.title), + h('div', { class: 'list-sub' }, playing.artist || '—'), + h('div', { class: 'list-sub' }, playing.album || playing.player), + ), + )); + + card.append(h('div', { class: 'transport' }, + h('button', { + title: 'Previous', + html: icon('previous', 18), + on: { click: () => post({ type: 'transport', action: 'previous' }) }, + }), + h('button', { + class: 'primary', + title: playing.status === 'Playing' ? 'Pause' : 'Play', + html: icon(playing.status === 'Playing' ? 'pause' : 'play', 20), + on: { click: () => post({ type: 'transport', action: 'playPause' }) }, + }), + h('button', { + title: 'Next', + html: icon('next', 18), + on: { click: () => post({ type: 'transport', action: 'next' }) }, + }), + )); + + if (playing.length > 0) { + card.append(h('div', { class: 'progress-row' }, + h('span', {}, formatDuration(playing.position)), + h('input', { + type: 'range', + min: 0, + max: Math.round(playing.length), + value: Math.round(playing.position), + on: { + change: (event: Event) => + post({ type: 'seek', seconds: Number((event.target as HTMLInputElement).value) }), + }, + }), + h('span', {}, formatDuration(playing.length)), + )); + } + + if ((state.players?.length ?? 0) > 1) { + card.append(h('p', { class: 'flyout-note' }, `Players: ${state.players?.join(', ')}`)); + } +} + +// Escape closes the flyout, the way a real one does. +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + post({ type: 'command', command: 'workbench.action.closePanel' }); + } +}); diff --git a/extension/media/src/lib/dom.ts b/extension/media/src/lib/dom.ts new file mode 100644 index 0000000..40a7c16 --- /dev/null +++ b/extension/media/src/lib/dom.ts @@ -0,0 +1,160 @@ +// Shared webview helpers. +// +// No framework: every one of these pages is small, and a bundled UI library +// would cost more than the whole extension does. `h()` is the entire abstraction. + +export interface VsCodeApi { + postMessage(message: unknown): void; + getState(): T | undefined; + setState(state: T): void; +} + +declare function acquireVsCodeApi(): VsCodeApi; + +export const vscode: VsCodeApi = acquireVsCodeApi(); + +export function post(message: unknown): void { + vscode.postMessage(message); +} + +export type Child = Node | string | null | undefined | false; + +export interface Attributes { + class?: string; + id?: string; + title?: string; + type?: string; + value?: string | number; + placeholder?: string; + min?: string | number; + max?: string | number; + step?: string | number; + src?: string; + href?: string; + alt?: string; + controls?: boolean; + disabled?: boolean; + checked?: boolean; + hidden?: boolean; + role?: string; + tabIndex?: number; + dataset?: Record; + style?: Partial; + html?: string; + on?: Partial void>>; + [key: `aria-${string}`]: string | undefined; +} + +export function h( + tag: K, + attributes: Attributes = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + for (const [key, value] of Object.entries(attributes)) { + if (value === undefined || value === null || value === false) { + continue; + } + if (key === 'on') { + for (const [event, handler] of Object.entries(value as Record)) { + element.addEventListener(event, handler); + } + } else if (key === 'dataset') { + Object.assign(element.dataset, value); + } else if (key === 'style') { + Object.assign(element.style, value); + } else if (key === 'html') { + // Only ever used with icon markup this file owns, never with user text. + element.innerHTML = String(value); + } else if (key === 'class') { + element.className = String(value); + } else if (key === 'tabIndex') { + element.tabIndex = Number(value); + } else if (key === 'disabled' || key === 'checked' || key === 'hidden' || key === 'controls') { + (element as unknown as Record)[key] = Boolean(value); + } else if (key === 'value') { + (element as unknown as Record).value = String(value); + } else { + element.setAttribute(key, String(value)); + } + } + for (const child of children.flat()) { + if (child === null || child === undefined || child === false) { + continue; + } + element.append(typeof child === 'string' ? document.createTextNode(child) : child); + } + return element; +} + +export function clear(node: HTMLElement): HTMLElement { + node.replaceChildren(); + return node; +} + +/** Like `node.append`, but skips the nulls conditional children produce. */ +export function append(node: HTMLElement, ...children: Child[]): HTMLElement { + for (const child of children) { + if (child !== null && child !== undefined && child !== false) { + node.append(child); + } + } + return node; +} + +export function root(): HTMLElement { + const existing = document.getElementById('root'); + if (existing) { + return existing; + } + const created = h('div', { id: 'root' }); + document.body.append(created); + return created; +} + +export function onMessage(handler: (message: T) => void): void { + window.addEventListener('message', (event: MessageEvent) => handler(event.data)); +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / 1024 ** exponent; + return `${value >= 100 || exponent === 0 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`; +} + +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '0:00'; + } + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` + : `${minutes}:${String(secs).padStart(2, '0')}`; +} + +/** Coalesce rapid slider input into one message per frame. */ +export function throttle void>(fn: T, ms: number): T { + let last = 0; + let pending: ReturnType | undefined; + return ((...args: Parameters) => { + const now = Date.now(); + const wait = ms - (now - last); + if (wait <= 0) { + last = now; + fn(...(args as never[])); + } else if (!pending) { + pending = setTimeout(() => { + pending = undefined; + last = Date.now(); + fn(...(args as never[])); + }, wait); + } + }) as T; +} diff --git a/extension/media/src/lib/icons.ts b/extension/media/src/lib/icons.ts new file mode 100644 index 0000000..6b34815 --- /dev/null +++ b/extension/media/src/lib/icons.ts @@ -0,0 +1,103 @@ +// Inline SVG icons. +// +// Codicons are a webfont that VS Code does not expose to extension webviews, and +// shipping the font would add ~70 KiB to every image for a dozen glyphs. These +// are drawn on a 24x24 grid and inherit currentColor, so they theme themselves. + +const PATHS: Record = { + power: 'M12 3v10M7.8 6.3a7 7 0 1 0 8.4 0', + restart: 'M4 12a8 8 0 1 1 2.3 5.7M4 12V6M4 12h6', + sleep: 'M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z', + logout: 'M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h11', + wifi: 'M2.5 9a15 15 0 0 1 19 0M5.5 12.5a10 10 0 0 1 13 0M8.5 16a5.5 5.5 0 0 1 7 0M12 19.5h.01', + wifiOff: 'M2.5 9a15 15 0 0 1 6-3.6M15 5.6A15 15 0 0 1 21.5 9M8.5 16a5.5 5.5 0 0 1 7 0M12 19.5h.01M3 3l18 18', + bluetooth: 'M7 7l10 10-5 4V3l5 4L7 17', + airplane: 'M21 15l-9-4V5.5a1.5 1.5 0 0 0-3 0V11l-9 4v2l9-2.5V19l-2.5 1.5V22l4-1 4 1v-1.5L12 19v-4.5L21 17z', + battery: 'M3 8h14a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM21 11v2', + bolt: 'M13 2L4 14h7l-1 8 9-12h-7l1-8z', + sun: 'M12 6.5V4M12 20v-2.5M6.5 12H4M20 12h-2.5M7.8 7.8L6 6M18 18l-1.8-1.8M7.8 16.2L6 18M18 6l-1.8 1.8', + moon: 'M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z', + accessibility: 'M12 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM4 8.5l8 1.5 8-1.5M12 10v5M12 15l-3 6M12 15l3 6', + volume: 'M11 5L6.5 9H3v6h3.5L11 19V5z', + volumeMute: 'M11 5L6.5 9H3v6h3.5L11 19V5zM16 9.5l5 5M21 9.5l-5 5', + volumeLow: 'M11 5L6.5 9H3v6h3.5L11 19V5zM15.5 9.5a3.5 3.5 0 0 1 0 5', + volumeHigh: 'M11 5L6.5 9H3v6h3.5L11 19V5zM15.5 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10', + play: 'M7 4l12 8-12 8V4z', + pause: 'M8 4v16M16 4v16', + next: 'M6 4l10 8-10 8V4zM19 4v16', + previous: 'M18 4L8 12l10 8V4zM5 4v16', + chevronLeft: 'M15 5l-7 7 7 7', + chevronRight: 'M9 5l7 7-7 7', + chevronUp: 'M5 15l7-7 7 7', + chevronDown: 'M5 9l7 7 7-7', + close: 'M6 6l12 12M18 6L6 18', + check: 'M4 12.5l5 5L20 6.5', + lock: 'M6 11h12v9H6v-9zM8.5 11V7.5a3.5 3.5 0 1 1 7 0V11', + refresh: 'M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5', + folder: 'M3 6.5h6l2 2.5h10v10H3v-12.5z', + file: 'M6 3h8l4 4v14H6V3zM14 3v4h4', + home: 'M4 11l8-7 8 7M6 10v10h12V10', + disk: 'M3 7a9 4 0 1 0 18 0A9 4 0 1 0 3 7M3 7v10a9 4 0 0 0 18 0V7', + download: 'M12 3v12M7 11l5 5 5-5M4 20h16', + image: 'M3 5h18v14H3V5zM3 16l5-5 4 4 3-3 6 6', + music: 'M9 18V5l10-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM19 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z', + video: 'M3 6h13v12H3V6zM16 10l5-3v10l-5-3', + trash: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13', + plus: 'M12 5v14M5 12h14', + 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', + 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', + open: 'M4 5h6l2 2h8v12H4V5z', + editor: 'M4 4h16v16H4V4zM4 9h16', + undo: 'M9 8H5V4M5.5 8.5A7.5 7.5 0 1 1 4 13', + redo: 'M15 8h4V4M18.5 8.5A7.5 7.5 0 1 0 20 13', + ethernet: 'M12 3v8M8 15H4v6h4v-6zM14 15h-4v6h4v-6zM20 15h-4v6h4v-6zM4 11h16', + globe: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z', + cpu: 'M7 7h10v10H7V7zM9 3v4M15 3v4M9 17v4M15 17v4M3 9h4M3 15h4M17 9h4M17 15h4', + memory: 'M3 7h18v10H3V7zM7 17v3M12 17v3M17 17v3M7 11v2M12 11v2M17 11v2', + search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM16 16l5 5', + warning: 'M12 3l9.5 17H2.5L12 3zM12 10v5M12 18h.01', +}; + +/** Solid glyphs, drawn with a fill instead of a stroke. */ +const FILLED = new Set(['play', 'stop', 'airplane', 'bolt', 'next', 'previous']); + +export function icon(name: keyof typeof PATHS | string, size = 18): string { + const path = PATHS[name] ?? PATHS.file; + const style = FILLED.has(name) + ? 'fill="currentColor" stroke="none"' + : 'fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"'; + return ``; +} + +/** Wi-Fi bars for a 0-100 signal strength. */ +export function signalIcon(signal: number, size = 18): string { + const bars = signal >= 75 ? 4 : signal >= 50 ? 3 : signal >= 25 ? 2 : 1; + const arcs = [ + { d: 'M12 19.5h.01', level: 1 }, + { d: 'M8.5 16a5.5 5.5 0 0 1 7 0', level: 2 }, + { d: 'M5.5 12.5a10 10 0 0 1 13 0', level: 3 }, + { d: 'M2.5 9a15 15 0 0 1 19 0', level: 4 }, + ]; + const paths = arcs + .map((arc) => ``) + .join(''); + return ``; +} + +/** Battery pictogram whose fill tracks the charge. */ +export function batteryIcon(level: number, charging: boolean, size = 18): string { + const width = Math.max(0, Math.min(13, Math.round((level / 100) * 13))); + const bolt = charging + ? '' + : ''; + return ``; +} diff --git a/extension/media/src/notepad.ts b/extension/media/src/notepad.ts new file mode 100644 index 0000000..6fe8270 --- /dev/null +++ b/extension/media/src/notepad.ts @@ -0,0 +1,80 @@ +// Notepad: a plain-text scratchpad that saves to a real file. +// +// Deliberately not a second editor - anything that wants syntax highlighting or +// a diff belongs in VS Code proper, and "Open in editor" is one click away. + +import { clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { HostMessage } from '../../src/webview/protocol'; + +let path: string | undefined; +let dirty = false; + +const textarea = h('textarea', { + id: 'notepad-text', + placeholder: 'Start typing…', + on: { + input: () => { + dirty = true; + renderStatus(); + }, + }, +}); + +const status = h('div', { class: 'status' }); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + button('New', 'file', () => { + if (!dirty || confirm('Discard unsaved changes?')) { + post({ type: 'newNote' }); + } + }), + button('Open', 'open', () => post({ type: 'openNote' })), + button('Save', 'save', () => post({ type: 'saveNote', text: textarea.value, path }), true), + button('Save as', 'save', () => post({ type: 'saveNote', text: textarea.value, path, saveAs: true })), + h('span', { class: 'spacer' }), + button('Open in editor', 'editor', () => post({ type: 'noteToEditor', text: textarea.value })), + ), + h('div', { class: 'body', style: { display: 'flex' } }, textarea), + status, +)); + +function button(label: string, glyph: string, onClick: () => void, primary = false): HTMLElement { + return h('button', { class: `button${primary ? ' primary' : ''}`, on: { click: onClick } }, + h('span', { html: icon(glyph, 15) }), label); +} + +onMessage((message) => { + if (message.type !== 'note') { + return; + } + path = message.path; + textarea.value = message.text; + dirty = message.dirty; + renderStatus(); +}); + +function renderStatus(): void { + const text = textarea.value; + const words = text.trim() ? text.trim().split(/\s+/).length : 0; + clear(status).append( + h('span', {}, path ? `${path}${dirty ? ' •' : ''}` : dirty ? 'Unsaved note •' : 'Unsaved note'), + h('span', {}, `${text.length} characters`), + h('span', {}, `${words} words`), + h('span', {}, `${text ? text.split('\n').length : 0} lines`), + ); +} + +document.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + post({ type: 'saveNote', text: textarea.value, path, saveAs: event.shiftKey }); + } else if ((event.ctrlKey || event.metaKey) && event.key === 'o') { + event.preventDefault(); + post({ type: 'openNote' }); + } +}); + +renderStatus(); +post({ type: 'ready' }); diff --git a/extension/media/src/paint.ts b/extension/media/src/paint.ts new file mode 100644 index 0000000..a847d8c --- /dev/null +++ b/extension/media/src/paint.ts @@ -0,0 +1,364 @@ +// Paint: brush, eraser, shapes, fill, text, undo/redo, open and save PNG. + +import { clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { HostMessage } from '../../src/webview/protocol'; + +type Tool = 'brush' | 'eraser' | 'line' | 'rect' | 'ellipse' | 'fill' | 'text' | 'picker'; + +const COLORS = [ + '#000000', '#7f7f7f', '#c3c3c3', '#ffffff', + '#ed1c24', '#ff7f27', '#fff200', '#22b14c', + '#00a2e8', '#3f48cc', '#a349a4', '#b97a57', + '#ffaec9', '#ffc90e', '#efe4b0', '#b5e61d', +]; + +const WIDTH = 1000; +const HEIGHT = 640; + +let tool: Tool = 'brush'; +let color = '#000000'; +let lineWidth = 4; +let drawing = false; +let startX = 0; +let startY = 0; +/** Canvas contents before the in-progress shape, so a drag can be previewed. */ +let snapshot: ImageData | undefined; + +const undoStack: string[] = []; +const redoStack: string[] = []; + +const canvas = h('canvas', { id: 'canvas' }) as HTMLCanvasElement; +canvas.width = WIDTH; +canvas.height = HEIGHT; +const context = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D; +context.fillStyle = '#ffffff'; +context.fillRect(0, 0, WIDTH, HEIGHT); +context.lineCap = 'round'; +context.lineJoin = 'round'; + +const toolsPane = h('div', { class: 'paint-tools' }); +const swatchesPane = h('div', { class: 'swatches' }); +const widthLabel = h('span', { class: 'slider-value' }, '4'); + +clear(root()).append(h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('button', { + class: 'button', on: { click: () => post({ type: 'openImage' }) }, + }, h('span', { html: icon('open', 15) }), 'Open'), + h('button', { + class: 'button primary', + on: { click: () => post({ type: 'savePng', dataUrl: canvas.toDataURL('image/png') }) }, + }, h('span', { html: icon('save', 15) }), 'Save PNG'), + h('button', { class: 'button', on: { click: undo } }, h('span', { html: icon('undo', 15) }), 'Undo'), + h('button', { class: 'button', on: { click: redo } }, h('span', { html: icon('redo', 15) }), 'Redo'), + h('button', { + class: 'button', + on: { + click: () => { + if (confirm('Clear the canvas?')) { + pushUndo(); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, WIDTH, HEIGHT); + } + }, + }, + }, h('span', { html: icon('trash', 15) }), 'Clear'), + h('span', { class: 'spacer' }), + h('div', { class: 'field' }, + h('label', {}, 'Size'), + h('input', { + type: 'range', min: 1, max: 60, value: lineWidth, + style: { width: '110px' }, + on: { + input: (event: Event) => { + lineWidth = Number((event.target as HTMLInputElement).value); + widthLabel.textContent = String(lineWidth); + }, + }, + }), + widthLabel, + ), + swatchesPane, + h('input', { + type: 'color', value: color, + title: 'Custom colour', + on: { input: (event: Event) => setColor((event.target as HTMLInputElement).value) }, + }), + ), + h('div', { class: 'body' }, h('div', { class: 'paint-wrap' }, + toolsPane, + h('div', { class: 'canvas-area' }, canvas), + )), +)); + +const TOOLS: [Tool, string, string][] = [ + ['brush', 'editor', 'Brush'], + ['eraser', 'close', 'Eraser'], + ['line', 'chevronRight', 'Line'], + ['rect', 'stop', 'Rectangle'], + ['ellipse', 'globe', 'Ellipse'], + ['fill', 'disk', 'Fill'], + ['text', 'file', 'Text'], + ['picker', 'search', 'Pick colour'], +]; + +function renderTools(): void { + clear(toolsPane); + for (const [name, glyph, label] of TOOLS) { + toolsPane.append(h('button', { + class: `tool${tool === name ? ' active' : ''}`, + title: label, + html: icon(glyph, 18), + on: { click: () => { tool = name; renderTools(); } }, + })); + } +} + +function renderSwatches(): void { + clear(swatchesPane); + for (const value of COLORS) { + swatchesPane.append(h('button', { + class: `swatch${value === color ? ' active' : ''}`, + style: { background: value }, + title: value, + on: { click: () => setColor(value) }, + })); + } +} + +function setColor(value: string): void { + color = value; + renderSwatches(); +} + +// ------------------------------------------------------------------- undo + +function pushUndo(): void { + undoStack.push(canvas.toDataURL()); + if (undoStack.length > 25) { + undoStack.shift(); + } + redoStack.length = 0; +} + +function restore(dataUrl: string): void { + const image = new Image(); + image.onload = () => { + context.clearRect(0, 0, WIDTH, HEIGHT); + context.drawImage(image, 0, 0); + }; + image.src = dataUrl; +} + +function undo(): void { + const previous = undoStack.pop(); + if (!previous) { + return; + } + redoStack.push(canvas.toDataURL()); + restore(previous); +} + +function redo(): void { + const next = redoStack.pop(); + if (!next) { + return; + } + undoStack.push(canvas.toDataURL()); + restore(next); +} + +// ---------------------------------------------------------------- drawing + +/** Canvas is scaled to fit the viewport, so pointer coords need unscaling. */ +function pointOf(event: PointerEvent): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + return { + x: ((event.clientX - rect.left) / rect.width) * WIDTH, + y: ((event.clientY - rect.top) / rect.height) * HEIGHT, + }; +} + +canvas.addEventListener('pointerdown', (event) => { + const { x, y } = pointOf(event); + canvas.setPointerCapture(event.pointerId); + + if (tool === 'picker') { + const data = context.getImageData(Math.floor(x), Math.floor(y), 1, 1).data; + setColor(`#${[data[0], data[1], data[2]].map((c) => c.toString(16).padStart(2, '0')).join('')}`); + return; + } + + if (tool === 'text') { + const text = prompt('Text to draw'); + if (text) { + pushUndo(); + context.fillStyle = color; + context.font = `${Math.max(12, lineWidth * 5)}px var(--vscode-font-family, sans-serif)`; + context.fillText(text, x, y); + } + return; + } + + if (tool === 'fill') { + pushUndo(); + floodFill(Math.floor(x), Math.floor(y), color); + return; + } + + pushUndo(); + drawing = true; + startX = x; + startY = y; + snapshot = context.getImageData(0, 0, WIDTH, HEIGHT); + + if (tool === 'brush' || tool === 'eraser') { + context.beginPath(); + context.moveTo(x, y); + } +}); + +canvas.addEventListener('pointermove', (event) => { + if (!drawing) { + return; + } + const { x, y } = pointOf(event); + context.lineWidth = lineWidth; + context.strokeStyle = tool === 'eraser' ? '#ffffff' : color; + + if (tool === 'brush' || tool === 'eraser') { + context.lineTo(x, y); + context.stroke(); + return; + } + + // Shape tools redraw from the snapshot so the preview follows the pointer. + if (snapshot) { + context.putImageData(snapshot, 0, 0); + } + context.beginPath(); + if (tool === 'line') { + context.moveTo(startX, startY); + context.lineTo(x, y); + } else if (tool === 'rect') { + context.rect(startX, startY, x - startX, y - startY); + } else { + context.ellipse( + (startX + x) / 2, (startY + y) / 2, + Math.abs(x - startX) / 2, Math.abs(y - startY) / 2, + 0, 0, Math.PI * 2, + ); + } + context.stroke(); +}); + +const finish = (): void => { + drawing = false; + snapshot = undefined; + context.closePath(); +}; +canvas.addEventListener('pointerup', finish); +canvas.addEventListener('pointercancel', finish); +canvas.addEventListener('pointerleave', () => { + if (drawing && (tool === 'brush' || tool === 'eraser')) { + finish(); + } +}); + +/** Scanline flood fill; the recursive version blows the stack on a full canvas. */ +function floodFill(x: number, y: number, fill: string): void { + const image = context.getImageData(0, 0, WIDTH, HEIGHT); + const data = image.data; + const target = offset(x, y); + const start = [data[target], data[target + 1], data[target + 2], data[target + 3]]; + + const parsed = fill.replace('#', ''); + const replacement = [ + parseInt(parsed.slice(0, 2), 16), + parseInt(parsed.slice(2, 4), 16), + parseInt(parsed.slice(4, 6), 16), + 255, + ]; + if (start.every((value, index) => value === replacement[index])) { + return; + } + + const matches = (index: number): boolean => + Math.abs(data[index] - start[0]) < 12 + && Math.abs(data[index + 1] - start[1]) < 12 + && Math.abs(data[index + 2] - start[2]) < 12 + && Math.abs(data[index + 3] - start[3]) < 12; + + const stack: [number, number][] = [[x, y]]; + while (stack.length) { + const [px, py] = stack.pop() as [number, number]; + if (py < 0 || py >= HEIGHT) { + continue; + } + let left = px; + while (left >= 0 && matches(offset(left, py))) { + left--; + } + left++; + let spanAbove = false; + let spanBelow = false; + for (let cx = left; cx < WIDTH && matches(offset(cx, py)); cx++) { + const index = offset(cx, py); + data[index] = replacement[0]; + data[index + 1] = replacement[1]; + data[index + 2] = replacement[2]; + data[index + 3] = replacement[3]; + + if (py > 0 && matches(offset(cx, py - 1)) !== spanAbove) { + spanAbove = !spanAbove; + if (spanAbove) { + stack.push([cx, py - 1]); + } + } + if (py < HEIGHT - 1 && matches(offset(cx, py + 1)) !== spanBelow) { + spanBelow = !spanBelow; + if (spanBelow) { + stack.push([cx, py + 1]); + } + } + } + } + context.putImageData(image, 0, 0); +} + +function offset(x: number, y: number): number { + return (y * WIDTH + x) * 4; +} + +onMessage((message) => { + if (message.type !== 'image') { + return; + } + const image = new Image(); + image.onload = () => { + pushUndo(); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, WIDTH, HEIGHT); + // Fit rather than stretch, so an opened photo keeps its aspect ratio. + const scale = Math.min(WIDTH / image.width, HEIGHT / image.height, 1); + const width = image.width * scale; + const height = image.height * scale; + context.drawImage(image, (WIDTH - width) / 2, (HEIGHT - height) / 2, width, height); + }; + image.src = message.uri; +}); + +document.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'z') { + event.preventDefault(); + event.shiftKey ? redo() : undo(); + } else if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + post({ type: 'savePng', dataUrl: canvas.toDataURL('image/png') }); + } +}); + +renderTools(); +renderSwatches(); +post({ type: 'ready' }); diff --git a/extension/media/src/recorder.ts b/extension/media/src/recorder.ts new file mode 100644 index 0000000..7ca47cf --- /dev/null +++ b/extension/media/src/recorder.ts @@ -0,0 +1,112 @@ +// Voice recorder. +// +// The record button talks to a pw-record subprocess in the extension host, +// because VS Code denies webviews the microphone permission outright. Playback +// of the finished file is ordinary