diff --git a/README.md b/README.md index dfa7bd6..20725a9 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,16 @@ 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: -- **An all-apps button**, in the bottom-left corner — a searchable grid of every - program on the machine, the way a start menu works. +- **All Apps**, in the activity bar — a searchable grid of everything on the + machine, the way a start menu works: the shell's own apps, the web apps you + installed, and every program that ships a `.desktop` file. Right-click a web + app to change where it opens or to uninstall it. - **A tray**, at the right end of the status bar. Left to right: notifications, now playing, battery, volume, network, Bluetooth, the clock and date, and the - power button in the corner. Each one opens a card in the side bar. (VS Code has no API for - a popup anchored to a status bar item; the side bar is the closest thing that - does not take the terminal panel away from you. `vscodeos.flyout.location` - puts the cards back in the bottom panel if you prefer them there.) + power button in the corner. Each one opens a popup over the editor. (VS Code + has no API for a popup *anchored* to a status bar item, so these are quick + picks, which open at the top of the window. `vscodeos.flyout.location` swaps + them for the richer webview cards, in the activity bar or the bottom panel.) - **Power** — sleep, restart, shut down and log out, with a confirmation. - **Calendar** — a month grid with today highlighted, on the clock. - **Power settings**, on the battery — energy saver, a brightness slider whose @@ -89,24 +91,33 @@ rather than being installed from the Marketplace. Its source is in claims that name, so without this those notifications simply vanished. - **Task Manager**, in the activity bar — processes with CPU and memory, per-core meters, load average, uptime and CPU temperature, sortable and filterable, with - End task. + End task. It is the only thing in the System container. - **Files** — a graphical file explorer with a places sidebar, grid and list views, rename, trash, copy and paste. **Everything opens in the editor**: text in the text editor, images in the built-in preview, video and audio in the media player. **Archives browse like folders** — zip, tar and everything compressed — with Extract here, Extract to… and Compress. -- **Recycle Bin** — in the Files app's places list and in the activity bar, - sharing one backend. Shows what you deleted, where it came from and when, with - Restore, Delete permanently and Empty. Before this, "Move to trash" put files - somewhere nothing could read them back from. +- **Recycle Bin** — a place in the Files app, browsed with the same columns and + the same selection as any other folder. Shows what you deleted, where it came + from and when, with Restore, Delete permanently and Empty. Before this, "Move + to trash" put files somewhere nothing could read them back from. - **Browser** — a real browser rendered *inside* an editor tab, with tabs, an address bar and history. It drives a headless Chromium and streams its picture back, which is the only way to show sites that refuse to be framed. "Open in browser" hands the page to a real window when that is the better answer. - **Media Player** — video and audio in a tab, with a playlist of whatever else is in the folder. -- **Music** — transport controls for whatever is playing, over MPRIS, plus - one-click launchers for Spotify Web and YouTube Music. +- **Music** — an offline player for the files in `~/Music`: a searchable + library, a queue, shuffle, repeat and a scrubber. The tray's music item keeps + the MPRIS transport, so it still controls a browser or `mpv` playing in the + background. +- **Marketplace** — find and install web apps. A catalogue of well-known ones + ships with the shell, so it is browsable with no network, and any other site + can be installed by pasting its address. Installing reads the site's own app + manifest for its name and icon, and asks whether it should open in a window of + its own or in an editor tab. The Browser grows an **Install app** button on + any page that publishes a manifest. Installed apps appear in All Apps and get + a `.desktop` entry, so the rest of the machine can see them too. - **System Settings** — Display (resolution, refresh rate, orientation, which screen is primary, with a fifteen-second revert in case the monitor cannot show the mode), Keyboard (layout and key repeat), Sound (which speakers play @@ -130,12 +141,13 @@ There is deliberately no Notepad: the editor is a better one, and the file manager now opens text in it. 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). Everything browser-shaped prefers -`microsoft-edge-stable` if you install it yourself, and falls back to Chromium. +**DRM audio and video cannot play inside the editor** (VS Code's Electron ships +no Widevine, so Spotify's Web Playback SDK cannot work — which is why a web app +installed with its own window is a real Chromium and works), 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). Everything +browser-shaped 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. @@ -250,7 +262,7 @@ VSCODEOS_RESPAWN=1 # 0 = do not relaunch when VS Code exits ## Day-to-day The **Updates** pane of **System Settings** does all of this with buttons — open -it from the all-apps button in the bottom-left corner. By hand: +it from **All Apps** in the activity bar. By hand: ```bash sudo pacman -Syu # update the Arch base diff --git a/extension/README.md b/extension/README.md index 28f5782..9c7fdcb 100644 --- a/extension/README.md +++ b/extension/README.md @@ -12,31 +12,64 @@ accidentally uninstalled. | | | | --- | --- | -| **Launcher** | An all-apps button in the bottom-left corner: a searchable grid of every program | +| **All Apps** | An activity bar view: a searchable grid of the shell's apps, the installed web apps and every `.desktop` file on the machine | +| **Marketplace** | Find and install web apps (PWAs) from a bundled catalogue, or from any address | | **Tray** | Notifications, now-playing, battery, volume, network, Bluetooth, clock and date, and the power button, at the right end of the status bar | -| **Flyouts** | Apps, power, calendar, power settings, volume mixer, network picker, Bluetooth, music player and notifications | +| **Tray menus** | Power, calendar, power settings, volume mixer, network picker, Bluetooth, music and notifications — quick picks by default | | **Notifications** | Serves `org.freedesktop.Notifications`, turning every desktop notification on the machine into an editor notification | | **Task Manager** | Processes with CPU/RAM, per-core meters, load, uptime and thermals, in the activity bar | -| **Files** | A graphical file explorer: places sidebar, grid/list, rename, trash, copy/paste, and archives that browse like folders. Everything opens in the editor | -| **Recycle Bin** | Restore or permanently delete what the Files app trashed, from the places list or the activity bar | -| **Browser** | A headless Chromium streamed into an editor tab, with tabs, an address bar and history | +| **Files** | A graphical file explorer: places sidebar, grid/list, rename, trash, copy/paste, and archives that browse like folders. Everything opens in the editor. The Recycle Bin is one of its places | +| **Browser** | A headless Chromium streamed into an editor tab, with tabs, an address bar, history and an Install app button | | **Media Player** | Video and audio in a tab, with a folder playlist | -| **Music** | MPRIS transport for whatever is playing, plus launchers for Spotify Web and YouTube Music | +| **Music** | An offline player for `~/Music`: library, search, queue, shuffle and repeat | | **System Settings** | Display, keyboard, sound devices, storage, updates and about, in one app | | **Firewall** | A GUI over `ufw`: master switch, default policies, rules and presets | | **Apps** | Calculator, Paint, Screenshot and Voice Recorder | Every feature is behind a `vscodeos..enabled` setting, all defaulting to -on. The all-apps button, or `VS Code OS: All Apps…` in the command palette, -lists everything. - -### Four things worth knowing - -**The flyouts are not popups.** VS Code has no API to anchor one to a status bar -item, and the only floating-window route — moving an editor to an auxiliary -window — brings editor tab chrome with it and cannot be sized or placed. So they -are a webview view, in the side bar by default. The bottom panel, where they used -to live, is where the terminal is; `vscodeos.flyout.location` puts them back. +on. The All Apps view, or `VS Code OS: All Apps…` in the command palette, lists +everything. + +### Six things worth knowing + +**The tray menus are quick picks.** VS Code has no API to anchor a popup to a +status bar item and never has — but that was the wrong thing to conclude from. A +quick pick *is* a popup: it floats over the editor, takes the keyboard and closes +on Escape. What it cannot be is *anchored*, which is a much smaller loss than +taking the side bar away every time somebody wants to see the clock. Items carry +a `keepOpen` flag so a toggle acts and the list redraws in place, which is what +makes a volume level or a Wi-Fi switch usable. + +The webview cards those replaced are still there, behind +`vscodeos.flyout.location` — they can draw a real slider, a month grid and album +art, which a list of rows cannot. `sidebar` puts them in a Tray container in the +activity bar; `panel` puts them in the bottom panel, where the terminal is. + +They cannot go in the **secondary** side bar, on the right: +`viewsContainers.secondarySideBar` is gated behind the proposed +`contribSecondarySideBar` API, and this ships as a built-in (see +[Packaging](#packaging)). Dragging the container there by hand works, and VS Code +remembers it. + +**Web apps are a URL, an icon and a desktop entry.** `src/sys/webapps.ts` fetches +a site, reads its `` (falling back to the page's `` +and favicon, because most of the web publishes no manifest), keeps a copy of the +icon and writes `~/.local/share/applications/vscodeos-webapp-<id>.desktop` so the +app exists to the rest of the machine and not only to this shell. That entry is +marked `X-VSCodeOS-WebApp`, which is how the `.desktop` scan in +`src/sys/desktopApps.ts` knows to leave those to the web app list instead of +showing every one of them twice. + +Where an app opens is chosen when it is installed: a Chromium `--app=` window +(frameless, single-site, and it exports MPRIS so the tray transport controls it) +or an editor tab in the built-in browser. The desktop entry of an editor-mode app +runs `code --open-url vscode://vscodeos.vscodeos-core/webapp?id=…`, the same +route the Print key uses, so starting it from any launcher lands in the editor. + +**Only web apps can be uninstalled.** They are the only thing this shell +installed. Removing a package would mean a password-free `pacman -Rns` for +arbitrary package names — a new path to root for the sake of a context menu item, +and not a trade worth making. Native entries are listed and labelled instead. **The browser is a screencast.** Every site worth visiting sends `X-Frame-Options`, which is why VS Code's own Simple Browser shows a blank @@ -90,13 +123,13 @@ npm test # node:test, no framework to install npm run package # production bundles ``` -CI runs all three before either image build starts. The tests cover the two -self-contained modules — the calculator's expression evaluator and the -formatters — because everything else here talks to `/proc`, `nmcli` or the -VS Code API and is verified by running the thing. That is a narrow surface, but -it is the surface where a mistake is invisible: unary minus alone accounted for -three real bugs (`5 − −3`, `2 × −3`, and the `±` key emitting an ASCII hyphen -the tokenizer did not handle). +CI runs all three before either image build starts. The tests cover the +self-contained modules under `src/util` and the calculator's evaluator, because +everything else here talks to `/proc`, `nmcli` or the VS Code API and is verified +by running the thing. That is a narrow surface, but it is the surface where a +mistake is invisible: unary minus alone accounted for three real bugs (`5 − −3`, +`2 × −3`, and the `±` key emitting an ASCII hyphen the tokenizer did not handle), +and an `Exec=` line split the wrong way launches the wrong program. ## Layout @@ -104,12 +137,13 @@ the tokenizer did not handle). src/ extension.ts activate(): wires everything, one DisposableStore sys/ the only code that touches the machine - statusbar/ the tray, and the priority ladder that orders it - views/ flyout (side bar), task manager and recycle bin (activity - bar) providers - apps/ registry, file explorer, browser, media player, firewall, - system settings (settings/updates.ts inside it), mini-apps, - panel plumbing + statusbar/ the tray and the priority ladder that orders it, plus + menus.ts — the quick pick version of every tray menu + views/ all apps and task manager (activity bar), flyout (the + optional tray cards) providers + apps/ registry, file explorer, browser, marketplace, media player, + music player, firewall, system settings (settings/updates.ts + inside it), mini-apps, panel plumbing, the web app catalogue webview/ HTML shell + the host↔webview message types media/ src/ one TypeScript entry point per page, shared code in src/lib @@ -157,10 +191,16 @@ control. 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. +- **No popup *anchored* to a status bar item.** There is no such API. The tray + menus are quick picks, which open centred at the top of the window rather than + over the item that was clicked. The webview cards behind + `vscodeos.flyout.location` have the matching limit: the panel's height is + workbench layout state with no API, so a card opens at whatever height the + panel was last left at. +- **Extensions cannot put a view container on the right.** + `viewsContainers.secondarySideBar` is behind the proposed + `contribSecondarySideBar` API, which a built-in cannot use. Users can drag a + container there themselves and VS Code remembers it. - **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 @@ -169,11 +209,16 @@ These are VS Code and Electron limits, not missing work: - **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. +- **DRM audio and video cannot play inside VS Code.** Stock Electron ships no + Widevine CDM, so Spotify's Web Playback SDK cannot decrypt anything, and + `open.spotify.com` refuses to be framed either. A web app installed from the + Marketplace and set to open in *its own window* works, because that is a real + Chromium — which is also why that is the option offered for the streaming + services. The Music app plays local files and needs none of this. +- **No tags on local music.** The Music app's track names are file names and its + "albums" are folder names. Reading ID3 and Vorbis comments means a tag parsing + dependency, which is not a thing to add to an extension that ships inside an + ISO for one line of a list row. - **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 diff --git a/extension/media/css/vscodeos.css b/extension/media/css/vscodeos.css index 306f15f..38ac941 100644 --- a/extension/media/css/vscodeos.css +++ b/extension/media/css/vscodeos.css @@ -468,6 +468,12 @@ input[type='range']::-webkit-slider-thumb { color: var(--vscodeos-accent-fg); } +/* Shuffle and repeat are modes, not actions, so they stay lit while they last. */ +.transport button.on { + color: var(--vscodeos-accent); + background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.2)); +} + .progress-row { display: flex; align-items: center; @@ -543,6 +549,80 @@ input[type='range']::-webkit-slider-thumb { line-height: 1.25; } +/* ------------------------------------------------------------- all apps */ + +/* The view lives in the activity bar, which is narrow, so the toolbar stacks + the search box over the filter chips instead of putting them side by side. */ +.app-toolbar { + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px; +} + +.app-toolbar .app-search-row { + margin-bottom: 0; +} + +.app-filters { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.chip { + padding: 3px 10px; + border-radius: 999px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-tile-fg); + font-size: 0.86em; +} + +.chip:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(127, 127, 127, 0.24)); +} + +.chip.on { + background: var(--vscodeos-accent); + color: var(--vscodeos-accent-fg); +} + +.app-body { + padding: 0 10px 12px; +} + +/* A real icon, from a manifest or an icon theme. Boxed to the glyph's footprint + so a 512px PNG and a 26px drawing make the same size tile. */ +.app-tile-image { + width: 26px; + height: 26px; + object-fit: contain; +} + +.context-menu { + position: fixed; + z-index: 30; + min-width: 180px; + max-width: 260px; + padding: 4px; + border-radius: 6px; + border: 1px solid var(--vscodeos-card-border); + background: var(--vscodeos-card-bg); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.34); +} + +.context-head { + padding: 6px 8px 8px; + margin-bottom: 4px; + border-bottom: 1px solid var(--vscodeos-card-border); +} + +.context-head .list-sub { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* An icon-button that is a toggle rather than an action. */ .icon-button.on { background: var(--vscodeos-accent); @@ -1116,6 +1196,100 @@ audio { width: 100%; } +/* ---------------------------------------------------------- marketplace */ + +/* The address box shares the toolbar with the search box; it gets the wider + share, because a URL is longer than a search term. */ +.market-address { + flex: 2; + min-width: 220px; +} + +.market-row { + padding: 9px 10px; + align-items: center; +} + +.market-letter { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex: 0 0 auto; + border-radius: 6px; + color: #fff; + font-weight: 600; + font-size: 1.05em; +} + +.market-actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +/* ---------------------------------------------------------------- music */ + +/* The library scrolls; the transport is pinned to the bottom of the tab, so + what is playing never scrolls out of reach of the pause button. */ +.music-body { + padding: 6px 10px 10px; +} + +.music-list .list-row { + padding: 7px 10px; +} + +.music-bar { + flex: 0 0 auto; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 14px; + padding: 8px 14px; + border-top: 1px solid var(--vscodeos-card-border); + background: var(--vscodeos-card-bg); +} + +.music-now { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.music-art { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + border-radius: 5px; + background: var(--vscodeos-tile-bg); + color: var(--vscodeos-muted); +} + +.music-bar .transport { + margin: 0; +} + +.music-time { + min-width: 38px; + text-align: center; +} + +/* Below this the three columns cannot all fit, so they stack: what is playing, + then the buttons, then the scrubber. */ +@media (max-width: 720px) { + .music-bar { + grid-template-columns: 1fr; + gap: 6px; + } +} + /* --------------------------------------------------------- media player */ .player-body { @@ -1392,30 +1566,6 @@ audio { margin-left: 8px; } -/* ------------------------------------------------------------ recycle bin */ - -/* The bin shares the activity bar container with the Task Manager, so its rows - are the narrow kind: name over origin, with no columns. */ -.bin .toolbar { - gap: 6px; -} - -.bin-actions { - display: flex; - gap: 6px; - padding: 6px 10px; - border-bottom: 1px solid var(--vscodeos-card-border); -} - -.bin-actions .button { - flex: 1; - justify-content: center; -} - -.bin-count { - color: var(--vscodeos-muted); -} - /* In the Files app's list view the size column carries the original location instead, which is far longer than a byte count and has to be able to lose its middle rather than push the date off the row. */ diff --git a/extension/media/icons/apps.svg b/extension/media/icons/apps.svg new file mode 100644 index 0000000..9369bd4 --- /dev/null +++ b/extension/media/icons/apps.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> + <rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/> + <rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/> + <rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/> + <rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/> +</svg> diff --git a/extension/media/icons/recyclebin.svg b/extension/media/icons/marketplace.svg similarity index 60% rename from extension/media/icons/recyclebin.svg rename to extension/media/icons/marketplace.svg index f155fb7..c16900b 100644 --- a/extension/media/icons/recyclebin.svg +++ b/extension/media/icons/marketplace.svg @@ -1,6 +1,5 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> - <path d="M3.5 6.5h17"/> - <path d="M9 6.5V4h6v2.5"/> - <path d="M5.5 6.5l1 13h11l1-13"/> - <path d="M10 10.5v6M14 10.5v6"/> + <path d="M4 9.5h16V20H4z"/> + <path d="M2.5 9.5L4.5 4h15l2 5.5"/> + <path d="M9.5 20v-6h5v6"/> </svg> diff --git a/extension/media/icons/music.svg b/extension/media/icons/music.svg new file mode 100644 index 0000000..dfd854d --- /dev/null +++ b/extension/media/icons/music.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> + <path d="M9 18V5l11-2v13"/> + <circle cx="6" cy="18" r="3"/> + <circle cx="17" cy="16" r="3"/> +</svg> diff --git a/extension/media/icons/tray.svg b/extension/media/icons/tray.svg new file mode 100644 index 0000000..3a8e824 --- /dev/null +++ b/extension/media/icons/tray.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> + <rect x="2.5" y="7.5" width="19" height="9" rx="2"/> + <path d="M6.5 12h.01M10 12h.01M13.5 12h.01"/> + <path d="M17.5 9.5v5"/> +</svg> diff --git a/extension/media/src/allapps.ts b/extension/media/src/allapps.ts new file mode 100644 index 0000000..ddf19c6 --- /dev/null +++ b/extension/media/src/allapps.ts @@ -0,0 +1,278 @@ +// All Apps. +// +// A start menu: type, see fewer things, press Enter. It lives in the activity +// bar, which is narrow, so the layout is a search box, a row of source filters +// and a grid of tiles - and the tiles carry a real icon when the app has one +// (a web app's manifest icon, a desktop entry's theme icon) and a glyph when it +// does not. +// +// Right-clicking a tile opens the actions, rather than hanging buttons off +// every tile: at 96px there is no room for them, and only web apps have more +// than one thing that can be done to them anyway. + +import { append, clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { AllAppsState, AppEntry, AppSource, HostMessage } from '../../src/webview/protocol'; + +type Filter = 'all' | AppSource; + +const FILTERS: { id: Filter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'builtin', label: 'Apps' }, + { id: 'webapp', label: 'Web' }, + { id: 'system', label: 'System' }, +]; + +const SOURCE_LABEL: Record<AppSource, string> = { + builtin: 'Built in', + webapp: 'Web app', + system: 'Installed on this computer', +}; + +let state: AllAppsState | undefined; +let query = ''; +let filter: Filter = 'all'; + +const search = h('input', { + class: 'app-search', + type: 'text', + placeholder: 'Search apps', + 'aria-label': 'Search apps', + on: { + input: (event: Event) => { + query = (event.target as HTMLInputElement).value; + renderGrid(); + }, + keydown: (event: KeyboardEvent) => { + if (event.key === 'Enter') { + const first = visible()[0]; + if (first) { + launch(first); + } + } + }, + }, +}); + +const chips = h('div', { class: 'app-filters' }); +const grid = h('div', { class: 'app-grid' }); +const count = h('span', {}); + +clear(root()).append( + h('div', { class: 'app' }, + h('div', { class: 'toolbar app-toolbar' }, + h('div', { class: 'app-search-row' }, + h('span', { class: 'app-search-icon', html: icon('search', 16) }), + search, + ), + chips, + ), + h('div', { class: 'body app-body' }, grid), + h('div', { class: 'status' }, + count, + h('span', { class: 'spacer' }), + // Nothing watches the application directories, so an app installed + // from the terminal needs a nudge to show up here. + h('button', { + class: 'link-button', + title: 'Look for newly installed applications', + on: { click: () => post({ type: 'refreshApps' }) }, + }, 'Refresh'), + h('button', { + class: 'link-button', + title: 'Find and install web apps', + on: { click: () => post({ type: 'command', command: 'vscodeos.marketplace.open' }) }, + }, 'Get more apps'), + ), + ), +); + +onMessage<HostMessage>((message) => { + if (message.type !== 'apps') { + return; + } + state = message.state; + renderChips(); + renderGrid(); +}); + +post({ type: 'ready' }); + +// ------------------------------------------------------------------ filtering + +/** + * Every word has to appear somewhere, so "voice rec" finds the recorder but + * "voice paint" finds nothing. The same rule the tray launcher used. + */ +function matches(app: AppEntry, text: string): boolean { + if (!text) { + return true; + } + const haystack = [app.title, app.description, ...(app.keywords ?? [])].join(' ').toLowerCase(); + return text.toLowerCase().split(/\s+/).filter(Boolean).every((word) => haystack.includes(word)); +} + +function visible(): AppEntry[] { + return (state?.apps ?? []) + .filter((app) => filter === 'all' || app.source === filter) + .filter((app) => matches(app, query)); +} + +function renderChips(): void { + clear(chips); + for (const entry of FILTERS) { + // A filter for a source with nothing in it is a button that empties the + // grid, so it is not offered. + const available = entry.id === 'all' + || (state?.apps ?? []).some((app) => app.source === entry.id); + if (!available) { + continue; + } + chips.append(h('button', { + class: `chip${filter === entry.id ? ' on' : ''}`, + on: { + click: () => { + filter = entry.id; + renderChips(); + renderGrid(); + }, + }, + }, entry.label)); + } +} + +// ----------------------------------------------------------------- the grid + +function launch(app: AppEntry): void { + closeMenu(); + post({ type: 'launchApp', source: app.source, id: app.id }); +} + +function tile(app: AppEntry): HTMLElement { + const glyph = app.iconUrl + ? h('img', { class: 'app-tile-image', src: app.iconUrl, alt: '' }) + : h('span', { class: 'app-tile-icon', html: icon(app.icon, 26) }); + + return h('button', { + class: 'app-tile', + title: `${app.title}\n${app.description}`, + on: { + click: () => launch(app), + contextmenu: (event: MouseEvent) => { + event.preventDefault(); + openMenu(app, event.clientX, event.clientY); + }, + }, + }, + glyph, + h('span', { class: 'app-tile-label' }, app.title), + ); +} + +function renderGrid(): void { + closeMenu(); + clear(grid); + const list = visible(); + + if (!state) { + grid.append(h('div', { class: 'empty' }, 'Loading…')); + return; + } + if (list.length === 0) { + grid.append(h('div', { class: 'empty' }, + query ? 'No app matches that.' : 'Nothing to show here.')); + } + for (const app of list) { + grid.append(tile(app)); + } + + const total = state.apps.length; + count.textContent = list.length === total + ? `${total} app${total === 1 ? '' : 's'}` + : `${list.length} of ${total}`; +} + +// -------------------------------------------------------------- context menu + +let menu: HTMLElement | undefined; + +function closeMenu(): void { + menu?.remove(); + menu = undefined; +} + +function menuItem(label: string, glyph: string, onClick: () => void): HTMLElement { + return h('button', { + class: 'list-row', + on: { click: () => { closeMenu(); onClick(); } }, + }, + h('span', { html: icon(glyph, 15) }), + h('span', { class: 'list-main' }, h('div', { class: 'list-name' }, label)), + ); +} + +function openMenu(app: AppEntry, x: number, y: number): void { + closeMenu(); + const items: HTMLElement[] = [menuItem('Open', 'open', () => launch(app))]; + + if (app.source === 'webapp') { + const toWindow = app.openIn !== 'window'; + items.push(menuItem( + toWindow ? 'Open in its own window' : 'Open in an editor tab', + toWindow ? 'fullscreen' : 'editor', + () => post({ type: 'setAppOpenIn', id: app.id, openIn: toWindow ? 'window' : 'editor' }), + )); + } + if (app.removable) { + items.push(menuItem('Uninstall', 'trash', () => + post({ type: 'uninstallApp', id: app.id, name: app.title }))); + } + + // Not `.flyout`: that card goes transparent and borderless below 460px so it + // can fill the side bar, and this view is always narrower than that. + const created = h('div', { class: 'context-menu' }, + h('div', { class: 'context-head' }, + h('div', { class: 'list-name' }, app.title), + h('div', { class: 'list-sub' }, app.url ?? SOURCE_LABEL[app.source]), + ), + ); + append(created, ...items); + + // Placed against the viewport, then nudged back inside it: the activity bar + // is narrow and a menu opened on a right-hand tile would otherwise hang off + // the edge with its actions unreachable. + created.style.left = `${x}px`; + created.style.top = `${y}px`; + document.body.append(created); + const box = created.getBoundingClientRect(); + if (box.right > window.innerWidth) { + created.style.left = `${Math.max(4, window.innerWidth - box.width - 4)}px`; + } + if (box.bottom > window.innerHeight) { + created.style.top = `${Math.max(4, window.innerHeight - box.height - 4)}px`; + } + menu = created; +} + +window.addEventListener('click', (event) => { + if (menu && !menu.contains(event.target as Node)) { + closeMenu(); + } +}); + +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + if (menu) { + closeMenu(); + return; + } + if (query) { + query = ''; + search.value = ''; + renderGrid(); + } + } +}); + +// Typing should start immediately, the way a start menu does. +setTimeout(() => search.focus(), 0); diff --git a/extension/media/src/browser.ts b/extension/media/src/browser.ts index 07e3b07..537fd82 100644 --- a/extension/media/src/browser.ts +++ b/extension/media/src/browser.ts @@ -50,6 +50,17 @@ const navButton = (glyph: string, title: string, action: 'back' | 'forward' | 'r const backButton = navButton('chevronLeft', 'Back', 'back'); const forwardButton = navButton('chevronRight', 'Forward', 'forward'); +// Shown only for a page that declares a web app manifest, which is the browser's +// own answer to "can this be installed?" - the same question Chrome puts in its +// address bar, and the reason this button lives here rather than in the +// Marketplace, where finding out would mean fetching the page a second time. +const installButton = h('button', { + class: 'button primary', + title: 'Install this site as an app', + hidden: true, + on: { click: () => post({ type: 'browserInstallApp' }) }, +}, h('span', { html: icon('download', 15) }), 'Install app'); + clear(root()).append(h('div', { class: 'app' }, tabsRow, h('div', { class: 'toolbar browser-toolbar' }, @@ -58,6 +69,7 @@ clear(root()).append(h('div', { class: 'app' }, navButton('refresh', 'Reload', 'reload'), navButton('home', 'Homepage', 'home'), address, + installButton, h('button', { class: 'button', title: 'Open this page in a real browser window', @@ -85,6 +97,10 @@ onMessage<HostMessage>((message) => { } backButton.disabled = !state.canGoBack; forwardButton.disabled = !state.canGoForward; + installButton.hidden = !state.installable; + if (state.installable) { + installButton.title = `Install ${state.installable.name} as an app`; + } renderTabs(); return; } diff --git a/extension/media/src/flyout.ts b/extension/media/src/flyout.ts index f451503..c285449 100644 --- a/extension/media/src/flyout.ts +++ b/extension/media/src/flyout.ts @@ -1,14 +1,15 @@ -// The nine tray flyouts, drawn as one card. +// The eight tray cards, drawn as one card. // -// In the bottom panel the host is full width, so the card pins itself to the -// bottom-right corner and reads as a flyout rising out of the tray item that was -// clicked. In the side bar - the default - the container is already the right -// width and the anchoring does nothing, which is exactly what is wanted there. +// The optional half of the tray - `vscodeos.flyout.location` has to be set to +// `sidebar` or `panel` for any of this to be on screen; the default is a quick +// pick. In the bottom panel the host is full width, so the card pins itself to +// the bottom-right corner and reads as a flyout rising out of the tray item +// that was clicked. In a side bar container the width is already about right +// and the anchoring does nothing, which is what is wanted there. import { append, clear, h, onMessage, post, root, throttle, formatDuration } from './lib/dom'; import { icon, signalIcon } from './lib/icons'; import type { - AppEntry, BluetoothDevice, FlyoutKind, FlyoutState, @@ -16,13 +17,11 @@ import type { NotificationRecord, } from '../../src/webview/protocol'; -let kind: FlyoutKind = 'apps'; +let kind: FlyoutKind = 'power'; let state: FlyoutState | undefined; let busy: string | undefined; /** Month the calendar is showing; the clock keeps ticking regardless. */ let calendarMonth = new Date(); -/** What has been typed into the launcher's search box. */ -let appQuery = ''; const host = h('div', { class: 'flyout-host' }); const card = h('div', { class: 'flyout' }); @@ -35,7 +34,6 @@ onMessage<HostMessage>((message) => { if (message.kind !== kind) { kind = message.kind; calendarMonth = new Date(); - appQuery = ''; busy = undefined; } render(); @@ -71,12 +69,10 @@ setInterval(() => { function render(): void { card.classList.toggle( 'wide', - kind === 'network' || kind === 'music' || kind === 'apps' - || kind === 'bluetooth' || kind === 'notifications', + kind === 'network' || kind === 'music' || kind === 'bluetooth' || kind === 'notifications', ); clear(card); switch (kind) { - case 'apps': return renderApps(); case 'power': return renderPower(); case 'calendar': return renderCalendar(); case 'volume': return renderVolume(); @@ -92,78 +88,6 @@ function title(text: string): HTMLElement { return h('h2', { class: 'flyout-title' }, text); } -// -------------------------------------------------------------- app launcher - -function matches(app: AppEntry, query: string): boolean { - if (!query) { - return true; - } - const haystack = [app.title, app.description, ...(app.keywords ?? [])].join(' ').toLowerCase(); - // Every word has to appear somewhere, so "voice rec" finds the recorder but - // "voice paint" finds nothing. - return query.toLowerCase().split(/\s+/).filter(Boolean).every((word) => haystack.includes(word)); -} - -function renderApps(): void { - const open = (app: AppEntry): void => post({ type: 'command', command: app.command }); - - const search = h('input', { - class: 'app-search', - type: 'text', - placeholder: 'Search apps', - value: appQuery, - 'aria-label': 'Search apps', - on: { - input: (event: Event) => { - appQuery = (event.target as HTMLInputElement).value; - renderGrid(); - }, - keydown: (event: KeyboardEvent) => { - if (event.key === 'Enter') { - const first = (state?.apps ?? []).filter((app) => matches(app, appQuery))[0]; - if (first) { - open(first); - } - } - }, - }, - }); - - const grid = h('div', { class: 'app-grid' }); - - function renderGrid(): void { - const list = (state?.apps ?? []).filter((app) => matches(app, appQuery)); - clear(grid); - if (list.length === 0) { - grid.append(h('div', { class: 'empty' }, 'No app matches that.')); - return; - } - for (const app of list) { - grid.append(h('button', { - class: 'app-tile', - title: app.description, - on: { click: () => open(app) }, - }, - h('span', { class: 'app-tile-icon', html: icon(app.icon, 26) }), - h('span', { class: 'app-tile-label' }, app.title), - )); - } - } - - card.append( - h('div', { class: 'app-search-row' }, - h('span', { class: 'app-search-icon', html: icon('search', 16) }), - search, - ), - grid, - ); - renderGrid(); - - // Typing should start immediately, the way a start menu does. The view is - // focused by the host unless it was opened with preserveFocus. - setTimeout(() => search.focus(), 0); -} - // ------------------------------------------------------------------- power type PowerName = 'poweroff' | 'reboot' | 'suspend' | 'logout'; @@ -692,21 +616,25 @@ function renderBluetooth(): void { function renderMusic(): void { card.append(title('Music')); - const launchers = h('div', { class: 'tiles', style: { gridTemplateColumns: 'repeat(2, 1fr)' } }, + // The card used to be two bookmarks - Spotify Web and YouTube Music - which + // is not a music player and is useless on a machine with no network. The + // player is a real app now; this card is the transport for whatever is + // playing, wherever it is playing. + card.append(h('div', { class: 'tiles', style: { gridTemplateColumns: '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); + on: { click: () => post({ type: 'command', command: 'vscodeos.music.open' }) }, + }, + h('span', { html: icon('music', 20) }), + h('span', { class: 'tile-label' }, + 'Open Music', + h('div', { class: 'tile-sub' }, 'The music on this computer'), + )), + )); if (!state?.mprisAvailable) { card.append(h('p', { class: 'flyout-note' }, - 'Install playerctl to control playback from here: sudo pacman -S playerctl')); + 'Install playerctl to control other players from here: sudo pacman -S playerctl')); return; } @@ -714,8 +642,7 @@ function renderMusic(): void { 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.')); + card.append(h('div', { class: 'empty' }, 'Nothing is playing.')); return; } diff --git a/extension/media/src/lib/icons.ts b/extension/media/src/lib/icons.ts index d000ff7..a238a4d 100644 --- a/extension/media/src/lib/icons.ts +++ b/extension/media/src/lib/icons.ts @@ -77,6 +77,13 @@ const PATHS: Record<string, string> = { archive: 'M3 6.5h18v4H3v-4zM4.5 10.5v9h15v-9M10 14h4', keyboard: 'M2.5 6.5h19v11h-19v-11zM6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M6 14h12', display: 'M3 4.5h18v11H3v-4.5zM8.5 20h7M12 15.5V20', + // A shop front with its awning: the Marketplace. + store: 'M4 9.5h16V20H4V9.5zM2.5 9.5L4.5 4h15l2 5.5M9.5 20v-6h5v6', + // A box seen in three-quarter view: something installed by the package manager. + package: 'M12 2.5l8.5 4.75v9.5L12 21.5l-8.5-4.75v-9.5L12 2.5zM3.5 7.25L12 12l8.5-4.75M12 12v9.5', + shuffle: 'M3 7h3.5l3 4M3 17h3.5L14 6.5h4M14 17.5h4M16 3.5L19.5 7 16 10.5M16 14.5l3.5 3.5-3.5 3.5', + repeat: 'M4 11V9a3 3 0 0 1 3-3h13M17 3l3 3-3 3M20 13v2a3 3 0 0 1-3 3H4M7 21l-3-3 3-3', + link: 'M10.5 13.5a4.5 4.5 0 0 0 6.4 0l2.6-2.6a4.5 4.5 0 0 0-6.4-6.4l-1.3 1.3M13.5 10.5a4.5 4.5 0 0 0-6.4 0l-2.6 2.6a4.5 4.5 0 0 0 6.4 6.4l1.3-1.3', }; /** Solid glyphs, drawn with a fill instead of a stroke. */ diff --git a/extension/media/src/marketplace.ts b/extension/media/src/marketplace.ts new file mode 100644 index 0000000..996b61f --- /dev/null +++ b/extension/media/src/marketplace.ts @@ -0,0 +1,250 @@ +// The Marketplace page. +// +// Two panes, the same shape as System Settings: categories down the left, +// results on the right. The search box searches the whole catalogue rather than +// the open category - typing "regex" and being told there are no results +// because you happen to be looking at Media would be a poor answer. +// +// Icons are not fetched for the browse list. They come from each site's own +// manifest, which means one request per row to draw a grid nobody has asked to +// install anything from yet; a letter tile costs nothing and says as much. + +import { clear, h, onMessage, post, root } from './lib/dom'; +import { icon } from './lib/icons'; +import type { HostMessage, MarketplaceItem, MarketplaceState } from '../../src/webview/protocol'; + +let state: MarketplaceState | undefined; +let query = ''; +let busy: string | undefined; + +const rail = h('div', { class: 'places settings-rail' }); +const pane = h('div', { class: 'files settings-pane' }); + +const address = h('input', { + class: 'app-search', + type: 'text', + placeholder: 'Paste a web address to install any site', + 'aria-label': 'Install from a web address', + on: { + keydown: (event: KeyboardEvent) => { + if (event.key === 'Enter') { + installTyped(); + } + }, + }, +}); + +const search = h('input', { + class: 'app-search', + type: 'text', + placeholder: 'Search', + 'aria-label': 'Search the catalogue', + on: { + input: (event: Event) => { + query = (event.target as HTMLInputElement).value; + render(); + }, + }, +}); + +clear(root()).append( + h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('div', { class: 'app-search-row', style: { flex: '1', marginBottom: '0' } }, + h('span', { class: 'app-search-icon', html: icon('search', 16) }), + search, + ), + h('div', { class: 'app-search-row market-address', style: { marginBottom: '0' } }, + h('span', { class: 'app-search-icon', html: icon('link', 16) }), + address, + ), + h('button', { + class: 'button primary', + title: 'Read the site and install it as an app', + on: { click: () => installTyped() }, + }, h('span', { html: icon('download', 15) }), 'Install'), + ), + h('div', { class: 'body' }, h('div', { class: 'explorer' }, rail, pane)), + ), +); + +onMessage<HostMessage>((message) => { + if (message.type === 'marketplace') { + state = message.state; + busy = undefined; + render(); + return; + } + if (message.type === 'marketplaceBusy') { + busy = message.label; + render(); + } +}); + +post({ type: 'ready' }); + +function installTyped(): void { + const url = address.value.trim(); + if (!url) { + return; + } + post({ type: 'installWebApp', url }); + address.value = ''; +} + +// ------------------------------------------------------------------- render + +function render(): void { + renderRail(); + renderPane(); +} + +function renderRail(): void { + clear(rail); + if (!state) { + return; + } + for (const category of state.categories) { + const count = state.items.filter((item) => + category === 'Installed' ? item.installed : item.category === category).length; + rail.append(h('button', { + class: `list-row${state.category === category && !query ? ' active' : ''}`, + on: { + click: () => { + query = ''; + search.value = ''; + post({ type: 'marketplaceCategory', category }); + }, + }, + }, + h('span', { html: icon(category === 'Installed' ? 'check' : 'store', 17) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, category), + h('div', { class: 'list-sub' }, `${count} app${count === 1 ? '' : 's'}`), + ), + )); + } +} + +/** A word-for-word match over the whole catalogue, the same rule All Apps uses. */ +function matches(item: MarketplaceItem, text: string): boolean { + const haystack = [item.name, item.description, item.url, ...(item.keywords ?? [])] + .join(' ') + .toLowerCase(); + return text.toLowerCase().split(/\s+/).filter(Boolean).every((word) => haystack.includes(word)); +} + +function shown(): MarketplaceItem[] { + if (!state) { + return []; + } + // A search leaves the category behind on purpose: looking for something by + // name should find it wherever it happens to be filed. + if (query) { + return state.items.filter((item) => matches(item, query)); + } + return state.items.filter((item) => + state?.category === 'Installed' ? item.installed : item.category === state?.category); +} + +/** A coloured initial, so a list of thirty rows is not thirty identical glyphs. */ +function letterTile(item: MarketplaceItem): HTMLElement { + let hash = 0; + for (const character of item.name) { + hash = (hash * 31 + character.charCodeAt(0)) % 360; + } + return h('span', { + class: 'market-letter', + style: { background: `hsl(${hash}, 42%, 42%)` }, + }, item.name.slice(0, 1).toUpperCase()); +} + +function row(item: MarketplaceItem): HTMLElement { + const actions = h('span', { class: 'market-actions' }); + + if (item.installed) { + actions.append( + h('span', { class: 'update-badge current' }, 'Installed'), + h('button', { + class: 'button', + title: 'Open this app', + on: { click: () => post({ type: 'launchApp', source: 'webapp', id: item.appId }) }, + }, 'Open'), + h('button', { + class: 'icon-button', + title: 'Uninstall', + html: icon('trash', 15), + on: { click: () => post({ type: 'uninstallApp', id: item.appId, name: item.name }) }, + }), + ); + } else { + actions.append( + h('button', { + class: 'button primary', + title: `Install ${item.name}`, + on: { click: () => post({ type: 'installWebApp', url: item.url }) }, + }, 'Install'), + h('button', { + class: 'icon-button', + title: 'Visit the site first', + html: icon('globe', 15), + on: { click: () => post({ type: 'openExternal', path: item.url }) }, + }), + ); + } + + return h('div', { class: 'list-row market-row', title: item.url }, + letterTile(item), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, item.name), + h('div', { class: 'list-sub' }, item.description), + ), + actions, + ); +} + +function renderPane(): void { + clear(pane); + if (!state) { + pane.append(h('div', { class: 'empty' }, 'Loading…')); + return; + } + + const items = shown(); + const title = query ? `Results for “${query}”` : state.category; + pane.append(h('div', { class: 'pane-head' }, + h('div', {}, + h('div', { class: 'pane-title' }, title), + h('div', { class: 'pane-sub' }, + state.category === 'Installed' && !query + ? 'Apps this shell installed. Uninstalling removes the shortcut, not your account.' + : 'Installing reads the site’s own app manifest for its name and icon.'), + ), + )); + + if (busy) { + pane.append(h('div', { class: 'empty' }, busy)); + } + + if (!state.canOpenWindows) { + pane.append(h('p', { class: 'flyout-note' }, + 'No Chromium-based browser is installed, so web apps will open in an editor tab. ' + + 'Run: sudo pacman -S chromium')); + } + + if (items.length === 0) { + pane.append(h('div', { class: 'empty' }, + query + ? 'Nothing in the catalogue matches that. Any site can still be installed by pasting its address above.' + : state.category === 'Installed' + ? 'No web apps installed yet.' + : 'Nothing here yet.')); + return; + } + + const list = h('div', { class: 'list' }); + for (const item of items) { + list.append(row(item)); + } + pane.append(list); +} diff --git a/extension/media/src/music.ts b/extension/media/src/music.ts new file mode 100644 index 0000000..fbf5501 --- /dev/null +++ b/extension/media/src/music.ts @@ -0,0 +1,345 @@ +// The music player. +// +// One <audio> element and a list. Everything else - the queue, shuffle, repeat, +// what "next" means when a search is filtering the library - is decided here, +// because the element only knows about one file at a time. +// +// The queue is a copy of whatever was on screen when playback started, not a +// live view of the filter. Typing into the search box while a track plays must +// not silently change what comes next. + +import { clear, formatDuration, h, onMessage, post, root, vscode } from './lib/dom'; +import { icon } from './lib/icons'; +import type { HostMessage, MusicLibrary, Track } from '../../src/webview/protocol'; + +interface Saved { + volume: number; + shuffle: boolean; + repeat: boolean; +} + +const saved = vscode.getState<Saved>() ?? { volume: 1, shuffle: false, repeat: false }; + +let library: MusicLibrary | undefined; +let query = ''; +/** Indices into `library.tracks`, in the order they will play. */ +let queue: number[] = []; +let position = -1; + +const audio = new Audio(); +audio.volume = saved.volume; +audio.preload = 'metadata'; + +const search = h('input', { + class: 'app-search', + type: 'text', + placeholder: 'Search the library', + 'aria-label': 'Search the library', + on: { + input: (event: Event) => { + query = (event.target as HTMLInputElement).value; + renderList(); + }, + }, +}); + +const list = h('div', { class: 'list music-list' }); +const nowTitle = h('div', { class: 'list-name', style: { fontWeight: '600' } }, 'Nothing playing'); +const nowSub = h('div', { class: 'list-sub' }, '—'); +const elapsed = h('span', { class: 'music-time' }, '0:00'); +const total = h('span', { class: 'music-time' }, '0:00'); +const banner = h('div', { class: 'error-banner', hidden: true }); +const status = h('span', {}); + +const seek = h('input', { + type: 'range', + min: 0, + max: 1000, + value: 0, + 'aria-label': 'Seek', + on: { + input: (event: Event) => { + if (Number.isFinite(audio.duration) && audio.duration > 0) { + audio.currentTime = (Number((event.target as HTMLInputElement).value) / 1000) * audio.duration; + } + }, + }, +}); + +const playButton = button('play', 'Play', () => togglePlay(), 'primary'); +const shuffleButton = button('shuffle', 'Shuffle', () => { + saved.shuffle = !saved.shuffle; + persist(); + // Reshuffle from where we are, so the current track keeps playing. + rebuildQueue(currentIndex()); + renderTransport(); +}); +const repeatButton = button('repeat', 'Repeat', () => { + saved.repeat = !saved.repeat; + persist(); + renderTransport(); +}); + +clear(root()).append( + h('div', { class: 'app' }, + h('div', { class: 'toolbar' }, + h('div', { class: 'app-search-row', style: { flex: '1', marginBottom: '0' } }, + h('span', { class: 'app-search-icon', html: icon('search', 16) }), + search, + ), + h('button', { + class: 'button', + title: 'Scan the library again', + on: { click: () => post({ type: 'musicRefresh' }) }, + }, h('span', { html: icon('refresh', 15) }), 'Rescan'), + h('button', { + class: 'button', + title: 'Play music from a different folder', + on: { click: () => post({ type: 'musicChooseFolder' }) }, + }, h('span', { html: icon('folder', 15) }), 'Change folder'), + ), + banner, + h('div', { class: 'body music-body' }, list), + h('div', { class: 'music-bar' }, + h('div', { class: 'music-now' }, + h('span', { class: 'music-art', html: icon('music', 22) }), + h('span', { class: 'list-main' }, nowTitle, nowSub), + ), + h('div', { class: 'transport' }, + button('previous', 'Previous', () => step(-1)), + playButton, + button('next', 'Next', () => step(1)), + shuffleButton, + repeatButton, + ), + h('div', { class: 'progress-row' }, + elapsed, + seek, + total, + ), + ), + h('div', { class: 'status' }, status), + ), +); + +function button(glyph: string, title: string, onClick: () => void, extra = ''): HTMLButtonElement { + return h('button', { + class: extra, + title, + html: icon(glyph, extra === 'primary' ? 20 : 18), + on: { click: onClick }, + }); +} + +onMessage<HostMessage>((message) => { + if (message.type !== 'music') { + return; + } + library = message.library; + // Every index in the queue points into the old array, so it has to go. + queue = []; + position = -1; + renderList(); + renderTransport(); +}); + +post({ type: 'ready' }); + +// ------------------------------------------------------------------ the list + +function filtered(): number[] { + const tracks = library?.tracks ?? []; + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const indices: number[] = []; + for (let index = 0; index < tracks.length; index++) { + const haystack = `${tracks[index].name} ${tracks[index].folder}`.toLowerCase(); + if (words.every((word) => haystack.includes(word))) { + indices.push(index); + } + } + return indices; +} + +function renderList(): void { + clear(list); + const tracks = library?.tracks ?? []; + const indices = filtered(); + + banner.hidden = !library?.error; + if (library?.error) { + banner.textContent = `${library.error} Put some music in it, or choose another folder.`; + } + + if (indices.length === 0) { + list.append(h('div', { class: 'empty' }, + !library + ? 'Reading the library…' + : tracks.length === 0 + ? `No audio files under ${library.directory}.` + : 'Nothing matches that.')); + } + + const playing = currentIndex(); + for (const index of indices) { + const track = tracks[index]; + list.append(h('button', { + class: `list-row${index === playing ? ' active' : ''}`, + title: track.path, + on: { click: () => playFrom(index) }, + }, + h('span', { html: icon(index === playing && !audio.paused ? 'pause' : 'music', 17) }), + h('span', { class: 'list-main' }, + h('div', { class: 'list-name' }, track.name), + h('div', { class: 'list-sub' }, track.folder || library?.directory || ''), + ), + )); + } + + status.textContent = library + ? `${tracks.length} track${tracks.length === 1 ? '' : 's'}` + + (indices.length === tracks.length ? '' : `, ${indices.length} shown`) + + (library.truncated ? ' (library capped at 5000)' : '') + : ''; +} + +// ----------------------------------------------------------------- playback + +function currentIndex(): number { + return position >= 0 && position < queue.length ? queue[position] : -1; +} + +/** + * Build the play order from what is on screen. Shuffle is a shuffled copy with + * `start` moved to the front, so turning it on mid-track does not restart the + * track that is playing. + */ +function rebuildQueue(start: number): void { + const indices = filtered(); + if (indices.length === 0) { + queue = []; + position = -1; + return; + } + + if (!saved.shuffle) { + queue = indices; + position = Math.max(0, queue.indexOf(start)); + return; + } + + const rest = indices.filter((index) => index !== start); + for (let i = rest.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [rest[i], rest[j]] = [rest[j], rest[i]]; + } + queue = indices.includes(start) ? [start, ...rest] : rest; + position = 0; +} + +function playFrom(index: number): void { + rebuildQueue(index); + play(); +} + +function play(): void { + const track = library?.tracks[currentIndex()]; + if (!track) { + return; + } + audio.src = track.uri; + void audio.play().catch(() => { + banner.hidden = false; + banner.textContent = `${track.name} could not be played. The editor may not have a decoder for it.`; + }); + renderNowPlaying(track); + renderList(); +} + +function togglePlay(): void { + if (currentIndex() < 0) { + const first = filtered()[0]; + if (first !== undefined) { + playFrom(first); + } + return; + } + if (audio.paused) { + void audio.play(); + } else { + audio.pause(); + } + renderTransport(); + renderList(); +} + +function step(delta: number): void { + if (queue.length === 0) { + return; + } + // Wrapping in both directions: the end of a queue is the start of it, which + // is what every other player does and costs nothing. + position = (position + delta + queue.length) % queue.length; + play(); +} + +function renderNowPlaying(track: Track): void { + nowTitle.textContent = track.name; + nowSub.textContent = track.folder || library?.directory || ''; + renderTransport(); +} + +function renderTransport(): void { + playButton.innerHTML = icon(audio.paused ? 'play' : 'pause', 20); + playButton.title = audio.paused ? 'Play' : 'Pause'; + shuffleButton.className = saved.shuffle ? 'on' : ''; + repeatButton.className = saved.repeat ? 'on' : ''; +} + +function persist(): void { + vscode.setState<Saved>(saved); +} + +audio.addEventListener('timeupdate', () => { + elapsed.textContent = formatDuration(audio.currentTime); + if (Number.isFinite(audio.duration) && audio.duration > 0) { + seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000)); + } +}); + +audio.addEventListener('loadedmetadata', () => { + total.textContent = formatDuration(audio.duration); +}); + +audio.addEventListener('ended', () => { + if (saved.repeat && queue.length === 1) { + void audio.play(); + return; + } + if (position >= queue.length - 1 && !saved.repeat) { + // The end of the queue: stop rather than loop back to the first track. + audio.pause(); + renderTransport(); + renderList(); + return; + } + step(1); +}); + +audio.addEventListener('play', () => { renderTransport(); renderList(); }); +audio.addEventListener('pause', () => { renderTransport(); renderList(); }); + +window.addEventListener('keydown', (event) => { + if (event.target === search) { + return; + } + if (event.key === ' ') { + event.preventDefault(); + togglePlay(); + } else if (event.key === 'ArrowRight' && event.ctrlKey) { + step(1); + } else if (event.key === 'ArrowLeft' && event.ctrlKey) { + step(-1); + } +}); + +renderTransport(); diff --git a/extension/media/src/recyclebin.ts b/extension/media/src/recyclebin.ts deleted file mode 100644 index 6f8c621..0000000 --- a/extension/media/src/recyclebin.ts +++ /dev/null @@ -1,128 +0,0 @@ -// The Recycle Bin view in the activity bar. -// -// Narrow by design - it shares its container with the Task Manager - so each -// row is the name, where it came from, and when. Selection is multi-select the -// way the file explorer's is, because restoring twenty things one at a time is -// not a feature. - -import { clear, formatBytes, h, onMessage, post, root } from './lib/dom'; -import { icon } from './lib/icons'; -import type { FileEntry } from '../../src/webview/protocol'; - -interface TrashMessage { - type: 'trash'; - entries: (FileEntry & { originalPath?: string; deletedAt?: number })[]; -} - -let entries: TrashMessage['entries'] = []; -const selection = new Set<string>(); - -const list = h('div', { class: 'list' }); -const emptyButton = h('button', { - class: 'button', - on: { click: () => post({ type: 'emptyTrash' }) }, -}, h('span', { html: icon('trash', 15) }), 'Empty'); - -const restoreButton = h('button', { - class: 'button primary', - on: { click: () => post({ type: 'restoreFromTrash', paths: [...selection] }) }, -}, 'Restore'); - -const deleteButton = h('button', { - class: 'button', - on: { click: () => post({ type: 'deleteFromTrash', paths: [...selection] }) }, -}, 'Delete'); - -const actions = h('div', { class: 'bin-actions', hidden: true }, restoreButton, deleteButton); - -clear(root()).append(h('div', { class: 'app bin' }, - h('div', { class: 'toolbar' }, - h('span', { html: icon('trash', 15) }), - h('span', { class: 'bin-count' }, ''), - h('span', { class: 'spacer' }), - emptyButton, - ), - actions, - h('div', { class: 'body' }, list), -)); - -const countLabel = document.querySelector('.bin-count') as HTMLElement; - -onMessage<TrashMessage>((message) => { - if (message.type !== 'trash') { - return; - } - entries = message.entries; - // Anything restored or deleted elsewhere should not stay selected here. - for (const name of [...selection]) { - if (!entries.some((entry) => entry.path === name)) { - selection.delete(name); - } - } - render(); -}); - -post({ type: 'ready' }); - -function whenLabel(at: number | undefined): string { - if (!at) { - return 'unknown date'; - } - const date = new Date(at); - const days = Math.floor((Date.now() - at) / 86400000); - if (days === 0) { - return `today, ${date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; - } - if (days === 1) { - return 'yesterday'; - } - return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); -} - -function render(): void { - countLabel.textContent = entries.length === 0 - ? 'Recycle Bin' - : `${entries.length} item${entries.length === 1 ? '' : 's'}`; - emptyButton.disabled = entries.length === 0; - actions.hidden = selection.size === 0; - restoreButton.textContent = `Restore ${selection.size}`; - deleteButton.textContent = `Delete ${selection.size}`; - - clear(list); - if (entries.length === 0) { - list.append(h('div', { class: 'empty' }, 'The Recycle Bin is empty.')); - return; - } - - for (const entry of entries) { - const selected = selection.has(entry.path); - list.append(h('div', { - class: `list-row${selected ? ' active' : ''}`, - title: entry.originalPath ?? entry.name, - tabIndex: 0, - on: { - click: (event: MouseEvent) => { - if (!event.ctrlKey && !event.metaKey) { - selection.clear(); - } - if (selected) { - selection.delete(entry.path); - } else { - selection.add(entry.path); - } - render(); - }, - dblclick: () => post({ type: 'restoreFromTrash', paths: [entry.path] }), - }, - }, - h('span', { html: icon(entry.isDirectory ? 'folder' : 'file', 16) }), - h('span', { class: 'list-main' }, - h('div', { class: 'list-name' }, entry.name), - h('div', { class: 'list-sub' }, - entry.originalPath - ? `${entry.originalPath} · ${whenLabel(entry.deletedAt)}` - : `Original location unknown · ${formatBytes(entry.size)}`), - ), - )); - } -} diff --git a/extension/package.json b/extension/package.json index 6213bdd..96837e3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -43,10 +43,20 @@ }, "viewsContainers": { "activitybar": [ + { + "id": "vscodeos-apps", + "title": "All Apps", + "icon": "media/icons/apps.svg" + }, { "id": "vscodeos-system", "title": "System", "icon": "media/icons/system.svg" + }, + { + "id": "vscodeos-tray", + "title": "Tray", + "icon": "media/icons/tray.svg" } ], "panel": [ @@ -58,26 +68,29 @@ ] }, "views": { - "vscodeos-system": [ + "vscodeos-apps": [ { "type": "webview", - "id": "vscodeos.flyout", - "name": "System", - "when": "config.vscodeos.flyout.location == 'sidebar'" - }, + "id": "vscodeos.allApps", + "name": "All Apps", + "icon": "media/icons/apps.svg" + } + ], + "vscodeos-system": [ { "type": "webview", "id": "vscodeos.taskManager", "name": "Task Manager", "icon": "media/icons/system.svg", "when": "config.vscodeos.taskManager.enabled" - }, + } + ], + "vscodeos-tray": [ { "type": "webview", - "id": "vscodeos.recycleBin", - "name": "Recycle Bin", - "icon": "media/icons/recyclebin.svg", - "when": "config.vscodeos.recycleBin.enabled" + "id": "vscodeos.flyout", + "name": "Tray", + "when": "config.vscodeos.flyout.location == 'sidebar'" } ], "vscodeos-flyout": [ @@ -158,7 +171,12 @@ }, { "command": "vscodeos.music.show", - "title": "Show Music Player", + "title": "Show Music Controls", + "category": "VS Code OS" + }, + { + "command": "vscodeos.music.open", + "title": "Music", "category": "VS Code OS" }, { @@ -275,6 +293,21 @@ "command": "vscodeos.apps.menu", "title": "All Apps\u2026", "category": "VS Code OS" + }, + { + "command": "vscodeos.marketplace.open", + "title": "Marketplace", + "category": "VS Code OS" + }, + { + "command": "vscodeos.webApps.install", + "title": "Install Web App\u2026", + "category": "VS Code OS" + }, + { + "command": "vscodeos.webApps.launch", + "title": "Open Web App", + "category": "VS Code OS" } ], "configuration": { @@ -298,14 +331,16 @@ "vscodeos.flyout.location": { "type": "string", "enum": [ + "popup", "sidebar", "panel" ], "enumDescriptions": [ - "In the side bar, next to the Task Manager. Leaves the bottom panel to the terminal.", - "In the bottom panel, directly above the status bar." + "As a popup over the editor, which is what a tray menu should be.", + "As cards in a Tray container in the activity bar. Richer: real sliders, a month grid and album art.", + "As cards in the bottom panel, directly above the status bar. Takes the panel away from the terminal." ], - "default": "sidebar", + "default": "popup", "description": "Where the tray menus (power, clock, network, Bluetooth, volume, music) open." }, "vscodeos.taskManager.enabled": { @@ -325,16 +360,26 @@ "default": true, "description": "Enable the graphical file explorer." }, - "vscodeos.recycleBin.enabled": { + "vscodeos.allApps.systemApps": { + "type": "boolean", + "default": true, + "description": "List the machine's other applications in All Apps, from their .desktop files. Turn off to show only the shell's own apps and installed web apps." + }, + "vscodeos.webApps.enabled": { "type": "boolean", "default": true, - "description": "Show the Recycle Bin view in the activity bar. The Files app lists it as a place either way." + "description": "Enable the Marketplace and installed web apps (PWAs)." }, "vscodeos.music.enabled": { "type": "boolean", "default": true, "description": "Enable the music player and its status bar transport." }, + "vscodeos.music.directory": { + "type": "string", + "default": "~/Music", + "description": "Folder the Music app builds its library from." + }, "vscodeos.notifications.enabled": { "type": "boolean", "default": true, @@ -421,6 +466,10 @@ { "command": "vscodeos.power.settings", "when": "config.vscodeos.statusBar.enabled" + }, + { + "command": "vscodeos.webApps.launch", + "when": "false" } ] } diff --git a/extension/src/apps/browser.ts b/extension/src/apps/browser.ts index c550742..a8798cf 100644 --- a/extension/src/apps/browser.ts +++ b/extension/src/apps/browser.ts @@ -25,6 +25,7 @@ import * as path from 'node:path'; // to resolve the same way. import type { Browser as PuppeteerBrowser, CDPSession, Page } from 'puppeteer-core' with { 'resolution-mode': 'import' }; import { detect, open as openExternally } from '../sys/browser'; +import type { WebAppService } from '../sys/webapps'; import { normaliseAddress } from '../util/url'; import { AppPanels } from './panels'; import type { AppOptions } from './panels'; @@ -70,7 +71,10 @@ export class Browser implements vscode.Disposable { /** Where to go once the webview has told us how big it is. */ private pending: string | undefined; - constructor(private readonly panels: AppPanels) {} + constructor( + private readonly panels: AppPanels, + private readonly webApps: WebAppService, + ) {} private get config(): vscode.WorkspaceConfiguration { return vscode.workspace.getConfiguration('vscodeos'); @@ -278,10 +282,45 @@ export class Browser implements vscode.Disposable { }))); const history = await this.historyFlags(tab); - const state: BrowserState = { url, title, loading: false, ...history, tabs }; + const state: BrowserState = { + url, + title, + loading: false, + ...history, + tabs, + installable: await this.installable(tab, url, title), + }; this.post({ type: 'browserState', state }); } + /** + * Whether the page in front of you is installable as an app. + * + * This is the one place in the shell that can answer that question the way + * a browser does, because it *is* a browser: the page is loaded, so the + * manifest link is right there in the DOM. Everywhere else has to fetch the + * page again to find out. + */ + private async installable(tab: Tab, url: string, title: string): Promise<BrowserState['installable']> { + if (!url.startsWith('http') || !this.config.get<boolean>('webApps.enabled', true)) { + return undefined; + } + try { + const href = await tab.page.evaluate( + () => document.querySelector<HTMLLinkElement>('link[rel~="manifest"]')?.href ?? '', + ); + if (!href) { + return undefined; + } + // Offering to install something that is already installed is a + // button that does nothing useful, so it hides once it has been used. + return (await this.webApps.findForUrl(url)) ? undefined : { url, name: title || url }; + } catch { + // Mid-navigation, or a page that will not run script for us. + return undefined; + } + } + /** * Whether back and forward would do anything. Puppeteer has no accessor for * this, but the CDP navigation history has both the entries and the index. @@ -428,6 +467,17 @@ export class Browser implements vscode.Disposable { return; } + case 'browserInstallApp': { + const url = this.active?.page.url(); + if (url) { + // The Marketplace owns the install flow - reading the + // manifest, asking where the app should open, reporting the + // result - so this is the same three steps either way in. + await vscode.commands.executeCommand('vscodeos.webApps.install', url); + } + return; + } + default: return; } diff --git a/extension/src/apps/marketplace.ts b/extension/src/apps/marketplace.ts new file mode 100644 index 0000000..021199a --- /dev/null +++ b/extension/src/apps/marketplace.ts @@ -0,0 +1,220 @@ +// The Marketplace. +// +// Where web apps come from. A curated list to browse (apps/webAppCatalogue.ts) +// and a box to paste an address into, because the list can never be complete +// and a store that only offers what somebody else chose is not much of a store. +// +// Either route ends in the same place: fetch the site, read its manifest, ask +// where it should open, install. The catalogue supplies a URL and nothing more, +// so what actually gets installed is whatever the site says about itself. +// +// This is deliberately not called an "app store": nothing is bought, nothing is +// reviewed, and nothing is downloaded but an icon. + +import * as vscode from 'vscode'; +import * as browserSys from '../sys/browser'; +import type { OpenIn, WebAppService } from '../sys/webapps'; +import { appIdFromUrl, hostOf, sameSite } from '../util/webapp'; +import { CATALOGUE, CATEGORIES } from './webAppCatalogue'; +import { AppPanels } from './panels'; +import type { AppOptions } from './panels'; +import type { HostMessage, MarketplaceItem, MarketplaceState, WebviewMessage } from '../webview/protocol'; +import { log } from '../log'; + +const PANEL: AppOptions = { + id: 'marketplace', + title: 'Marketplace', + script: 'marketplace', + icon: 'marketplace', +}; + +export class Marketplace { + private category: string = CATEGORIES[1]; + + constructor( + private readonly panels: AppPanels, + private readonly webApps: WebAppService, + ) {} + + open(): void { + const existed = this.panels.get(PANEL.id) !== undefined; + this.panels.open({ ...PANEL, onMessage: (message) => this.handle(message) }); + if (existed) { + void this.refresh(); + } + } + + private post(message: HostMessage): void { + void this.panels.get(PANEL.id)?.webview.postMessage(message); + } + + private async handle(message: WebviewMessage): Promise<void> { + try { + switch (message.type) { + case 'ready': + await this.refresh(); + return; + + case 'marketplaceCategory': + this.category = message.category; + await this.refresh(); + return; + + case 'installWebApp': + await this.install(message.url); + return; + + case 'uninstallApp': { + const choice = await vscode.window.showWarningMessage( + `Uninstall "${message.name}"?`, + { modal: true, detail: 'The app and its shortcut are removed.' }, + 'Uninstall', + ); + if (choice === 'Uninstall') { + await this.webApps.uninstall(message.id); + } + await this.refresh(); + return; + } + + case 'launchApp': + await vscode.commands.executeCommand('vscodeos.webApps.launch', message.id); + return; + + case 'openExternal': + await vscode.commands.executeCommand('vscodeos.browser.open', message.path); + return; + + default: + return; + } + } catch (error) { + log.error('marketplace', error); + void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async refresh(): Promise<void> { + const installed = await this.webApps.list(); + + // Matched by site, not by id: an app installs at its manifest's + // start_url, which is usually deeper than the catalogue's address, so + // https://docs.google.com lands as .../document/u/0/. Comparing ids + // would leave every installed catalogue app still offering "Install" + // and appear a second time under Installed. + const matched = new Set<string>(); + const items: MarketplaceItem[] = CATALOGUE.map((entry) => { + const app = installed.find((candidate) => sameSite(candidate.url, entry.url)); + if (app) { + matched.add(app.id); + } + return { + id: entry.id, + // The real id when it is installed, so Open and Uninstall reach it. + appId: app?.id ?? appIdFromUrl(entry.url), + name: entry.name, + description: entry.description, + url: entry.url, + category: entry.category, + keywords: entry.keywords, + installed: app !== undefined, + }; + }); + + // Anything installed from a pasted URL is not in the catalogue, and the + // Installed tab would be a lie without it. + for (const app of installed) { + if (!matched.has(app.id)) { + items.push({ + id: `installed-${app.id}`, + appId: app.id, + name: app.name, + description: app.description, + url: app.url, + category: 'Installed', + installed: true, + }); + } + } + + const state: MarketplaceState = { + category: this.category, + categories: [...CATEGORIES], + items, + canOpenWindows: browserSys.detect()?.chromiumLike === true, + }; + this.post({ type: 'marketplace', state }); + } + + /** + * Install whatever is at an address. + * + * Three steps the user sees: reading the site, choosing where it opens, and + * the result. The middle one is a quick pick rather than a setting, because + * the answer is different for a music service (its own window, so Chromium + * exports MPRIS and the tray can control it) and a wiki (an editor tab, so + * it stays inside the kiosk). + */ + async install(address: string): Promise<void> { + this.post({ type: 'marketplaceBusy', label: `Reading ${hostOf(address) || address}…` }); + const found = await this.webApps.discover(address); + if (!found.ok) { + this.post({ type: 'marketplaceBusy' }); + void vscode.window.showErrorMessage(found.message); + await this.refresh(); + return; + } + + const info = found.info; + const openIn = await this.askWhereToOpen(info.name); + if (!openIn) { + this.post({ type: 'marketplaceBusy' }); + await this.refresh(); + return; + } + + this.post({ type: 'marketplaceBusy', label: `Installing ${info.name}…` }); + const result = await this.webApps.install(info, openIn); + this.post({ type: 'marketplaceBusy' }); + await this.refresh(); + + if (!result.ok) { + void vscode.window.showErrorMessage(`${info.name} could not be installed: ${result.message}`); + return; + } + const open = 'Open it'; + const choice = await vscode.window.showInformationMessage( + `${info.name} is installed. You will find it in All Apps.` + + (info.fromManifest ? '' : ' The site published no app manifest, so its name and icon come from the page.'), + open, + ); + if (choice === open) { + await vscode.commands.executeCommand('vscodeos.webApps.launch', info.id); + } + } + + private async askWhereToOpen(name: string): Promise<OpenIn | undefined> { + const chromium = browserSys.detect()?.chromiumLike === true; + const items: (vscode.QuickPickItem & { value: OpenIn })[] = [ + { + value: 'window', + label: '$(multiple-windows) Its own window', + description: chromium ? undefined : 'No Chromium-like browser installed', + detail: chromium + ? 'A frameless single-site window. Media keys and the tray transport work, because Chromium exports MPRIS.' + : 'Falls back to opening the page in whatever browser is installed.', + }, + { + value: 'editor', + label: '$(browser) An editor tab', + detail: 'Stays inside the editor, in the built-in browser. Heavier, and no media keys.', + }, + ]; + const picked = await vscode.window.showQuickPick(items, { + title: `Where should ${name} open?`, + placeHolder: 'This can be changed later from All Apps', + ignoreFocusOut: true, + }); + return picked?.value; + } +} diff --git a/extension/src/apps/musicPlayer.ts b/extension/src/apps/musicPlayer.ts new file mode 100644 index 0000000..409b1ed --- /dev/null +++ b/extension/src/apps/musicPlayer.ts @@ -0,0 +1,194 @@ +// Music. +// +// What used to be here was two buttons: one opened Spotify Web in a browser +// window, the other YouTube Music. Neither is a music player - they are +// bookmarks - and neither works on a machine with no network, which a kiosk +// often is. This plays the files on the disk. +// +// The Media Player next door opens one file and lists its folder. This is the +// other shape: a library, scanned once from ~/Music, searchable, with a queue +// that survives moving between albums. They share `mediaKind()` so the two +// never disagree about what is audio, and the Files app still routes a +// double-clicked track to the Media Player - opening one file is what that app +// is for. +// +// `localResourceRoots` is the library directory and nothing else, fixed when +// the panel opens. That is the whole reason this is a separate app from the +// Media Player, which has to widen its roots per file and reload the page each +// time it does. + +import * as vscode from 'vscode'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expandHome } from '../util/format'; +import { mediaKind } from '../util/media'; +import { AppPanels } from './panels'; +import type { AppOptions } from './panels'; +import type { HostMessage, MusicLibrary, Track, WebviewMessage } from '../webview/protocol'; +import { log } from '../log'; + +const PANEL: AppOptions = { + id: 'music', + title: 'Music', + script: 'music', + icon: 'music', +}; + +/** + * A library that is a whole disk is a scan that never ends, and the answer + * nobody wants. Deep enough for Artist/Album/Disc 1, wide enough for a real + * collection. + */ +const MAX_DEPTH = 5; +const MAX_TRACKS = 5000; + +export class MusicPlayer { + private library: MusicLibrary | undefined; + + constructor(private readonly panels: AppPanels) {} + + private get directory(): string { + const configured = vscode.workspace.getConfiguration('vscodeos') + .get<string>('music.directory', '~/Music') || '~/Music'; + return expandHome(configured, os.homedir()); + } + + open(): void { + const existed = this.panels.get(PANEL.id) !== undefined; + this.panels.open({ + ...PANEL, + localRoots: [vscode.Uri.file(this.directory)], + onMessage: (message) => this.handle(message), + }); + if (existed) { + // A revealed panel sends no second `ready`, so push what it has. + void this.send(); + } + } + + private post(message: HostMessage): void { + void this.panels.get(PANEL.id)?.webview.postMessage(message); + } + + private async handle(message: WebviewMessage): Promise<void> { + try { + switch (message.type) { + case 'ready': + await this.send(); + return; + + case 'musicRefresh': + this.library = undefined; + await this.send(); + return; + + case 'musicChooseFolder': + await this.chooseFolder(); + return; + + case 'revealPath': + await vscode.commands.executeCommand('vscodeos.files.open', message.path); + return; + + default: + return; + } + } catch (error) { + log.error('music', error); + void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error)); + } + } + + /** + * Point the library somewhere else, for good. + * + * The setting is the record rather than a field on this object, because the + * roots a webview may read from are fixed when it is created - so changing + * the folder means a new panel, and the new panel has to be able to find out + * where to look. + */ + private async chooseFolder(): Promise<void> { + const picked = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: vscode.Uri.file(this.directory), + openLabel: 'Use this folder', + }); + const chosen = picked?.[0]?.fsPath; + if (!chosen || chosen === this.directory) { + return; + } + await vscode.workspace.getConfiguration('vscodeos') + .update('music.directory', chosen, vscode.ConfigurationTarget.Global); + this.library = undefined; + this.panels.get(PANEL.id)?.dispose(); + this.open(); + } + + private async send(): Promise<void> { + const panel = this.panels.get(PANEL.id); + if (!panel) { + return; + } + if (!this.library) { + this.library = await this.scan(panel); + } + this.post({ type: 'music', library: this.library }); + } + + private async scan(panel: vscode.WebviewPanel): Promise<MusicLibrary> { + const directory = this.directory; + const tracks: Track[] = []; + let truncated = false; + + const walk = async (current: string, depth: number): Promise<void> => { + if (depth > MAX_DEPTH || tracks.length >= MAX_TRACKS) { + return; + } + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (error) { + log.debug(`music: could not read ${current}: ${String(error)}`); + return; + } + // Files before directories, so a flat ~/Music is listed in full + // before the scan disappears into the first subfolder. + const names = entries.slice().sort((a, b) => + Number(a.isDirectory()) - Number(b.isDirectory()) + || a.name.localeCompare(b.name, undefined, { numeric: true })); + + for (const entry of names) { + if (tracks.length >= MAX_TRACKS) { + truncated = true; + return; + } + if (entry.name.startsWith('.')) { + continue; + } + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(full, depth + 1); + } else if (mediaKind(entry.name) === 'audio') { + tracks.push({ + name: entry.name.replace(/\.[^.]+$/, ''), + path: full, + uri: String(panel.webview.asWebviewUri(vscode.Uri.file(full))), + folder: current === directory ? '' : path.basename(current), + }); + } + } + }; + + try { + await fs.access(directory); + } catch { + return { directory, tracks: [], error: `${directory} does not exist yet.` }; + } + + await walk(directory, 0); + return { directory, tracks, truncated }; + } +} diff --git a/extension/src/apps/registry.ts b/extension/src/apps/registry.ts index 0302a2c..88640f6 100644 --- a/extension/src/apps/registry.ts +++ b/extension/src/apps/registry.ts @@ -12,7 +12,7 @@ import type * as vscode from 'vscode'; import type { AppEntry } from '../webview/protocol'; -export interface AppDescriptor extends AppEntry { +export interface AppDescriptor extends Omit<AppEntry, 'source'> { enabled?: (config: vscode.WorkspaceConfiguration) => boolean; } @@ -48,12 +48,21 @@ export const APPS: AppDescriptor[] = [ { id: 'music', title: 'Music', - description: 'Spotify and YouTube Music', + description: 'Play the music on this computer', icon: 'music', - command: 'vscodeos.music.show', - keywords: ['spotify', 'youtube', 'player'], + command: 'vscodeos.music.open', + keywords: ['audio', 'songs', 'album', 'library', 'mp3', 'flac', 'playlist'], enabled: (config) => config.get<boolean>('music.enabled', true), }, + { + id: 'marketplace', + title: 'Marketplace', + description: 'Find and install web apps', + icon: 'store', + command: 'vscodeos.marketplace.open', + keywords: ['store', 'install', 'pwa', 'web app', 'apps', 'add'], + enabled: (config) => config.get<boolean>('webApps.enabled', true), + }, { id: 'calculator', title: 'Calculator', @@ -148,5 +157,7 @@ export const APPS: AppDescriptor[] = [ /** The launcher's list: everything whose feature switch is on, as plain data. */ export function availableApps(config: vscode.WorkspaceConfiguration): AppEntry[] { - return APPS.filter((app) => app.enabled?.(config) ?? true).map(({ enabled: _enabled, ...entry }) => entry); + return APPS + .filter((app) => app.enabled?.(config) ?? true) + .map(({ enabled: _enabled, ...entry }) => ({ ...entry, source: 'builtin' as const })); } diff --git a/extension/src/apps/webAppCatalogue.ts b/extension/src/apps/webAppCatalogue.ts new file mode 100644 index 0000000..17c9bb0 --- /dev/null +++ b/extension/src/apps/webAppCatalogue.ts @@ -0,0 +1,299 @@ +// What the Marketplace has to offer. +// +// There is no registry of web apps to query - no index, no API, nothing with a +// stable shape - so a store that only worked by search would be a store with an +// empty front page. This list is compiled into the extension instead, which +// also means it is browsable on a machine that has never been online, which a +// freshly flashed one has not. +// +// Nothing here is downloaded from this file: it is a name, a sentence and a +// URL. Installing fetches the site's own manifest (sys/webapps.ts), so the app +// that lands is whatever the site says it is, not whatever was written here. +// If a site changes its address the worst case is one failed fetch with the +// reason shown. + +export interface CatalogueEntry { + id: string; + name: string; + description: string; + url: string; + category: string; + keywords?: string[]; +} + +export const CATEGORIES = [ + 'Installed', + 'Productivity', + 'Development', + 'Media', + 'Social', + 'Utilities', +] as const; + +export const CATALOGUE: CatalogueEntry[] = [ + // ------------------------------------------------------------ productivity + { + id: 'google-docs', + name: 'Google Docs', + description: 'Write documents in the browser', + url: 'https://docs.google.com', + category: 'Productivity', + keywords: ['word', 'writing', 'office'], + }, + { + id: 'google-sheets', + name: 'Google Sheets', + description: 'Spreadsheets in the browser', + url: 'https://sheets.google.com', + category: 'Productivity', + keywords: ['excel', 'spreadsheet', 'office'], + }, + { + id: 'outlook', + name: 'Outlook', + description: 'Microsoft mail and calendar', + url: 'https://outlook.live.com', + category: 'Productivity', + keywords: ['email', 'mail', 'hotmail', 'microsoft'], + }, + { + id: 'proton-mail', + name: 'Proton Mail', + description: 'Encrypted mail', + url: 'https://mail.proton.me', + category: 'Productivity', + keywords: ['email', 'privacy', 'encrypted'], + }, + { + id: 'notion', + name: 'Notion', + description: 'Notes, documents and databases', + url: 'https://www.notion.so', + category: 'Productivity', + keywords: ['notes', 'wiki', 'docs'], + }, + { + id: 'todoist', + name: 'Todoist', + description: 'Tasks and projects', + url: 'https://app.todoist.com', + category: 'Productivity', + keywords: ['tasks', 'todo', 'gtd'], + }, + { + id: 'trello', + name: 'Trello', + description: 'Boards, lists and cards', + url: 'https://trello.com', + category: 'Productivity', + keywords: ['kanban', 'boards', 'project'], + }, + { + id: 'proton-calendar', + name: 'Proton Calendar', + description: 'Encrypted calendar', + url: 'https://calendar.proton.me', + category: 'Productivity', + keywords: ['calendar', 'schedule', 'privacy'], + }, + + // ------------------------------------------------------------- development + { + id: 'github', + name: 'GitHub', + description: 'Repositories, issues and pull requests', + url: 'https://github.com', + category: 'Development', + keywords: ['git', 'code', 'repository'], + }, + { + id: 'gitlab', + name: 'GitLab', + description: 'Repositories and CI', + url: 'https://gitlab.com', + category: 'Development', + keywords: ['git', 'code', 'ci'], + }, + { + id: 'vscode-dev', + name: 'VS Code for the Web', + description: 'The editor, in a browser tab', + url: 'https://vscode.dev', + category: 'Development', + keywords: ['editor', 'code', 'web'], + }, + { + id: 'stackblitz', + name: 'StackBlitz', + description: 'Full-stack projects that run in the browser', + url: 'https://stackblitz.com', + category: 'Development', + keywords: ['ide', 'sandbox', 'node'], + }, + { + id: 'codepen', + name: 'CodePen', + description: 'Front-end snippets and demos', + url: 'https://codepen.io', + category: 'Development', + keywords: ['html', 'css', 'javascript', 'sandbox'], + }, + { + id: 'devdocs', + name: 'DevDocs', + description: 'API documentation for everything, offline-capable', + url: 'https://devdocs.io', + category: 'Development', + keywords: ['docs', 'reference', 'api', 'manual'], + }, + { + id: 'regex101', + name: 'regex101', + description: 'Build and explain regular expressions', + url: 'https://regex101.com', + category: 'Development', + keywords: ['regex', 'pattern', 'test'], + }, + + // -------------------------------------------------------------------- media + { + id: 'youtube', + name: 'YouTube', + description: 'Video', + url: 'https://www.youtube.com', + category: 'Media', + keywords: ['video', 'watch', 'streaming'], + }, + { + id: 'youtube-music', + name: 'YouTube Music', + description: 'Streaming music', + url: 'https://music.youtube.com', + category: 'Media', + keywords: ['music', 'streaming', 'songs'], + }, + { + id: 'spotify', + name: 'Spotify', + description: 'Streaming music. Needs a real browser window: VS Code’s Electron ships no Widevine', + url: 'https://open.spotify.com', + category: 'Media', + keywords: ['music', 'streaming', 'podcasts'], + }, + { + id: 'soundcloud', + name: 'SoundCloud', + description: 'Independent music and mixes', + url: 'https://soundcloud.com', + category: 'Media', + keywords: ['music', 'streaming', 'dj'], + }, + { + id: 'photopea', + name: 'Photopea', + description: 'A full image editor that opens PSD files', + url: 'https://www.photopea.com', + category: 'Media', + keywords: ['photoshop', 'image', 'editor', 'psd'], + }, + { + id: 'excalidraw', + name: 'Excalidraw', + description: 'Hand-drawn-looking diagrams', + url: 'https://excalidraw.com', + category: 'Media', + keywords: ['draw', 'diagram', 'whiteboard', 'sketch'], + }, + { + id: 'tldraw', + name: 'tldraw', + description: 'A very good whiteboard', + url: 'https://www.tldraw.com', + category: 'Media', + keywords: ['draw', 'whiteboard', 'diagram'], + }, + + // ------------------------------------------------------------------- social + { + id: 'element', + name: 'Element', + description: 'Matrix chat', + url: 'https://app.element.io', + category: 'Social', + keywords: ['chat', 'matrix', 'messaging'], + }, + { + id: 'discord', + name: 'Discord', + description: 'Voice and text chat', + url: 'https://discord.com/app', + category: 'Social', + keywords: ['chat', 'voice', 'gaming'], + }, + { + id: 'telegram', + name: 'Telegram', + description: 'Messaging', + url: 'https://web.telegram.org', + category: 'Social', + keywords: ['chat', 'messaging', 'sms'], + }, + { + id: 'whatsapp', + name: 'WhatsApp', + description: 'Messaging, paired with a phone', + url: 'https://web.whatsapp.com', + category: 'Social', + keywords: ['chat', 'messaging', 'sms'], + }, + { + id: 'mastodon', + name: 'Mastodon', + description: 'The federated timeline', + url: 'https://mastodon.social', + category: 'Social', + keywords: ['fediverse', 'microblog', 'social'], + }, + + // ---------------------------------------------------------------- utilities + { + id: 'openstreetmap', + name: 'OpenStreetMap', + description: 'Maps built by the people who use them', + url: 'https://www.openstreetmap.org', + category: 'Utilities', + keywords: ['map', 'navigation', 'directions'], + }, + { + id: 'wikipedia', + name: 'Wikipedia', + description: 'The encyclopedia', + url: 'https://en.m.wikipedia.org', + category: 'Utilities', + keywords: ['reference', 'encyclopedia', 'search'], + }, + { + id: 'archwiki', + name: 'Arch Wiki', + description: 'The documentation this machine runs on', + url: 'https://wiki.archlinux.org', + category: 'Utilities', + keywords: ['arch', 'linux', 'docs', 'help'], + }, + { + id: 'squoosh', + name: 'Squoosh', + description: 'Compress images without leaving the machine', + url: 'https://squoosh.app', + category: 'Utilities', + keywords: ['image', 'compress', 'optimise', 'resize'], + }, + { + id: 'internet-radio', + name: 'Radio Garden', + description: 'Live radio from anywhere on the globe', + url: 'https://radio.garden', + category: 'Utilities', + keywords: ['radio', 'music', 'streaming'], + }, +]; diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 83b84cd..f1d6a14 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -11,21 +11,27 @@ import * as vscode from 'vscode'; import { AppPanels } from './apps/panels'; import { Browser } from './apps/browser'; import { FileExplorer } from './apps/fileExplorer'; +import { Marketplace } from './apps/marketplace'; import { MediaPlayer } from './apps/mediaPlayer'; import { MiniApps } from './apps/miniApps'; +import { MusicPlayer } from './apps/musicPlayer'; import { Firewall } from './apps/firewall'; import { SystemSettings } from './apps/systemSettings'; import { StatusBar } from './statusbar'; +import { TrayMenus } from './statusbar/menus'; +import { AllAppsProvider } from './views/allApps'; import { FlyoutProvider, runPowerAction } from './views/flyout'; -import { RecycleBinProvider } from './views/recycleBin'; import { TaskManagerProvider } from './views/taskManager'; import { MprisMonitor } from './sys/mpris'; import { NotificationServer } from './sys/notifications'; +import { WebAppService } from './sys/webapps'; import * as audio from './sys/audio'; +import * as browserSys from './sys/browser'; import * as display from './sys/display'; import * as keyboard from './sys/keyboard'; -import { TrashService } from './sys/trash'; +import { TRASH_PATH, TrashService } from './sys/trash'; import * as mpris from './sys/mpris'; +import type { FlyoutKind } from './webview/protocol'; import { log } from './log'; export function activate(context: vscode.ExtensionContext): void { @@ -56,9 +62,18 @@ export function activate(context: vscode.ExtensionContext): void { } // --- the tray ---------------------------------------------------------- + // + // Two implementations of the same eight menus. Quick picks are the default + // and are what a tray menu should be - a popup over the editor, gone on + // Escape. The webview cards are richer (real sliders, a month grid, album + // art) but they have to live in a container, which means taking the side + // bar or the panel away from whatever was in it. + + const menus = new TrayMenus(music, notifications); + context.subscriptions.push(menus); // Contributed to both the side bar and the bottom panel under a `when` on - // vscodeos.flyout.location, so exactly one of these ever resolves. Both are + // vscodeos.flyout.location, so at most one of these ever resolves. Both are // registered because the setting can change without a reload. const flyout = new FlyoutProvider(context, music, notifications); for (const viewId of [FlyoutProvider.sidebarViewId, FlyoutProvider.panelViewId]) { @@ -69,6 +84,12 @@ export function activate(context: vscode.ExtensionContext): void { ); } + /** Every tray item goes through here; the setting decides which one answers. */ + const showTray = (kind: FlyoutKind): Thenable<void> => + config().get<string>('flyout.location', 'popup') === 'popup' + ? menus.show(kind) + : flyout.show(kind); + if (config().get<boolean>('statusBar.enabled', true)) { const statusBar = new StatusBar(music, notifications); statusBar.start(); @@ -85,27 +106,34 @@ export function activate(context: vscode.ExtensionContext): void { taskManager, ); - // --- recycle bin ------------------------------------------------------- + // --- the recycle bin --------------------------------------------------- // - // One service behind both surfaces - this view and the Files app's places - // list - so restoring in either redraws the other. + // No view of its own any more: it is a place in the Files app, which is + // where a bin belongs and where it can be browsed with the same columns and + // the same selection as everything else. const trash = new TrashService(); - const recycleBin = new RecycleBinProvider(context, trash); + + // --- apps -------------------------------------------------------------- + + const webApps = new WebAppService(); + context.subscriptions.push({ dispose: () => webApps.dispose() }); + + const allApps = new AllAppsProvider(context, webApps); context.subscriptions.push( - vscode.window.registerWebviewViewProvider(RecycleBinProvider.viewId, recycleBin, { + vscode.window.registerWebviewViewProvider(AllAppsProvider.viewId, allApps, { webviewOptions: { retainContextWhenHidden: true }, }), - recycleBin, + allApps, ); - // --- apps -------------------------------------------------------------- - const panels = new AppPanels(context); const files = new FileExplorer(context, trash); const apps = new MiniApps(panels); const player = new MediaPlayer(panels); - const browser = new Browser(panels); + const musicApp = new MusicPlayer(panels); + const browser = new Browser(panels, webApps); + const marketplace = new Marketplace(panels, webApps); const settings = new SystemSettings(panels); const firewall = new Firewall(panels); context.subscriptions.push( @@ -130,22 +158,24 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(vscode.commands.registerCommand(command, handler)); }; - register('vscodeos.power.menu', () => flyout.show('power')); + register('vscodeos.power.menu', () => showTray('power')); register('vscodeos.power.shutdown', () => runPowerAction('poweroff')); register('vscodeos.power.restart', () => runPowerAction('reboot')); register('vscodeos.power.sleep', () => runPowerAction('suspend')); register('vscodeos.power.logout', () => runPowerAction('logout')); - register('vscodeos.calendar.show', () => flyout.show('calendar')); - register('vscodeos.power.settings', () => flyout.show('powersettings')); - register('vscodeos.volume.show', () => flyout.show('volume')); - register('vscodeos.network.show', () => flyout.show('network')); - register('vscodeos.bluetooth.show', () => flyout.show('bluetooth')); - register('vscodeos.music.show', () => flyout.show('music')); - register('vscodeos.apps.menu', () => flyout.show('apps')); - register('vscodeos.notifications.show', () => flyout.show('notifications')); + register('vscodeos.calendar.show', () => showTray('calendar')); + register('vscodeos.power.settings', () => showTray('powersettings')); + register('vscodeos.volume.show', () => showTray('volume')); + register('vscodeos.network.show', () => showTray('network')); + register('vscodeos.bluetooth.show', () => showTray('bluetooth')); + register('vscodeos.music.show', () => showTray('music')); + register('vscodeos.notifications.show', () => showTray('notifications')); register('vscodeos.notifications.clear', () => notifications.clear()); + register('vscodeos.apps.menu', () => + vscode.commands.executeCommand(`${AllAppsProvider.viewId}.focus`)); + register('vscodeos.volume.up', () => audio.step(5)); register('vscodeos.volume.down', () => audio.step(-5)); register('vscodeos.volume.toggleMute', () => audio.toggleMute()); @@ -181,6 +211,66 @@ export function activate(context: vscode.ExtensionContext): void { register('vscodeos.apps.screenshotRegion', guard(() => apps.captureRegion())); register('vscodeos.apps.recorder', guard(() => apps.recorderApp())); register('vscodeos.apps.player', guard((path?: string) => player.open(path))); + + register('vscodeos.music.open', () => { + if (!config().get<boolean>('music.enabled', true)) { + return vscode.window.showInformationMessage('The music player is disabled in settings.'); + } + return musicApp.open(); + }); + + // --- web apps ---------------------------------------------------------- + + const webAppsEnabled = (): boolean => config().get<boolean>('webApps.enabled', true); + + register('vscodeos.marketplace.open', () => { + if (!webAppsEnabled()) { + return vscode.window.showInformationMessage('Web apps are disabled in settings.'); + } + return marketplace.open(); + }); + + register('vscodeos.webApps.install', async (url?: string) => { + if (!webAppsEnabled()) { + void vscode.window.showInformationMessage('Web apps are disabled in settings.'); + return; + } + const address = typeof url === 'string' && url + ? url + : await vscode.window.showInputBox({ + title: 'Install a web app', + prompt: 'The address of the site to install', + placeHolder: 'https://example.com', + ignoreFocusOut: true, + }); + if (!address) { + return; + } + // Through the Marketplace panel so the progress and the result are shown + // in one place, whichever route asked for the install. + marketplace.open(); + await marketplace.install(address); + }); + + register('vscodeos.webApps.launch', async (id?: string) => { + const installed = await webApps.list(); + const app = typeof id === 'string' ? installed.find((candidate) => candidate.id === id) : undefined; + if (!app) { + void vscode.window.showWarningMessage('That web app is no longer installed.'); + return; + } + if (app.openIn === 'editor') { + await vscode.commands.executeCommand('vscodeos.browser.open', app.url); + return; + } + if (!browserSys.open(app.url, { + preferred: config().get<string>('browser.command') || undefined, + appMode: true, + })) { + // No browser to give it a window of its own; the editor has one. + await vscode.commands.executeCommand('vscodeos.browser.open', app.url); + } + }); // Kept as an alias rather than removed: it is in the README, in muscle // memory and in the command palette, and it still opens the same four rows. register('vscodeos.apps.updater', () => settings.open('updates')); @@ -190,8 +280,11 @@ export function activate(context: vscode.ExtensionContext): void { register('vscodeos.settings.sound', () => settings.open('sound')); register('vscodeos.settings.storage', () => settings.open('storage')); + // The bin lost its activity bar view; the Files app has always listed it as + // a place, so the command opens it there rather than becoming a dead entry + // in the command palette. register('vscodeos.recycleBin.open', () => - vscode.commands.executeCommand(`${RecycleBinProvider.viewId}.focus`)); + vscode.commands.executeCommand('vscodeos.files.open', TRASH_PATH)); register('vscodeos.firewall.open', () => { if (!config().get<boolean>('firewall.enabled', true)) { @@ -200,21 +293,31 @@ export function activate(context: vscode.ExtensionContext): void { return firewall.open(); }); - // --- the Print key ----------------------------------------------------- + // --- incoming URIs ----------------------------------------------------- - // Openbox binds Print to /usr/local/bin/vscodeos-screenshot, which hands this - // URI to the running editor. Electron never sees the key itself on X11, so a - // contributed keybinding could not do this job. + // Two things outside the editor hand work back to it this way: + // + // /screenshot openbox's Print binding, /usr/local/bin/vscodeos-screenshot. + // Electron never sees the Print key on X11, so a contributed + // keybinding could not do this job. + // /webapp the desktop entry of a web app set to open in an editor + // tab, started from any launcher on the machine. context.subscriptions.push(vscode.window.registerUriHandler({ handleUri(uri: vscode.Uri) { - if (uri.path !== '/screenshot') { - log.debug(`ignoring unknown URI path ${uri.path}`); + const query = new URLSearchParams(uri.query); + if (uri.path === '/screenshot') { + void vscode.commands.executeCommand( + query.get('mode') === 'region' + ? 'vscodeos.apps.screenshotRegion' + : 'vscodeos.apps.screenshot', + ); + return; + } + if (uri.path === '/webapp') { + void vscode.commands.executeCommand('vscodeos.webApps.launch', query.get('id') ?? ''); return; } - const mode = new URLSearchParams(uri.query).get('mode'); - void vscode.commands.executeCommand( - mode === 'region' ? 'vscodeos.apps.screenshotRegion' : 'vscodeos.apps.screenshot', - ); + log.debug(`ignoring unknown URI path ${uri.path}`); }, })); diff --git a/extension/src/statusbar/index.ts b/extension/src/statusbar/index.ts index bdf5106..dfd6f91 100644 --- a/extension/src/statusbar/index.ts +++ b/extension/src/statusbar/index.ts @@ -33,20 +33,15 @@ const PRIORITY = { power: -Number.MAX_VALUE, } as const; -/** - * The launcher, on the other side of the bar. Left alignment reads the other - * way round - a higher priority is further left - so this is a large positive - * number to put the button in the corner. The one thing that can still sit left - * of it is the remote indicator, and that is hidden on a machine with no remote - * extension installed, which is both images. - */ -const LAUNCHER_PRIORITY = 100000; +// The all-apps button used to sit in the other corner, at a large positive +// priority. It is a view in the activity bar now: a start menu is not a tray +// item, and the corner of a status bar is a poor place to keep the one control +// that reaches everything on the machine. const SLOW_TICK_MS = 5000; export class StatusBar implements vscode.Disposable { private readonly items: vscode.StatusBarItem[] = []; - private readonly launcher: vscode.StatusBarItem; private readonly power: vscode.StatusBarItem; private readonly clock: vscode.StatusBarItem; private readonly batteryItem: vscode.StatusBarItem; @@ -80,16 +75,6 @@ export class StatusBar implements vscode.Disposable { this.clock = this.create('clock', PRIORITY.clock, 'vscodeos.calendar.show', 'Date and time'); this.power = this.create('power', PRIORITY.power, 'vscodeos.power.menu', 'Power'); - this.launcher = this.create( - 'apps', - LAUNCHER_PRIORITY, - 'vscodeos.apps.menu', - 'All apps', - vscode.StatusBarAlignment.Left, - ); - this.launcher.text = '$(menu)'; - this.launcher.tooltip = new vscode.MarkdownString('**All apps** — every program on this machine'); - // No codicon is a power symbol - the nearest of the 753 is circle-slash, // which is a "no entry" sign - so this one comes from the extension's own // icon font, registered under contributes.icons in package.json. It is @@ -101,14 +86,12 @@ export class StatusBar implements vscode.Disposable { this.notifications.on('change', () => this.renderNotifications()); } - private create( - id: string, - priority: number, - command: string, - name: string, - alignment = vscode.StatusBarAlignment.Right, - ): vscode.StatusBarItem { - const item = vscode.window.createStatusBarItem(`vscodeos.${id}`, alignment, priority); + private create(id: string, priority: number, command: string, name: string): vscode.StatusBarItem { + const item = vscode.window.createStatusBarItem( + `vscodeos.${id}`, + vscode.StatusBarAlignment.Right, + priority, + ); item.name = `VS Code OS: ${name}`; item.command = command; this.items.push(item); @@ -116,7 +99,6 @@ export class StatusBar implements vscode.Disposable { } start(): void { - this.launcher.show(); this.power.show(); this.tickClock(); void this.tickSlow(); diff --git a/extension/src/statusbar/menus.ts b/extension/src/statusbar/menus.ts new file mode 100644 index 0000000..21ab95a --- /dev/null +++ b/extension/src/statusbar/menus.ts @@ -0,0 +1,751 @@ +// The tray menus, as popups. +// +// For a long time these were webview cards in the side bar, because VS Code has +// no API to anchor a popup to a status bar item and never has. That is still +// true - but it was the wrong thing to conclude from. A quick pick is a popup: +// it floats over the editor, it takes the keyboard, Escape closes it, and it +// costs nothing to draw. What it cannot do is be *anchored* to the item that +// opened it. That is a much smaller loss than taking the side bar away every +// time somebody wants to see the clock. +// +// So this is the default now, and the cards in views/flyout.ts are what +// `vscodeos.flyout.location` switches to for anyone who wants them. +// +// Everything here is stable API. That is not a preference: the shell ships as a +// built-in extension, and proposed APIs for built-ins are gated on Microsoft's +// product.json (see extension/README.md). Item buttons, separators, title +// buttons and `busy` are all stable. +// +// The one pattern worth knowing: an item can carry `keepOpen`, which acts and +// then rebuilds the list in place. That is what makes a volume slider or a +// Wi-Fi switch usable - a popup that closed on every toggle would be unusable +// for exactly the things people open these menus to do. + +import * as vscode from 'vscode'; +import * as audio from '../sys/audio'; +import * as backlight from '../sys/backlight'; +import * as battery from '../sys/battery'; +import * as bluetooth from '../sys/bluetooth'; +import * as display from '../sys/display'; +import * as mpris from '../sys/mpris'; +import * as network from '../sys/network'; +import * as power from '../sys/power'; +import type { MprisMonitor } from '../sys/mpris'; +import type { NotificationServer } from '../sys/notifications'; +import { formatDuration } from '../util/format'; +import { runPowerAction } from '../views/flyout'; +import type { FlyoutKind } from '../webview/protocol'; +import { log } from '../log'; + +interface MenuItem extends vscode.QuickPickItem { + /** + * What accepting the row does. Rows without one are read-only. + * + * Returns `unknown` rather than `void`: almost every sys/ function answers + * with an ok/message result or a boolean, and nothing here reads them - + * the redraw that follows shows what happened. Typing this as `void` would + * mean a `void` wrapper around each one for no gain. + */ + run?: () => unknown; + /** Redraw the menu after `run` instead of closing it. */ + keepOpen?: boolean; + /** What each of this row's `buttons` does, by index. */ + onButton?: (index: number) => unknown; +} + +const separator = (label: string): MenuItem => ({ label, kind: vscode.QuickPickItemKind.Separator }); + +const MINUS = new vscode.ThemeIcon('remove'); +const PLUS = new vscode.ThemeIcon('add'); + +const TITLES: Record<FlyoutKind, string> = { + power: 'Power', + powersettings: 'Power & Battery', + calendar: 'Calendar', + volume: 'Volume', + network: 'Network', + bluetooth: 'Bluetooth', + music: 'Music', + notifications: 'Notifications', +}; + +export class TrayMenus implements vscode.Disposable { + private pick: vscode.QuickPick<MenuItem> | undefined; + private openKind: FlyoutKind | undefined; + /** The calendar's month, kept between redraws of the same popup. */ + private month = new Date(); + private scanning = false; + + constructor( + private readonly music: MprisMonitor, + private readonly notifications: NotificationServer, + ) {} + + /** + * Open a menu, or close it if it is the one already showing - which is what + * clicking a tray item a second time has always done here. + */ + async show(kind: FlyoutKind): Promise<void> { + if (this.openKind === kind) { + this.close(); + return; + } + this.close(); + this.month = new Date(); + + const pick = vscode.window.createQuickPick<MenuItem>(); + this.pick = pick; + this.openKind = kind; + pick.title = TITLES[kind]; + pick.matchOnDescription = true; + + pick.onDidAccept(() => { + const item = pick.selectedItems[0]; + if (!item?.run) { + return; + } + if (!item.keepOpen) { + pick.hide(); + } + void this.act(kind, item.run, item.keepOpen === true); + }); + + pick.onDidTriggerItemButton((event) => { + const index = event.item.buttons?.indexOf(event.button) ?? -1; + if (index >= 0 && event.item.onButton) { + void this.act(kind, () => event.item.onButton?.(index), true); + } + }); + + pick.onDidTriggerButton((button) => { + void this.act(kind, () => this.titleButton(kind, button), true); + }); + + pick.onDidHide(() => { + if (this.pick === pick) { + this.pick = undefined; + this.openKind = undefined; + } + pick.dispose(); + }); + + pick.busy = true; + pick.show(); + await this.draw(kind); + } + + close(): void { + this.pick?.hide(); + } + + dispose(): void { + this.close(); + } + + /** Run an action, then redraw if the menu is meant to stay up. */ + private async act(kind: FlyoutKind, run: () => unknown, redraw: boolean): Promise<void> { + try { + await run(); + } catch (error) { + log.error(`tray menu ${kind}`, error); + } + if (redraw && this.openKind === kind) { + await this.draw(kind); + } + } + + /** + * Rebuild the visible list. + * + * The active item is put back afterwards: without that, every toggle would + * bounce the selection to the top of the list, and holding "+" on the + * brightness row would be impossible. + */ + private async draw(kind: FlyoutKind): Promise<void> { + const pick = this.pick; + if (!pick || this.openKind !== kind) { + return; + } + pick.busy = true; + const activeLabel = pick.activeItems[0]?.label; + let items: MenuItem[] = []; + try { + items = await this.build(kind, pick); + } catch (error) { + log.error(`tray menu ${kind}`, error); + items = [{ label: 'Something went wrong reading this.' }]; + } + if (this.pick !== pick || this.openKind !== kind) { + return; + } + pick.items = items; + const restored = items.find((item) => item.label === activeLabel && item.kind !== vscode.QuickPickItemKind.Separator); + if (restored) { + pick.activeItems = [restored]; + } + pick.busy = false; + } + + private build(kind: FlyoutKind, pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + switch (kind) { + case 'power': return this.powerItems(pick); + case 'powersettings': return this.powerSettingsItems(pick); + case 'calendar': return this.calendarItems(pick); + case 'volume': return this.volumeItems(pick); + case 'network': return this.networkItems(pick); + case 'bluetooth': return this.bluetoothItems(pick); + case 'music': return this.musicItems(pick); + default: return this.notificationItems(pick); + } + } + + private titleButton(kind: FlyoutKind, button: vscode.QuickInputButton): void { + if (kind !== 'calendar') { + return; + } + const id = (button as vscode.QuickInputButton & { id?: string }).id; + if (id === 'today') { + this.month = new Date(); + } else { + this.month = new Date( + this.month.getFullYear(), + this.month.getMonth() + (id === 'next' ? 1 : -1), + 1, + ); + } + } + + // ------------------------------------------------------------------ power + + private async powerItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + const state = await battery.getState(); + pick.placeholder = state.present + ? `Battery ${state.level}% · ${state.charging ? 'charging' : state.status.toLowerCase()}` + : 'On mains power'; + + const action = (label: string, glyph: string, name: power.PowerAction, detail: string): MenuItem => ({ + label: `$(${glyph}) ${label}`, + detail, + // runPowerAction does its own confirmation and saves open editors. + run: () => runPowerAction(name), + }); + + return [ + action('Shut down', 'circle-slash', 'poweroff', 'Turn the computer off'), + action('Restart', 'debug-restart', 'reboot', 'Turn it off and on again'), + ...(power.canSuspend() + ? [action('Sleep', 'debug-pause', 'suspend', 'Keep everything in memory')] + : []), + action('Log out', 'sign-out', 'logout', 'End the session'), + ]; + } + + private async powerSettingsItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + const [state, brightness] = await Promise.all([battery.getState(), backlight.getState()]); + const saver = display.isEnergySaverOn(); + const night = display.isNightLightOn(); + + pick.placeholder = state.present + ? `${state.level}% · ${battery.describeRemaining(state) ?? (state.charging ? 'Charging' : 'On battery')}` + : 'On mains power'; + + const items: MenuItem[] = [ + { + label: `$(zap) Energy saver`, + description: saver ? 'On' : 'Off', + detail: saver ? 'Dimmed, and the screen sleeps sooner' : 'Full performance', + keepOpen: true, + run: () => display.setEnergySaver(!saver), + }, + ]; + + if (brightness.available && brightness.writable) { + const percent = brightness.percent; + items.push({ + label: `$(lightbulb) Brightness`, + description: `${percent}%`, + detail: 'Use the − and + buttons on this row', + buttons: [ + { iconPath: MINUS, tooltip: 'Dimmer' }, + { iconPath: PLUS, tooltip: 'Brighter' }, + ], + keepOpen: true, + // Read the level again rather than stepping from `percent`: + // that was captured when the list was drawn, and holding "+" + // fires faster than the redraw that would refresh it, so every + // press after the first would recompute the same value. + onButton: async (index) => { + const now = await backlight.getState(); + await backlight.setPercent(clamp(now.percent + (index === 0 ? -10 : 10), 1, 100)); + }, + }); + items.push({ + label: `$(color-mode) Night light`, + description: night ? 'On' : 'Off', + detail: 'Warms the screen colours', + keepOpen: true, + run: async () => { + if (!(await display.setNightLight(!night))) { + void vscode.window.showWarningMessage('Night light needs xrandr and a running X session.'); + } + }, + }); + } + + if (state.present) { + items.push(separator('Battery'), { + label: `$(${battery.iconFor(state)}) ${state.level}%`, + description: state.charging ? 'Charging' : state.onAc ? 'Plugged in' : 'On battery', + detail: battery.describeRemaining(state), + }); + } + return items; + } + + // --------------------------------------------------------------- calendar + + /** + * A month grid in a list. + * + * One row per week, days padded to two characters with U+2007 FIGURE SPACE - + * which is defined to be exactly as wide as a digit, so the columns line up + * in a proportional font. Today is wrapped in brackets, because a quick pick + * has no way to colour part of a label. + */ + private calendarItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + const now = new Date(); + const shown = this.month; + pick.title = shown.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + pick.placeholder = now.toLocaleString(undefined, { + weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + pick.buttons = [ + withId({ iconPath: new vscode.ThemeIcon('chevron-left'), tooltip: 'Previous month' }, 'previous'), + withId({ iconPath: new vscode.ThemeIcon('calendar'), tooltip: 'This month' }, 'today'), + withId({ iconPath: new vscode.ThemeIcon('chevron-right'), tooltip: 'Next month' }, 'next'), + ]; + + const first = new Date(shown.getFullYear(), shown.getMonth(), 1); + // Monday-first, matching the card this replaces. + const offset = (first.getDay() + 6) % 7; + const days = new Date(shown.getFullYear(), shown.getMonth() + 1, 0).getDate(); + const isThisMonth = now.getFullYear() === shown.getFullYear() && now.getMonth() === shown.getMonth(); + + const cells: string[] = Array.from({ length: offset }, () => pad('')); + for (let day = 1; day <= days; day++) { + cells.push(isThisMonth && day === now.getDate() ? `[${pad(String(day))}]` : ` ${pad(String(day))} `); + } + + const items: MenuItem[] = [{ label: header() }]; + for (let start = 0; start < cells.length; start += 7) { + items.push({ label: cells.slice(start, start + 7).join('') }); + } + return Promise.resolve(items); + } + + // ----------------------------------------------------------------- volume + + private async volumeItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + const state = await audio.getState(); + if (!state.available) { + pick.placeholder = 'No audio device found'; + return [{ label: 'No audio device found.' }]; + } + pick.placeholder = state.muted ? 'Muted' : `${state.volume}%`; + + const items: MenuItem[] = [{ + label: `$(${audio.iconFor(state)}) Volume`, + description: state.muted ? 'Muted' : `${state.volume}%`, + detail: 'Enter mutes; the − and + buttons change the level', + buttons: [ + { iconPath: MINUS, tooltip: 'Quieter' }, + { iconPath: PLUS, tooltip: 'Louder' }, + ], + keepOpen: true, + run: () => audio.toggleMute(), + // `step` reads the current level itself, which matters when the + // button is pressed faster than the list redraws. + onButton: (index) => audio.step(index === 0 ? -5 : 5), + }]; + + if (state.sinks.length > 0) { + items.push(separator('Play through')); + for (const sink of state.sinks) { + items.push({ + label: `${sink.isDefault ? '$(check)' : '$(blank)'} ${sink.name}`, + keepOpen: true, + run: () => audio.setDefaultSink(sink.id), + }); + } + } + + if (state.input) { + items.push(separator('Microphone'), { + label: `$(${state.input.muted ? 'mute' : 'mic'}) Microphone`, + description: state.input.muted ? 'Muted' : `${state.input.volume}%`, + buttons: [ + { iconPath: MINUS, tooltip: 'Quieter' }, + { iconPath: PLUS, tooltip: 'Louder' }, + ], + keepOpen: true, + run: () => audio.toggleInputMute(), + onButton: async (index) => { + const now = await audio.getInputLevel(); + await audio.setInputVolume(clamp(now.volume + (index === 0 ? -5 : 5), 0, 100)); + }, + }); + } + + if (state.sources.length > 0) { + items.push(separator('Record from')); + for (const source of state.sources) { + items.push({ + label: `${source.isDefault ? '$(check)' : '$(blank)'} ${source.name}`, + keepOpen: true, + run: () => audio.setDefaultSource(source.id), + }); + } + } + return items; + } + + // ---------------------------------------------------------------- network + + private async networkItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + const [state, airplane] = await Promise.all([network.getState(), network.isAirplaneMode()]); + if (!state.available) { + pick.placeholder = 'NetworkManager is not running'; + return [{ label: 'NetworkManager is not running.' }]; + } + + const wifi = state.active.find((connection) => connection.type.includes('wireless')); + pick.placeholder = wifi?.name + ?? (state.active.length > 0 ? state.active[0].name : 'Not connected'); + + // No icon: codicon has no aeroplane, and the nearest glyphs all read as + // something else. + const items: MenuItem[] = [{ + label: 'Airplane mode', + description: airplane ? 'On' : 'Off', + detail: airplane ? 'Every radio is off' : undefined, + keepOpen: true, + run: () => network.setAirplaneMode(!airplane), + }]; + + if (state.wifiHardware) { + items.push({ + label: `$(${state.wifiEnabled ? 'radio-tower' : 'circle-slash'}) Wi-Fi`, + description: state.wifiEnabled ? 'On' : 'Off', + keepOpen: true, + run: () => network.setWifiEnabled(!state.wifiEnabled), + }); + } + + const wired = state.active.filter((candidate) => !candidate.type.includes('wireless')); + if (wired.length > 0) { + items.push(separator('Wired')); + } + for (const connection of wired) { + items.push({ + label: `$(plug) ${connection.name}`, + description: connection.device, + detail: 'Enter disconnects', + keepOpen: true, + run: () => network.deactivate(connection.name), + }); + } + + if (!state.wifiHardware) { + return items; + } + if (!state.wifiEnabled) { + items.push(separator('Wi-Fi'), { label: 'Wi-Fi is off.' }); + return items; + } + + items.push(separator('Networks'), { + label: '$(refresh) Scan again', + keepOpen: true, + run: async () => { + if (this.scanning) { + return; + } + this.scanning = true; + try { + await network.getState(true); + } finally { + this.scanning = false; + } + }, + }); + + if (state.accessPoints.length === 0) { + items.push({ label: 'No networks in range.' }); + } + for (const point of state.accessPoints) { + const secured = point.security !== '' && point.security !== '--'; + items.push({ + label: `${point.inUse ? '$(check)' : '$(blank)'} ${point.ssid}`, + description: `${bars(point.signal)}${secured ? ' $(lock)' : ''}`, + detail: point.inUse + ? 'Connected — Enter disconnects' + : point.known ? 'Saved' : secured ? point.security : 'Open', + buttons: point.known + ? [{ iconPath: new vscode.ThemeIcon('trash'), tooltip: 'Forget this network' }] + : undefined, + keepOpen: true, + run: () => this.connect(point.ssid, secured, point.known, point.inUse), + onButton: () => network.forget(point.ssid), + }); + } + return items; + } + + private async connect(ssid: string, secured: boolean, known: boolean, inUse: boolean): Promise<void> { + if (inUse) { + await network.deactivate(ssid); + return; + } + let password: string | undefined; + if (secured && !known) { + password = await vscode.window.showInputBox({ + prompt: `Password for ${ssid}`, + password: true, + ignoreFocusOut: true, + }); + if (password === undefined) { + return; + } + } + const result = await network.connect(ssid, password); + if (!result.ok) { + void vscode.window.showErrorMessage(`Could not connect to ${ssid}: ${result.message}`); + } + } + + // -------------------------------------------------------------- bluetooth + + private async bluetoothItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + const state = await bluetooth.getState(); + if (!state.available) { + pick.placeholder = 'No Bluetooth adapter'; + return [{ label: 'No Bluetooth adapter on this machine.' }]; + } + pick.placeholder = state.powered ? 'On' : 'Off'; + + const items: MenuItem[] = [{ + label: `$(${state.powered ? 'check' : 'circle-slash'}) Bluetooth`, + description: state.powered ? 'On' : 'Off', + keepOpen: true, + run: () => bluetooth.setPowered(!state.powered), + }]; + + if (!state.powered) { + return items; + } + + items.push({ + label: '$(refresh) Look for devices', + detail: this.scanning ? 'Scanning…' : undefined, + keepOpen: true, + run: async () => { + if (this.scanning) { + return; + } + this.scanning = true; + try { + await bluetooth.scan(); + } finally { + this.scanning = false; + } + }, + }); + + const paired = state.devices.filter((device) => device.paired); + const nearby = state.devices.filter((device) => !device.paired); + + items.push(separator('My devices')); + if (paired.length === 0) { + items.push({ label: 'Nothing paired yet.' }); + } + for (const device of paired) { + items.push({ + label: `${device.connected ? '$(check)' : '$(blank)'} ${device.name}`, + description: device.mac, + detail: device.connected ? 'Enter disconnects' : 'Enter connects', + buttons: [{ iconPath: new vscode.ThemeIcon('trash'), tooltip: 'Forget this device' }], + keepOpen: true, + run: async () => { + const result = device.connected + ? (await bluetooth.disconnect(device.mac), { ok: true as const, message: undefined }) + : await bluetooth.connect(device.mac); + if (!result.ok) { + void vscode.window.showErrorMessage(`Bluetooth: ${result.message}`); + } + }, + onButton: () => this.forget(device.mac, device.name), + }); + } + + items.push(separator('Nearby')); + if (nearby.length === 0) { + items.push({ label: 'Nothing found yet. Use "Look for devices".' }); + } + for (const device of nearby) { + items.push({ + label: `$(blank) ${device.name}`, + description: device.mac, + detail: 'Enter pairs', + keepOpen: true, + run: async () => { + const result = await bluetooth.pair(device.mac); + if (!result.ok) { + void vscode.window.showErrorMessage(`Bluetooth: ${result.message}`); + } + }, + }); + } + return items; + } + + private async forget(mac: string, name: string): Promise<void> { + const choice = await vscode.window.showWarningMessage( + `Forget "${name}"?`, + { modal: true, detail: 'You will have to pair it again to use it.' }, + 'Forget', + ); + if (choice === 'Forget') { + await bluetooth.forget(mac); + } + } + + // ------------------------------------------------------------------ music + + private async musicItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + const items: MenuItem[] = [{ + label: '$(library) Open Music', + detail: 'Play the music on this computer', + run: () => vscode.commands.executeCommand('vscodeos.music.open'), + }]; + + if (!mpris.isAvailable()) { + pick.placeholder = 'playerctl is not installed'; + items.push(separator('Now playing'), { + label: 'Install playerctl to control other players from here', + detail: 'sudo pacman -S playerctl', + }); + return items; + } + + const playing = (await this.music.refreshPosition()) ?? this.music.current; + if (!playing || playing.status === 'Stopped') { + pick.placeholder = 'Nothing is playing'; + return items; + } + + pick.placeholder = playing.artist ? `${playing.artist} — ${playing.title}` : playing.title; + items.push( + separator(`In ${playing.player}`), + { + label: `$(${playing.status === 'Playing' ? 'debug-pause' : 'play'}) ${playing.title}`, + description: playing.length > 0 + ? `${formatDuration(playing.position)} / ${formatDuration(playing.length)}` + : undefined, + detail: [playing.artist, playing.album].filter(Boolean).join(' · ') || undefined, + buttons: [ + { iconPath: new vscode.ThemeIcon('chevron-left'), tooltip: 'Previous' }, + { iconPath: new vscode.ThemeIcon('chevron-right'), tooltip: 'Next' }, + ], + keepOpen: true, + run: () => mpris.playPause(), + onButton: (index) => (index === 0 ? mpris.previous() : mpris.next()), + }, + ); + return items; + } + + // ---------------------------------------------------------- notifications + + private notificationItems(pick: vscode.QuickPick<MenuItem>): Promise<MenuItem[]> { + pick.buttons = []; + if (!this.notifications.running) { + pick.placeholder = 'Another daemon owns the desktop bus'; + return Promise.resolve([{ + label: 'Another notification daemon owns the desktop bus, so notifications go there instead.', + }]); + } + + const records = this.notifications.records; + // Opening the menu is reading them; the tray badge clears with it. + this.notifications.markAllRead(); + pick.placeholder = records.length === 0 + ? 'Nothing to catch up on' + : `${records.length} notification${records.length === 1 ? '' : 's'}`; + + if (records.length === 0) { + return Promise.resolve([{ label: 'Nothing to catch up on.' }]); + } + + const now = Date.now(); + const items: MenuItem[] = records.map((record) => ({ + label: `$(${record.urgency === 'critical' ? 'warning' : 'bell'}) ${record.text}`, + description: `${record.appName} · ${whenLabel(record.at, now)}`, + buttons: [{ iconPath: new vscode.ThemeIcon('close'), tooltip: 'Dismiss' }], + keepOpen: true, + onButton: () => this.notifications.dismiss(record.id), + })); + + items.push(separator(''), { + label: '$(clear-all) Clear all', + run: () => this.notifications.clear(), + }); + return Promise.resolve(items); + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.round(value))); +} + +/** U+2007 FIGURE SPACE: exactly one digit wide, so the calendar columns align. */ +const FIGURE_SPACE = ' '; + +function pad(text: string): string { + return ` ${text.padStart(2, FIGURE_SPACE)} `; +} + +function header(): string { + return ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((day) => ` ${day} `).join(''); +} + +function bars(signal: number): string { + const filled = signal >= 75 ? 4 : signal >= 50 ? 3 : signal >= 25 ? 2 : 1; + return `${'▮'.repeat(filled)}${'▯'.repeat(4 - filled)} ${signal}%`; +} + +/** "just now", "6 min ago", "14:32" - the resolution people actually want. */ +function whenLabel(at: number, now: number): string { + const seconds = Math.max(0, Math.round((now - at) / 1000)); + if (seconds < 45) { + return 'just now'; + } + if (seconds < 3600) { + return `${Math.round(seconds / 60)} min ago`; + } + const date = new Date(at); + return seconds < 86400 + ? date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); +} + +/** Title buttons come back by identity, so they need something to be told apart by. */ +function withId(button: vscode.QuickInputButton, id: string): vscode.QuickInputButton { + return Object.assign(button, { id }); +} diff --git a/extension/src/sys/appIcons.ts b/extension/src/sys/appIcons.ts new file mode 100644 index 0000000..3ae74d5 --- /dev/null +++ b/extension/src/sys/appIcons.ts @@ -0,0 +1,151 @@ +// Finding the picture for a desktop entry's `Icon=` line. +// +// The value is either an absolute path - easy - or a *name*, which the icon +// theme specification says to resolve by walking the current theme, then its +// parents, then hicolor, across a directory layout each theme describes in its +// own index.theme. A full implementation is a lot of work for a launcher grid, +// and on these images it lands on the same file this shorter search does. +// +// The shape matters more than the search does. Looking a name up by stat'ing +// every candidate path is four roots times five themes times eight sizes times +// two layouts times three extensions - nearly a thousand syscalls for one icon, +// and a miss costs the full thousand. A machine with three hundred desktop +// entries would spend that three hundred times to draw one grid. +// +// So it is inverted: every icon directory is read once, in preference order, +// into a name -> path map. Forty readdirs, then every lookup is free, including +// the misses. + +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { log } from '../log'; + +/** Big enough to look right on a tile, small enough not to matter as a data URI. */ +const SIZES = ['128x128', '96x96', '64x64', 'scalable', '48x48', '256x256', '512x512', '32x32']; + +/** Searched in this order, so the first theme to have a name wins it. */ +const THEMES = ['hicolor', 'Adwaita', 'breeze', 'Papirus', 'gnome']; + +/** No browser renders XPM, so it is not worth indexing: the glyph is better. */ +const EXTENSIONS = new Set(['.png', '.svg']); + +const MIME: Record<string, string> = { + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', +}; + +/** A tile is 96px; anything past this is a file somebody has mis-installed. */ +const MAX_BYTES = 256 * 1024; + +let index: Promise<Map<string, string>> | undefined; +const encoded = new Map<string, string | undefined>(); + +function iconRoots(): string[] { + const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return [ + path.join(dataHome, 'icons'), + path.join(os.homedir(), '.icons'), + '/usr/local/share/icons', + '/usr/share/icons', + ]; +} + +/** + * Every directory that might hold an application icon, best first. + * + * The user's own themes come before the system's, larger sizes before smaller, + * and pixmaps last - it is where packages put an icon when they have not been + * themed at all. + */ +function searchPath(): string[] { + const directories: string[] = []; + for (const root of iconRoots()) { + for (const theme of THEMES) { + for (const size of SIZES) { + // apps/ is where an application icon belongs; some themes write + // the size the other way round, so both layouts are indexed. + directories.push(path.join(root, theme, size, 'apps')); + directories.push(path.join(root, theme, 'apps', size)); + } + } + } + directories.push('/usr/share/pixmaps', '/usr/local/share/pixmaps'); + return directories; +} + +async function buildIndex(): Promise<Map<string, string>> { + const found = new Map<string, string>(); + const directories = searchPath(); + + // Sequential on purpose: most of these do not exist, ENOENT is cheap, and + // firing eight hundred readdirs at once on a Pi's SD card is not. + for (const directory of directories) { + let names: string[]; + try { + names = await fs.readdir(directory); + } catch { + continue; + } + for (const name of names) { + const extension = path.extname(name).toLowerCase(); + if (!EXTENSIONS.has(extension)) { + continue; + } + const key = name.slice(0, -extension.length); + // First writer wins: the search path is already in preference order. + if (!found.has(key)) { + found.set(key, path.join(directory, name)); + } + } + } + + log.debug(`icon index: ${found.size} names`); + return found; +} + +/** + * An icon name as a data URI the webviews can draw, or undefined if there is no + * icon for it. + */ +export async function dataUri(name: string): Promise<string | undefined> { + if (!name) { + return undefined; + } + if (encoded.has(name)) { + return encoded.get(name); + } + + let result: string | undefined; + try { + // An absolute path is not a name at all; the spec allows it and plenty + // of third-party packages use it. + let file: string | undefined = name; + if (!name.startsWith('/')) { + index ??= buildIndex(); + file = (await index).get(name); + } + + const extension = file ? path.extname(file).toLowerCase() : ''; + if (file && MIME[extension]) { + const bytes = await fs.readFile(file); + if (bytes.length <= MAX_BYTES) { + result = `data:${MIME[extension]};base64,${bytes.toString('base64')}`; + } + } + } catch { + result = undefined; + } + + encoded.set(name, result); + return result; +} + +/** Forget everything, so an install or an uninstall is picked up. */ +export function clearCache(): void { + index = undefined; + encoded.clear(); +} diff --git a/extension/src/sys/browser.ts b/extension/src/sys/browser.ts index 5630114..217f5ba 100644 --- a/extension/src/sys/browser.ts +++ b/extension/src/sys/browser.ts @@ -9,8 +9,7 @@ // Web" and "YouTube Music" feel like apps rather than tabs - and Chromium // exports MPRIS for them, so the status bar transport controls them. -import { spawn } from 'node:child_process'; -import { which } from './exec'; +import { launch, which } from './exec'; import { log } from '../log'; const CANDIDATES = [ @@ -70,29 +69,15 @@ export function open(url: string, options: { preferred?: string; appMode?: boole return undefined; } const args = options.appMode && browser.chromiumLike ? [`--app=${url}`] : [url]; - try { - const child = spawn(browser.command, args, { detached: true, stdio: 'ignore' }); - child.unref(); - log.info(`launched ${browser.name}: ${url}`); - return browser; - } catch (error) { - log.error(`could not launch ${browser.name}`, error); + if (!launch(browser.command, args)) { return undefined; } + log.info(`launched ${browser.name}: ${url}`); + return browser; } /** Hand a file or folder to the desktop's default handler. */ export function openWithDefaultApp(path: string): boolean { const opener = which('xdg-open'); - if (!opener) { - return false; - } - try { - const child = spawn(opener, [path], { detached: true, stdio: 'ignore' }); - child.unref(); - return true; - } catch (error) { - log.error('xdg-open failed', error); - return false; - } + return opener ? launch(opener, [path]) : false; } diff --git a/extension/src/sys/desktopApps.ts b/extension/src/sys/desktopApps.ts new file mode 100644 index 0000000..2c5ac15 --- /dev/null +++ b/extension/src/sys/desktopApps.ts @@ -0,0 +1,161 @@ +// What else is installed on this machine. +// +// The All Apps view used to list the shell's own apps and nothing else, which +// made it a menu of eleven things on a machine with three hundred programs on +// it. `.desktop` files under the XDG application directories are how Linux +// answers "what is installed", so this reads them. +// +// The parsing is in util/desktop.ts; this is the filesystem half - where to +// look, what to leave out, and how to start one. Deliberately cheap: one +// readdir per directory and a read per file, cached until something asks for a +// refresh, because this runs when a side bar view becomes visible. + +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { launch as spawnDetached, which } from './exec'; +import { execArgv, parseDesktopEntry } from '../util/desktop'; +import { log } from '../log'; + +export interface DesktopApp { + /** The desktop file id: its name without the .desktop suffix. */ + id: string; + name: string; + description: string; + /** Absolute path of the .desktop file. */ + file: string; + /** Icon name or absolute path, straight out of the entry. */ + icon: string; + categories: string[]; + keywords: string[]; + terminal: boolean; + argv: string[]; +} + +/** Nothing under here is an application anybody wants in a launcher. */ +const HIDDEN_CATEGORIES = new Set(['Screensaver', 'Settings']); + +/** + * Entries this shell already provides better, or that are the shell. Listing + * VS Code inside VS Code's own start menu is not useful, and the mini-apps have + * real entries of their own. + */ +const SUPPRESSED_IDS = new Set(['code', 'code-url-handler', 'code-oss', 'visual-studio-code']); + +function directories(): string[] { + const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + const dataDirs = (process.env.XDG_DATA_DIRS || '/usr/local/share:/usr/share').split(':').filter(Boolean); + // The user's own directory last, so a local override of a system entry wins + // the id collision below. + return [...dataDirs, dataHome].map((directory) => path.join(directory, 'applications')); +} + +/** + * Cached because the list is read far more often than it changes: opening the + * view reads it, and so does every launch, which would otherwise be a second + * full pass over three hundred files to find the one that was clicked. Nothing + * watches the directories, so `refresh()` is what picks up an install. + */ +let cached: Promise<DesktopApp[]> | undefined; + +/** + * Every launchable application on the machine. + * + * Entries carrying X-VSCodeOS-WebApp are skipped: those are ours, sys/webapps.ts + * has richer records for them, and listing both would show every web app twice. + */ +export function list(): Promise<DesktopApp[]> { + cached ??= scan(); + return cached; +} + +/** Forget the cache, so the next `list()` reads the directories again. */ +export function refresh(): void { + cached = undefined; +} + +async function scan(): Promise<DesktopApp[]> { + const byId = new Map<string, DesktopApp>(); + + for (const directory of directories()) { + let names: string[]; + try { + names = await fs.readdir(directory); + } catch { + // A machine with no /usr/local/share/applications is normal. + continue; + } + + for (const name of names) { + if (!name.endsWith('.desktop')) { + continue; + } + const id = name.slice(0, -'.desktop'.length); + const file = path.join(directory, name); + const app = await read(id, file); + if (app) { + byId.set(id, app); + } else { + // A user entry that is Hidden=true is a deletion of the system + // one with the same id, per the spec. + byId.delete(id); + } + } + } + + return [...byId.values()].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); +} + +async function read(id: string, file: string): Promise<DesktopApp | undefined> { + let text: string; + try { + text = await fs.readFile(file, 'utf8'); + } catch { + return undefined; + } + + const entry = parseDesktopEntry(text); + if (!entry || entry.noDisplay || entry.webAppId || !entry.name || SUPPRESSED_IDS.has(id)) { + return undefined; + } + if (entry.categories.some((category) => HIDDEN_CATEGORIES.has(category))) { + return undefined; + } + + const argv = execArgv(entry.exec); + if (argv.length === 0) { + return undefined; + } + + return { + id, + name: entry.name, + description: entry.comment || entry.categories.join(', ') || 'Installed application', + file, + icon: entry.icon, + categories: entry.categories, + keywords: entry.keywords, + terminal: entry.terminal, + argv, + }; +} + +/** + * Start one. + * + * `gio launch` is the right tool when it is there - it applies the entry's + * working directory, its environment and its startup notification, none of + * which a bare spawn of the Exec line does. Without it, argv it is; the + * splitting follows the spec (util/desktop.ts) and nothing goes near a shell. + */ +export function launch(app: DesktopApp): boolean { + const gio = which('gio'); + const ok = gio + ? spawnDetached(gio, ['launch', app.file]) + : spawnDetached(app.argv[0], app.argv.slice(1)); + if (ok) { + log.info(`launched ${app.id}`); + } + return ok; +} diff --git a/extension/src/sys/exec.ts b/extension/src/sys/exec.ts index 7bc802c..97d6e8c 100644 --- a/extension/src/sys/exec.ts +++ b/extension/src/sys/exec.ts @@ -68,6 +68,37 @@ export function start(command: string, args: string[] = [], options: { cwd?: str return spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], cwd: options.cwd }); } +/** + * Start a program and let go of it: a browser window, another desktop's app. + * + * Detached and unref'd, which is the difference that matters - a child in this + * process group dies with the editor, and a launcher whose apps close when the + * editor restarts would be a strange launcher. Its output goes nowhere, because + * there is nobody to read it. + * + * The binary is resolved first, because `spawn` does not fail here for one that + * is missing: ENOENT arrives later as an `error` event, which would be an + * unhandled emitter error in the extension host, and the caller would already + * have been told the launch worked. `error` is still listened for, since the + * check and the exec are not atomic. + */ +export function launch(command: string, args: string[] = []): boolean { + const resolved = which(command); + if (!resolved) { + log.error(`could not launch ${command}: not found`); + return false; + } + try { + const child = spawn(resolved, args, { detached: true, stdio: 'ignore' }); + child.on('error', (error) => log.error(`${command} exited immediately`, error)); + child.unref(); + return true; + } catch (error) { + log.error(`could not launch ${command}`, error); + return false; + } +} + const whichCache = new Map<string, string | undefined>(); /** diff --git a/extension/src/sys/http.ts b/extension/src/sys/http.ts new file mode 100644 index 0000000..aa20101 --- /dev/null +++ b/extension/src/sys/http.ts @@ -0,0 +1,154 @@ +// The one place this shell reaches the network from. +// +// Installing a web app means fetching a page, a manifest and an icon, and that +// is the whole of it - there is no HTTP client dependency here, and adding one +// for three GETs would be the wrong trade in an extension that ships inside an +// ISO. `node:https` does the work; what this adds is the three things a raw +// request does not have and every caller needs: a timeout, a redirect limit and +// a hard cap on how many bytes an untrusted server can make us hold in memory. + +import * as http from 'node:http'; +import * as https from 'node:https'; +import { log } from '../log'; + +export interface HttpResponse { + ok: boolean; + status: number; + /** The URL the body actually came from, after redirects. */ + url: string; + contentType: string; + body: Buffer; +} + +export interface GetOptions { + /** Refuse a body larger than this, rather than buffering it. */ + maxBytes?: number; + timeoutMs?: number; + accept?: string; +} + +const DEFAULT_MAX_BYTES = 4 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 15000; +const MAX_REDIRECTS = 5; + +const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) VSCodeOS/1.0 Safari/537.36'; + +/** + * GET a URL, following redirects. Never throws: a DNS failure, a timeout and a + * 404 all come back as `ok: false`, because every caller is either drawing a + * dialog or reporting into a webview and would rather say why than crash. + */ +export async function get(url: string, options: GetOptions = {}): Promise<HttpResponse> { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + let current = url; + + for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) { + const response = await once(current, options, maxBytes); + if (response.redirectTo) { + current = response.redirectTo; + continue; + } + return response.result; + } + + return { ok: false, status: 0, url: current, contentType: '', body: Buffer.alloc(0) }; +} + +/** GET and parse as JSON, or undefined if either half failed. */ +export async function getJson(url: string, options: GetOptions = {}): Promise<unknown> { + const response = await get(url, { accept: 'application/manifest+json, application/json', ...options }); + if (!response.ok) { + return undefined; + } + try { + return JSON.parse(response.body.toString('utf8')); + } catch (error) { + log.debug(`not JSON: ${url} (${String(error)})`); + return undefined; + } +} + +interface Attempt { + result: HttpResponse; + redirectTo?: string; +} + +function once(url: string, options: GetOptions, maxBytes: number): Promise<Attempt> { + return new Promise((resolve) => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + resolve({ result: fail(url, 0) }); + return; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + resolve({ result: fail(url, 0) }); + return; + } + + const transport = parsed.protocol === 'https:' ? https : http; + const request = transport.get( + parsed, + { + headers: { + 'User-Agent': USER_AGENT, + Accept: options.accept ?? 'text/html,application/xhtml+xml,*/*', + 'Accept-Language': 'en', + }, + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }, + (response) => { + const status = response.statusCode ?? 0; + const location = response.headers.location; + if (status >= 300 && status < 400 && location) { + response.resume(); + let next: string; + try { + next = new URL(location, url).toString(); + } catch { + resolve({ result: fail(url, status) }); + return; + } + resolve({ result: fail(url, status), redirectTo: next }); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + // Stop reading rather than buffer an unbounded body from + // a server we have no reason to trust. + request.destroy(); + return; + } + chunks.push(chunk); + }); + response.on('end', () => { + resolve({ + result: { + ok: status >= 200 && status < 300 && size <= maxBytes, + status, + url, + contentType: String(response.headers['content-type'] ?? ''), + body: Buffer.concat(chunks), + }, + }); + }); + response.on('error', () => resolve({ result: fail(url, status) })); + }, + ); + + request.on('timeout', () => request.destroy()); + request.on('error', (error) => { + log.debug(`GET ${url} failed: ${error.message}`); + resolve({ result: fail(url, 0) }); + }); + }); +} + +function fail(url: string, status: number): HttpResponse { + return { ok: false, status, url, contentType: '', body: Buffer.alloc(0) }; +} diff --git a/extension/src/sys/webapps.ts b/extension/src/sys/webapps.ts new file mode 100644 index 0000000..84d52e2 --- /dev/null +++ b/extension/src/sys/webapps.ts @@ -0,0 +1,380 @@ +// Installed web apps. +// +// A PWA is a URL with a manifest: a name, an icon and a start address. There is +// nothing to unpack and nothing to compile, so "installing" one here means +// remembering those three facts, keeping a copy of the icon, and writing the +// desktop entry that makes the app visible to anything else on the machine that +// reads ~/.local/share/applications. +// +// Two places own that record and they are kept in step deliberately: +// +// apps.json what the All Apps view and the Marketplace read. The registry. +// *.desktop what the rest of the desktop reads, marked X-VSCodeOS-WebApp so +// the .desktop scan in desktopApps.ts knows to leave them to us +// rather than listing every app twice. +// +// Nothing here needs root: everything is under the user's own home. + +import { EventEmitter } from 'node:events'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as browser from './browser'; +import { which } from './exec'; +import * as http from './http'; +import { escapeDesktopExec, escapeDesktopValue } from '../util/desktop'; +import { + hostOf, + manifestHref, + normaliseSiteUrl, + resolve, + sameSite, + webAppFromManifest, + webAppFromPage, +} from '../util/webapp'; +import type { WebAppInfo } from '../util/webapp'; +import { log } from '../log'; + +export type { WebAppInfo } from '../util/webapp'; + +/** + * Where a web app opens. + * + * `window` is a real Chromium `--app=` window: frameless, single-site, and - + * the part that matters on this machine - it exports MPRIS, so the tray + * transport controls whatever is playing in it. `editor` keeps it inside the + * kiosk, in the screencast browser. Chosen per app when it is installed, + * because which one is right depends entirely on the app. + */ +export type OpenIn = 'window' | 'editor'; + +export interface InstalledWebApp { + id: string; + name: string; + description: string; + url: string; + openIn: OpenIn; + installedAt: number; + /** Absolute path of the downloaded icon, when there was one to download. */ + iconFile?: string; + /** Absolute path of the desktop entry, absent when it could not be written. */ + desktopFile?: string; +} + +/** Icons are launcher tiles, not wallpapers; anything larger is a mistake. */ +const MAX_ICON_BYTES = 512 * 1024; +const MAX_PAGE_BYTES = 2 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 256 * 1024; + +/** The URI the desktop entry of an editor-mode app hands back to the editor. */ +export const WEBAPP_URI = 'vscode://vscodeos.vscodeos-core/webapp'; + +/** Where `code` is on both images, and what to fall back to off them. */ +function codeCommand(): string { + return which('/opt/visual-studio-code/bin/code') ?? which('code') ?? 'code'; +} + +const ICON_TYPES: Record<string, string> = { + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.ico': 'image/x-icon', +}; + +function dataHome(): string { + return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); +} + +export interface InstallResult { + ok: boolean; + app?: InstalledWebApp; + message?: string; +} + +export class WebAppService extends EventEmitter { + private cache: InstalledWebApp[] | undefined; + + private get root(): string { + return path.join(dataHome(), 'vscodeos', 'webapps'); + } + + private get registryFile(): string { + return path.join(this.root, 'apps.json'); + } + + private get iconDir(): string { + return path.join(this.root, 'icons'); + } + + private get applicationsDir(): string { + return path.join(dataHome(), 'applications'); + } + + // ------------------------------------------------------------- registry + + async list(): Promise<InstalledWebApp[]> { + if (this.cache) { + return this.cache; + } + let apps: InstalledWebApp[] = []; + try { + const parsed: unknown = JSON.parse(await fs.readFile(this.registryFile, 'utf8')); + if (Array.isArray(parsed)) { + apps = parsed.filter(isWebApp); + } + } catch { + // Nothing installed yet, or a file we cannot read; either way the + // answer is an empty list rather than an error nobody can act on. + } + apps.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + this.cache = apps; + return apps; + } + + async isInstalled(id: string): Promise<boolean> { + return (await this.list()).some((app) => app.id === id); + } + + /** + * The installed app for an address, if there is one. + * + * Matched by origin rather than by id, because an app is installed at its + * manifest's `start_url` and that is usually a deeper path than the address + * it was found at. See `sameSite` in util/webapp.ts. + */ + async findForUrl(url: string): Promise<InstalledWebApp | undefined> { + return (await this.list()).find((app) => sameSite(app.url, url)); + } + + private async save(apps: InstalledWebApp[]): Promise<void> { + await fs.mkdir(this.root, { recursive: true }); + await fs.writeFile(this.registryFile, `${JSON.stringify(apps, null, 2)}\n`, 'utf8'); + this.cache = apps; + this.emit('change'); + } + + // -------------------------------------------------------------- discovery + + /** + * Everything a site says about itself as an app. + * + * The manifest is the good answer and most sites worth installing have one. + * The rest still have a <title>, a description and a favicon, which makes a + * perfectly usable launcher entry - refusing to install those would mean + * refusing most of the web for the sake of a spec. + */ + async discover(address: string): Promise<{ ok: true; info: WebAppInfo } | { ok: false; message: string }> { + const url = normaliseSiteUrl(address); + if (!url) { + return { ok: false, message: `"${address}" is not a web address.` }; + } + + const page = await http.get(url, { maxBytes: MAX_PAGE_BYTES }); + if (!page.ok) { + return { + ok: false, + message: page.status > 0 + ? `${hostOf(url)} answered ${page.status}.` + : `Could not reach ${hostOf(url)}.`, + }; + } + const html = page.body.toString('utf8'); + // page.url is where the body actually came from, so a site that + // redirects http -> https -> /app installs at the address it settled on. + const pageUrl = page.url; + + const href = manifestHref(html); + const manifestUrl = href ? resolve(pageUrl, href) : undefined; + if (manifestUrl) { + const manifest = await http.getJson(manifestUrl, { maxBytes: MAX_MANIFEST_BYTES }); + const info = manifest ? webAppFromManifest(manifest, manifestUrl, pageUrl) : undefined; + if (info) { + return { ok: true, info }; + } + log.debug(`manifest at ${manifestUrl} was unusable; falling back to the page`); + } + + return { ok: true, info: webAppFromPage(html, pageUrl) }; + } + + // --------------------------------------------------------------- install + + async install(info: WebAppInfo, openIn: OpenIn): Promise<InstallResult> { + try { + // Reinstalling replaces, and "the same app" is the same site rather + // than the same id - a site reached two ways installs at one + // start_url, and two rows for it would both claim the same window. + const existing = await this.list(); + const superseded = existing.filter((app) => app.id === info.id || sameSite(app.url, info.url)); + for (const app of superseded) { + await this.removeFiles(app); + } + const apps = existing.filter((app) => !superseded.includes(app)); + const iconFile = info.iconUrl ? await this.downloadIcon(info.id, info.iconUrl) : undefined; + + const app: InstalledWebApp = { + id: info.id, + name: info.name || hostOf(info.url), + description: info.description || hostOf(info.url), + url: info.url, + openIn, + installedAt: Date.now(), + iconFile, + }; + app.desktopFile = await this.writeDesktopEntry(app); + + apps.push(app); + apps.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + await this.save(apps); + log.info(`installed web app ${app.id} (${app.url})`); + return { ok: true, app }; + } catch (error) { + log.error(`could not install ${info.url}`, error); + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + + async uninstall(id: string): Promise<boolean> { + const apps = await this.list(); + const app = apps.find((candidate) => candidate.id === id); + if (!app) { + return false; + } + await this.removeFiles(app); + await this.save(apps.filter((candidate) => candidate.id !== id)); + log.info(`uninstalled web app ${id}`); + return true; + } + + /** The icon and the desktop entry. Never fatal: a missing file is the goal. */ + private async removeFiles(app: InstalledWebApp): Promise<void> { + for (const file of [app.iconFile, app.desktopFile]) { + if (file) { + await fs.rm(file, { force: true }).catch(() => undefined); + } + } + } + + /** Change where an installed app opens, without reinstalling it. */ + async setOpenIn(id: string, openIn: OpenIn): Promise<void> { + const apps = await this.list(); + const app = apps.find((candidate) => candidate.id === id); + if (!app || app.openIn === openIn) { + return; + } + app.openIn = openIn; + // The Exec line encodes the choice, so the desktop entry has to follow. + app.desktopFile = await this.writeDesktopEntry(app); + await this.save(apps); + } + + private async downloadIcon(id: string, url: string): Promise<string | undefined> { + const response = await http.get(url, { maxBytes: MAX_ICON_BYTES, accept: 'image/*' }); + if (!response.ok || response.body.length === 0) { + log.debug(`no icon for ${id}: ${url}`); + return undefined; + } + const extension = extensionFor(url, response.contentType); + const file = path.join(this.iconDir, `${id}${extension}`); + await fs.mkdir(this.iconDir, { recursive: true }); + await fs.writeFile(file, response.body); + return file; + } + + /** + * The desktop entry, so the app exists to the rest of the machine and not + * only to this shell. `StartupWMClass` is what lets a window manager match + * the Chromium app window back to this entry. + */ + private async writeDesktopEntry(app: InstalledWebApp): Promise<string | undefined> { + const file = path.join(this.applicationsDir, `vscodeos-webapp-${app.id}.desktop`); + const exec = this.execLine(app); + const lines = [ + '[Desktop Entry]', + 'Type=Application', + `Name=${escapeDesktopValue(app.name)}`, + `Comment=${escapeDesktopValue(app.description)}`, + `Exec=${escapeDesktopExec(exec)}`, + app.iconFile ? `Icon=${escapeDesktopValue(app.iconFile)}` : undefined, + 'Terminal=false', + 'Categories=Network;X-WebApps;', + `StartupWMClass=${escapeDesktopValue(`chrome-${hostOf(app.url)}__-Default`)}`, + `X-VSCodeOS-WebApp=${app.id}`, + '', + ].filter((line): line is string => line !== undefined); + + try { + await fs.mkdir(this.applicationsDir, { recursive: true }); + await fs.writeFile(file, lines.join('\n'), 'utf8'); + return file; + } catch (error) { + // Not fatal: the app still works from All Apps, it is only invisible + // to other launchers. + log.debug(`could not write ${file}: ${String(error)}`); + return undefined; + } + } + + private execLine(app: InstalledWebApp): string { + if (app.openIn === 'editor') { + // Nothing outside the editor can open an editor tab, so the entry + // hands the app back to the running VS Code the same way the Print + // key does - `code --open-url` and the URI handler in extension.ts. + return `${codeCommand()} --open-url "${WEBAPP_URI}?id=${encodeURIComponent(app.id)}"`; + } + const detected = browser.detect(); + return detected?.chromiumLike + ? `${detected.command} --app=${app.url}` + : `xdg-open ${app.url}`; + } + + // ---------------------------------------------------------------- launch + + /** The icon as a data URI, for the webviews that list these apps. */ + async iconDataUri(app: InstalledWebApp): Promise<string | undefined> { + if (!app.iconFile) { + return undefined; + } + try { + const bytes = await fs.readFile(app.iconFile); + if (bytes.length > MAX_ICON_BYTES) { + return undefined; + } + const mime = ICON_TYPES[path.extname(app.iconFile).toLowerCase()] ?? 'image/png'; + return `data:${mime};base64,${bytes.toString('base64')}`; + } catch { + return undefined; + } + } + + dispose(): void { + this.removeAllListeners(); + } +} + +function isWebApp(value: unknown): value is InstalledWebApp { + if (typeof value !== 'object' || value === null) { + return false; + } + const app = value as Partial<InstalledWebApp>; + return typeof app.id === 'string' && typeof app.name === 'string' && typeof app.url === 'string'; +} + +/** Extension for a downloaded icon, from the URL first and the content type second. */ +function extensionFor(url: string, contentType: string): string { + let fromUrl = ''; + try { + fromUrl = path.extname(new URL(url).pathname).toLowerCase(); + } catch { + fromUrl = ''; + } + if (ICON_TYPES[fromUrl]) { + return fromUrl; + } + const mime = contentType.split(';')[0].trim().toLowerCase(); + const match = Object.entries(ICON_TYPES).find(([, value]) => value === mime); + return match?.[0] ?? '.png'; +} diff --git a/extension/src/util/desktop.ts b/extension/src/util/desktop.ts new file mode 100644 index 0000000..6f9fec1 --- /dev/null +++ b/extension/src/util/desktop.ts @@ -0,0 +1,172 @@ +// Reading XDG desktop entries. +// +// `.desktop` files are how a Linux machine says what is installed on it, so +// they are what the All Apps view lists beyond the shell's own apps. The format +// is an INI file with one group that matters, a fixed set of keys, and an `Exec` +// line that is *not* a shell command line: it has its own quoting rules and a +// set of field codes standing in for the files being opened. +// +// Parsing lives here, away from the filesystem walk in src/sys/desktopApps.ts, +// because this is the half where a mistake is invisible - an Exec line split +// wrongly launches the wrong thing - and the half that can be tested. + +export interface DesktopEntry { + name: string; + comment: string; + exec: string; + icon: string; + terminal: boolean; + /** The entry asks not to be shown in menus; the All Apps view honours it. */ + noDisplay: boolean; + categories: string[]; + keywords: string[]; + /** X-VSCodeOS-WebApp, written by the web app installer so its own entries are recognisable. */ + webAppId?: string; +} + +function isTrue(value: string | undefined): boolean { + return value?.toLowerCase() === 'true'; +} + +function splitList(value: string | undefined): string[] { + return (value ?? '').split(';').map((part) => part.trim()).filter(Boolean); +} + +/** + * Read the `[Desktop Entry]` group. + * + * Returns undefined for everything that is not a launchable application: Link + * and Directory entries, `Hidden=true` (which means "deleted", not "not shown") + * and anything with no Exec line. + */ +export function parseDesktopEntry(text: string): DesktopEntry | undefined { + const fields = new Map<string, string>(); + let inGroup = false; + + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) { + continue; + } + if (line.startsWith('[')) { + // Only the first group is the application itself; the rest are + // desktop actions ("New Window", "Private Window") with their own + // Name and Exec, which would otherwise overwrite the real ones. + inGroup = line === '[Desktop Entry]'; + continue; + } + if (!inGroup) { + continue; + } + const equals = line.indexOf('='); + if (equals <= 0) { + continue; + } + const key = line.slice(0, equals).trim(); + // Localised keys are "Name[pt_BR]". There is no locale to match against + // here, and picking a translation at random is worse than the C name. + if (key.endsWith(']')) { + continue; + } + if (!fields.has(key)) { + fields.set(key, line.slice(equals + 1).trim()); + } + } + + const exec = fields.get('Exec') ?? ''; + if ((fields.get('Type') ?? 'Application') !== 'Application' || !exec || isTrue(fields.get('Hidden'))) { + return undefined; + } + + return { + name: fields.get('Name') ?? '', + comment: fields.get('Comment') ?? fields.get('GenericName') ?? '', + exec, + icon: fields.get('Icon') ?? '', + terminal: isTrue(fields.get('Terminal')), + noDisplay: isTrue(fields.get('NoDisplay')), + categories: splitList(fields.get('Categories')), + keywords: splitList(fields.get('Keywords')), + webAppId: fields.get('X-VSCodeOS-WebApp') || undefined, + }; +} + +/** Field codes: the file names, URLs and icon the launcher would substitute in. */ +const FIELD_CODE = /%[fFuUdDnNickvm]/g; + +/** + * Split an `Exec` line into argv. + * + * Not a shell split: the specification allows only double quotes, escapes + * `\\ \" \` \$` inside them, and reserves `%` for field codes. Nothing here is + * handed to a shell either - src/sys/exec.ts spawns argv directly - so the one + * job is to cut the string the same way a compliant launcher would. + */ +export function execArgv(exec: string): string[] { + const args: string[] = []; + let current = ''; + let started = false; + let quoted = false; + + for (let i = 0; i < exec.length; i++) { + const character = exec[i]; + if (quoted) { + if (character === '\\' && i + 1 < exec.length) { + current += exec[++i]; + continue; + } + if (character === '"') { + quoted = false; + continue; + } + current += character; + continue; + } + if (character === '"') { + quoted = true; + started = true; + continue; + } + if (character === ' ' || character === '\t') { + if (started) { + args.push(current); + current = ''; + started = false; + } + continue; + } + current += character; + started = true; + } + if (started) { + args.push(current); + } + + // A field code stands for something being opened, and nothing is: drop them, + // then drop the arguments that were nothing but a field code. The program + // itself is index 0 and always survives. + return args + .map((arg) => arg.replace(FIELD_CODE, '').replace(/%%/g, '%')) + .filter((arg, index) => index === 0 || arg !== ''); +} + +/** Escape a value for writing back into a desktop entry. */ +export function escapeDesktopValue(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/\r/g, '') + .replace(/\t/g, '\\t'); +} + +/** + * Escape a value for an `Exec` line, where `%` is not a literal. + * + * A percent-encoded URL is the case that matters: `https://x/a%20b` reaches the + * launcher as the field code `%2` followed by `0b`, GLib rejects the whole entry + * as having an unrecognised field code, and the app vanishes from every menu on + * the machine without saying why. `%%` is the spec's literal percent. + */ +export function escapeDesktopExec(value: string): string { + return escapeDesktopValue(value).replace(/%/g, '%%'); +} diff --git a/extension/src/util/webapp.ts b/extension/src/util/webapp.ts new file mode 100644 index 0000000..4f0c7bd --- /dev/null +++ b/extension/src/util/webapp.ts @@ -0,0 +1,280 @@ +// Reading a web app manifest. +// +// Installing a PWA comes down to four facts: what it is called, what to open, +// what to draw and what to say about it. A site publishes those in a +// `<link rel="manifest">` document; the ones that do not still have a <title> +// and a favicon, which is enough to make a usable launcher entry. +// +// Everything here is pure - a string of HTML or a parsed JSON value in, plain +// data out - so the network half in src/sys/webapps.ts stays small and this +// half is testable. + +export interface WebAppInfo { + /** Slug derived from the URL; the id of the installed app and of its files. */ + id: string; + name: string; + description: string; + /** Absolute URL the app opens at. */ + url: string; + /** Absolute URL of the best icon the manifest offered, when there was one. */ + iconUrl?: string; + themeColor?: string; + /** False when the site published no manifest and this was built from the page. */ + fromManifest: boolean; +} + +export interface ManifestIcon { + src: string; + sizes?: string; + type?: string; + purpose?: string; +} + +/** Read one attribute out of a single HTML tag. */ +function attribute(tag: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(tag); + if (!match) { + return undefined; + } + return decodeEntities(match[2] ?? match[3] ?? match[4] ?? ''); +} + +/** The five that matter in an attribute value; nothing here renders HTML. */ +function decodeEntities(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;|'/g, "'") + .replace(/&/g, '&'); +} + +function tags(html: string, name: string): string[] { + return html.match(new RegExp(`<${name}\\b[^>]*>`, 'gi')) ?? []; +} + +/** The href of `<link rel="manifest">`, still relative to the page. */ +export function manifestHref(html: string): string | undefined { + for (const tag of tags(html, 'link')) { + const rel = (attribute(tag, 'rel') ?? '').toLowerCase().split(/\s+/); + if (rel.includes('manifest')) { + const href = attribute(tag, 'href'); + if (href) { + return href; + } + } + } + return undefined; +} + +/** The best `<link rel="icon">`, for sites with no manifest. */ +export function faviconHref(html: string): string | undefined { + let best: { href: string; score: number } | undefined; + for (const tag of tags(html, 'link')) { + const rel = (attribute(tag, 'rel') ?? '').toLowerCase().split(/\s+/); + const href = attribute(tag, 'href'); + if (!href || !rel.some((value) => value === 'icon' || value === 'apple-touch-icon')) { + continue; + } + // A touch icon is a large square by definition, which is what a launcher + // tile wants; a bare "icon" is often a 16px favicon. + const score = (rel.includes('apple-touch-icon') ? 1000 : 0) + largestSize(attribute(tag, 'sizes')); + if (!best || score > best.score) { + best = { href, score }; + } + } + return best?.href; +} + +export function documentTitle(html: string): string | undefined { + const match = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html); + const title = match ? decodeEntities(match[1].replace(/\s+/g, ' ').trim()) : ''; + return title || undefined; +} + +export function metaDescription(html: string): string | undefined { + for (const tag of tags(html, 'meta')) { + const name = (attribute(tag, 'name') ?? attribute(tag, 'property') ?? '').toLowerCase(); + if (name === 'description' || name === 'og:description') { + const content = attribute(tag, 'content')?.replace(/\s+/g, ' ').trim(); + if (content) { + return content; + } + } + } + return undefined; +} + +/** "192x192 512x512" -> 512. `sizes="any"` counts as large, because SVG is. */ +function largestSize(sizes: string | undefined): number { + if (!sizes) { + return 0; + } + if (/\bany\b/i.test(sizes)) { + return 512; + } + let largest = 0; + for (const token of sizes.split(/\s+/)) { + const match = /^(\d+)x(\d+)$/i.exec(token); + if (match) { + largest = Math.max(largest, Math.min(Number(match[1]), Number(match[2]))); + } + } + return largest; +} + +/** + * The icon to install with. Anything marked `maskable` loses to a plain one - + * a maskable icon is drawn assuming the platform will crop it to a circle, and + * nothing here crops - and among the rest the one nearest 192px wins, because + * that is roughly a launcher tile and upscaling a 32px favicon looks it. + */ +export function pickIcon(icons: ManifestIcon[], target = 192): ManifestIcon | undefined { + const scored = icons + .filter((icon) => typeof icon.src === 'string' && icon.src.length > 0) + .map((icon) => { + const purposes = (icon.purpose ?? 'any').toLowerCase().split(/\s+/); + const maskableOnly = purposes.includes('maskable') && !purposes.includes('any'); + const monochrome = purposes.includes('monochrome'); + const size = largestSize(icon.sizes) || (/\.svg($|\?)/i.test(icon.src) ? 512 : 0); + return { icon, maskableOnly, monochrome, distance: Math.abs((size || 64) - target) }; + }) + .filter((candidate) => !candidate.monochrome); + + if (scored.length === 0) { + return undefined; + } + scored.sort((a, b) => + Number(a.maskableOnly) - Number(b.maskableOnly) || a.distance - b.distance); + return scored[0].icon; +} + +/** + * A slug for the URL, used as the app's id, its icon file name and the name of + * its desktop entry. Host first so apps from one site sort together, then the + * path, so two apps on the same host do not collide. + */ +export function appIdFromUrl(url: string): string { + let host = ''; + let pathname = ''; + try { + const parsed = new URL(url); + host = parsed.hostname.replace(/^www\./, ''); + pathname = parsed.pathname; + } catch { + host = url; + } + const slug = `${host}${pathname}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return (slug || 'web-app').slice(0, 64); +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +/** Turn a fetched manifest into the facts an installed app needs. */ +export function webAppFromManifest( + manifest: unknown, + manifestUrl: string, + pageUrl: string, +): WebAppInfo | undefined { + if (typeof manifest !== 'object' || manifest === null) { + return undefined; + } + const fields = manifest as Record<string, unknown>; + const start = asString(fields.start_url) ?? pageUrl; + const url = resolve(manifestUrl, start) ?? pageUrl; + const icons = Array.isArray(fields.icons) ? (fields.icons as ManifestIcon[]) : []; + const icon = pickIcon(icons); + + return { + id: appIdFromUrl(url), + name: asString(fields.name) ?? asString(fields.short_name) ?? hostOf(url), + description: asString(fields.description) ?? hostOf(url), + url, + iconUrl: icon ? resolve(manifestUrl, icon.src) : undefined, + themeColor: asString(fields.theme_color), + fromManifest: true, + }; +} + +/** Everything a site with no manifest still tells you about itself. */ +export function webAppFromPage(html: string, pageUrl: string): WebAppInfo { + const favicon = faviconHref(html); + return { + id: appIdFromUrl(pageUrl), + name: documentTitle(html)?.split(/\s+[|·—–-]\s+/)[0].slice(0, 64) ?? hostOf(pageUrl), + description: metaDescription(html)?.slice(0, 200) ?? hostOf(pageUrl), + url: pageUrl, + iconUrl: favicon ? resolve(pageUrl, favicon) : resolve(pageUrl, '/favicon.ico'), + fromManifest: false, + }; +} + +/** + * What was typed into "Install from URL", as a URL, or undefined. + * + * Deliberately not `normaliseAddress` from util/url.ts: that turns anything + * unrecognisable into a web search, which is right for an address bar and wrong + * here - installing a search results page as an app is never what was meant. A + * bare host still gets a scheme, because typing one is normal. + */ +export function normaliseSiteUrl(input: string): string | undefined { + const text = input.trim(); + if (!text) { + return undefined; + } + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) + ? text + : /^localhost(:\d+)?([/?#].*)?$/i.test(text) || /^[^\s/?#]+\.[a-z]{2,}(:\d+)?([/?#].*)?$/i.test(text) + ? `https://${text}` + : undefined; + if (!candidate) { + return undefined; + } + try { + const url = new URL(candidate); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : undefined; + } catch { + return undefined; + } +} + +/** + * Whether two addresses are the same installed app. + * + * Not id equality: an app is installed at its manifest's `start_url`, which is + * routinely a deeper path than the address it was found at - + * `https://docs.google.com` installs as `https://docs.google.com/document/u/0/`. + * Comparing ids would leave the catalogue offering to install Google Docs + * forever, and the Browser's Install button showing on a site already installed. + * The origin is the thing that actually identifies the app. + */ +export function sameSite(a: string, b: string): boolean { + try { + return new URL(a).origin === new URL(b).origin; + } catch { + return false; + } +} + +export function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +/** Absolute URL for a possibly-relative reference, or undefined if it is neither. */ +export function resolve(base: string, reference: string): string | undefined { + try { + const url = new URL(reference, base); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : undefined; + } catch { + return undefined; + } +} diff --git a/extension/src/views/allApps.ts b/extension/src/views/allApps.ts new file mode 100644 index 0000000..b99baff --- /dev/null +++ b/extension/src/views/allApps.ts @@ -0,0 +1,232 @@ +// All Apps, in the activity bar. +// +// This used to be a card in the tray, opened from a $(menu) button in the +// bottom-left corner of the status bar. A start menu is not a tray item: it is +// the one place you go to find anything on the machine, and the activity bar is +// where VS Code puts the things you go to. So it is a view of its own, with an +// icon that is always there rather than a button competing with the status bar. +// +// The list is three sources merged into one grid: +// +// builtin the shell's own apps, from apps/registry.ts +// webapp PWAs installed through the Marketplace, from sys/webapps.ts +// system everything else on the machine, from its .desktop files +// +// Only the middle one can be uninstalled, because it is the only one this shell +// installed. Removing a pacman package would mean a password-free path to root +// for arbitrary package removal, and that is not a trade worth making for a +// context menu item. + +import * as vscode from 'vscode'; +import * as appIcons from '../sys/appIcons'; +import * as desktopApps from '../sys/desktopApps'; +import type { WebAppService } from '../sys/webapps'; +import { availableApps } from '../apps/registry'; +import { render, webviewOptions } from '../webview/html'; +import type { AllAppsState, AppEntry, HostMessage, OpenIn, WebviewMessage } from '../webview/protocol'; +import { log } from '../log'; + +export class AllAppsProvider implements vscode.WebviewViewProvider, vscode.Disposable { + static readonly viewId = 'vscodeos.allApps'; + + private view: vscode.WebviewView | undefined; + private readonly unsubscribe: () => void; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly webApps: WebAppService, + ) { + // Installing or removing an app anywhere - the Marketplace, the browser's + // install button, this view's own context menu - redraws this grid. + const onChange = (): void => void this.refresh(); + this.webApps.on('change', onChange); + this.unsubscribe = () => this.webApps.off('change', onChange); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = webviewOptions(this.context); + view.webview.html = render(view.webview, this.context, { title: 'All Apps', script: 'allapps' }); + + view.webview.onDidReceiveMessage((message: WebviewMessage) => void this.handle(message)); + view.onDidChangeVisibility(() => { + if (view.visible) { + void this.refresh(); + } + }); + view.onDidDispose(() => (this.view = undefined)); + } + + private post(message: HostMessage): void { + void this.view?.webview.postMessage(message); + } + + private get config(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration('vscodeos'); + } + + /** + * Rebuild the list. Only while the view is on screen: scanning every + * .desktop file on the machine and base64-ing their icons is not work to do + * because somebody installed an app in a tab nobody is looking at. + */ + private async refresh(): Promise<void> { + if (!this.view?.visible) { + return; + } + const showSystem = this.config.get<boolean>('allApps.systemApps', true); + const webAppsEnabled = this.config.get<boolean>('webApps.enabled', true); + + const apps: AppEntry[] = availableApps(this.config); + try { + if (webAppsEnabled) { + apps.push(...(await this.webAppEntries())); + } + if (showSystem) { + apps.push(...(await this.systemEntries())); + } + } catch (error) { + log.error('all apps', error); + } + + const state: AllAppsState = { apps, systemApps: showSystem, webApps: webAppsEnabled }; + this.post({ type: 'apps', state }); + } + + private async webAppEntries(): Promise<AppEntry[]> { + const installed = await this.webApps.list(); + return Promise.all(installed.map(async (app) => ({ + id: app.id, + title: app.name, + description: app.description, + icon: 'globe', + command: '', + keywords: ['web app', 'pwa', app.url], + source: 'webapp' as const, + iconUrl: await this.webApps.iconDataUri(app), + removable: true, + openIn: app.openIn, + url: app.url, + }))); + } + + private async systemEntries(): Promise<AppEntry[]> { + const installed = await desktopApps.list(); + return Promise.all(installed.map(async (app) => ({ + id: app.id, + title: app.name, + description: app.description, + icon: 'package', + command: '', + keywords: [...app.keywords, ...app.categories], + source: 'system' as const, + iconUrl: await appIcons.dataUri(app.icon), + }))); + } + + // -------------------------------------------------------------- messages + + private async handle(message: WebviewMessage): Promise<void> { + try { + switch (message.type) { + case 'ready': + case 'refreshApps': + // Nothing watches the application or icon directories, so + // this is what picks up something installed from a terminal. + desktopApps.refresh(); + appIcons.clearCache(); + await this.refresh(); + return; + + case 'launchApp': + await this.launch(message.source, message.id); + return; + + case 'uninstallApp': + await this.uninstall(message.id, message.name); + return; + + case 'setAppOpenIn': + await this.webApps.setOpenIn(message.id, message.openIn as OpenIn); + await this.refresh(); + return; + + case 'command': + await vscode.commands.executeCommand(message.command); + return; + + default: + return; + } + } catch (error) { + log.error('all apps', error); + void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async launch(source: AppEntry['source'], id: string): Promise<void> { + if (source === 'builtin') { + const app = availableApps(this.config).find((candidate) => candidate.id === id); + if (app) { + await vscode.commands.executeCommand(app.command); + } + return; + } + if (source === 'webapp') { + await vscode.commands.executeCommand('vscodeos.webApps.launch', id); + return; + } + + const app = (await desktopApps.list()).find((candidate) => candidate.id === id); + if (!app) { + await this.refresh(); + return; + } + if (app.terminal) { + // A Terminal=true entry is a command-line program with a menu entry; + // starting it detached would run it with nowhere to draw. The editor + // has a terminal, so it goes there - and this is the one place the + // argv has to become a command line again, so it is quoted rather + // than joined. `/opt/My App/tool` is not two arguments. + const terminal = vscode.window.createTerminal({ name: app.name }); + terminal.sendText(app.argv.map(shellQuote).join(' ')); + terminal.show(); + return; + } + if (!desktopApps.launch(app)) { + void vscode.window.showErrorMessage(`${app.name} could not be started.`); + } + } + + private async uninstall(id: string, name: string): Promise<void> { + const choice = await vscode.window.showWarningMessage( + `Uninstall "${name}"?`, + { modal: true, detail: 'The app and its shortcut are removed. Nothing on the site itself changes.' }, + 'Uninstall', + ); + if (choice !== 'Uninstall') { + return; + } + if (await this.webApps.uninstall(id)) { + void vscode.window.showInformationMessage(`Uninstalled ${name}.`); + } + await this.refresh(); + } + + dispose(): void { + this.unsubscribe(); + } +} + +/** + * One argv element, safe to paste into a shell. + * + * Single quotes, because inside them a POSIX shell expands nothing at all; the + * only character that needs work is the single quote itself, which has to be + * closed, escaped and reopened. + */ +function shellQuote(argument: string): string { + return /^[\w@%+=:,./-]+$/.test(argument) + ? argument + : `'${argument.replace(/'/g, `'\\''`)}'`; +} diff --git a/extension/src/views/flyout.ts b/extension/src/views/flyout.ts index ce5b098..2fe101c 100644 --- a/extension/src/views/flyout.ts +++ b/extension/src/views/flyout.ts @@ -1,14 +1,19 @@ -// The flyouts: apps, power, calendar, power settings, volume, network, -// bluetooth, music and notifications. +// The tray cards: power, calendar, power settings, volume, network, bluetooth, +// music and notifications. // -// A VS Code extension cannot draw a popup anchored to a status bar item - there -// is no such API, and the only floating-window route (moving an editor to an -// auxiliary window) drags editor tab chrome along with it and cannot be sized or -// placed. So these are webview views, which VS Code will host in the side bar or -// in the bottom panel. The side bar is the default: the bottom panel is where -// the terminal lives, and clicking the clock should not close it. +// These are the *optional* half of the tray. The default is a quick pick +// (src/statusbar/menus.ts), because a menu that takes the side bar away from +// whatever was in it is not really a menu. What these can do that a quick pick +// cannot is draw: a real brightness slider, a month grid, album art, a signal +// meter. `vscodeos.flyout.location` chooses, and this provider is registered +// either way because the setting can change without a reload. // -// One provider serves all nine cards, because a container can only hold one +// A webview view is the only surface available for that. There is no API to +// anchor a popup to a status bar item, and the one floating-window route - +// moving an editor to an auxiliary window - drags editor tab chrome with it and +// cannot be sized or placed. +// +// One provider serves all eight cards, because a container can only hold one // view without splitting the space between them, and because they share their // whole refresh loop. @@ -23,13 +28,11 @@ import * as network from '../sys/network'; import * as power from '../sys/power'; import { MprisMonitor } from '../sys/mpris'; import type { NotificationServer } from '../sys/notifications'; -import { availableApps } from '../apps/registry'; import { render, webviewOptions } from '../webview/html'; import type { FlyoutKind, FlyoutState, HostMessage, WebviewMessage } from '../webview/protocol'; import { log } from '../log'; const TITLES: Record<FlyoutKind, string> = { - apps: 'Apps', power: 'Power', powersettings: 'Power Settings', calendar: 'Calendar', @@ -44,11 +47,10 @@ const REFRESH_MS = 2000; /** * Cards the host has nothing new to say about. Polling them would rebuild the - * card every two seconds for no reason - and in the launcher's case it would - * rebuild the search box out from under whoever is typing into it. The calendar - * ticks its own clock inside the webview. + * card every two seconds for no reason; the calendar ticks its own clock inside + * the webview. */ -const STATIC_KINDS: ReadonlySet<FlyoutKind> = new Set<FlyoutKind>(['apps', 'calendar']); +const STATIC_KINDS: ReadonlySet<FlyoutKind> = new Set<FlyoutKind>(['calendar']); export type FlyoutLocation = 'sidebar' | 'panel'; @@ -58,7 +60,7 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { static readonly panelViewId = 'vscodeos.flyout.panel'; private view: vscode.WebviewView | undefined; - private kind: FlyoutKind = 'apps'; + private kind: FlyoutKind = 'power'; private timer: NodeJS.Timeout | undefined; private refreshing = false; private scanning = false; @@ -106,10 +108,8 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { this.view.title = TITLES[kind]; this.view.show?.(true); } - // The launcher wants the keyboard, so somebody can start typing straight - // into its search box; every other card is read, not typed into, and - // stealing focus from the editor for those would be rude. - await vscode.commands.executeCommand(`${this.viewId}.focus`, { preserveFocus: kind !== 'apps' }); + // These cards are read, not typed into, so the editor keeps the keyboard. + await vscode.commands.executeCommand(`${this.viewId}.focus`, { preserveFocus: true }); this.post({ type: 'flyout', kind }); // The kind decides whether this card polls at all. if (this.view?.visible) { @@ -173,12 +173,8 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { } this.refreshing = true; try { - const config = vscode.workspace.getConfiguration('vscodeos'); const state: FlyoutState = { kind: this.kind, now: Date.now() }; - if (this.kind === 'apps') { - state.apps = availableApps(config); - } if (this.kind === 'powersettings' || this.kind === 'power') { state.battery = await battery.getState(); } @@ -396,14 +392,6 @@ export class FlyoutProvider implements vscode.WebviewViewProvider { await this.refresh(); return; - case 'launchMusic': { - const url = message.service === 'spotify' - ? 'https://open.spotify.com' - : 'https://music.youtube.com'; - await vscode.commands.executeCommand('vscodeos.browser.open', url); - return; - } - case 'dismissNotification': this.notifications.dismiss(message.id); await this.refresh(); diff --git a/extension/src/views/recycleBin.ts b/extension/src/views/recycleBin.ts deleted file mode 100644 index 5c25bf5..0000000 --- a/extension/src/views/recycleBin.ts +++ /dev/null @@ -1,122 +0,0 @@ -// The Recycle Bin, in the activity bar. -// -// The same TrashService the Files app browses, in a view that is one click away -// rather than three. Restoring here redraws the Files app and vice versa, -// because both listen to the service's `change` event. -// -// Like the Task Manager, this only reads the trash while it is actually -// visible: a hidden view re-listing a bin full of files on every change would -// be work nobody asked for. - -import * as vscode from 'vscode'; -import { render, webviewOptions } from '../webview/html'; -import type { TrashService } from '../sys/trash'; -import type { WebviewMessage } from '../webview/protocol'; -import { log } from '../log'; - -export class RecycleBinProvider implements vscode.WebviewViewProvider, vscode.Disposable { - static readonly viewId = 'vscodeos.recycleBin'; - - private view: vscode.WebviewView | undefined; - private readonly subscription: () => void; - - constructor( - private readonly context: vscode.ExtensionContext, - private readonly trash: TrashService, - ) { - const onChange = (): void => void this.refresh(); - this.trash.on('change', onChange); - this.subscription = () => this.trash.off('change', onChange); - } - - resolveWebviewView(view: vscode.WebviewView): void { - this.view = view; - view.webview.options = webviewOptions(this.context); - view.webview.html = render(view.webview, this.context, { - title: 'Recycle Bin', - script: 'recyclebin', - }); - - view.webview.onDidReceiveMessage((message: WebviewMessage) => void this.handle(message)); - view.onDidChangeVisibility(() => { - if (view.visible) { - void this.refresh(); - } - }); - view.onDidDispose(() => (this.view = undefined)); - } - - private async refresh(): Promise<void> { - if (!this.view?.visible) { - return; - } - try { - const entries = await this.trash.list(); - void this.view.webview.postMessage({ type: 'trash', entries }); - } catch (error) { - log.error('recycle bin', error); - } - } - - private async handle(message: WebviewMessage): Promise<void> { - switch (message.type) { - case 'ready': - await this.refresh(); - return; - - case 'restoreFromTrash': { - const { restored, skipped } = await this.trash.restore(message.paths); - if (skipped.length > 0) { - void vscode.window.showWarningMessage( - `${skipped.length} item${skipped.length === 1 ? '' : 's'} could not be restored: ` - + 'the original location is unknown.', - ); - } else if (restored > 0) { - void vscode.window.showInformationMessage( - `Restored ${restored} item${restored === 1 ? '' : 's'}.`, - ); - } - return; - } - - case 'deleteFromTrash': { - const label = message.paths.length === 1 ? 'this item' : `${message.paths.length} items`; - const choice = await vscode.window.showWarningMessage( - `Permanently delete ${label}?`, - { modal: true, detail: 'This cannot be undone.' }, - 'Delete permanently', - ); - if (choice === 'Delete permanently') { - await this.trash.remove(message.paths); - } - return; - } - - case 'emptyTrash': { - if (await this.trash.isEmpty()) { - return; - } - const choice = await vscode.window.showWarningMessage( - 'Empty the Recycle Bin?', - { modal: true, detail: 'Everything in it is deleted for good.' }, - 'Empty Recycle Bin', - ); - if (choice === 'Empty Recycle Bin') { - await this.trash.empty(); - } - return; - } - - case 'command': - await vscode.commands.executeCommand(message.command); - return; - - default: - return; - } - } - - dispose(): void { - this.subscription(); - } -} diff --git a/extension/src/webview/protocol.ts b/extension/src/webview/protocol.ts index 3fda2f5..846cfca 100644 --- a/extension/src/webview/protocol.ts +++ b/extension/src/webview/protocol.ts @@ -31,30 +31,95 @@ export type { NotificationRecord } from '../sys/notifications'; export type { ProcessInfo, SystemInfo } from '../sys/procfs'; export type { StorageState, MountUsage, DirectoryUsage, CleanupCategory, CleanupId } from '../sys/storage'; +/** + * The tray menus. No 'apps' any more: the launcher is a view of its own in the + * activity bar (src/views/allApps.ts), not a card in the tray. + */ export type FlyoutKind = - | 'apps' | 'power' | 'powersettings' | 'calendar' + | 'power' | 'powersettings' | 'calendar' | 'volume' | 'network' | 'bluetooth' | 'music' | 'notifications'; +/** Where an entry in All Apps came from, which decides what can be done to it. */ +export type AppSource = 'builtin' | 'webapp' | 'system'; + +/** Where an installed web app opens. */ +export type OpenIn = 'window' | 'editor'; + /** - * One entry in the app launcher. Deliberately plain data: the registry that - * produces these lives on the host and knows how to test whether an app is - * enabled, but only the serialisable half crosses into the webview. + * One entry in the app launcher. Deliberately plain data: the host knows how to + * test whether an app is enabled, where its icon file is and how to start it, + * but only the serialisable half crosses into the webview. */ export interface AppEntry { id: string; title: string; description: string; - /** Key in media/src/lib/icons.ts. */ + /** Key in media/src/lib/icons.ts, used when there is no `iconUrl`. */ icon: string; + /** Command to run for a built-in; empty for the other two sources. */ command: string; /** Extra search terms, for words that are not in the title or description. */ keywords?: string[]; + source: AppSource; + /** A real icon, as a data URI: the web app's, or the desktop entry's. */ + iconUrl?: string; + /** Web apps only: this shell installed it, so this shell can remove it. */ + removable?: boolean; + /** Web apps only. */ + openIn?: OpenIn; + url?: string; +} + +export interface AllAppsState { + apps: AppEntry[]; + /** False when the .desktop scan is off, so the view can say so. */ + systemApps: boolean; + webApps: boolean; +} + +/** One row in the Marketplace: a catalogue entry, or an app already installed. */ +export interface MarketplaceItem { + id: string; + /** The web app id it would install as, so "installed" can be answered. */ + appId: string; + name: string; + description: string; + url: string; + category: string; + keywords?: string[]; + installed: boolean; +} + +export interface MarketplaceState { + category: string; + categories: string[]; + items: MarketplaceItem[]; + /** False when there is no Chromium-like browser to run a web app in. */ + canOpenWindows: boolean; +} + +/** One playable file in the music library. */ +export interface Track { + name: string; + path: string; + /** Webview URI; the library directory is a fixed local resource root. */ + uri: string; + /** The containing folder's name, which stands in for an album. */ + folder: string; +} + +export interface MusicLibrary { + directory: string; + tracks: Track[]; + /** Set when the directory could not be read at all. */ + error?: string; + /** True when the scan stopped at its cap rather than at the end. */ + truncated?: boolean; } export interface FlyoutState { kind: FlyoutKind; now: number; - apps?: AppEntry[]; battery?: BatteryState; audio?: AudioState; network?: NetworkState; @@ -154,6 +219,8 @@ export interface BrowserState { canGoBack: boolean; canGoForward: boolean; tabs: BrowserTab[]; + /** Set when the page declares a web app manifest, so it can be installed. */ + installable?: { url: string; name: string }; } /** Host -> webview. */ @@ -165,6 +232,13 @@ export type HostMessage = | { type: 'tasks'; system: SystemInfo; processes: ProcessInfo[] } | { type: 'taskError'; message: string } | { type: 'files'; path: string; entries: FileEntry[]; places: Place[]; error?: string } + // all apps + | { type: 'apps'; state: AllAppsState } + // marketplace + | { type: 'marketplace'; state: MarketplaceState } + | { type: 'marketplaceBusy'; label?: string } + // music player + | { type: 'music'; library: MusicLibrary } | { type: 'shot'; path: string; uri: string } | { type: 'shotError'; message: string } | { type: 'recording'; state: 'idle' | 'recording'; path?: string; uri?: string; startedAt?: number } @@ -217,9 +291,19 @@ export type WebviewMessage = | { type: 'energySaver'; enabled: boolean } | { type: 'transport'; action: 'playPause' | 'next' | 'previous' } | { type: 'seek'; seconds: number } - | { type: 'launchMusic'; service: 'spotify' | 'ytmusic' } | { type: 'command'; command: string } | { type: 'closeFlyout' } + // all apps + | { type: 'launchApp'; source: AppSource; id: string } + | { type: 'uninstallApp'; id: string; name: string } + | { type: 'setAppOpenIn'; id: string; openIn: OpenIn } + | { type: 'refreshApps' } + // marketplace + | { type: 'marketplaceCategory'; category: string } + | { type: 'installWebApp'; url: string } + // music player + | { type: 'musicRefresh' } + | { type: 'musicChooseFolder' } // notifications | { type: 'dismissNotification'; id: number } | { type: 'clearNotifications' } @@ -264,6 +348,7 @@ export type WebviewMessage = | { type: 'browserInput'; input: BrowserInput } | { type: 'browserTab'; action: 'new' | 'close' | 'select'; id?: string } | { type: 'browserExternal' } + | { type: 'browserInstallApp' } // firewall | { type: 'firewallRefresh' } | { type: 'firewallToggle'; enabled: boolean } diff --git a/extension/test/index.test.ts b/extension/test/index.test.ts index e0d7e4c..fb7557b 100644 --- a/extension/test/index.test.ts +++ b/extension/test/index.test.ts @@ -33,6 +33,19 @@ import { parseXrandrOutputs, } from '../src/util/parse'; import { codeUpdateUrl, normaliseAddress } from '../src/util/url'; +import { escapeDesktopExec, escapeDesktopValue, execArgv, parseDesktopEntry } from '../src/util/desktop'; +import { + appIdFromUrl, + documentTitle, + faviconHref, + manifestHref, + metaDescription, + normaliseSiteUrl, + pickIcon, + sameSite, + webAppFromManifest, + webAppFromPage, +} from '../src/util/webapp'; describe('calculator', () => { const cases: [string, string][] = [ @@ -629,3 +642,247 @@ describe('trashinfo files', () => { assert.equal(parseTrashInfo('Path=/tmp/100%.txt')?.path, '/tmp/100%.txt'); }); }); + +describe('desktop entries', () => { + const firefox = [ + '[Desktop Entry]', + 'Version=1.0', + 'Name=Firefox', + 'Name[pt_BR]=Raposa de Fogo', + 'Comment=Browse the World Wide Web', + 'GenericName=Web Browser', + 'Exec=/usr/lib/firefox/firefox %u', + 'Icon=firefox', + 'Terminal=false', + 'Type=Application', + 'Categories=Network;WebBrowser;', + 'Keywords=Internet;WWW;Browser;', + '', + '[Desktop Action new-private-window]', + 'Name=New Private Window', + 'Exec=/usr/lib/firefox/firefox --private-window %u', + ].join('\n'); + + it('reads the fields the launcher needs', () => { + const entry = parseDesktopEntry(firefox); + assert.equal(entry?.name, 'Firefox'); + assert.equal(entry?.comment, 'Browse the World Wide Web'); + assert.equal(entry?.icon, 'firefox'); + assert.equal(entry?.terminal, false); + assert.equal(entry?.noDisplay, false); + assert.deepEqual(entry?.categories, ['Network', 'WebBrowser']); + assert.deepEqual(entry?.keywords, ['Internet', 'WWW', 'Browser']); + }); + + // A translated name would be picked at random: there is no locale to match + // against here, so the unlocalised key has to win. + it('ignores localised keys', () => { + assert.equal(parseDesktopEntry(firefox)?.name, 'Firefox'); + }); + + // The action groups have their own Name and Exec. Reading past the first + // group would launch "New Private Window" for every click on Firefox. + it('stops at the end of the [Desktop Entry] group', () => { + assert.equal(parseDesktopEntry(firefox)?.exec, '/usr/lib/firefox/firefox %u'); + }); + + it('falls back to GenericName when there is no Comment', () => { + const entry = parseDesktopEntry('[Desktop Entry]\nName=Thing\nGenericName=A Thing\nExec=thing'); + assert.equal(entry?.comment, 'A Thing'); + }); + + it('rejects everything that is not a launchable application', () => { + assert.equal(parseDesktopEntry('[Desktop Entry]\nType=Link\nName=A\nExec=a\nURL=http://a'), undefined); + assert.equal(parseDesktopEntry('[Desktop Entry]\nType=Directory\nName=A\nExec=a'), undefined); + assert.equal(parseDesktopEntry('[Desktop Entry]\nName=A\nExec=a\nHidden=true'), undefined); + assert.equal(parseDesktopEntry('[Desktop Entry]\nName=A'), undefined); + assert.equal(parseDesktopEntry(''), undefined); + }); + + it('keeps NoDisplay as a flag rather than a rejection', () => { + // Hidden means deleted; NoDisplay only means "not in menus", and the + // caller decides. Conflating them would drop the wrong entries. + assert.equal(parseDesktopEntry('[Desktop Entry]\nName=A\nExec=a\nNoDisplay=true')?.noDisplay, true); + }); + + it('reads the marker on entries the web app installer wrote', () => { + const entry = parseDesktopEntry( + '[Desktop Entry]\nName=Excalidraw\nExec=chromium --app=https://excalidraw.com\nX-VSCodeOS-WebApp=excalidraw-com', + ); + assert.equal(entry?.webAppId, 'excalidraw-com'); + }); + + it('escapes values on the way back out', () => { + assert.equal(escapeDesktopValue('a\\b\nc'), 'a\\\\b\\nc'); + }); + + // A lone % in Exec is a field code. A percent-encoded URL would reach the + // launcher as "%2" followed by "0b", GLib would reject the whole entry as + // having an unrecognised field code, and the app would vanish from every + // menu on the machine without saying why. + it('doubles the percent signs in an Exec line', () => { + assert.equal( + escapeDesktopExec('chromium --app=https://x.example/a%20b'), + 'chromium --app=https://x.example/a%%20b', + ); + assert.equal(escapeDesktopValue('a%20b'), 'a%20b'); + }); +}); + +describe('Exec lines', () => { + it('splits on whitespace', () => { + assert.deepEqual(execArgv('gimp-2.10'), ['gimp-2.10']); + assert.deepEqual(execArgv(' env GDK_SCALE=2 inkscape '), ['env', 'GDK_SCALE=2', 'inkscape']); + }); + + it('keeps a quoted argument whole', () => { + assert.deepEqual( + execArgv('"/opt/My App/run" --flag "two words"'), + ['/opt/My App/run', '--flag', 'two words'], + ); + }); + + it('honours backslash escapes inside quotes', () => { + assert.deepEqual(execArgv('sh -c "echo \\"hi\\""'), ['sh', '-c', 'echo "hi"']); + }); + + // A field code stands for the files being opened, and nothing is being + // opened: passing "%U" through would hand the program a literal argument. + it('drops field codes', () => { + assert.deepEqual(execArgv('firefox %u'), ['firefox']); + assert.deepEqual(execArgv('code --new-window %F'), ['code', '--new-window']); + assert.deepEqual(execArgv('thing --file=%f --keep'), ['thing', '--file=', '--keep']); + }); + + it('unescapes %% and never drops the program', () => { + assert.deepEqual(execArgv('report --scale 50%%'), ['report', '--scale', '50%']); + assert.deepEqual(execArgv('%f'), ['']); + }); +}); + +describe('web app manifests', () => { + it('finds the manifest link whatever the attribute order', () => { + assert.equal(manifestHref('<link href="/app.webmanifest" rel="manifest">'), '/app.webmanifest'); + assert.equal(manifestHref("<link rel='manifest' href='/m.json'>"), '/m.json'); + assert.equal(manifestHref('<link rel="MANIFEST" href=/bare.json>'), '/bare.json'); + assert.equal(manifestHref('<link rel="stylesheet" href="/a.css">'), undefined); + }); + + it('prefers a touch icon over a 16px favicon', () => { + const html = [ + '<link rel="icon" sizes="16x16" href="/small.png">', + '<link rel="apple-touch-icon" href="/touch.png">', + ].join(''); + assert.equal(faviconHref(html), '/touch.png'); + }); + + it('reads the title and description', () => { + const html = '<title> Excalidraw | Hand-drawn ' + + ''; + assert.equal(documentTitle(html), 'Excalidraw | Hand-drawn'); + assert.equal(metaDescription(html), 'Virtual whiteboard'); + }); + + it('decodes entities in attribute values', () => { + assert.equal(manifestHref(''), '/m.json?a=1&b=2'); + }); + + // A maskable icon is drawn assuming the platform crops it to a circle, and + // nothing here crops: it would arrive with its corners cut off. + it('picks a plain icon near 192px over a maskable one', () => { + const picked = pickIcon([ + { src: '/mask.png', sizes: '192x192', purpose: 'maskable' }, + { src: '/plain.png', sizes: '192x192' }, + { src: '/huge.png', sizes: '1024x1024' }, + ]); + assert.equal(picked?.src, '/plain.png'); + }); + + it('takes a maskable icon rather than none at all', () => { + assert.equal(pickIcon([{ src: '/mask.png', sizes: '512x512', purpose: 'maskable' }])?.src, '/mask.png'); + assert.equal(pickIcon([]), undefined); + }); + + it('ignores monochrome icons, which are for notification badges', () => { + assert.equal(pickIcon([{ src: '/badge.png', sizes: '96x96', purpose: 'monochrome' }]), undefined); + }); + + it('derives an id from the host and path', () => { + assert.equal(appIdFromUrl('https://www.excalidraw.com/'), 'excalidraw-com'); + assert.equal(appIdFromUrl('https://music.youtube.com'), 'music-youtube-com'); + assert.equal(appIdFromUrl('https://example.com/apps/mail/'), 'example-com-apps-mail'); + }); + + it('resolves a relative start_url and icon against the manifest', () => { + const info = webAppFromManifest( + { + name: 'Mail', + description: 'Read mail', + start_url: '/inbox', + icons: [{ src: 'icons/192.png', sizes: '192x192' }], + }, + 'https://example.com/static/app.webmanifest', + 'https://example.com/', + ); + assert.equal(info?.url, 'https://example.com/inbox'); + assert.equal(info?.iconUrl, 'https://example.com/static/icons/192.png'); + assert.equal(info?.id, 'example-com-inbox'); + assert.equal(info?.fromManifest, true); + }); + + it('falls back to short_name and the host', () => { + const info = webAppFromManifest({ short_name: 'Sh' }, 'https://a.example/m.json', 'https://a.example/'); + assert.equal(info?.name, 'Sh'); + assert.equal(info?.description, 'a.example'); + }); + + // Most of the web publishes no manifest; refusing those would mean refusing + // most of the web for the sake of a spec. + it('builds an app from a page with no manifest', () => { + const info = webAppFromPage( + 'Arch Wiki — the docs', + 'https://wiki.archlinux.org/', + ); + assert.equal(info.name, 'Arch Wiki'); + assert.equal(info.iconUrl, 'https://wiki.archlinux.org/favicon.png'); + assert.equal(info.fromManifest, false); + }); + + it('guesses a favicon when the page names none', () => { + assert.equal(webAppFromPage('A', 'https://a.example/x').iconUrl, 'https://a.example/favicon.ico'); + }); + + // An app installs at its manifest's start_url, which is routinely deeper + // than the address it was found at. Matching installs by id would leave the + // Marketplace offering to install Google Docs forever, and list it twice. + it('matches an installed app by site rather than by id', () => { + assert.ok(sameSite('https://docs.google.com/document/u/0/', 'https://docs.google.com')); + assert.ok(sameSite('https://a.example/x?y=1#z', 'https://a.example/')); + assert.equal(sameSite('https://a.example', 'https://b.example'), false); + // A subdomain is a different app: music.youtube.com is not youtube.com. + assert.equal(sameSite('https://music.youtube.com', 'https://www.youtube.com'), false); + assert.equal(sameSite('https://a.example', 'http://a.example'), false); + assert.equal(sameSite('not a url', 'https://a.example'), false); + }); + + it('does not confuse two apps on one host', () => { + assert.notEqual(appIdFromUrl('https://example.com/mail'), appIdFromUrl('https://example.com/chat')); + }); +}); + +describe('install addresses', () => { + it('accepts URLs and bare hosts', () => { + assert.equal(normaliseSiteUrl('https://excalidraw.com'), 'https://excalidraw.com/'); + assert.equal(normaliseSiteUrl(' excalidraw.com/x '), 'https://excalidraw.com/x'); + assert.equal(normaliseSiteUrl('localhost:3000'), 'https://localhost:3000/'); + }); + + // Unlike an address bar, this must not turn nonsense into a web search: + // installing a results page as an app is never what was meant. + it('rejects anything that is not an address', () => { + assert.equal(normaliseSiteUrl('how do I install a pwa'), undefined); + assert.equal(normaliseSiteUrl('file:///etc/passwd'), undefined); + assert.equal(normaliseSiteUrl('javascript:alert(1)'), undefined); + assert.equal(normaliseSiteUrl(''), undefined); + }); +}); diff --git a/rootfs-common/etc/skel/.config/Code/User/settings.json b/rootfs-common/etc/skel/.config/Code/User/settings.json index fcb33e9..d4d7e2c 100644 --- a/rootfs-common/etc/skel/.config/Code/User/settings.json +++ b/rootfs-common/etc/skel/.config/Code/User/settings.json @@ -49,12 +49,21 @@ "vscodeos.clock.dateFormat": "dd/MM/yyyy", "vscodeos.taskManager.enabled": true, "vscodeos.files.enabled": true, - "vscodeos.recycleBin.enabled": true, "vscodeos.music.enabled": true, + "vscodeos.music.directory": "~/Music", "vscodeos.apps.enabled": true, "vscodeos.firewall.enabled": true, "vscodeos.power.confirm": true, + // All Apps lists the shell's own apps, the web apps installed from the + // Marketplace, and everything else on the machine that ships a .desktop file. + "vscodeos.allApps.systemApps": true, + "vscodeos.webApps.enabled": true, + + // The tray menus open as popups over the editor. "sidebar" and "panel" swap + // them for the richer webview cards, at the cost of a container. + "vscodeos.flyout.location": "popup", + // The shell serves org.freedesktop.Notifications, so notify-send and any // application's notifications become editor notifications. Nothing else on // either image claims that bus name; turn this off to leave it to a daemon