Bootstrap the Dashward repo per arch/UBUNTU-DASHBOARD-SPACE.md: - pnpm-workspaces monorepo (sdk, extension, container, widgets-builtin/*) - GNOME extension stub (metadata.json, src/*.ts placeholders for warden, guard, supervisor, entry UX, DBus service) - WebKit container stub (GJS main + page-side runtime + dashboard.html) - TypeScript widget SDK (defineWidget + types) - Builtin clock widget as the first SDK consumer example - DBus interface XML (proto/shell.iface.xml) and shared types - esbuild configs for extension and container; tsc for SDK - Design doc copied in at repo root for discoverability No functional logic yet -- all components are placeholders that compose in extension.ts so the build chain can be exercised. P1 (workspace warden) starts next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
684 lines
29 KiB
Markdown
684 lines
29 KiB
Markdown
# Ubuntu Dashboard Space — Design
|
||
|
||
Status: design. Goal: a macOS-Dashboard-style **dedicated rightmost workspace**
|
||
on GNOME/Ubuntu that hosts user-defined widgets. Widgets are written against
|
||
a TypeScript SDK and rendered as web components inside an embedded WebKit
|
||
view; the GNOME Shell extension is the workspace warden and the IPC bridge,
|
||
nothing heavier.
|
||
|
||
Codename: **Dashward** (decided 2026-05-22; see §19).
|
||
|
||
---
|
||
|
||
## 1. Goals & non-goals
|
||
|
||
### Goals
|
||
|
||
- A workspace that is **created by the extension** at enable time, lives at
|
||
the rightmost position, and is **destroyed** at disable time. It is not
|
||
one of the user's existing workspaces, even temporarily.
|
||
- **Regular windows cannot enter it** — neither by overview drag, by
|
||
`super+shift+arrow`, by `wmctrl`/`xdotool`, nor by being newly mapped
|
||
there. Attempts get bounced to the prior workspace immediately.
|
||
- Widgets are **web components in TS**. A buggy widget cannot take down
|
||
GNOME Shell.
|
||
- Hot-reload during widget development.
|
||
- Light/dark theme follows the system.
|
||
- Zero hand-edits to user dotfiles outside `~/.local/share/dashward/` and
|
||
the extension install dir.
|
||
|
||
### Non-goals (v1)
|
||
|
||
- Cross-distro support beyond Ubuntu 25.04 / GNOME 48. KDE, XFCE, Cinnamon
|
||
are out of scope until v1 lands and stabilizes.
|
||
- A widget marketplace, signing, or sandboxing beyond what WebKit's process
|
||
model already provides. Widgets are trusted code at v1.
|
||
- macOS-style "ripple in" widget add animation. Edit mode is utilitarian.
|
||
- Multi-monitor span: dashboard appears on the **primary** monitor only;
|
||
other monitors get the regular last-workspace view.
|
||
- Replacing the regular workspace switcher UX. The dashboard is reached by a
|
||
dedicated gesture/shortcut, not by being workspace N+1 in the existing
|
||
switcher.
|
||
|
||
---
|
||
|
||
## 2. Hard constraints (carried from chat)
|
||
|
||
| C# | Constraint | Resolution |
|
||
|---|---|---|
|
||
| C1 | Dashboard does **not** reuse any existing workspace | §5: append a new workspace at enable, mark it, remove at disable |
|
||
| C2 | Dashboard is **always rightmost** | §5: position invariant maintained on every workspace mutation |
|
||
| C3 | Regular windows cannot be **dragged into** dashboard | §6: layered defense on `window-added`, overview drop target, gesture handlers |
|
||
| C4 | Widgets in **TypeScript** | §9: SDK is a TS package; widgets are TS modules bundled to ESM |
|
||
| C5 | Avoid the GridSpaces lockout failure mode | §17: VM-only iteration loop; extension never touches `gnome-extensions enable` on host until VM-green |
|
||
|
||
---
|
||
|
||
## 3. Reality check (probed 2026-05-22 on ubuntu2504-test)
|
||
|
||
| Component | Version | Note |
|
||
|---|---|---|
|
||
| Ubuntu | 25.04 (Plucky) | |
|
||
| GNOME Shell | 48.0-1ubuntu1 | ESM extension format |
|
||
| Mutter | 48.1 (`libmutter-16-0`) | API gen 16, `Meta.WorkspaceManager` stable |
|
||
| gjs | 1.82.1 | SpiderMonkey 128 era — ESM, top-level await OK |
|
||
| WebKit2GTK 4.1 (GTK3) | 2.50.4 + `gir1.2-webkit2-4.1` pre-installed | **Chosen** for the container — zero new GIR deps |
|
||
| WebKitGTK 6.0 (GTK4) | 2.50.4 lib present, GIR not installed | Migration target post-v1 |
|
||
| Session | Wayland (gdm `Ubuntu on Wayland` default) | |
|
||
| Pre-existing ext | `ding`, `tiling-assistant`, `ubuntu-appindicators`, `ubuntu-dock` | tiling-assistant is the relevant precedent for window manipulation |
|
||
|
||
Implication: we can build v1 with only what's already on disk — no new apt
|
||
packages required. (Build chain is in dev only; not shipped onto the VM at
|
||
runtime.)
|
||
|
||
---
|
||
|
||
## 4. Architecture
|
||
|
||
Three processes, two IPC channels:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ gnome-shell (GJS, in-process) │
|
||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Dashward extension │ │
|
||
│ │ • WorkspaceWarden (create/destroy + position invariant) │ │
|
||
│ │ • WindowGuard (block window-added on dashboard ws) │ │
|
||
│ │ • EntryUX (gesture, shortcut, panel indicator) │ │
|
||
│ │ • ContainerSupervisor (spawn/respawn web container, pin window) │ │
|
||
│ │ • DBusService "top.hangmanlab.Dashward.Shell" │ │
|
||
│ └─────────┬────────────────────────────────────────────────────────┘ │
|
||
└────────────┼────────────────────────────────────────────────────────────┘
|
||
│ DBus session bus
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ dashward-container (GJS + WebKit2 4.1, separate process) │
|
||
│ • One GtkWindow, fullscreen, undecorated, app_id = container │
|
||
│ • WebKitWebView loads file://…/dashboard.html │
|
||
│ • DBus client: pushes shell events into the page via │
|
||
│ UserContentManager.evaluate_javascript() │
|
||
│ • UserContentManager script-message handlers receive page→shell calls │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
│ WebKit user-content-manager bridge
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ dashboard.html (WebKit content process, sandboxed by WebKit) │
|
||
│ • Runtime: dashboard-runtime.js (host shell, layout grid, edit mode) │
|
||
│ • SDK: @dashward/widget-sdk (TS, bundled in) │
|
||
│ • Widgets: each loaded into its own <iframe> for crash isolation │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Why the container is its own process**: GJS code crashing inside
|
||
`gnome-shell` kills the session. Container is forked under `Gio.Subprocess`;
|
||
when it crashes, supervisor sees the exit, hides the dashboard slot's
|
||
content, and respawns with backoff. Shell stays up.
|
||
|
||
**Why widgets are in `<iframe>`s and not just `<div>`s**: a widget that
|
||
loops, throws unhandled, or leaks memory only affects its iframe — the host
|
||
page and other widgets keep running. Iframes also give us a clean origin
|
||
boundary for widget asset paths.
|
||
|
||
---
|
||
|
||
## 5. Workspace lifecycle
|
||
|
||
### Enable
|
||
|
||
1. Snapshot user state:
|
||
- `org.gnome.mutter dynamic-workspaces` → save under
|
||
`~/.local/state/dashward/saved-settings.json`.
|
||
- `org.gnome.desktop.wm.preferences num-workspaces` → save.
|
||
- Current workspace count `N` (from `Meta.WorkspaceManager`).
|
||
2. Set `dynamic-workspaces = false` (we need a fixed slot at the end that
|
||
doesn't get GC'd when empty).
|
||
3. Set `num-workspaces = N + 1`.
|
||
4. Take the new last workspace (index `N`) as **the dashboard slot**. Tag
|
||
it: stash its `Meta.Workspace` reference under `wm._dashSpaceWs` and
|
||
record the workspace's index in `WorkspaceWarden.index`.
|
||
5. Spawn the container; pin its window to `wm._dashSpaceWs` (see §7).
|
||
6. Install signal handlers (§6).
|
||
|
||
### Steady state — invariants
|
||
|
||
After **every** workspace mutation (workspace appended/removed/reordered)
|
||
the warden re-checks:
|
||
|
||
- `WorkspaceWarden.ws` still exists in `WorkspaceManager.get_workspaces()`.
|
||
If not (something forcibly removed it), recreate at the end. Log.
|
||
- Its index equals `WorkspaceManager.get_n_workspaces() - 1`. If not, move
|
||
it to the end via `reorder_workspace(ws, N-1)`.
|
||
|
||
### Disable
|
||
|
||
1. Kill container.
|
||
2. Remove the dashboard workspace via
|
||
`WorkspaceManager.remove_workspace(ws, current_time)`.
|
||
3. Restore `num-workspaces` and `dynamic-workspaces` to snapshotted values.
|
||
4. Disconnect all signals; clear `wm._dashSpaceWs`.
|
||
|
||
### Edge cases
|
||
|
||
- **User changes `num-workspaces` externally** while extension is enabled:
|
||
warden treats this as the new "N", appends dashboard at N (effectively
|
||
`num-workspaces = newN + 1`). Saved setting is updated.
|
||
- **User logs out / shell restart (Wayland: only logout; X11: alt-F2 `r`)**:
|
||
`disable()` is called by Shell. If we somehow miss it (crash), a
|
||
best-effort cleanup runs on next `enable()`: detect workspace tagged with
|
||
our marker (`Meta.Workspace.get_work_area_all_monitors` is not enough;
|
||
we tag via a sticky window — see §7) and remove.
|
||
- **Other extensions also manage workspaces** (e.g. tiling-assistant
|
||
adjusts but doesn't add/remove; should be compatible. WorkspaceMatrix
|
||
would conflict — flag in README as incompatible).
|
||
|
||
---
|
||
|
||
## 6. Window-defense rules
|
||
|
||
### Layer 1: `window-added` bounce-back
|
||
|
||
```js
|
||
ws.connectObject('window-added', (_, window) => {
|
||
if (window === containerWindow) return; // whitelist
|
||
if (window.is_override_redirect()) return; // tooltip, menu, etc.
|
||
const fallback = priorWorkspaceFor(window);
|
||
window.change_workspace(fallback);
|
||
}, this);
|
||
```
|
||
|
||
`priorWorkspaceFor` heuristic: the window's `get_user_time()` workspace if
|
||
known, else workspace `N-1` (dashboard index minus 1), else workspace 0.
|
||
|
||
This single hook catches:
|
||
- new windows mapped to dashboard (`window-created` race: even if the
|
||
window initially picks dashboard via `_NET_WM_DESKTOP`, `window-added`
|
||
fires immediately when it joins);
|
||
- programmatic moves via `wmctrl -r ... -t N`, `xdotool windowmove`,
|
||
`Meta.Window.change_workspace`;
|
||
- keyboard `super+shift+→` on the rightmost workspace.
|
||
|
||
### Layer 2: overview drop refusal
|
||
|
||
In the activities overview, workspace thumbnails are drop targets via
|
||
`WorkspaceThumbnail` + `_acceptDrop`. We monkey-patch the dashboard
|
||
thumbnail's `acceptDrop` to return `false` (and the visual to show "no
|
||
drop" cursor). Cleaner than letting Layer 1 bounce it back after the visual
|
||
drop completed.
|
||
|
||
### Layer 3: hide from regular switcher
|
||
|
||
The dashboard workspace renders specially in the overview: instead of the
|
||
normal thumbnail, we draw a placeholder card with the Dashward icon and
|
||
"Dashboard" label. This is purely cosmetic (Layers 1+2 already enforce);
|
||
the goal is **user clarity** — they don't try to drag, because the slot
|
||
visibly isn't a regular workspace.
|
||
|
||
Implementation: override `WorkspaceThumbnail` for the specific workspace
|
||
via `WorkspacesView._workspaces[N-1]` patching, or simpler: hide it from
|
||
the thumbnail strip entirely. The dashboard is accessed via gesture /
|
||
shortcut (§14), not by clicking its thumbnail. **Default: hide from strip.**
|
||
|
||
### Layer 4: workspace switcher (Super+Page_Up/Down)
|
||
|
||
By default this cycles through `0..N`. We want Super+Page_Down on the last
|
||
real workspace to **not** land on dashboard. Implementation: hook
|
||
`Meta.KeyBindingAction.WORKSPACE_DOWN` and clamp target to `N-1`. (User
|
||
still gets to dashboard via dedicated gesture/shortcut.)
|
||
|
||
---
|
||
|
||
## 7. Web container process
|
||
|
||
### Window properties
|
||
|
||
- Wayland app_id: `top.hangmanlab.dashward.container` (set via
|
||
`GLib.set_prgname` + `Gtk.Window.set_default_icon_name` — for Wayland
|
||
app_id detection on the Shell side, we identify by
|
||
`Meta.Window.get_gtk_application_id()`).
|
||
- Title: `Dashward`.
|
||
- Decorations: none (`set_decorated(false)`).
|
||
- Fullscreen on the primary monitor.
|
||
- Type hint: `NORMAL` (not `DOCK`, since dock windows skip workspaces).
|
||
- Stickiness: **NOT sticky**; it must belong to the dashboard workspace
|
||
only.
|
||
|
||
### Lifetime managed by `ContainerSupervisor`
|
||
|
||
```
|
||
spawn() → wait for window-created signal → match by app_id →
|
||
move to dashboardWs (Layer 1 whitelist needed!) → record windowId →
|
||
WebKit ready → first eval: bootstrap dashboard-runtime.js
|
||
```
|
||
|
||
Crash recovery: restart with exponential backoff (1s, 2s, 5s, 10s, then
|
||
give up and surface error in panel indicator). The container's stderr
|
||
streams to `~/.local/state/dashward/container.log`.
|
||
|
||
### Visibility model
|
||
|
||
The container window **always exists** on the dashboard workspace; user
|
||
"opens dashboard" = switching to that workspace; "closes" = switching
|
||
away. No need to map/unmap the window per visit — Mutter culls it from
|
||
rendering when its workspace isn't active. This is the same model the
|
||
overview uses for inactive workspace windows.
|
||
|
||
**Exception**: on disable / shell-shutdown, container is gracefully asked
|
||
to quit via DBus, then SIGTERM after 2s.
|
||
|
||
---
|
||
|
||
## 8. IPC layers
|
||
|
||
### 8.1 Shell ↔ Container (DBus)
|
||
|
||
Bus name: `top.hangmanlab.Dashward.Shell` (owned by the extension).
|
||
Object path: `/top/hangmanlab/Dashward`. Interface methods:
|
||
|
||
| Method | Direction | Purpose |
|
||
|---|---|---|
|
||
| `Ready()` | container → shell | container finished bootstrap; safe to enter edit mode |
|
||
| `RequestEnterDashboard()` | container → shell | "switch to dashboard workspace" (used by `<a href>` clicks etc.) |
|
||
| `RequestExitDashboard()` | container → shell | "switch to previous workspace" |
|
||
| `GetSystemInfo() → a{sv}` | container → shell | brightness, battery, network, theme |
|
||
| `WidgetIPC(widgetId, method, args)` | container → shell | namespaced widget-to-shell calls; routed by SDK |
|
||
|
||
Signals (shell → container):
|
||
|
||
| Signal | Payload | Purpose |
|
||
|---|---|---|
|
||
| `ThemeChanged` | `s` (`"light"`/`"dark"`) | system theme toggle |
|
||
| `EditModeRequested` | `b` (enter/exit) | extension's panel button or keybind |
|
||
| `SystemInfoChanged` | `a{sv}` (partial) | battery/network/etc updates |
|
||
|
||
### 8.2 Container GJS ↔ WebKit page (UserContentManager)
|
||
|
||
GJS-side container exposes script-message handlers:
|
||
|
||
```js
|
||
ucm.register_script_message_handler('shellCall'); // page → GJS
|
||
ucm.register_script_message_handler('log'); // widget logs to GJS stderr
|
||
```
|
||
|
||
Page-side bridge:
|
||
|
||
```js
|
||
window.__dashShell__ = {
|
||
call(method, args) {
|
||
return new Promise((resolve, reject) => {
|
||
const id = nextId();
|
||
pending.set(id, { resolve, reject });
|
||
webkit.messageHandlers.shellCall.postMessage({ id, method, args });
|
||
});
|
||
}
|
||
};
|
||
```
|
||
|
||
Reverse (GJS → page): `webView.evaluate_javascript(...)`.
|
||
|
||
### 8.3 Page ↔ widget iframe
|
||
|
||
`window.postMessage` with the SDK's wrapper. SDK injects into each iframe a
|
||
preload script that exposes:
|
||
|
||
```ts
|
||
const dashboard = {
|
||
config: { get(): T, set(T): Promise<void>, onChange(cb) },
|
||
system: { onThemeChange(cb), getTheme(): 'light'|'dark', /* … */ },
|
||
lifecycle: { onMount(cb), onUnmount(cb), onVisibilityChange(cb) },
|
||
shell: { call(method, args): Promise<any> }, // gated by manifest permissions
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Widget SDK
|
||
|
||
Package: `@dashward/widget-sdk` (TypeScript, ESM, distributed as both
|
||
source-for-bundle and as a CDN-style local file the runtime can load).
|
||
|
||
### Authoring API
|
||
|
||
```ts
|
||
import { defineWidget } from '@dashward/widget-sdk';
|
||
|
||
interface ClockConfig { hour12: boolean; showSeconds: boolean }
|
||
|
||
export default defineWidget<ClockConfig>({
|
||
id: 'clock',
|
||
defaultConfig: { hour12: false, showSeconds: true },
|
||
|
||
mount(host, { config, system, lifecycle }) {
|
||
const root = host.attachShadow({ mode: 'open' });
|
||
root.innerHTML = `<div class="clock"></div>`;
|
||
const tick = () => { /* render */ };
|
||
|
||
const timer = setInterval(tick, 1000);
|
||
lifecycle.onUnmount(() => clearInterval(timer));
|
||
|
||
config.onChange(() => tick());
|
||
system.onThemeChange(t => root.host.dataset.theme = t);
|
||
},
|
||
|
||
configUI(host, { config }) {
|
||
// optional: render a settings panel for edit mode
|
||
},
|
||
});
|
||
```
|
||
|
||
Surfaces:
|
||
|
||
| API | Capability |
|
||
|---|---|
|
||
| `config.get/set/onChange` | persistent JSON config; auto-saved to `~/.local/share/dashward/widgets/<id>/config.json` |
|
||
| `system.getTheme/onThemeChange` | follow Shell theme |
|
||
| `system.getBattery/getNetwork/getBrightness` | shell DBus relay (requires manifest permission) |
|
||
| `lifecycle.onMount/onUnmount/onVisibilityChange` | called by host when widget appears/disappears |
|
||
| `shell.call(method, args)` | escape hatch to namespaced shell DBus calls (requires permission) |
|
||
|
||
What's **not** exposed: file system, network sockets, arbitrary
|
||
`Gio`/`Meta` access. Widgets that need that file an upstream issue.
|
||
|
||
---
|
||
|
||
## 10. Widget manifest
|
||
|
||
`widget.json` at the widget root:
|
||
|
||
```json
|
||
{
|
||
"id": "clock",
|
||
"name": "Clock",
|
||
"version": "0.1.0",
|
||
"entry": "dist/index.js",
|
||
"icon": "icon.svg",
|
||
"size": { "w": 2, "h": 2, "minW": 1, "minH": 1, "maxW": 4, "maxH": 4 },
|
||
"permissions": ["timer"],
|
||
"configSchema": { "hour12": "boolean", "showSeconds": "boolean" }
|
||
}
|
||
```
|
||
|
||
Permissions vocabulary v1: `timer`, `battery`, `network`, `brightness`,
|
||
`notifications`. Permission grant is currently implicit (manifest declares
|
||
it, runtime allows it); a user-visible grant UI is v2.
|
||
|
||
Widget directory layout:
|
||
|
||
```
|
||
widgets/clock/
|
||
├── widget.json
|
||
├── src/
|
||
│ └── index.ts
|
||
├── dist/
|
||
│ └── index.js # esbuild-bundled, ESM, references SDK as bare import
|
||
└── icon.svg
|
||
```
|
||
|
||
Install location at runtime:
|
||
|
||
```
|
||
~/.local/share/dashward/widgets/
|
||
├── clock/ # user's widgets (drop a dir here, runtime sees it)
|
||
├── weather/
|
||
└── …
|
||
```
|
||
|
||
Plus a built-in pack shipped with the extension at
|
||
`extension/widgets-builtin/` (clock, calendar, system-stats, notes).
|
||
|
||
---
|
||
|
||
## 11. Layout & edit mode
|
||
|
||
### Grid
|
||
|
||
- 12 columns wide; rows grow vertically as needed.
|
||
- Cell aspect ratio: 1:1 at the canonical size of `floor(viewportWidth /
|
||
12)` px. So a widget with `size: { w: 3, h: 2 }` occupies 3 cols × 2
|
||
rows = 3w × 2h grid cells, naturally responsive.
|
||
- Widgets snap to grid; no free positioning in v1.
|
||
|
||
### Storage
|
||
|
||
User layout in `~/.local/share/dashward/layout.json`:
|
||
|
||
```json
|
||
{
|
||
"widgets": [
|
||
{ "instanceId": "clock-1", "widgetId": "clock", "x": 0, "y": 0, "w": 2, "h": 2 },
|
||
{ "instanceId": "weather-1", "widgetId": "weather", "x": 2, "y": 0, "w": 3, "h": 2 }
|
||
]
|
||
}
|
||
```
|
||
|
||
Multiple instances of the same widget are allowed (hence `instanceId` vs
|
||
`widgetId`); each has its own config file scoped by `instanceId`.
|
||
|
||
### Edit mode
|
||
|
||
Triggered by:
|
||
- Top-edge floating button (visible only when dashboard is active).
|
||
- Keyboard `e` while on dashboard.
|
||
- Long-press on any widget.
|
||
|
||
In edit mode: widgets show resize handles + delete X; a "+" tray slides
|
||
in from the right with available widgets; drag to add. Exit on `Esc` or
|
||
clicking outside.
|
||
|
||
Out of edit mode: widgets are passive; no drag/resize.
|
||
|
||
---
|
||
|
||
## 12. Theming
|
||
|
||
- Container reads `org.gnome.desktop.interface color-scheme` at startup.
|
||
- Shell subscribes to GSettings changes; pushes `ThemeChanged` DBus
|
||
signal to container; container does `document.documentElement.dataset.
|
||
theme = 'dark' | 'light'`.
|
||
- SDK exposes `system.onThemeChange(cb)`; widgets style via CSS
|
||
`[data-theme="dark"]` attribute selectors (no JS theming, CSS does it).
|
||
- Background is a configurable color + optional wallpaper image (defaults
|
||
to a desaturated copy of the user's desktop wallpaper).
|
||
|
||
---
|
||
|
||
## 13. Entry / exit UX
|
||
|
||
Three ways in:
|
||
|
||
| Path | Default binding | Implementation |
|
||
|---|---|---|
|
||
| Gesture | 4-finger swipe right past last workspace | hook `Shell.WorkspacesView._swipeTracker`; on `end` with target > N-1, jump to dashboard |
|
||
| Keyboard | `Super+Grave` | `Meta.KeyBinding` via Shell.ActionMode.NORMAL |
|
||
| Panel button | left-click Dashward indicator | `PanelMenu.Button` with custom icon |
|
||
|
||
All three call the same `EntryUX.openDashboard()` which animates the
|
||
workspace switch via existing Shell APIs.
|
||
|
||
Exit: same gesture/shortcut/button toggles. Plus `Esc` while on dashboard
|
||
(but only if no widget has focus capturing it).
|
||
|
||
---
|
||
|
||
## 14. Persistence & permissions
|
||
|
||
Filesystem layout (all under `XDG_*` standards):
|
||
|
||
```
|
||
~/.local/share/dashward/
|
||
├── layout.json # grid layout
|
||
├── widgets/<instanceId>/config.json # per-instance config
|
||
└── widgets-installed/<widgetId>/ # user-installed widget bundles
|
||
~/.local/state/dashward/
|
||
├── saved-settings.json # gsettings snapshot for restore
|
||
├── container.log
|
||
└── extension.log
|
||
~/.cache/dashward/ # WebKit cache, regenerable
|
||
```
|
||
|
||
Nothing else outside the extension's install directory is touched.
|
||
|
||
Permissions today: declared in manifest, allowed by runtime. v2: a
|
||
first-run prompt per widget; revocable from edit mode.
|
||
|
||
---
|
||
|
||
## 15. Directory layout (the repo)
|
||
|
||
```
|
||
Dashward/
|
||
├── extension/ # GNOME Shell extension
|
||
│ ├── metadata.json # GNOME-mandated, shell-version: ["48"]
|
||
│ ├── src/
|
||
│ │ ├── extension.ts # entry, exports default class
|
||
│ │ ├── workspace-warden.ts
|
||
│ │ ├── window-guard.ts
|
||
│ │ ├── container-supervisor.ts
|
||
│ │ ├── entry-ux.ts
|
||
│ │ ├── dbus-service.ts
|
||
│ │ └── prefs.ts # GNOME Extension prefs window
|
||
│ ├── dist/ # esbuild output, single extension.js
|
||
│ ├── widgets-builtin/ # ships with extension
|
||
│ │ ├── clock/
|
||
│ │ ├── calendar/
|
||
│ │ ├── system-stats/
|
||
│ │ └── notes/
|
||
│ └── tsconfig.json
|
||
│
|
||
├── container/ # WebKit kiosk process
|
||
│ ├── src/
|
||
│ │ ├── main.ts # GJS bootstrap
|
||
│ │ ├── window.ts # GtkWindow + WebKitWebView
|
||
│ │ ├── dbus-client.ts
|
||
│ │ └── bridge.ts # UserContentManager wiring
|
||
│ ├── runtime/ # injected into the page
|
||
│ │ ├── runtime.ts # grid host, edit mode, widget loader
|
||
│ │ ├── dashboard.html
|
||
│ │ └── styles.css
|
||
│ └── dist/
|
||
│
|
||
├── sdk/ # @dashward/widget-sdk
|
||
│ ├── src/
|
||
│ │ ├── index.ts
|
||
│ │ ├── define-widget.ts
|
||
│ │ ├── config.ts
|
||
│ │ ├── system.ts
|
||
│ │ └── types.ts
|
||
│ ├── package.json
|
||
│ └── tsconfig.json
|
||
│
|
||
├── widgets/ # example/3rd-party widgets dev tree
|
||
│ └── … # each is a self-contained TS project
|
||
│
|
||
├── proto/ # DBus xml + shared TS types
|
||
│ ├── shell.iface.xml
|
||
│ └── types.ts
|
||
│
|
||
├── scripts/
|
||
│ ├── dev-vm.sh # rsync to VM + reload extension
|
||
│ ├── package.sh # produces .shell-extension.zip
|
||
│ └── lint.sh
|
||
│
|
||
└── README.md
|
||
```
|
||
|
||
---
|
||
|
||
## 16. Build chain
|
||
|
||
Single root `package.json` + pnpm workspaces (`sdk`, `extension`,
|
||
`container`, `widgets/*`).
|
||
|
||
| Subproject | Bundler | Output |
|
||
|---|---|---|
|
||
| `sdk` | tsc | ESM, types, published as workspace dep |
|
||
| `extension` | esbuild | single `dist/extension.js` (GNOME loads one file) |
|
||
| `container` | esbuild | single `dist/main.js` (GJS-friendly ESM) |
|
||
| each widget | esbuild | `dist/index.js`, SDK marked external (loaded by runtime) |
|
||
|
||
GJS specifics (carried from your [[project_fabric_esm]] habit):
|
||
- `"module": "NodeNext"` in tsconfig; explicit `.js` import suffixes; default-import any CJS dep.
|
||
- `Gio` / `Meta` etc. imported via `gi://` URLs.
|
||
- Type stubs: `@girs/gnome-shell`, `@girs/meta-16`, `@girs/gtk-3.0`,
|
||
`@girs/webkit2-4.1`.
|
||
|
||
Dev cycle:
|
||
|
||
```bash
|
||
pnpm dev # watches src/, rebuilds extension+container+widgets
|
||
pnpm vm:sync # rsync dist → VM, restart shell (or just kill container)
|
||
```
|
||
|
||
Inside the VM, a small helper script (`~/dashward-dev/reload.sh`):
|
||
- Wayland: re-enable extension via `gnome-extensions disable && enable`
|
||
(a full Shell restart on Wayland requires logout — see §17).
|
||
- Container-only changes: `pkill dashward-container`; supervisor
|
||
respawns.
|
||
|
||
---
|
||
|
||
## 17. Implementation phases
|
||
|
||
| Phase | Deliverable | Acceptance |
|
||
|---|---|---|
|
||
| P0 | Repo skeleton, build chain, hello-world extension that logs on enable | extension shows in `gnome-extensions list` on VM |
|
||
| P1 | WorkspaceWarden in isolation: appends/removes ws, position invariant | manual: enable → see N+1 workspaces; disable → back to N; mutations preserved |
|
||
| P2 | WindowGuard | manual: try `wmctrl -r foo -t <dashboard>`, `super+shift+→`, overview drag — all bounce |
|
||
| P3 | Container spawn + WebKit window + pin to dashboard | container window appears on dashboard ws only |
|
||
| P4 | DBus + WebKit bridge round-trip | `dashboard.system.getTheme()` returns shell-side value |
|
||
| P5 | Runtime: grid host, layout.json load/save, one builtin widget (clock) | clock renders, persists across enable cycles |
|
||
| P6 | SDK + 3 more builtins (calendar, system-stats, notes) | each widget works in isolation; iframe crash containment verified |
|
||
| P7 | Edit mode (add/remove/resize/rearrange) | layout edits persist; instances scoped configs |
|
||
| P8 | Entry UX (gesture, shortcut, panel button) | all three paths open dashboard |
|
||
| P9 | Polish: theming, animations, error surfaces | passes a manual checklist |
|
||
| P10 | Stretch: WebKit 6.0 migration, multi-monitor, widget marketplace stub | post-v1 |
|
||
|
||
**Critical**: P1, P2, P3 all run **only on the VM** ([[project_gridspaces_test_vm]]). The host's GNOME Shell never sees the extension until P9 and only via `gnome-extensions install` from a packaged zip — never via a `~/.local/share/gnome-shell/extensions/` symlink, which would survive logout and lock us out as in [[project_gridspaces_lockout]].
|
||
|
||
---
|
||
|
||
## 18. Risks & open questions
|
||
|
||
### Risks
|
||
|
||
| R# | Risk | Mitigation |
|
||
|---|---|---|
|
||
| R1 | Wayland shell restart requires logout — slow iteration | Container hot-reload covers 80%; for extension changes, accept the logout cost; consider a nested Wayland session (`mutter --nested`) for ultra-fast iteration in P5+ |
|
||
| R2 | `dynamic-workspaces = false` is intrusive — user may rely on dynamic behavior | Restore on disable; document in README; consider an "advanced mode" later that keeps dynamic mode and races the GC, but **not v1** |
|
||
| R3 | Overview shows the dashboard slot oddly when hidden from strip | Spend a P9 day on the cosmetic; if upstream `WorkspacesView` proves fragile across GNOME minor versions, fall back to "show the slot with a placeholder thumbnail" |
|
||
| R4 | WebKitGTK 4.1 (GTK3) is older — long-term deprecation risk | Migration path: swap container to GTK4 + WebKitGTK 6.0 (install `gir1.2-webkit-6.0`). API surface is similar; isolated to `container/`, won't touch SDK or widgets |
|
||
| R5 | Widget IPC permissions are honor-system in v1 | Acceptable for personal-use v1; v2 adds first-run prompt |
|
||
| R6 | tiling-assistant or other extensions interact with workspace count | Document incompatibility list in README; test against default Ubuntu 25.04 extension set on VM during P1 |
|
||
| R7 | A misbehaving extension load could crash shell on enable | Wrap entire `enable()` in try/catch; on first-throw, write error to `~/.local/state/dashward/extension.log` and bail rather than partial-init |
|
||
|
||
### Open questions
|
||
|
||
| Q# | Question | Status |
|
||
|---|---|---|
|
||
| Q1 | Project name | **Decided 2026-05-22: `Dashward`** |
|
||
| Q2 | Where does the repo live? | **Decided 2026-05-22: local `~/dev/Dashward/`; remote = `https://git.hangman-lab.top/hzhang/Dashward.git` (Gitea on server.t0)** |
|
||
| Q3 | Should widgets be able to span the entire dashboard (full-bleed)? | Open — no in v1; revisit |
|
||
| Q4 | Multi-monitor: dashboard on primary only, or per-monitor? | Open — primary only in v1 |
|
||
| Q5 | Should `Super+Grave` clash check happen at install or runtime? | Open — runtime check on `enable()`; if taken, fall back to no shortcut and warn |
|
||
| Q6 | Widget config schema validation (Zod? JSON Schema? none?) | Open — none in v1; SDK trusts the widget's TS types |
|
||
| Q7 | Do we ship a widget devtools page (live reload of widget under dev)? | Open — P10 stretch |
|
||
|
||
---
|
||
|
||
## 19. Naming, hosting & next actions
|
||
|
||
**Codename:** Dashward (decided 2026-05-22).
|
||
|
||
**Local working tree:** `~/dev/Dashward/`. Not under HangmanLab — Dashward is a personal tool, orthogonal to the lab estate.
|
||
|
||
**Remote:** Gitea on `server.t0` at `https://git.hangman-lab.top/hzhang/Dashward.git`. Created when the P0 skeleton is ready to push. Per [[project-server-topology]] t0 hosts Git + auth; per [[project-local-sim-env]] the local-sim hijack of `*.hangman-lab.top` is **opt-in** (set up by `setup-sim.sh`) — when the hijack is off (default state), `git.hangman-lab.top` resolves to prod, which is what we want for pushing Dashward.
|
||
|
||
Next concrete step:
|
||
|
||
1. ✅ Q1, Q2 decided.
|
||
2. Create the repo skeleton on disk (§15) — empty files, working `pnpm install`, no logic.
|
||
3. Create the Gitea repo (via Gitea API or web UI) and `git push -u origin main`.
|
||
4. P0 hello-world extension on the VM, prove the dev loop end-to-end.
|
||
5. Begin P1.
|
||
|
||
See [[project-gridspaces-lockout]] and [[project-gridspaces-test-vm]] for the testing discipline this project inherits.
|