chore: P0 skeleton
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>
This commit is contained in:
683
UBUNTU-DASHBOARD-SPACE.md
Normal file
683
UBUNTU-DASHBOARD-SPACE.md
Normal file
@@ -0,0 +1,683 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user