@sirenodeck/sirenodeck 0.1.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/bin/_wrapper-shared.js +42 -0
- package/bin/dev.js +95 -0
- package/bin/sirenodeck.js +42 -0
- package/config/default-config.yml +117 -0
- package/config/starter-config.yml +54 -0
- package/dist/main.mjs +64702 -0
- package/package.json +140 -0
- package/src/action/executor.ts +156 -0
- package/src/action/index.ts +7 -0
- package/src/addon/api-types.ts +15 -0
- package/src/addon/api.ts +461 -0
- package/src/addon/check-runner.ts +84 -0
- package/src/addon/index.ts +5 -0
- package/src/addon/loader.ts +441 -0
- package/src/addon/registry.ts +230 -0
- package/src/addon/spec.ts +69 -0
- package/src/api/addon.ts +33 -0
- package/src/api/asset-url.ts +24 -0
- package/src/api/index.ts +50 -0
- package/src/api/protocol-internal.ts +656 -0
- package/src/api/protocol.ts +2 -0
- package/src/api/react/index.ts +11 -0
- package/src/api/react/registry.ts +91 -0
- package/src/api/react/use-addon-channel.ts +26 -0
- package/src/api/react/use-button-state.tsx +23 -0
- package/src/api/react/use-deck.ts +24 -0
- package/src/assets/logo.png +0 -0
- package/src/assets/logo72x72.png +0 -0
- package/src/assets/logoFull.png +0 -0
- package/src/builtin-addons/brightness/README.md +51 -0
- package/src/builtin-addons/brightness/buttons/brightness/backend.ts +13 -0
- package/src/builtin-addons/brightness/buttons/brightness/config.ts +10 -0
- package/src/builtin-addons/brightness/buttons/brightness/frontend.tsx +50 -0
- package/src/builtin-addons/brightness/domain/macos.ts +46 -0
- package/src/builtin-addons/brightness/index.d.ts +5 -0
- package/src/builtin-addons/brightness/index.ts +20 -0
- package/src/builtin-addons/brightness/poller.ts +27 -0
- package/src/builtin-addons/brightness/providers/darwin.ts +107 -0
- package/src/builtin-addons/brightness/providers/index.ts +81 -0
- package/src/builtin-addons/brightness/providers/linux.ts +108 -0
- package/src/builtin-addons/brightness/providers/windows.ts +77 -0
- package/src/builtin-addons/brightness/sirenodeck.json +6 -0
- package/src/builtin-addons/coding-agents/assets/claude-code.svg +1 -0
- package/src/builtin-addons/coding-agents/assets/opencode-dark-square.svg +1 -0
- package/src/builtin-addons/coding-agents/buttons/agent/backend.ts +82 -0
- package/src/builtin-addons/coding-agents/buttons/agent/config.ts +13 -0
- package/src/builtin-addons/coding-agents/buttons/agent/frontend.tsx +130 -0
- package/src/builtin-addons/coding-agents/buttons/agent/pages.tsx +150 -0
- package/src/builtin-addons/coding-agents/buttons/summary/backend.ts +48 -0
- package/src/builtin-addons/coding-agents/buttons/summary/config.ts +11 -0
- package/src/builtin-addons/coding-agents/buttons/summary/frontend.tsx +254 -0
- package/src/builtin-addons/coding-agents/decks/agents.ts +69 -0
- package/src/builtin-addons/coding-agents/global/backend.ts +402 -0
- package/src/builtin-addons/coding-agents/global-entry.ts +7 -0
- package/src/builtin-addons/coding-agents/hook/script.ts +123 -0
- package/src/builtin-addons/coding-agents/index.ts +15 -0
- package/src/builtin-addons/coding-agents/manifest.ts +115 -0
- package/src/builtin-addons/coding-agents/providers/claude-code.ts +470 -0
- package/src/builtin-addons/coding-agents/providers/claude-instances.ts +184 -0
- package/src/builtin-addons/coding-agents/providers/opencode-instances.ts +108 -0
- package/src/builtin-addons/coding-agents/providers/opencode.ts +636 -0
- package/src/builtin-addons/coding-agents/providers/registry.ts +120 -0
- package/src/builtin-addons/coding-agents/providers/spawn.ts +112 -0
- package/src/builtin-addons/coding-agents/shared/claude-hook-events.ts +55 -0
- package/src/builtin-addons/coding-agents/shared/claude-status.ts +236 -0
- package/src/builtin-addons/coding-agents/shared/live-count.ts +39 -0
- package/src/builtin-addons/coding-agents/shared/notifier.ts +58 -0
- package/src/builtin-addons/coding-agents/shared/opencode-status.ts +82 -0
- package/src/builtin-addons/coding-agents/shared/snapshot.ts +66 -0
- package/src/builtin-addons/coding-agents/shared/state.ts +72 -0
- package/src/builtin-addons/coding-agents/sirenodeck.json +6 -0
- package/src/builtin-addons/coding-agents/types/types.ts +33 -0
- package/src/builtin-addons/core/README.md +89 -0
- package/src/builtin-addons/core/buttons/action/backend.ts +7 -0
- package/src/builtin-addons/core/buttons/action/config.ts +13 -0
- package/src/builtin-addons/core/buttons/action/frontend.tsx +16 -0
- package/src/builtin-addons/core/buttons/change-deck/backend.ts +18 -0
- package/src/builtin-addons/core/buttons/change-deck/config.ts +15 -0
- package/src/builtin-addons/core/buttons/change-deck/frontend.tsx +18 -0
- package/src/builtin-addons/core/buttons/media-sample/backend.ts +8 -0
- package/src/builtin-addons/core/buttons/media-sample/config.ts +7 -0
- package/src/builtin-addons/core/buttons/media-sample/frontend.tsx +2 -0
- package/src/builtin-addons/core/buttons/page-nav/backend.ts +28 -0
- package/src/builtin-addons/core/buttons/page-nav/config.ts +9 -0
- package/src/builtin-addons/core/buttons/page-nav/frontend.tsx +68 -0
- package/src/builtin-addons/core/buttons/toggle/backend.ts +119 -0
- package/src/builtin-addons/core/buttons/toggle/config.ts +57 -0
- package/src/builtin-addons/core/buttons/toggle/frontend.tsx +57 -0
- package/src/builtin-addons/core/buttons/toggle/global-service.ts +174 -0
- package/src/builtin-addons/core/index.ts +120 -0
- package/src/builtin-addons/core/sirenodeck.json +6 -0
- package/src/builtin-addons/date-time/README.md +73 -0
- package/src/builtin-addons/date-time/buttons/analog-clock/backend.ts +10 -0
- package/src/builtin-addons/date-time/buttons/analog-clock/config.ts +4 -0
- package/src/builtin-addons/date-time/buttons/analog-clock/frontend.tsx +88 -0
- package/src/builtin-addons/date-time/buttons/custom/backend.ts +10 -0
- package/src/builtin-addons/date-time/buttons/custom/config.ts +9 -0
- package/src/builtin-addons/date-time/buttons/custom/frontend.tsx +26 -0
- package/src/builtin-addons/date-time/buttons/date/backend.ts +10 -0
- package/src/builtin-addons/date-time/buttons/date/config.ts +10 -0
- package/src/builtin-addons/date-time/buttons/date/frontend.tsx +42 -0
- package/src/builtin-addons/date-time/buttons/time/backend.ts +10 -0
- package/src/builtin-addons/date-time/buttons/time/config.ts +9 -0
- package/src/builtin-addons/date-time/buttons/time/frontend.tsx +29 -0
- package/src/builtin-addons/date-time/index.d.ts +5 -0
- package/src/builtin-addons/date-time/index.ts +30 -0
- package/src/builtin-addons/date-time/poller.ts +11 -0
- package/src/builtin-addons/date-time/shared/format-date.ts +36 -0
- package/src/builtin-addons/date-time/shared/format.ts +105 -0
- package/src/builtin-addons/date-time/shared/use-now.ts +10 -0
- package/src/builtin-addons/date-time/sirenodeck.json +6 -0
- package/src/builtin-addons/emoji-selector/README.md +45 -0
- package/src/builtin-addons/emoji-selector/assets/launcher.svg +10 -0
- package/src/builtin-addons/emoji-selector/assets/smileys.svg +8 -0
- package/src/builtin-addons/emoji-selector/buttons/category/backend.ts +14 -0
- package/src/builtin-addons/emoji-selector/buttons/category/config.ts +8 -0
- package/src/builtin-addons/emoji-selector/buttons/category/frontend.tsx +19 -0
- package/src/builtin-addons/emoji-selector/buttons/emoji/backend.ts +7 -0
- package/src/builtin-addons/emoji-selector/buttons/emoji/config.ts +9 -0
- package/src/builtin-addons/emoji-selector/buttons/emoji/frontend.tsx +11 -0
- package/src/builtin-addons/emoji-selector/buttons/launcher/backend.ts +13 -0
- package/src/builtin-addons/emoji-selector/buttons/launcher/config.ts +6 -0
- package/src/builtin-addons/emoji-selector/buttons/launcher/frontend.tsx +31 -0
- package/src/builtin-addons/emoji-selector/data/categories.ts +1084 -0
- package/src/builtin-addons/emoji-selector/decks/index.ts +137 -0
- package/src/builtin-addons/emoji-selector/index.d.ts +5 -0
- package/src/builtin-addons/emoji-selector/index.ts +29 -0
- package/src/builtin-addons/emoji-selector/sirenodeck.json +6 -0
- package/src/builtin-addons/emoji-selector/support.ts +49 -0
- package/src/builtin-addons/index.ts +13 -0
- package/src/builtin-addons/internal-settings/README.md +26 -0
- package/src/builtin-addons/internal-settings/backend.ts +58 -0
- package/src/builtin-addons/internal-settings/buttons/app-info/backend.ts +7 -0
- package/src/builtin-addons/internal-settings/buttons/app-info/config.ts +13 -0
- package/src/builtin-addons/internal-settings/buttons/app-info/frontend.tsx +18 -0
- package/src/builtin-addons/internal-settings/buttons/brightness/common/frontend.tsx +50 -0
- package/src/builtin-addons/internal-settings/buttons/brightness-down/backend.ts +16 -0
- package/src/builtin-addons/internal-settings/buttons/brightness-down/config.ts +6 -0
- package/src/builtin-addons/internal-settings/buttons/brightness-down/frontend.tsx +8 -0
- package/src/builtin-addons/internal-settings/buttons/brightness-up/backend.ts +16 -0
- package/src/builtin-addons/internal-settings/buttons/brightness-up/config.ts +6 -0
- package/src/builtin-addons/internal-settings/buttons/brightness-up/frontend.tsx +8 -0
- package/src/builtin-addons/internal-settings/decks/settings.ts +25 -0
- package/src/builtin-addons/internal-settings/index.d.ts +4 -0
- package/src/builtin-addons/internal-settings/index.ts +53 -0
- package/src/builtin-addons/internal-settings/logo72x72.png +0 -0
- package/src/builtin-addons/internal-settings/sirenodeck.json +6 -0
- package/src/builtin-addons/media/README.md +45 -0
- package/src/builtin-addons/media/backend.ts +143 -0
- package/src/builtin-addons/media/buttons/media-player/backend.ts +19 -0
- package/src/builtin-addons/media/buttons/media-player/components/MediaStatus.tsx +25 -0
- package/src/builtin-addons/media/buttons/media-player/components/MediaSurface.tsx +52 -0
- package/src/builtin-addons/media/buttons/media-player/components/ProgressBar.tsx +21 -0
- package/src/builtin-addons/media/buttons/media-player/components/status-meta.ts +36 -0
- package/src/builtin-addons/media/buttons/media-player/config.ts +4 -0
- package/src/builtin-addons/media/buttons/media-player/frontend.tsx +29 -0
- package/src/builtin-addons/media/buttons/volume/common/frontend.tsx +52 -0
- package/src/builtin-addons/media/buttons/volume/mute/backend.ts +13 -0
- package/src/builtin-addons/media/buttons/volume/mute/config.ts +4 -0
- package/src/builtin-addons/media/buttons/volume/mute/frontend.tsx +26 -0
- package/src/builtin-addons/media/buttons/volume/volume-down/backend.ts +14 -0
- package/src/builtin-addons/media/buttons/volume/volume-down/config.ts +6 -0
- package/src/builtin-addons/media/buttons/volume/volume-down/frontend.tsx +10 -0
- package/src/builtin-addons/media/buttons/volume/volume-up/backend.ts +14 -0
- package/src/builtin-addons/media/buttons/volume/volume-up/config.ts +6 -0
- package/src/builtin-addons/media/buttons/volume/volume-up/frontend.tsx +8 -0
- package/src/builtin-addons/media/checks.ts +104 -0
- package/src/builtin-addons/media/executor.ts +36 -0
- package/src/builtin-addons/media/index.d.ts +5 -0
- package/src/builtin-addons/media/index.ts +53 -0
- package/src/builtin-addons/media/progress.ts +16 -0
- package/src/builtin-addons/media/providers/darwin.ts +391 -0
- package/src/builtin-addons/media/providers/index.ts +27 -0
- package/src/builtin-addons/media/providers/linux.ts +196 -0
- package/src/builtin-addons/media/providers/types.ts +48 -0
- package/src/builtin-addons/media/providers/windows.ts +121 -0
- package/src/builtin-addons/media/sirenodeck.json +6 -0
- package/src/builtin-addons/media/state.ts +19 -0
- package/src/builtin-addons/register-builtins.ts +65 -0
- package/src/builtin-addons/session/README.md +14 -0
- package/src/builtin-addons/session/buttons/session-info/backend.ts +8 -0
- package/src/builtin-addons/session/buttons/session-info/config.ts +4 -0
- package/src/builtin-addons/session/buttons/session-info/frontend.tsx +2 -0
- package/src/builtin-addons/session/buttons/time/backend.ts +7 -0
- package/src/builtin-addons/session/buttons/time/config.ts +6 -0
- package/src/builtin-addons/session/buttons/time/frontend.tsx +2 -0
- package/src/builtin-addons/session/index.d.ts +5 -0
- package/src/builtin-addons/session/index.ts +18 -0
- package/src/builtin-addons/session/sirenodeck.json +6 -0
- package/src/builtin-addons/system-status/README.md +169 -0
- package/src/builtin-addons/system-status/buttons/_shared.ts +131 -0
- package/src/builtin-addons/system-status/buttons/system-status/frontend.tsx +224 -0
- package/src/builtin-addons/system-status/buttons/system-status/schemas.ts +67 -0
- package/src/builtin-addons/system-status/domain/catalog.ts +53 -0
- package/src/builtin-addons/system-status/domain/chart-sampler.ts +58 -0
- package/src/builtin-addons/system-status/domain/display-metrics.ts +128 -0
- package/src/builtin-addons/system-status/domain/index.ts +10 -0
- package/src/builtin-addons/system-status/domain/live-metrics.ts +1021 -0
- package/src/builtin-addons/system-status/domain/metric-ids.ts +0 -0
- package/src/builtin-addons/system-status/index.ts +16 -0
- package/src/builtin-addons/system-status/manifest.ts +105 -0
- package/src/builtin-addons/system-status/shared/metrics-catalog.ts +316 -0
- package/src/builtin-addons/system-status/sirenodeck.json +6 -0
- package/src/builtin-addons/test-buildin/decks/action-buttons-test.ts +56 -0
- package/src/builtin-addons/test-buildin/index.d.ts +5 -0
- package/src/builtin-addons/test-buildin/index.ts +16 -0
- package/src/builtin-addons/value-display/README.md +59 -0
- package/src/builtin-addons/value-display/backend.ts +86 -0
- package/src/builtin-addons/value-display/buttons/value-display/backend.ts +17 -0
- package/src/builtin-addons/value-display/buttons/value-display/config.ts +36 -0
- package/src/builtin-addons/value-display/buttons/value-display/frontend.tsx +54 -0
- package/src/builtin-addons/value-display/index.d.ts +5 -0
- package/src/builtin-addons/value-display/index.ts +23 -0
- package/src/builtin-addons/value-display/sirenodeck.json +6 -0
- package/src/builtin-addons/weather/README.md +46 -0
- package/src/builtin-addons/weather/backend.ts +121 -0
- package/src/builtin-addons/weather/buttons/weather/backend.ts +27 -0
- package/src/builtin-addons/weather/buttons/weather/config.ts +29 -0
- package/src/builtin-addons/weather/buttons/weather/frontend.tsx +63 -0
- package/src/builtin-addons/weather/buttons/weather/icons.tsx +88 -0
- package/src/builtin-addons/weather/buttons/weather/pages.tsx +109 -0
- package/src/builtin-addons/weather/domain/codes.ts +35 -0
- package/src/builtin-addons/weather/domain/fetch.ts +47 -0
- package/src/builtin-addons/weather/index.d.ts +5 -0
- package/src/builtin-addons/weather/index.ts +23 -0
- package/src/builtin-addons/weather/poller.ts +70 -0
- package/src/builtin-addons/weather/provider/city-key.ts +9 -0
- package/src/builtin-addons/weather/provider/codes.ts +35 -0
- package/src/builtin-addons/weather/provider/fetch.ts +97 -0
- package/src/builtin-addons/weather/provider/geocode.ts +67 -0
- package/src/builtin-addons/weather/provider/types.ts +42 -0
- package/src/builtin-addons/weather/sirenodeck.json +6 -0
- package/src/cli/coding-agents-onboarding.ts +434 -0
- package/src/cli/commands/addon-decks.ts +441 -0
- package/src/cli/commands/addon-registry.ts +639 -0
- package/src/cli/commands/emulator-mode.ts +289 -0
- package/src/cli/commands/install.ts +128 -0
- package/src/cli/commands/logs.ts +74 -0
- package/src/cli/commands/network-bind.ts +111 -0
- package/src/cli/commands/pipeline/helpers.ts +75 -0
- package/src/cli/commands/pipeline/preflight.ts +20 -0
- package/src/cli/commands/port-identity.ts +152 -0
- package/src/cli/commands/reload.ts +37 -0
- package/src/cli/commands/restart.ts +139 -0
- package/src/cli/commands/run.ts +2270 -0
- package/src/cli/commands/service/install.ts +121 -0
- package/src/cli/commands/service/render-template.ts +158 -0
- package/src/cli/commands/service-manager.ts +184 -0
- package/src/cli/commands/spawn-daemon.ts +251 -0
- package/src/cli/commands/start.ts +1061 -0
- package/src/cli/commands/status.ts +110 -0
- package/src/cli/commands/stop.ts +121 -0
- package/src/cli/commands/subprocess-supervisor.ts +203 -0
- package/src/cli/commands/system-requirements.ts +699 -0
- package/src/cli/cwd.ts +28 -0
- package/src/cli/first-run.ts +149 -0
- package/src/cli/http-server.ts +244 -0
- package/src/cli/index.ts +355 -0
- package/src/cli/install-root.ts +26 -0
- package/src/cli/main.ts +194 -0
- package/src/cli/package-manager.ts +175 -0
- package/src/cli/probe-deps.ts +45 -0
- package/src/cli/prompt.ts +22 -0
- package/src/cli/startup-display.ts +452 -0
- package/src/config/builtin-icons.ts +57 -0
- package/src/config/config-diff.ts +54 -0
- package/src/config/discovery.ts +64 -0
- package/src/config/icon-resolver.ts +90 -0
- package/src/config/include-resolver.ts +202 -0
- package/src/config/index.ts +9 -0
- package/src/config/loader.ts +187 -0
- package/src/config/mutation.ts +514 -0
- package/src/config/reference-expander.ts +84 -0
- package/src/config/schemas.ts +223 -0
- package/src/config/validation.ts +254 -0
- package/src/core/asset-id.ts +21 -0
- package/src/core/gesture-state.ts +344 -0
- package/src/core/icon-asset-registry.ts +127 -0
- package/src/core/icon-source.ts +86 -0
- package/src/core/index.ts +30 -0
- package/src/core/mime.ts +16 -0
- package/src/core/pagination.ts +90 -0
- package/src/core/pub-sub.ts +126 -0
- package/src/core/settings.ts +153 -0
- package/src/core/store.ts +88 -0
- package/src/core/watcher.ts +73 -0
- package/src/deck/addon-handler-bridge.ts +516 -0
- package/src/deck/deck-config.ts +271 -0
- package/src/deck/editor-surfaces.ts +87 -0
- package/src/deck/host-context.ts +18 -0
- package/src/deck/index.ts +102 -0
- package/src/deck/macro-parse.ts +182 -0
- package/src/deck/methods.ts +348 -0
- package/src/deck/paginate-deck.ts +98 -0
- package/src/deck/position-buttons.ts +57 -0
- package/src/deck/runtime/index.ts +5 -0
- package/src/deck/runtime/runtime.ts +1060 -0
- package/src/deck/runtime-subscriptions.ts +45 -0
- package/src/deck/system-back-injection.ts +62 -0
- package/src/deck/system-buttons/registry.tsx +97 -0
- package/src/deck/system-buttons/types.ts +14 -0
- package/src/device/black-frame.ts +73 -0
- package/src/device/index.ts +13 -0
- package/src/device/linux-udev.ts +36 -0
- package/src/device/models.ts +89 -0
- package/src/device/reconnecting.ts +193 -0
- package/src/device/registry.ts +38 -0
- package/src/device/stream-deck.ts +155 -0
- package/src/index.ts +74 -0
- package/src/outputClient/emulator.ts +357 -0
- package/src/outputClient/index.ts +26 -0
- package/src/outputClient/open-browser.ts +26 -0
- package/src/outputClient/real.ts +393 -0
- package/src/outputClient/types.ts +83 -0
- package/src/render/browser-renderer.ts +240 -0
- package/src/render/buffer-hash.ts +20 -0
- package/src/render/editor-handler.ts +197 -0
- package/src/render/emulator-server.ts +128 -0
- package/src/render/icon-source-resolver.ts +69 -0
- package/src/render/index.ts +16 -0
- package/src/render/protocol.ts +61 -0
- package/src/render/push-raw-image.ts +100 -0
- package/src/render/screenshot-cadence.ts +87 -0
- package/src/render/state-publisher.ts +110 -0
- package/src/render/vite-server.ts +138 -0
- package/src/render/ws-bridge.ts +315 -0
- package/src/system/device-selection.ts +80 -0
- package/src/system/glob-match.ts +74 -0
- package/src/system/providers/active-app/darwin.ts +117 -0
- package/src/system/providers/active-app/linux.ts +245 -0
- package/src/system/providers/active-app/wayland-gnome.ts +303 -0
- package/src/system/providers/active-app/windows.ts +94 -0
- package/src/system/providers/active-app.ts +97 -0
- package/src/system/providers/clipboard/darwin.ts +65 -0
- package/src/system/providers/clipboard/linux.ts +163 -0
- package/src/system/providers/clipboard/windows.ts +94 -0
- package/src/system/providers/clipboard.ts +84 -0
- package/src/system/providers/error.ts +16 -0
- package/src/system/providers/key-macro/darwin.ts +221 -0
- package/src/system/providers/key-macro/linux.ts +651 -0
- package/src/system/providers/key-macro/parser.ts +215 -0
- package/src/system/providers/key-macro/windows.ts +476 -0
- package/src/system/providers/key-macro.ts +79 -0
- package/src/system/providers/notification/darwin.ts +36 -0
- package/src/system/providers/notification/linux.ts +115 -0
- package/src/system/providers/notification/windows.ts +72 -0
- package/src/system/providers/notification.ts +75 -0
- package/src/system/providers/session/darwin.ts +82 -0
- package/src/system/providers/session/linux.ts +175 -0
- package/src/system/providers/session/windows.ts +73 -0
- package/src/system/providers/session.ts +89 -0
- package/src/system/providers/shared.ts +65 -0
- package/src/system/requirements.ts +235 -0
- package/src/system/setup-wizard/config-seed.ts +30 -0
- package/src/system/setup-wizard/format.ts +275 -0
- package/src/system/setup-wizard/index.ts +45 -0
- package/src/system/setup-wizard/open-settings.ts +26 -0
- package/src/system/setup-wizard/plan.ts +230 -0
- package/src/system/setup-wizard/probe-cache.ts +34 -0
- package/src/system/setup-wizard/probe.ts +486 -0
- package/src/system/setup-wizard/runtime-features.ts +85 -0
- package/src/system/setup-wizard/sudo.ts +178 -0
- package/src/system/setup-wizard/types.ts +133 -0
- package/src/system/virtual-stream-deck.ts +67 -0
- package/src/themes/css.ts +210 -0
- package/src/themes/default/assets/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/src/themes/default/assets/IBMPlexSans-VariableFont_wdth,wght.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Bold.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-BoldItalic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-ExtraLight.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-ExtraLightItalic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Italic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Light.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Medium.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Regular.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-Thin.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/IBMPlexMono-ThinItalic.ttf +0 -0
- package/src/themes/default/assets/IBM_Plex_Mono/OFL.txt +93 -0
- package/src/themes/default/assets/OFL.txt +93 -0
- package/src/themes/default/assets/README.txt +106 -0
- package/src/themes/default/assets/static/IBMPlexSans-Bold.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-BoldItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-ExtraLight.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-ExtraLightItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-Italic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-Light.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-LightItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-Medium.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-MediumItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-Regular.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-SemiBold.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-SemiBoldItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-Thin.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans-ThinItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-Bold.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-BoldItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-ExtraLight.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-ExtraLightItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-Italic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-Light.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-LightItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-Medium.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-MediumItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-Regular.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-SemiBold.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-SemiBoldItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-Thin.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_Condensed-ThinItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Bold.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-BoldItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-ExtraLight.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-ExtraLightItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Italic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Light.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-LightItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Medium.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-MediumItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Regular.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-SemiBold.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-SemiBoldItalic.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-Thin.ttf +0 -0
- package/src/themes/default/assets/static/IBMPlexSans_SemiCondensed-ThinItalic.ttf +0 -0
- package/src/themes/default/components.css +81 -0
- package/src/themes/default/sirenodeck.json +170 -0
- package/src/themes/index.ts +14 -0
- package/src/themes/light/assets/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/src/themes/light/assets/IBMPlexSans-VariableFont_wdth,wght.ttf +0 -0
- package/src/themes/light/assets/IBM_Plex_Mono/IBMPlexMono-Bold.ttf +0 -0
- package/src/themes/light/assets/IBM_Plex_Mono/IBMPlexMono-Medium.ttf +0 -0
- package/src/themes/light/assets/IBM_Plex_Mono/IBMPlexMono-Regular.ttf +0 -0
- package/src/themes/light/assets/IBM_Plex_Mono/OFL.txt +93 -0
- package/src/themes/light/assets/OFL.txt +93 -0
- package/src/themes/light/assets/README.txt +106 -0
- package/src/themes/light/assets/static/IBMPlexSans-Light.ttf +0 -0
- package/src/themes/light/assets/static/IBMPlexSans-LightItalic.ttf +0 -0
- package/src/themes/light/assets/static/IBMPlexSans-Thin.ttf +0 -0
- package/src/themes/light/components.css +70 -0
- package/src/themes/light/sirenodeck.json +170 -0
- package/src/themes/loader.ts +343 -0
- package/src/themes/manifest.ts +164 -0
- package/src/themes/use-resolved-theme.tsx +32 -0
- package/src/ui/ButtonFrame.tsx +230 -0
- package/src/ui/contexts/AssetCacheContext.tsx +43 -0
- package/src/ui/index.ts +20 -0
- package/src/ui/primitives/Chip.tsx +53 -0
- package/src/ui/primitives/Icon.tsx +209 -0
- package/src/ui/primitives/Label.tsx +82 -0
- package/src/ui/primitives/ProgressBar.tsx +38 -0
- package/src/ui/primitives/TapIndicator.tsx +67 -0
- package/src/ui/primitives/Text.tsx +810 -0
- package/src/ui/primitives/index.ts +6 -0
- package/src/ui/surfaces/BarsSurface.tsx +211 -0
- package/src/ui/surfaces/IconLabelProgressSurface.tsx +133 -0
- package/src/ui/surfaces/IconLabelSurface.tsx +59 -0
- package/src/ui/surfaces/LabelValueListSurface.tsx +202 -0
- package/src/ui/surfaces/PaginatedSurface.tsx +164 -0
- package/src/ui/surfaces/SplitActionSurface.tsx +98 -0
- package/src/ui/surfaces/TemporaryErrorSurface.tsx +68 -0
- package/src/ui/surfaces/ValueChart.tsx +218 -0
- package/src/ui/surfaces/index.ts +32 -0
- package/src/ui/surfaces/paginated-schema.ts +22 -0
- package/src/ui/theme-presentation-builder.ts +184 -0
- package/src/ui/theme-presentation.tsx +146 -0
- package/src/ui/utils/cn.ts +12 -0
- package/src/util/cache-paths.ts +38 -0
- package/src/util/config-diff.ts +16 -0
- package/src/util/daemon-control.ts +85 -0
- package/src/util/daemon.ts +575 -0
- package/src/util/device-config.ts +56 -0
- package/src/util/errors.ts +13 -0
- package/src/util/log-reader.ts +114 -0
- package/src/util/log-tail.ts +103 -0
- package/src/util/logger.ts +417 -0
- package/src/util/process-title.ts +79 -0
- package/src/util/single-instance.ts +202 -0
- package/src/version.ts +10 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/virtual-modules.ts +631 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,2270 @@
|
|
|
1
|
+
import { exec } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs"
|
|
10
|
+
import { homedir, networkInterfaces } from "node:os"
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
isAbsolute,
|
|
15
|
+
join,
|
|
16
|
+
resolve as resolvePath,
|
|
17
|
+
} from "node:path"
|
|
18
|
+
|
|
19
|
+
import type pino from "pino"
|
|
20
|
+
|
|
21
|
+
import { SIRENO_ADDON_API_VERSION } from "@/addon/api-types"
|
|
22
|
+
import { loadAddons } from "@/addon/loader"
|
|
23
|
+
import { AddonRegistry } from "@/addon/registry"
|
|
24
|
+
import { registerBuiltins } from "@/builtin-addons"
|
|
25
|
+
import { registerSystemStatusAddon } from "@/builtin-addons/system-status"
|
|
26
|
+
import { configChanged } from "@/util/config-diff"
|
|
27
|
+
import { loadConfig } from "@/config/loader"
|
|
28
|
+
import { resolveConfigPath, resolveXdgConfigHome } from "./pipeline/helpers"
|
|
29
|
+
import {
|
|
30
|
+
formatFullIssues,
|
|
31
|
+
serializeButtonSchemas,
|
|
32
|
+
validateButton,
|
|
33
|
+
validateFull,
|
|
34
|
+
validatePerDeck,
|
|
35
|
+
} from "@/config/validation"
|
|
36
|
+
import {
|
|
37
|
+
createDeckRuntime,
|
|
38
|
+
injectSystemButtons,
|
|
39
|
+
type Methods,
|
|
40
|
+
type PubSub,
|
|
41
|
+
type Runtime,
|
|
42
|
+
type RuntimeDeck,
|
|
43
|
+
type Store,
|
|
44
|
+
} from "@/deck"
|
|
45
|
+
import { paginateDeck } from "@/deck/paginate-deck"
|
|
46
|
+
import { positionButtons } from "@/deck/position-buttons"
|
|
47
|
+
import { subscribeNavigateDeck } from "@/deck/runtime-subscriptions"
|
|
48
|
+
import {
|
|
49
|
+
createActiveAppProvider,
|
|
50
|
+
type ActiveAppProvider,
|
|
51
|
+
} from "@/system/providers/active-app"
|
|
52
|
+
import { createKeyMacroProvider } from "@/system/providers/key-macro"
|
|
53
|
+
import { createNotificationProvider } from "@/system/providers/notification"
|
|
54
|
+
import {
|
|
55
|
+
createNullSessionProvider,
|
|
56
|
+
createSessionProvider,
|
|
57
|
+
} from "@/system/providers/session"
|
|
58
|
+
import type { CommandExecutor } from "@/system/providers/shared"
|
|
59
|
+
import {
|
|
60
|
+
checkRequirements,
|
|
61
|
+
formatCapabilityWarning,
|
|
62
|
+
type SystemCapability,
|
|
63
|
+
} from "@/system/requirements"
|
|
64
|
+
import { copyThemeAssets, resolveActiveTheme } from "@/themes/loader"
|
|
65
|
+
import { resolveAddonCacheDir } from "@/util/cache-paths"
|
|
66
|
+
import { probeGlobalPackageRoots } from "../package-manager"
|
|
67
|
+
import { installPackage } from "./install"
|
|
68
|
+
import { isNpmAddonSpec } from "@/addon/spec"
|
|
69
|
+
import { confirm } from "@/cli/prompt"
|
|
70
|
+
|
|
71
|
+
import { createActionExecutor } from "@/action/executor"
|
|
72
|
+
import {
|
|
73
|
+
getAssetByPath,
|
|
74
|
+
getUnsentAssets,
|
|
75
|
+
registerDeckIcon,
|
|
76
|
+
registerIconForDeck,
|
|
77
|
+
} from "@/core/icon-asset-registry"
|
|
78
|
+
import { ConfigWatcher } from "@/core/watcher"
|
|
79
|
+
import { bridgeAddonServices } from "@/deck/addon-handler-bridge"
|
|
80
|
+
import {
|
|
81
|
+
buildDeckConfigMessage,
|
|
82
|
+
buildDeckTree,
|
|
83
|
+
buildResolverOptions,
|
|
84
|
+
type AddonFrontendRef,
|
|
85
|
+
} from "@/deck/deck-config"
|
|
86
|
+
import { getHostContext } from "@/deck/host-context"
|
|
87
|
+
import { StatePublisher } from "@/render/state-publisher"
|
|
88
|
+
import { startWsBridge, type WsBridge } from "@/render/ws-bridge"
|
|
89
|
+
import { createConfigMutationService } from "@/config/mutation"
|
|
90
|
+
import {
|
|
91
|
+
createEditorMessageHandler,
|
|
92
|
+
type EditorMessageHandler,
|
|
93
|
+
} from "@/render/editor-handler"
|
|
94
|
+
|
|
95
|
+
import {
|
|
96
|
+
selectOutputClient,
|
|
97
|
+
type OutputClient,
|
|
98
|
+
type OutputHandle,
|
|
99
|
+
} from "@/outputClient"
|
|
100
|
+
import type { AddonInventoryEntry } from "@/api/protocol-internal"
|
|
101
|
+
import { loadDeviceConfig } from "@/util/device-config"
|
|
102
|
+
import {
|
|
103
|
+
collectAddonDefaultButtonConfig,
|
|
104
|
+
materializeAddonDecks,
|
|
105
|
+
} from "./addon-decks"
|
|
106
|
+
import {
|
|
107
|
+
collectBuiltinAddonRegistry,
|
|
108
|
+
type ScannedAddon,
|
|
109
|
+
type ScannedButtonType,
|
|
110
|
+
type ScannedDeck,
|
|
111
|
+
} from "./addon-registry"
|
|
112
|
+
import { resolveFrontendCwd } from "./emulator-mode"
|
|
113
|
+
import { selectLanAddresses } from "./network-bind"
|
|
114
|
+
|
|
115
|
+
export interface SignalProvider {
|
|
116
|
+
onSignal(handler: () => void): () => void
|
|
117
|
+
// ponytail: SIGUSR1 is the in-place-reload primitive (production's
|
|
118
|
+
// systemctl reload-or-restart and the dev `pnpm dev reload` both send
|
|
119
|
+
// it). Without a registered handler, Node's default action for SIGUSR1
|
|
120
|
+
// is to terminate the process — meaning every `reload` call today is
|
|
121
|
+
// a latent daemon crash. Wire the handler so SIGUSR1 triggers a
|
|
122
|
+
// runtime.invalidate() (re-broadcast deck-config + nudge the
|
|
123
|
+
// BrowserRenderer's screenshot tick). Tests construct their own
|
|
124
|
+
// SignalProvider; update them to supply onReload too.
|
|
125
|
+
onReload(handler: () => void): () => void
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const defaultSignals: SignalProvider = {
|
|
129
|
+
onSignal(handler: () => void): () => void {
|
|
130
|
+
process.once("SIGINT", handler)
|
|
131
|
+
process.once("SIGTERM", handler)
|
|
132
|
+
return () => {
|
|
133
|
+
process.off("SIGINT", handler)
|
|
134
|
+
process.off("SIGTERM", handler)
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
onReload(handler: () => void): () => void {
|
|
138
|
+
// ponytail: use `process.on` (not `process.once`) so the handler stays
|
|
139
|
+
// registered across reloads. `process.once` would fire once and then the
|
|
140
|
+
// second SIGUSR1 would fall through to Node's default (terminate).
|
|
141
|
+
// The returned unregister calls `process.off`, which works the same
|
|
142
|
+
// for either form — we just need the listener to persist.
|
|
143
|
+
process.on("SIGUSR1", handler)
|
|
144
|
+
return () => process.off("SIGUSR1", handler)
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface RunOptions {
|
|
149
|
+
readonly config?: string
|
|
150
|
+
readonly port?: number
|
|
151
|
+
readonly emulator?: boolean
|
|
152
|
+
readonly remote?: boolean
|
|
153
|
+
readonly deviceModel?: string
|
|
154
|
+
readonly frontendUrl?: string
|
|
155
|
+
readonly intervalMs?: number
|
|
156
|
+
readonly xdgConfigHome?: string
|
|
157
|
+
readonly homeDir?: string
|
|
158
|
+
readonly noAutoOpen?: boolean
|
|
159
|
+
readonly signals?: SignalProvider
|
|
160
|
+
readonly onChildren?: (pids: ReadonlyArray<number>) => void
|
|
161
|
+
readonly onAddonsUpdate?: (addons: ReadonlyArray<ScannedAddon>) => void
|
|
162
|
+
readonly logger: pino.Logger
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SetupAddonServicesOptions {
|
|
166
|
+
readonly runtime: Runtime
|
|
167
|
+
readonly methods: Methods
|
|
168
|
+
readonly decks: ReadonlyArray<RuntimeDeck>
|
|
169
|
+
readonly pubSub: PubSub
|
|
170
|
+
readonly scanned: ReadonlyArray<ScannedAddon>
|
|
171
|
+
readonly externalAddons: ReadonlyArray<ScannedAddon>
|
|
172
|
+
readonly addonByType: Map<string, AddonFrontendRef>
|
|
173
|
+
readonly executor: ReturnType<typeof createActionExecutor>
|
|
174
|
+
readonly statePublisher: Pick<
|
|
175
|
+
StatePublisher,
|
|
176
|
+
"registerChannel" | "setActiveDeck"
|
|
177
|
+
>
|
|
178
|
+
readonly bridge: Pick<WsBridge, "broadcast" | "registerCacheablePoller">
|
|
179
|
+
readonly isCompact: boolean
|
|
180
|
+
readonly initialDeck?: RuntimeDeck
|
|
181
|
+
readonly signal: AbortSignal
|
|
182
|
+
readonly store: Store
|
|
183
|
+
readonly logger: pino.Logger
|
|
184
|
+
// ponytail: type is inferred from buildResolverOptions — adding a named
|
|
185
|
+
// type here would re-import a stale pre-existing module
|
|
186
|
+
// (`@/render/icon-resolver`) that doesn't exist on disk.
|
|
187
|
+
readonly resolverOptions: ReturnType<typeof buildResolverOptions>
|
|
188
|
+
/** Optional host callback that re-materializes addon decks (dynamic decks). */
|
|
189
|
+
readonly requestDeckRebuild?: () => void
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface SetupAddonServicesResult {
|
|
193
|
+
readonly dispose: () => void
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const collectActiveDeckAddonNames = (
|
|
197
|
+
deck: RuntimeDeck,
|
|
198
|
+
addonByType: Map<string, AddonFrontendRef>,
|
|
199
|
+
): string[] => {
|
|
200
|
+
const addonNames = new Set<string>()
|
|
201
|
+
for (const button of deck.buttons ?? []) {
|
|
202
|
+
const entry = addonByType.get(button.type)
|
|
203
|
+
if (entry !== undefined) addonNames.add(entry.name)
|
|
204
|
+
}
|
|
205
|
+
return [...addonNames]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const setupAddonServices = (
|
|
209
|
+
options: SetupAddonServicesOptions,
|
|
210
|
+
): SetupAddonServicesResult => {
|
|
211
|
+
const {
|
|
212
|
+
runtime,
|
|
213
|
+
decks,
|
|
214
|
+
pubSub,
|
|
215
|
+
scanned,
|
|
216
|
+
externalAddons,
|
|
217
|
+
addonByType,
|
|
218
|
+
executor,
|
|
219
|
+
statePublisher,
|
|
220
|
+
bridge,
|
|
221
|
+
isCompact,
|
|
222
|
+
initialDeck,
|
|
223
|
+
signal,
|
|
224
|
+
store,
|
|
225
|
+
methods,
|
|
226
|
+
logger,
|
|
227
|
+
resolverOptions,
|
|
228
|
+
requestDeckRebuild,
|
|
229
|
+
} = options
|
|
230
|
+
|
|
231
|
+
void bridgeAddonServices({
|
|
232
|
+
runtime,
|
|
233
|
+
decks,
|
|
234
|
+
scanned,
|
|
235
|
+
externalAddons,
|
|
236
|
+
executor,
|
|
237
|
+
pubSub,
|
|
238
|
+
signal,
|
|
239
|
+
logger,
|
|
240
|
+
statePublisher,
|
|
241
|
+
bridge,
|
|
242
|
+
store,
|
|
243
|
+
methods,
|
|
244
|
+
...(requestDeckRebuild !== undefined ? { requestDeckRebuild } : {}),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const unsubscribeDeck = pubSub.subscribe(
|
|
248
|
+
"runtime:activeDeck",
|
|
249
|
+
(payload: unknown) => {
|
|
250
|
+
const deckId =
|
|
251
|
+
typeof payload === "object" && payload !== null && "deckId" in payload
|
|
252
|
+
? String((payload as { deckId: unknown }).deckId)
|
|
253
|
+
: undefined
|
|
254
|
+
if (deckId === undefined) return
|
|
255
|
+
const deck = decks.find((d) => d.id === deckId)
|
|
256
|
+
if (deck === undefined) return
|
|
257
|
+
statePublisher.setActiveDeck({
|
|
258
|
+
addonNames: collectActiveDeckAddonNames(deck, addonByType),
|
|
259
|
+
})
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
const unsubscribeBrightnessBridge = pubSub.subscribe<{
|
|
264
|
+
value: number
|
|
265
|
+
}>("sireno:settings:brightness", (payload) => {
|
|
266
|
+
bridge.broadcast({
|
|
267
|
+
type: "state",
|
|
268
|
+
channels: { "sireno:settings:brightness": payload },
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
let lastBroadcastedDeckId: string | undefined = initialDeck?.id
|
|
273
|
+
const unsubscribeDeckBroadcast = pubSub.subscribe(
|
|
274
|
+
"runtime:activeDeck",
|
|
275
|
+
(payload: unknown) => {
|
|
276
|
+
const deckId =
|
|
277
|
+
typeof payload === "object" && payload !== null && "deckId" in payload
|
|
278
|
+
? String((payload as { deckId: unknown }).deckId)
|
|
279
|
+
: undefined
|
|
280
|
+
if (deckId === undefined) return
|
|
281
|
+
if (deckId === lastBroadcastedDeckId) return
|
|
282
|
+
lastBroadcastedDeckId = deckId
|
|
283
|
+
const deck = decks.find((d) => d.id === deckId)
|
|
284
|
+
if (deck === undefined) return
|
|
285
|
+
const msg = buildDeckConfigMessage(
|
|
286
|
+
deck,
|
|
287
|
+
addonByType,
|
|
288
|
+
resolverOptions,
|
|
289
|
+
{
|
|
290
|
+
navStackDepth: runtime.navStackDepth(),
|
|
291
|
+
hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
|
|
292
|
+
inOverlayMode: runtime.getOverlay() !== null,
|
|
293
|
+
},
|
|
294
|
+
undefined,
|
|
295
|
+
isCompact,
|
|
296
|
+
(fullPath) => getAssetByPath(fullPath)?.id,
|
|
297
|
+
runtime.getAvailableOverlayDeckIcon(),
|
|
298
|
+
runtime.getAvailableOverlayDeckName(),
|
|
299
|
+
{ lockActive: runtime.isLockActive() },
|
|
300
|
+
)
|
|
301
|
+
bridge.broadcast(msg)
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const unsubscribeOverlayAvailableBroadcast = pubSub.subscribe(
|
|
306
|
+
"runtime:overlay-available",
|
|
307
|
+
() => {
|
|
308
|
+
const activeDeck = runtime.getActiveDeck()
|
|
309
|
+
if (activeDeck === undefined) return
|
|
310
|
+
const msg = buildDeckConfigMessage(
|
|
311
|
+
activeDeck,
|
|
312
|
+
addonByType,
|
|
313
|
+
resolverOptions,
|
|
314
|
+
{
|
|
315
|
+
navStackDepth: runtime.navStackDepth(),
|
|
316
|
+
hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
|
|
317
|
+
inOverlayMode: runtime.getOverlay() !== null,
|
|
318
|
+
},
|
|
319
|
+
undefined,
|
|
320
|
+
isCompact,
|
|
321
|
+
(fullPath) => getAssetByPath(fullPath)?.id,
|
|
322
|
+
runtime.getAvailableOverlayDeckIcon(),
|
|
323
|
+
runtime.getAvailableOverlayDeckName(),
|
|
324
|
+
{ lockActive: runtime.isLockActive() },
|
|
325
|
+
)
|
|
326
|
+
logger.info(
|
|
327
|
+
{
|
|
328
|
+
deckId: msg.deckId,
|
|
329
|
+
hasOverlayDeckAvailable: msg.hasOverlayDeckAvailable,
|
|
330
|
+
overlayDeckIcon: msg.overlayDeckIcon,
|
|
331
|
+
},
|
|
332
|
+
"orchestrator: broadcasting overlay-available update",
|
|
333
|
+
)
|
|
334
|
+
bridge.broadcast(msg)
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
// ponytail: periodic heartbeat — ensures the frontend's overlay state
|
|
339
|
+
// stays in sync after a transient disconnect or missed event. Fires every
|
|
340
|
+
// 2s, cheap (one deck-config per cycle), and idempotent on the frontend.
|
|
341
|
+
// Skip the broadcast when nothing observable changed since the last tick:
|
|
342
|
+
// an unconditional tick at 1s forced every client to re-render twice a
|
|
343
|
+
// second even with identical state, which drowned the emulator shell and
|
|
344
|
+
// starved the bridge's event loop during the 5s handshake window.
|
|
345
|
+
let lastHeartbeatKey = ""
|
|
346
|
+
const heartbeat = setInterval(() => {
|
|
347
|
+
const activeDeck = runtime.getActiveDeck()
|
|
348
|
+
if (activeDeck === undefined) return
|
|
349
|
+
const key = [
|
|
350
|
+
activeDeck.id,
|
|
351
|
+
runtime.navStackDepth(),
|
|
352
|
+
runtime.hasOverlayDeckAvailable(),
|
|
353
|
+
runtime.getAvailableOverlayDeckIcon(),
|
|
354
|
+
runtime.getAvailableOverlayDeckName(),
|
|
355
|
+
isCompact,
|
|
356
|
+
runtime.getOverlay() !== null,
|
|
357
|
+
].join("|")
|
|
358
|
+
if (key === lastHeartbeatKey) return
|
|
359
|
+
lastHeartbeatKey = key
|
|
360
|
+
const msg = buildDeckConfigMessage(
|
|
361
|
+
activeDeck,
|
|
362
|
+
addonByType,
|
|
363
|
+
resolverOptions,
|
|
364
|
+
{
|
|
365
|
+
navStackDepth: runtime.navStackDepth(),
|
|
366
|
+
hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
|
|
367
|
+
inOverlayMode: runtime.getOverlay() !== null,
|
|
368
|
+
},
|
|
369
|
+
undefined,
|
|
370
|
+
isCompact,
|
|
371
|
+
(fullPath) => getAssetByPath(fullPath)?.id,
|
|
372
|
+
runtime.getAvailableOverlayDeckIcon(),
|
|
373
|
+
runtime.getAvailableOverlayDeckName(),
|
|
374
|
+
{ lockActive: runtime.isLockActive() },
|
|
375
|
+
)
|
|
376
|
+
bridge.broadcast(msg)
|
|
377
|
+
}, 2000)
|
|
378
|
+
signal.addEventListener("abort", () => clearInterval(heartbeat))
|
|
379
|
+
|
|
380
|
+
const unsubscribeNavigate = subscribeNavigateDeck(pubSub, runtime)
|
|
381
|
+
|
|
382
|
+
const unsubscribeDispatch = pubSub.subscribe(
|
|
383
|
+
"runtime:dispatch",
|
|
384
|
+
async (payload: unknown) => {
|
|
385
|
+
if (
|
|
386
|
+
typeof payload !== "object" ||
|
|
387
|
+
payload === null ||
|
|
388
|
+
!("value" in payload)
|
|
389
|
+
) {
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const value = String((payload as { value: unknown }).value)
|
|
393
|
+
if (value.length === 0) return
|
|
394
|
+
try {
|
|
395
|
+
await methods.dispatch(value)
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.error({ err }, "[runtime:dispatch] addon dispatch failed")
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
const unsubscribeButtonError = pubSub.subscribe(
|
|
403
|
+
"runtime:buttonError",
|
|
404
|
+
(payload: unknown) => {
|
|
405
|
+
if (
|
|
406
|
+
typeof payload !== "object" ||
|
|
407
|
+
payload === null ||
|
|
408
|
+
!("deckId" in payload) ||
|
|
409
|
+
!("position" in payload)
|
|
410
|
+
) {
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const deckId = String((payload as { deckId: unknown }).deckId)
|
|
414
|
+
const position = Number((payload as { position: unknown }).position)
|
|
415
|
+
const durationMs =
|
|
416
|
+
"durationMs" in payload
|
|
417
|
+
? Number((payload as { durationMs: unknown }).durationMs)
|
|
418
|
+
: 5000
|
|
419
|
+
const details =
|
|
420
|
+
"details" in payload &&
|
|
421
|
+
typeof (payload as { details?: unknown }).details === "string"
|
|
422
|
+
? (payload as { details: string }).details
|
|
423
|
+
: undefined
|
|
424
|
+
if (!Number.isFinite(position) || position < 0) return
|
|
425
|
+
bridge.broadcast({
|
|
426
|
+
type: "button-error",
|
|
427
|
+
deckId,
|
|
428
|
+
position,
|
|
429
|
+
durationMs: Number.isFinite(durationMs) ? durationMs : 5000,
|
|
430
|
+
...(details !== undefined ? { details } : {}),
|
|
431
|
+
})
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if (initialDeck !== undefined) {
|
|
436
|
+
statePublisher.setActiveDeck({
|
|
437
|
+
addonNames: collectActiveDeckAddonNames(initialDeck, addonByType),
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
dispose: () => {
|
|
443
|
+
unsubscribeDeck()
|
|
444
|
+
unsubscribeDeckBroadcast()
|
|
445
|
+
unsubscribeOverlayAvailableBroadcast()
|
|
446
|
+
unsubscribeBrightnessBridge()
|
|
447
|
+
unsubscribeNavigate()
|
|
448
|
+
unsubscribeDispatch()
|
|
449
|
+
unsubscribeButtonError()
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface LoadConfigAndThemeResult {
|
|
455
|
+
readonly configPath: string
|
|
456
|
+
readonly theme: {
|
|
457
|
+
name: string
|
|
458
|
+
apiVersion: number
|
|
459
|
+
manifestPath: string
|
|
460
|
+
uiOverridesPath: string | null
|
|
461
|
+
}
|
|
462
|
+
readonly themeDir: string
|
|
463
|
+
readonly decks: ReadonlyArray<RuntimeDeck>
|
|
464
|
+
readonly sourceDecks: ReadonlyArray<RuntimeDeck>
|
|
465
|
+
readonly pubSub: PubSub
|
|
466
|
+
readonly runtime: Runtime
|
|
467
|
+
readonly methods: Methods
|
|
468
|
+
readonly store: Store
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
interface LoadConfigResult {
|
|
472
|
+
readonly configPath: string
|
|
473
|
+
readonly config: ReturnType<typeof loadConfig>["config"]
|
|
474
|
+
readonly registry: AddonRegistry
|
|
475
|
+
readonly theme: ReturnType<typeof resolveActiveTheme>["theme"]
|
|
476
|
+
readonly themeDir: string
|
|
477
|
+
readonly addonSpecToName: ReadonlyMap<string, string>
|
|
478
|
+
readonly addonEntryPaths: ReadonlyMap<string, string>
|
|
479
|
+
readonly addonBrowserEntryPaths: ReadonlyMap<string, string>
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const loadExternalAddonsIntoRegistry = async (
|
|
483
|
+
registry: AddonRegistry,
|
|
484
|
+
config: ReturnType<typeof loadConfig>["config"],
|
|
485
|
+
configDir: string,
|
|
486
|
+
homeDir: string,
|
|
487
|
+
logger: RunOptions["logger"],
|
|
488
|
+
configPath: string,
|
|
489
|
+
): Promise<{
|
|
490
|
+
specToName: Map<string, string>
|
|
491
|
+
nameToEntryPath: Map<string, string>
|
|
492
|
+
nameToBrowserEntryPath: Map<string, string>
|
|
493
|
+
}> => {
|
|
494
|
+
// ponytail: returns specifier→manifest name for addons that successfully
|
|
495
|
+
// registered. The override map in buildRuntime keys on manifest name (what
|
|
496
|
+
// materializeAddonDecks looks up) — the user wrote `src:` (path), so we
|
|
497
|
+
// resolve the two here. nameToEntryPath feeds buildExternalScannedAddons
|
|
498
|
+
// so the bridge can import the entry file at runtime.
|
|
499
|
+
const specToName = new Map<string, string>()
|
|
500
|
+
const nameToEntryPath = new Map<string, string>()
|
|
501
|
+
const nameToBrowserEntryPath = new Map<string, string>()
|
|
502
|
+
const entries = config.addons ?? []
|
|
503
|
+
if (entries.length === 0)
|
|
504
|
+
return { specToName, nameToEntryPath, nameToBrowserEntryPath }
|
|
505
|
+
const { roots: globalRoots, timedOut, timeoutMs } = probeGlobalPackageRoots()
|
|
506
|
+
for (const manager of timedOut)
|
|
507
|
+
logger.warn(
|
|
508
|
+
{ manager, timeoutMs },
|
|
509
|
+
`addons: ${manager} did not answer within ${timeoutMs}ms and was killed — globally installed addons may not resolve`,
|
|
510
|
+
)
|
|
511
|
+
let result = await loadAddons({
|
|
512
|
+
entries,
|
|
513
|
+
configDir,
|
|
514
|
+
homeDir,
|
|
515
|
+
currentApiVersion: SIRENO_ADDON_API_VERSION,
|
|
516
|
+
cacheDir: resolveAddonCacheDir(),
|
|
517
|
+
projectDir: configDir,
|
|
518
|
+
globalPackageRoots: globalRoots,
|
|
519
|
+
})
|
|
520
|
+
const missing = result.issues
|
|
521
|
+
.filter((issue) => issue.message.includes("is not installed"))
|
|
522
|
+
.map((issue) => issue.source)
|
|
523
|
+
if (missing.length > 0 && process.stdin.isTTY) {
|
|
524
|
+
for (const source of missing) {
|
|
525
|
+
const entry = entries.find(
|
|
526
|
+
(candidate) =>
|
|
527
|
+
(typeof candidate === "string" ? candidate : candidate.src) ===
|
|
528
|
+
source,
|
|
529
|
+
)
|
|
530
|
+
if (
|
|
531
|
+
await confirm({
|
|
532
|
+
message: `Addon '${source}' is not installed. Install it now?`,
|
|
533
|
+
initialValue: true,
|
|
534
|
+
})
|
|
535
|
+
) {
|
|
536
|
+
await installPackage({
|
|
537
|
+
config: configPath,
|
|
538
|
+
packageName: source,
|
|
539
|
+
...(typeof entry !== "string" && entry?.global === true
|
|
540
|
+
? { global: true }
|
|
541
|
+
: {}),
|
|
542
|
+
configure: false,
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
result = await loadAddons({
|
|
547
|
+
entries,
|
|
548
|
+
configDir,
|
|
549
|
+
homeDir,
|
|
550
|
+
currentApiVersion: SIRENO_ADDON_API_VERSION,
|
|
551
|
+
cacheDir: resolveAddonCacheDir(),
|
|
552
|
+
projectDir: configDir,
|
|
553
|
+
globalPackageRoots: globalRoots,
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
for (const issue of result.issues) {
|
|
557
|
+
const payload = { source: issue.source, message: issue.message }
|
|
558
|
+
const detail = `addon '${issue.source}': ${issue.message}`
|
|
559
|
+
if (issue.level === "error")
|
|
560
|
+
logger.error(payload, `addon load error — ${detail}`)
|
|
561
|
+
else if (issue.level === "warning")
|
|
562
|
+
logger.warn(payload, `addon load warning — ${detail}`)
|
|
563
|
+
else logger.info(payload, `addon load info — ${detail}`)
|
|
564
|
+
}
|
|
565
|
+
for (const loaded of result.addons) {
|
|
566
|
+
try {
|
|
567
|
+
registry.load(loaded.manifest)
|
|
568
|
+
specToName.set(loaded.source.specifier, loaded.manifest.name)
|
|
569
|
+
// ponytail: two consumers, opposite needs. The DAEMON imports the Node
|
|
570
|
+
// entry (host UI stubbed, so `import()` resolves); the BROWSER gets the
|
|
571
|
+
// dedicated bundle when the addon ships one (host UI left external, so
|
|
572
|
+
// vite resolves it to the real components — that is what fixed
|
|
573
|
+
// pomodoro's blank labels). These were briefly one map set to
|
|
574
|
+
// `browserEntryPath ?? entryPath`, which handed the daemon a bundle
|
|
575
|
+
// Node cannot resolve and silently killed every addon button handler.
|
|
576
|
+
nameToEntryPath.set(loaded.manifest.name, loaded.entryPath)
|
|
577
|
+
nameToBrowserEntryPath.set(
|
|
578
|
+
loaded.manifest.name,
|
|
579
|
+
loaded.browserEntryPath ?? loaded.entryPath,
|
|
580
|
+
)
|
|
581
|
+
logger.info(
|
|
582
|
+
{
|
|
583
|
+
addon: loaded.manifest.name,
|
|
584
|
+
source: loaded.source.specifier,
|
|
585
|
+
separateBrowserBundle: loaded.browserEntryPath !== null,
|
|
586
|
+
},
|
|
587
|
+
"addon loaded",
|
|
588
|
+
)
|
|
589
|
+
} catch (err) {
|
|
590
|
+
logger.error(
|
|
591
|
+
{ err, addon: loaded.manifest.name },
|
|
592
|
+
"addon failed to register",
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { specToName, nameToEntryPath, nameToBrowserEntryPath }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export const validateAndLoadConfig = async (
|
|
600
|
+
options: RunOptions,
|
|
601
|
+
): Promise<LoadConfigResult> => {
|
|
602
|
+
const resolved = resolveConfigPath(options)
|
|
603
|
+
const configPath = resolved.path
|
|
604
|
+
options.logger.info(
|
|
605
|
+
{ configPath, source: resolved.source },
|
|
606
|
+
`start: using config ${configPath}`,
|
|
607
|
+
)
|
|
608
|
+
const { config } = loadConfig({ configPath })
|
|
609
|
+
const registry = new AddonRegistry()
|
|
610
|
+
registerBuiltins(registry)
|
|
611
|
+
// ponytail: wire `config.addons[]` into the registry. Without this the
|
|
612
|
+
// loader's only caller was the test suite, so chrome-overlay et al. were
|
|
613
|
+
// never registered — builtins only. Load failures stay non-fatal (one bad
|
|
614
|
+
// addon must not kill the daemon); issues are surfaced through the logger.
|
|
615
|
+
const {
|
|
616
|
+
specToName: addonSpecToName,
|
|
617
|
+
nameToEntryPath: addonEntryPaths,
|
|
618
|
+
nameToBrowserEntryPath: addonBrowserEntryPaths,
|
|
619
|
+
} = await loadExternalAddonsIntoRegistry(
|
|
620
|
+
registry,
|
|
621
|
+
config,
|
|
622
|
+
dirname(configPath),
|
|
623
|
+
options.homeDir ?? homedir(),
|
|
624
|
+
options.logger,
|
|
625
|
+
configPath,
|
|
626
|
+
)
|
|
627
|
+
// ponytail: dump every deck type the registry holds so the operator can
|
|
628
|
+
// see at startup which addons registered and which got silently skipped.
|
|
629
|
+
options.logger.info(
|
|
630
|
+
{
|
|
631
|
+
addonDecks: registry.listDeckTypes?.(),
|
|
632
|
+
userDecks: Object.keys(config.decks),
|
|
633
|
+
addonConfigEntries: (config.addons ?? []).map((a) =>
|
|
634
|
+
typeof a === "string" ? a : a.src,
|
|
635
|
+
),
|
|
636
|
+
},
|
|
637
|
+
"registry: deck types loaded",
|
|
638
|
+
)
|
|
639
|
+
// Per-button config errors are non-fatal: each broken button is replaced
|
|
640
|
+
// with a `core:temporary-error` cell at its position and logged in full
|
|
641
|
+
// (the incorrect config + the schema issues) so the operator can see
|
|
642
|
+
// exactly what went wrong without restarting the daemon. We only fail
|
|
643
|
+
// startup on structural problems (missing main, duplicate positions,
|
|
644
|
+
// unknown/invalid button types in non-config ways).
|
|
645
|
+
const validation = validateFull(config, registry)
|
|
646
|
+
const structuralErrors = validation.issues.filter(
|
|
647
|
+
(i) => !i.path.includes(".config."),
|
|
648
|
+
)
|
|
649
|
+
if (structuralErrors.some((i) => i.level === "error")) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`Config validation failed:\n${formatFullIssues(structuralErrors)}`,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
const { roots: globalRoots, timedOut, timeoutMs } = probeGlobalPackageRoots()
|
|
655
|
+
for (const manager of timedOut)
|
|
656
|
+
options.logger.warn(
|
|
657
|
+
{ manager, timeoutMs },
|
|
658
|
+
`themes: ${manager} did not answer within ${timeoutMs}ms and was killed — globally installed themes may not resolve`,
|
|
659
|
+
)
|
|
660
|
+
const themeOptions = {
|
|
661
|
+
// ponytail: theme paths in config.yml are relative to the config file's
|
|
662
|
+
// directory, not to process.cwd(). `loadThemeFromPath` resolves via
|
|
663
|
+
// `path.resolve(themePath)`, which uses cwd — and the dev wrapper sets
|
|
664
|
+
// cwd to packages/cli/. Pre-resolve here so `./packages/themes/...`
|
|
665
|
+
// works regardless of where the daemon is launched from.
|
|
666
|
+
theme:
|
|
667
|
+
config.theme !== undefined &&
|
|
668
|
+
typeof config.theme === "string" &&
|
|
669
|
+
(config.theme.startsWith("./") || config.theme.startsWith("../"))
|
|
670
|
+
? resolvePath(dirname(configPath), config.theme)
|
|
671
|
+
: config.theme,
|
|
672
|
+
packageRoots: [join(dirname(configPath), "node_modules"), ...globalRoots],
|
|
673
|
+
}
|
|
674
|
+
let resolvedTheme: ReturnType<typeof resolveActiveTheme>
|
|
675
|
+
try {
|
|
676
|
+
resolvedTheme = resolveActiveTheme(registry, themeOptions)
|
|
677
|
+
} catch (error) {
|
|
678
|
+
const source =
|
|
679
|
+
config.theme === undefined
|
|
680
|
+
? null
|
|
681
|
+
: typeof config.theme === "string"
|
|
682
|
+
? config.theme
|
|
683
|
+
: config.theme.src
|
|
684
|
+
if (source === null || !isNpmAddonSpec(source) || !process.stdin.isTTY)
|
|
685
|
+
throw error
|
|
686
|
+
if (
|
|
687
|
+
!(await confirm({
|
|
688
|
+
message: `Theme '${source}' is not installed. Install it now?`,
|
|
689
|
+
initialValue: true,
|
|
690
|
+
}))
|
|
691
|
+
)
|
|
692
|
+
throw error
|
|
693
|
+
await installPackage({
|
|
694
|
+
config: configPath,
|
|
695
|
+
packageName: source,
|
|
696
|
+
...(typeof config.theme === "object" && config.theme.global === true
|
|
697
|
+
? { global: true }
|
|
698
|
+
: {}),
|
|
699
|
+
configure: false,
|
|
700
|
+
})
|
|
701
|
+
resolvedTheme = resolveActiveTheme(registry, themeOptions)
|
|
702
|
+
}
|
|
703
|
+
const { theme, getCss } = resolvedTheme
|
|
704
|
+
const themeDir: string = resolveFrontendCwd()
|
|
705
|
+
const cssContent: string = getCss()
|
|
706
|
+
if (cssContent.length > 0) {
|
|
707
|
+
const cssDir = join(themeDir, ".sirenodeck")
|
|
708
|
+
if (!existsSync(cssDir)) mkdirSync(cssDir, { recursive: true })
|
|
709
|
+
writeFileSync(join(cssDir, "theme.css"), cssContent, "utf8")
|
|
710
|
+
copyThemeAssets(dirname(theme.manifestPath), cssDir)
|
|
711
|
+
}
|
|
712
|
+
process.env["SIRENO_THEME_DIR"] = themeDir
|
|
713
|
+
process.env["SIRENO_THEME"] = JSON.stringify({
|
|
714
|
+
name: theme.name,
|
|
715
|
+
manifestPath: theme.manifestPath,
|
|
716
|
+
uiOverridesPath: theme.uiOverridesPath,
|
|
717
|
+
})
|
|
718
|
+
process.env["SIRENO_THEME_NAME"] = theme.name
|
|
719
|
+
return {
|
|
720
|
+
configPath,
|
|
721
|
+
config,
|
|
722
|
+
registry,
|
|
723
|
+
theme,
|
|
724
|
+
themeDir,
|
|
725
|
+
addonSpecToName,
|
|
726
|
+
addonEntryPaths,
|
|
727
|
+
addonBrowserEntryPaths,
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export const buildAddonConfigOverrides = (
|
|
732
|
+
addonEntries: ReadonlyArray<unknown>,
|
|
733
|
+
addonSpecToName: ReadonlyMap<string, string>,
|
|
734
|
+
logger: pino.Logger,
|
|
735
|
+
): Map<
|
|
736
|
+
string,
|
|
737
|
+
{
|
|
738
|
+
addonWideConfig: Record<string, unknown>
|
|
739
|
+
perDeck: Map<string, import("@/cli/commands/addon-decks").AddonDeckOverride>
|
|
740
|
+
defaults: { autoShow?: boolean } | undefined
|
|
741
|
+
}
|
|
742
|
+
> => {
|
|
743
|
+
const overrides = new Map<
|
|
744
|
+
string,
|
|
745
|
+
{
|
|
746
|
+
addonWideConfig: Record<string, unknown>
|
|
747
|
+
perDeck: Map<
|
|
748
|
+
string,
|
|
749
|
+
import("@/cli/commands/addon-decks").AddonDeckOverride
|
|
750
|
+
>
|
|
751
|
+
defaults: { autoShow?: boolean } | undefined
|
|
752
|
+
}
|
|
753
|
+
>()
|
|
754
|
+
for (const entry of addonEntries) {
|
|
755
|
+
// ponytail: `addons[]` entries are either a raw string spec or
|
|
756
|
+
// `{src, enabled?, config?}`. The user wrote `src:` (or just a string
|
|
757
|
+
// for the spec). Normalize here so we have a single source-of-truth key.
|
|
758
|
+
const src =
|
|
759
|
+
typeof entry === "string"
|
|
760
|
+
? entry
|
|
761
|
+
: typeof entry === "object" &&
|
|
762
|
+
entry !== null &&
|
|
763
|
+
typeof (entry as { src?: unknown }).src === "string"
|
|
764
|
+
? (entry as { src: string }).src
|
|
765
|
+
: null
|
|
766
|
+
if (src === null) continue
|
|
767
|
+
const cfg =
|
|
768
|
+
typeof entry === "object" && entry !== null
|
|
769
|
+
? (entry as { config?: unknown }).config
|
|
770
|
+
: undefined
|
|
771
|
+
if (typeof cfg !== "object" || cfg === null) continue
|
|
772
|
+
// ponytail: addonWideConfig is everything under `entry.config` EXCEPT
|
|
773
|
+
// `decks` (which carries per-deck overrides) and `defaults` (which carries
|
|
774
|
+
// addon-wide deck defaults). We accept unknown keys here because the
|
|
775
|
+
// schema only constrains the `decks` sub-record.
|
|
776
|
+
const {
|
|
777
|
+
decks: perDeckRaw,
|
|
778
|
+
defaults,
|
|
779
|
+
...rest
|
|
780
|
+
} = cfg as {
|
|
781
|
+
decks?: Record<
|
|
782
|
+
string,
|
|
783
|
+
import("@/cli/commands/addon-decks").AddonDeckOverride
|
|
784
|
+
>
|
|
785
|
+
defaults?: { autoShow?: boolean }
|
|
786
|
+
} & Record<string, unknown>
|
|
787
|
+
// ponytail: key on the loaded addon's manifest name — that's what
|
|
788
|
+
// materializeAddonDecks looks up. The user wrote `src:` (path or npm
|
|
789
|
+
// specifier); `addonSpecToName` resolves it post-load. If the addon
|
|
790
|
+
// didn't load (path typo, missing module), warn and drop the override.
|
|
791
|
+
const addonName = addonSpecToName.get(src)
|
|
792
|
+
if (addonName === undefined) {
|
|
793
|
+
logger.warn(
|
|
794
|
+
{ src },
|
|
795
|
+
"addon config override targets an addon that failed to load; ignoring",
|
|
796
|
+
)
|
|
797
|
+
continue
|
|
798
|
+
}
|
|
799
|
+
overrides.set(addonName, {
|
|
800
|
+
addonWideConfig: rest,
|
|
801
|
+
perDeck: new Map(Object.entries(perDeckRaw ?? {})),
|
|
802
|
+
defaults,
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
return overrides
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const buildRuntime = (
|
|
809
|
+
options: RunOptions,
|
|
810
|
+
loaded: LoadConfigResult,
|
|
811
|
+
keyCount: number,
|
|
812
|
+
lockActive: boolean = false,
|
|
813
|
+
): LoadConfigAndThemeResult => {
|
|
814
|
+
const { logger } = options
|
|
815
|
+
const { config, registry, theme, themeDir } = loaded
|
|
816
|
+
const sourcePath = realpathSync(loaded.configPath)
|
|
817
|
+
const sourceFingerprint = createHash("sha256")
|
|
818
|
+
.update(readFileSync(sourcePath, "utf8"))
|
|
819
|
+
.digest("hex")
|
|
820
|
+
|
|
821
|
+
const decks: RuntimeDeck[] = Object.entries(config.decks).flatMap(
|
|
822
|
+
([id, d]) => {
|
|
823
|
+
const objectButtons = d.buttons.flatMap((b, sourceButtonIndex) =>
|
|
824
|
+
typeof b === "string" ? [] : [{ ...b, sourceButtonIndex }],
|
|
825
|
+
)
|
|
826
|
+
// ponytail: recompute from snapshot every keyCount change — prior state
|
|
827
|
+
// is stale the moment keyCount differs. positionButtons is the
|
|
828
|
+
// authoritative assignment for the current device.
|
|
829
|
+
const runtimeButtons: RuntimeDeck["buttons"] = positionButtons(
|
|
830
|
+
objectButtons,
|
|
831
|
+
keyCount,
|
|
832
|
+
logger,
|
|
833
|
+
).map((b) => ({
|
|
834
|
+
id: `${b.position}-${id}-0`,
|
|
835
|
+
type: b.type,
|
|
836
|
+
...(b.position !== undefined ? { position: b.position } : {}),
|
|
837
|
+
...(typeof b.config === "object" && b.config !== null
|
|
838
|
+
? { config: b.config }
|
|
839
|
+
: {}),
|
|
840
|
+
...(b.actions !== undefined ? { actions: b.actions } : {}),
|
|
841
|
+
...(b.variant !== undefined && b.variant.length > 0
|
|
842
|
+
? { variant: b.variant }
|
|
843
|
+
: {}),
|
|
844
|
+
...(b.buttonColor !== undefined ? { buttonColor: b.buttonColor } : {}),
|
|
845
|
+
sourceTarget: {
|
|
846
|
+
sourcePath,
|
|
847
|
+
sourceDeckId: id,
|
|
848
|
+
sourceButtonIndex: b.sourceButtonIndex,
|
|
849
|
+
sourceButtonPath: `decks.${id}.buttons[${b.sourceButtonIndex}]`,
|
|
850
|
+
fingerprint: sourceFingerprint,
|
|
851
|
+
capability: "update" as const,
|
|
852
|
+
},
|
|
853
|
+
}))
|
|
854
|
+
const processNames =
|
|
855
|
+
d.trigger?.process_name !== undefined
|
|
856
|
+
? Array.isArray(d.trigger.process_name)
|
|
857
|
+
? d.trigger.process_name
|
|
858
|
+
: [d.trigger.process_name]
|
|
859
|
+
: undefined
|
|
860
|
+
const windowNames =
|
|
861
|
+
d.trigger?.window_name !== undefined
|
|
862
|
+
? Array.isArray(d.trigger.window_name)
|
|
863
|
+
? d.trigger.window_name
|
|
864
|
+
: [d.trigger.window_name]
|
|
865
|
+
: undefined
|
|
866
|
+
const sharedDeckFields = {
|
|
867
|
+
sourceDeckId: id,
|
|
868
|
+
editable: true,
|
|
869
|
+
isOverlay:
|
|
870
|
+
(processNames?.length ?? 0) > 0 || (windowNames?.length ?? 0) > 0,
|
|
871
|
+
paginated: d.paginated === true || runtimeButtons.length > keyCount - 1,
|
|
872
|
+
isMain: id === "main",
|
|
873
|
+
...(processNames !== undefined ? { processNames } : {}),
|
|
874
|
+
...(windowNames !== undefined ? { windowNames } : {}),
|
|
875
|
+
...(d.autoShow === true ? { autoShow: true } : {}),
|
|
876
|
+
...(d.icon !== undefined ? { icon: d.icon } : {}),
|
|
877
|
+
}
|
|
878
|
+
if (runtimeButtons.length > keyCount - 1) {
|
|
879
|
+
const pages = paginateDeck({
|
|
880
|
+
baseDeckId: id,
|
|
881
|
+
buttons: runtimeButtons,
|
|
882
|
+
keyCount,
|
|
883
|
+
})
|
|
884
|
+
return pages.map((p) => {
|
|
885
|
+
const mappedButtons: RuntimeDeck["buttons"] = (
|
|
886
|
+
p.deck.buttons ?? []
|
|
887
|
+
).map((b, i) => {
|
|
888
|
+
const {
|
|
889
|
+
position,
|
|
890
|
+
type,
|
|
891
|
+
config,
|
|
892
|
+
actions,
|
|
893
|
+
buttonColor,
|
|
894
|
+
sourceTarget,
|
|
895
|
+
id: _btnId,
|
|
896
|
+
...rest
|
|
897
|
+
} = b as {
|
|
898
|
+
position?: number
|
|
899
|
+
type: string
|
|
900
|
+
config?: unknown
|
|
901
|
+
actions?: RuntimeDeck["buttons"][number]["actions"]
|
|
902
|
+
buttonColor?:
|
|
903
|
+
| "blue"
|
|
904
|
+
| "green"
|
|
905
|
+
| "purple"
|
|
906
|
+
| "cyan"
|
|
907
|
+
| "magenta"
|
|
908
|
+
| "amber"
|
|
909
|
+
| "lime"
|
|
910
|
+
sourceTarget?: RuntimeDeck["buttons"][number]["sourceTarget"]
|
|
911
|
+
id?: string
|
|
912
|
+
}
|
|
913
|
+
const mergedConfig = {
|
|
914
|
+
...(typeof config === "object" && config !== null
|
|
915
|
+
? (config as Record<string, unknown>)
|
|
916
|
+
: {}),
|
|
917
|
+
...rest,
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
id: `${position}-${id}-${p.pageIndex}`,
|
|
921
|
+
type,
|
|
922
|
+
...(position !== undefined ? { position } : {}),
|
|
923
|
+
...(actions !== undefined ? { actions } : {}),
|
|
924
|
+
...(buttonColor !== undefined ? { buttonColor } : {}),
|
|
925
|
+
...(sourceTarget !== undefined ? { sourceTarget } : {}),
|
|
926
|
+
...(Object.keys(mergedConfig).length > 0
|
|
927
|
+
? { config: mergedConfig }
|
|
928
|
+
: {}),
|
|
929
|
+
}
|
|
930
|
+
})
|
|
931
|
+
return {
|
|
932
|
+
id: p.deckId,
|
|
933
|
+
name: d.name ?? id,
|
|
934
|
+
buttons: mappedButtons,
|
|
935
|
+
projectionId: p.deckId,
|
|
936
|
+
pageIndex: p.pageIndex,
|
|
937
|
+
...sharedDeckFields,
|
|
938
|
+
}
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
return [
|
|
942
|
+
{
|
|
943
|
+
id,
|
|
944
|
+
name: d.name ?? id,
|
|
945
|
+
buttons: runtimeButtons,
|
|
946
|
+
projectionId: id,
|
|
947
|
+
pageIndex: 0,
|
|
948
|
+
...sharedDeckFields,
|
|
949
|
+
},
|
|
950
|
+
]
|
|
951
|
+
},
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
// ponytail: phase 11 — collect per-addon overrides from
|
|
955
|
+
// `addons[i].config.decks.<deckId>` into a single map keyed by addon
|
|
956
|
+
// name. addonWideConfig carries opaque keys (the addon reads them in
|
|
957
|
+
// createDeck(s)); perDeck is keyed by addon-deck-id.
|
|
958
|
+
const addonConfigOverrides = buildAddonConfigOverrides(
|
|
959
|
+
config.addons ?? [],
|
|
960
|
+
loaded.addonSpecToName,
|
|
961
|
+
logger,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
const effectiveDecks: RuntimeDeck[] =
|
|
965
|
+
decks.length > 0
|
|
966
|
+
? decks
|
|
967
|
+
: [{ id: "main", name: "Main", isMain: true, buttons: [] }]
|
|
968
|
+
const sourceDecks = materializeAddonDecks(
|
|
969
|
+
registry,
|
|
970
|
+
effectiveDecks,
|
|
971
|
+
logger,
|
|
972
|
+
keyCount,
|
|
973
|
+
config.lock?.buttons,
|
|
974
|
+
addonConfigOverrides,
|
|
975
|
+
)
|
|
976
|
+
const allDecsWithSystemButtons = injectSystemButtons(sourceDecks, keyCount, {
|
|
977
|
+
lockActive,
|
|
978
|
+
})
|
|
979
|
+
const { decks: allDecks, errorsByDeck } = applyConfigErrorReplacements(
|
|
980
|
+
allDecsWithSystemButtons,
|
|
981
|
+
config,
|
|
982
|
+
registry,
|
|
983
|
+
logger,
|
|
984
|
+
)
|
|
985
|
+
const { runtime, methods, pubSub, store } = createDeckRuntime({
|
|
986
|
+
decks: allDecks,
|
|
987
|
+
logger,
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
configPath: loaded.configPath,
|
|
992
|
+
theme: {
|
|
993
|
+
name: theme.name,
|
|
994
|
+
apiVersion: theme.apiVersion,
|
|
995
|
+
manifestPath: theme.manifestPath,
|
|
996
|
+
uiOverridesPath: theme.uiOverridesPath,
|
|
997
|
+
},
|
|
998
|
+
themeDir,
|
|
999
|
+
decks: allDecks,
|
|
1000
|
+
sourceDecks,
|
|
1001
|
+
pubSub,
|
|
1002
|
+
runtime,
|
|
1003
|
+
methods,
|
|
1004
|
+
store,
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
interface DeckButtonError {
|
|
1009
|
+
position: number
|
|
1010
|
+
buttonId?: string
|
|
1011
|
+
details: string
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Walks each runtime deck and replaces any button whose (deckId, position)
|
|
1016
|
+
* was flagged as a config error with a `core:temporary-error` cell. The
|
|
1017
|
+
* full incorrect config and the full zod issues are logged once per
|
|
1018
|
+
* broken button so the operator can fix the YAML without digging through
|
|
1019
|
+
* a stack trace.
|
|
1020
|
+
*
|
|
1021
|
+
* ponytail: invalid buttons keep their assigned slot — the user sees exactly
|
|
1022
|
+
* which slot is broken. Validation uses the runtime button's assigned
|
|
1023
|
+
* position (from positionButtons), not the optional config-defined position.
|
|
1024
|
+
*
|
|
1025
|
+
* Returns the patched decks plus a per-deck error map for downstream
|
|
1026
|
+
* surfacing in `deck-config` messages.
|
|
1027
|
+
*/
|
|
1028
|
+
export const applyConfigErrorReplacements = (
|
|
1029
|
+
decks: ReadonlyArray<RuntimeDeck>,
|
|
1030
|
+
config: import("@/config/schemas").RawConfig,
|
|
1031
|
+
registry: AddonRegistry,
|
|
1032
|
+
logger: pino.Logger,
|
|
1033
|
+
): {
|
|
1034
|
+
decks: RuntimeDeck[]
|
|
1035
|
+
errorsByDeck: Map<string, DeckButtonError[]>
|
|
1036
|
+
} => {
|
|
1037
|
+
const brokenByDeckPosition = new Map<
|
|
1038
|
+
string,
|
|
1039
|
+
Map<number, { details: string; button: unknown }>
|
|
1040
|
+
>()
|
|
1041
|
+
|
|
1042
|
+
for (const deck of decks) {
|
|
1043
|
+
for (const btn of deck.buttons ?? []) {
|
|
1044
|
+
// ponytail: skip addon-injected decks — their buttons have semantic ids
|
|
1045
|
+
// (e.g. brightness-down, app-info, back) and `internal:true` services;
|
|
1046
|
+
// re-validating them via the user-config schema always fails.
|
|
1047
|
+
const isAddonInjectedDeck = deck.id.startsWith("internal-settings:")
|
|
1048
|
+
if (isAddonInjectedDeck) continue
|
|
1049
|
+
const position = btn.position
|
|
1050
|
+
if (position === undefined) continue
|
|
1051
|
+
// ponytail: skip system buttons — they are injected, not user-configured
|
|
1052
|
+
if (btn.type.startsWith("core:")) continue
|
|
1053
|
+
|
|
1054
|
+
const path = `decks.${deck.id}.buttons[@position:${position}]`
|
|
1055
|
+
const { issues, schemaIssues } = validateButton(
|
|
1056
|
+
btn as unknown as import("@/config/schemas").RawButtonDef,
|
|
1057
|
+
registry,
|
|
1058
|
+
path,
|
|
1059
|
+
deck.id,
|
|
1060
|
+
position,
|
|
1061
|
+
)
|
|
1062
|
+
if (issues.length === 0) continue
|
|
1063
|
+
|
|
1064
|
+
const details =
|
|
1065
|
+
schemaIssues
|
|
1066
|
+
.map((i) => `${[...i.path].join(".") || "(root)"}: ${i.message}`)
|
|
1067
|
+
.join("; ") || issues.map((i) => i.message).join("; ")
|
|
1068
|
+
logger.warn(
|
|
1069
|
+
{
|
|
1070
|
+
deckId: deck.id,
|
|
1071
|
+
position,
|
|
1072
|
+
buttonId: btn.id,
|
|
1073
|
+
path,
|
|
1074
|
+
config: btn,
|
|
1075
|
+
schemaIssues,
|
|
1076
|
+
},
|
|
1077
|
+
`invalid button at ${deck.id}:${position}: ${details}`,
|
|
1078
|
+
)
|
|
1079
|
+
let perDeckMap = brokenByDeckPosition.get(deck.id)
|
|
1080
|
+
if (perDeckMap === undefined) {
|
|
1081
|
+
perDeckMap = new Map()
|
|
1082
|
+
brokenByDeckPosition.set(deck.id, perDeckMap)
|
|
1083
|
+
}
|
|
1084
|
+
perDeckMap.set(position, { details, button: btn })
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const errorsByDeck = new Map<string, DeckButtonError[]>()
|
|
1089
|
+
const patched = decks.map((deck) => {
|
|
1090
|
+
const brokenMap = brokenByDeckPosition.get(deck.id)
|
|
1091
|
+
if (brokenMap === undefined || brokenMap.size === 0) return deck
|
|
1092
|
+
const errors: DeckButtonError[] = []
|
|
1093
|
+
const buttons = deck.buttons.map((btn) => {
|
|
1094
|
+
const position = btn.position
|
|
1095
|
+
if (position === undefined) return btn
|
|
1096
|
+
const broken = brokenMap.get(position)
|
|
1097
|
+
if (broken === undefined) return btn
|
|
1098
|
+
errors.push({ position, details: broken.details })
|
|
1099
|
+
return {
|
|
1100
|
+
id: `${position}-${deck.id}-0`,
|
|
1101
|
+
type: "core:temporary-error" as const,
|
|
1102
|
+
position,
|
|
1103
|
+
config: { details: broken.details },
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
errorsByDeck.set(deck.id, errors)
|
|
1107
|
+
return { ...deck, buttons, buttonErrors: errors }
|
|
1108
|
+
})
|
|
1109
|
+
return { decks: patched, errorsByDeck }
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const loadConfigAndTheme = async (
|
|
1113
|
+
options: RunOptions,
|
|
1114
|
+
keyCount: number = 15,
|
|
1115
|
+
): Promise<LoadConfigAndThemeResult> => {
|
|
1116
|
+
const loaded = await validateAndLoadConfig(options)
|
|
1117
|
+
return buildRuntime(options, loaded, keyCount)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
interface SystemProviders {
|
|
1121
|
+
readonly activeApp: ActiveAppProvider
|
|
1122
|
+
readonly session: import("@/system/providers/session").SessionProvider
|
|
1123
|
+
readonly keyMacro: import("@/system/providers/key-macro").KeyMacroProvider
|
|
1124
|
+
readonly notification: import("@/system/providers/notification").NotificationProvider
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const startSystemProviders = async (
|
|
1128
|
+
options: RunOptions,
|
|
1129
|
+
runtime: Runtime,
|
|
1130
|
+
methods: Methods,
|
|
1131
|
+
): Promise<SystemProviders> => {
|
|
1132
|
+
const { logger } = options
|
|
1133
|
+
const { spawn } = await import("node:child_process")
|
|
1134
|
+
const executor: CommandExecutor = {
|
|
1135
|
+
async run(
|
|
1136
|
+
command: string,
|
|
1137
|
+
args: ReadonlyArray<string>,
|
|
1138
|
+
execOptions?: { timeoutMs?: number },
|
|
1139
|
+
) {
|
|
1140
|
+
// Uses 'exit' (not 'close') so tools that keep stdio fds open after their
|
|
1141
|
+
// main exits — notably wl-copy — don't hang the runtime. Streams are
|
|
1142
|
+
// drained via 'data' events until 'exit' fires; once the process exits,
|
|
1143
|
+
// no further writes are possible.
|
|
1144
|
+
const timeoutMs = execOptions?.timeoutMs
|
|
1145
|
+
const start = Date.now()
|
|
1146
|
+
return await new Promise((resolve) => {
|
|
1147
|
+
const proc = spawn(command, [...args], {
|
|
1148
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1149
|
+
})
|
|
1150
|
+
let stdout = ""
|
|
1151
|
+
let stderr = ""
|
|
1152
|
+
let timedOut = false
|
|
1153
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
1154
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
1155
|
+
stdout += chunk.toString()
|
|
1156
|
+
})
|
|
1157
|
+
proc.stderr.on("data", (chunk: Buffer) => {
|
|
1158
|
+
stderr += chunk.toString()
|
|
1159
|
+
})
|
|
1160
|
+
const onExit = (code: number | null): void => {
|
|
1161
|
+
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
1162
|
+
resolve({
|
|
1163
|
+
exitCode: timedOut ? -1 : (code ?? -1),
|
|
1164
|
+
stdout,
|
|
1165
|
+
stderr,
|
|
1166
|
+
})
|
|
1167
|
+
}
|
|
1168
|
+
proc.on("error", (err) => {
|
|
1169
|
+
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
1170
|
+
resolve({
|
|
1171
|
+
exitCode: -1,
|
|
1172
|
+
stdout,
|
|
1173
|
+
stderr: stderr ? `${stderr}\n${err.message}` : err.message,
|
|
1174
|
+
})
|
|
1175
|
+
})
|
|
1176
|
+
proc.on("exit", onExit)
|
|
1177
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1178
|
+
killTimer = setTimeout(() => {
|
|
1179
|
+
timedOut = true
|
|
1180
|
+
proc.kill("SIGKILL")
|
|
1181
|
+
}, timeoutMs)
|
|
1182
|
+
}
|
|
1183
|
+
})
|
|
1184
|
+
},
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const env = { ...process.env } as Readonly<Record<string, string>>
|
|
1188
|
+
const platform = process.platform
|
|
1189
|
+
|
|
1190
|
+
// ponytail: when the CLI is launched from a stripped-PATH environment
|
|
1191
|
+
// (systemd/launchd/IDE), `which ydotool` returns nothing even when the
|
|
1192
|
+
// binary exists at /usr/local/bin. Probe a few well-known install dirs
|
|
1193
|
+
// as a fallback so the requirements check doesn't false-negative.
|
|
1194
|
+
const wellKnownBinDirs = [
|
|
1195
|
+
"/usr/local/bin",
|
|
1196
|
+
"/usr/bin",
|
|
1197
|
+
`${homedir()}/.local/bin`,
|
|
1198
|
+
"/snap/bin",
|
|
1199
|
+
"/opt/homebrew/bin",
|
|
1200
|
+
]
|
|
1201
|
+
const extraFsProbe = (command: string): boolean => {
|
|
1202
|
+
for (const dir of wellKnownBinDirs) {
|
|
1203
|
+
if (existsSync(join(dir, command))) return true
|
|
1204
|
+
}
|
|
1205
|
+
return false
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const requirements = await checkRequirements({
|
|
1209
|
+
platform,
|
|
1210
|
+
executor,
|
|
1211
|
+
env,
|
|
1212
|
+
extraFsProbe,
|
|
1213
|
+
})
|
|
1214
|
+
methods.setRequirements(requirements)
|
|
1215
|
+
for (const [capability, status] of Object.entries(requirements)) {
|
|
1216
|
+
const warning = formatCapabilityWarning(
|
|
1217
|
+
capability as SystemCapability,
|
|
1218
|
+
status,
|
|
1219
|
+
)
|
|
1220
|
+
if (warning.length > 0) {
|
|
1221
|
+
logger.warn({ capability, status }, warning)
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const [activeApp, session, keyMacro, notification] = await Promise.all([
|
|
1226
|
+
createActiveAppProvider({ platform, executor, logger }),
|
|
1227
|
+
options.emulator === true
|
|
1228
|
+
? createNullSessionProvider(logger)
|
|
1229
|
+
: createSessionProvider({
|
|
1230
|
+
platform,
|
|
1231
|
+
executor,
|
|
1232
|
+
logger,
|
|
1233
|
+
}),
|
|
1234
|
+
createKeyMacroProvider({ platform, executor, env, logger, extraFsProbe }),
|
|
1235
|
+
createNotificationProvider({
|
|
1236
|
+
platform,
|
|
1237
|
+
executor,
|
|
1238
|
+
env,
|
|
1239
|
+
logger,
|
|
1240
|
+
extraFsProbe,
|
|
1241
|
+
}),
|
|
1242
|
+
])
|
|
1243
|
+
|
|
1244
|
+
runtime.setActiveAppProvider(activeApp)
|
|
1245
|
+
runtime.setSessionProvider(session)
|
|
1246
|
+
methods.setKeyMacroProvider(keyMacro)
|
|
1247
|
+
methods.setNotificationProvider(notification)
|
|
1248
|
+
|
|
1249
|
+
return { activeApp, session, keyMacro, notification }
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
interface AddonRegistryBundle {
|
|
1253
|
+
readonly scanned: ReadonlyArray<ScannedAddon>
|
|
1254
|
+
readonly addonByType: Map<string, AddonFrontendRef>
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// ponytail: shape a ScannedAddon into the addon-spec the frontend vite
|
|
1258
|
+
// plugin expects (see packages/cli/src/vite/virtual-modules.ts). Both
|
|
1259
|
+
// builtins and third-party addons share the same shape — only the source
|
|
1260
|
+
// of `frontendEntry` differs. Reused by the runPipeline pass that merges
|
|
1261
|
+
// externalScanned into SIRENO_ADDONS after both scanned arrays exist.
|
|
1262
|
+
// ponytail: vite serves the ADDON SOURCE, not the bundled dist. The dist
|
|
1263
|
+
// bundle aliases host-UI imports (e.g. '@/ui/primitives/Label') to an
|
|
1264
|
+
// inert stub so plain Node can import it for manifest/globalService —
|
|
1265
|
+
// serving that artifact to the browser amputates every core-UI label.
|
|
1266
|
+
// Source restores vite's real host-alias resolution, so addons get the
|
|
1267
|
+
// full core kit (any @sirenodeck/sirenodeck/ui component). Falls back to the
|
|
1268
|
+
// dist entry when src/index.ts doesn't exist (npm-cached third-party
|
|
1269
|
+
// addons ship compiled output only).
|
|
1270
|
+
const browserFrontendMain = (entry: string): string => {
|
|
1271
|
+
const dir = dirname(entry)
|
|
1272
|
+
// Entry lives in <pkg>/dist (JSON-manifest addons, compiled) or directly
|
|
1273
|
+
// in <pkg> (regex-scanned builtins' index.ts). Normalize to pkg root.
|
|
1274
|
+
const pkgRoot = basename(dir) === "dist" ? dirname(dir) : dir
|
|
1275
|
+
const src = resolvePath(pkgRoot, "src", "index.ts")
|
|
1276
|
+
return existsSync(src) ? src : entry
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export const addonSpecFromScanned = (s: ScannedAddon) => ({
|
|
1280
|
+
name: s.name,
|
|
1281
|
+
frontend:
|
|
1282
|
+
s.browserEntry != null
|
|
1283
|
+
? { main: browserFrontendMain(s.browserEntry) }
|
|
1284
|
+
: s.frontendEntry !== null
|
|
1285
|
+
? { main: browserFrontendMain(s.frontendEntry) }
|
|
1286
|
+
: undefined,
|
|
1287
|
+
buttons: s.types.map((t) => ({ type: t })),
|
|
1288
|
+
buttonTypes: Object.fromEntries(
|
|
1289
|
+
Object.entries(s.buttonTypes).map(([type, info]) => [
|
|
1290
|
+
type,
|
|
1291
|
+
info.exportName,
|
|
1292
|
+
]),
|
|
1293
|
+
),
|
|
1294
|
+
defaultButton: s.defaultButton,
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
const addonOverrideFields: Array<
|
|
1298
|
+
"name" | "icon" | "autoShow" | "trigger" | "config"
|
|
1299
|
+
> = ["name", "icon", "autoShow", "trigger", "config"]
|
|
1300
|
+
|
|
1301
|
+
// ponytail: shape a ScannedAddon into the AddonsPage inventory entry the
|
|
1302
|
+
// emulator renders. Mirrors the AddonsPage TypeScript shape so the WS
|
|
1303
|
+
// message can flow straight into a prop without further mapping.
|
|
1304
|
+
export const addonInventoryFromScanned = (
|
|
1305
|
+
s: ScannedAddon,
|
|
1306
|
+
addonIndex = 0,
|
|
1307
|
+
materializedDecks: ReadonlyArray<RuntimeDeck> = [],
|
|
1308
|
+
defaultConfig?: unknown,
|
|
1309
|
+
): AddonInventoryEntry => {
|
|
1310
|
+
const generatedDecks = materializedDecks.filter(
|
|
1311
|
+
(deck) => deck.addonOwner?.addonName === s.name,
|
|
1312
|
+
)
|
|
1313
|
+
const generatedIds = new Set(
|
|
1314
|
+
generatedDecks.map((deck) => deck.sourceDeckId ?? deck.id),
|
|
1315
|
+
)
|
|
1316
|
+
const sourceDecks = s.decks
|
|
1317
|
+
.filter(
|
|
1318
|
+
(deck) => !deck.id.endsWith(":__multi__") && !generatedIds.has(deck.id),
|
|
1319
|
+
)
|
|
1320
|
+
.map((deck) => ({
|
|
1321
|
+
id: deck.id,
|
|
1322
|
+
generated: false,
|
|
1323
|
+
pageIndex: 0,
|
|
1324
|
+
isOverlay: deck.isOverlay === true || deck.hasTrigger,
|
|
1325
|
+
paginated: deck.paginated,
|
|
1326
|
+
buttons: [],
|
|
1327
|
+
internal: deck.internal,
|
|
1328
|
+
addonIndex,
|
|
1329
|
+
overrideKey: deck.id,
|
|
1330
|
+
overrideFields: addonOverrideFields,
|
|
1331
|
+
}))
|
|
1332
|
+
const decks = [
|
|
1333
|
+
...sourceDecks,
|
|
1334
|
+
...generatedDecks.map((deck) => ({
|
|
1335
|
+
id: deck.id,
|
|
1336
|
+
sourceId: deck.sourceDeckId,
|
|
1337
|
+
generated: true,
|
|
1338
|
+
pageIndex: deck.pageIndex ?? 0,
|
|
1339
|
+
isOverlay: deck.isOverlay === true || deck.isOverlayDeck === true,
|
|
1340
|
+
paginated: deck.paginated === true,
|
|
1341
|
+
buttons: deck.buttons.map((button) => ({
|
|
1342
|
+
type: button.type,
|
|
1343
|
+
generated: true,
|
|
1344
|
+
...(button.position !== undefined ? { position: button.position } : {}),
|
|
1345
|
+
...(button.config !== undefined ? { config: button.config } : {}),
|
|
1346
|
+
})),
|
|
1347
|
+
internal:
|
|
1348
|
+
s.decks.find((source) => source.id === deck.sourceDeckId)?.internal ??
|
|
1349
|
+
false,
|
|
1350
|
+
addonIndex: deck.addonOwner?.addonIndex ?? addonIndex,
|
|
1351
|
+
overrideKey: deck.addonOwner?.overrideKey ?? deck.sourceDeckId ?? deck.id,
|
|
1352
|
+
overrideFields: addonOverrideFields,
|
|
1353
|
+
})),
|
|
1354
|
+
]
|
|
1355
|
+
return {
|
|
1356
|
+
name: s.name,
|
|
1357
|
+
addonIndex,
|
|
1358
|
+
...(s.path !== undefined ? { path: s.path } : {}),
|
|
1359
|
+
internal: s.internal === true,
|
|
1360
|
+
source: s.source,
|
|
1361
|
+
buttonTypes: Object.entries(s.buttonTypes).map(([type, info]) => ({
|
|
1362
|
+
type,
|
|
1363
|
+
internal: info.internal,
|
|
1364
|
+
generated: false,
|
|
1365
|
+
...(info.defaultConfig !== undefined
|
|
1366
|
+
? { defaultConfig: info.defaultConfig }
|
|
1367
|
+
: {}),
|
|
1368
|
+
})),
|
|
1369
|
+
defaultButton: s.defaultButton ?? null,
|
|
1370
|
+
...(defaultConfig !== undefined ? { defaultConfig } : {}),
|
|
1371
|
+
decks,
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const buildAddonBundle = async (): Promise<AddonRegistryBundle> => {
|
|
1376
|
+
const registry = await collectBuiltinAddonRegistry()
|
|
1377
|
+
|
|
1378
|
+
return { scanned: registry.scanned, addonByType: registry.byType }
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ponytail: previously SIRENO_ADDONS was populated inside buildAddonBundle
|
|
1382
|
+
// from builtins only, so third-party addons (e.g. addon-pomodoro) never
|
|
1383
|
+
// reached the frontend vite plugin's addonRegistry — ButtonSurface hit
|
|
1384
|
+
// the `registryEntry === undefined` branch and rendered an empty
|
|
1385
|
+
// ButtonFrame. External addons are registered via buildExternalScannedAddons
|
|
1386
|
+
// after validateAndLoadConfig, so the merged env-var write has to happen
|
|
1387
|
+
// here in runPipeline, after both scanned arrays exist.
|
|
1388
|
+
export const publishSIRENO_ADDONS = (
|
|
1389
|
+
scanned: ReadonlyArray<ScannedAddon>,
|
|
1390
|
+
externalScanned: ReadonlyArray<ScannedAddon>,
|
|
1391
|
+
): void => {
|
|
1392
|
+
if (process.env["SIRENO_ADDONS"] !== undefined) return
|
|
1393
|
+
process.env["SIRENO_ADDONS"] = JSON.stringify(
|
|
1394
|
+
[...scanned, ...externalScanned].map(addonSpecFromScanned),
|
|
1395
|
+
)
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// ponytail: merge registry-loaded third-party addons into the bridge
|
|
1399
|
+
// inventory. The bridge only receives the builtin `scanned` array; without
|
|
1400
|
+
// this pass, AddonsPage shows builtins only and hides e.g. chrome-overlay.
|
|
1401
|
+
export const buildExternalScannedAddons = (
|
|
1402
|
+
registry: AddonRegistry,
|
|
1403
|
+
builtinScanned: ReadonlyArray<ScannedAddon>,
|
|
1404
|
+
externalAddonDirs: ReadonlyMap<string, string>,
|
|
1405
|
+
externalEntryPaths: ReadonlyMap<string, string>,
|
|
1406
|
+
externalBrowserEntryPaths: ReadonlyMap<string, string> = new Map(),
|
|
1407
|
+
): ReadonlyArray<ScannedAddon> => {
|
|
1408
|
+
const builtinNames = new Set(builtinScanned.map((a) => a.name))
|
|
1409
|
+
const out: ScannedAddon[] = []
|
|
1410
|
+
for (const manifest of registry.listAddons()) {
|
|
1411
|
+
if (builtinNames.has(manifest.name)) continue
|
|
1412
|
+
const types = Object.keys(manifest.buttonTypes)
|
|
1413
|
+
if (types.length === 0 && (manifest.decks ?? []).length === 0) continue
|
|
1414
|
+
const path = externalAddonDirs.get(manifest.name) ?? ""
|
|
1415
|
+
const entryPath = externalEntryPaths.get(manifest.name) ?? ""
|
|
1416
|
+
const browserPath = externalBrowserEntryPaths.get(manifest.name) ?? ""
|
|
1417
|
+
const buttonTypes: Record<string, ScannedButtonType> = {}
|
|
1418
|
+
for (const [type, def] of Object.entries(manifest.buttonTypes)) {
|
|
1419
|
+
buttonTypes[type] = {
|
|
1420
|
+
exportName: def.name ?? type,
|
|
1421
|
+
internal: def.service?.internal === true || def.internal === true,
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const decks: ScannedDeck[] = []
|
|
1425
|
+
for (const entry of manifest.decks ?? []) {
|
|
1426
|
+
if (typeof entry.createDecks === "function") {
|
|
1427
|
+
decks.push({
|
|
1428
|
+
id: `${manifest.name}:__multi__`,
|
|
1429
|
+
hasTrigger: false,
|
|
1430
|
+
paginated: false,
|
|
1431
|
+
buttons: -1,
|
|
1432
|
+
internal: entry.internal ?? false,
|
|
1433
|
+
})
|
|
1434
|
+
} else if (typeof entry.createDeck === "function") {
|
|
1435
|
+
const trigger = (entry as { trigger?: unknown }).trigger as
|
|
1436
|
+
| Record<string, unknown>
|
|
1437
|
+
| undefined
|
|
1438
|
+
const hasTrigger =
|
|
1439
|
+
trigger !== undefined &&
|
|
1440
|
+
(trigger["process_name"] !== undefined ||
|
|
1441
|
+
trigger["window_name"] !== undefined)
|
|
1442
|
+
decks.push({
|
|
1443
|
+
id: entry.id,
|
|
1444
|
+
hasTrigger,
|
|
1445
|
+
paginated: hasTrigger,
|
|
1446
|
+
buttons: -1,
|
|
1447
|
+
internal: entry.internal ?? false,
|
|
1448
|
+
})
|
|
1449
|
+
} else {
|
|
1450
|
+
const trigger = (entry as { trigger?: unknown }).trigger as
|
|
1451
|
+
| Record<string, unknown>
|
|
1452
|
+
| undefined
|
|
1453
|
+
const hasTrigger =
|
|
1454
|
+
trigger !== undefined &&
|
|
1455
|
+
(trigger["process_name"] !== undefined ||
|
|
1456
|
+
trigger["window_name"] !== undefined)
|
|
1457
|
+
decks.push({
|
|
1458
|
+
id: entry.id,
|
|
1459
|
+
hasTrigger,
|
|
1460
|
+
paginated: hasTrigger,
|
|
1461
|
+
buttons: Array.isArray(entry.buttons) ? entry.buttons.length : 0,
|
|
1462
|
+
internal: entry.internal ?? false,
|
|
1463
|
+
})
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const hasButtonTypes = types.length > 0
|
|
1467
|
+
const hasGlobalService = manifest.globalService !== undefined
|
|
1468
|
+
// ponytail: both slots point to the same entry file because one module
|
|
1469
|
+
// exports both `manifest.buttonTypes` and `manifest.globalService`. A
|
|
1470
|
+
// third-party addon that supplies only decks gets `null` for both and
|
|
1471
|
+
// the bridge skips its frontend/global service wiring (today's behavior).
|
|
1472
|
+
// ponytail: frontendEntry/globalServiceEntry are imported by the DAEMON
|
|
1473
|
+
// (addon-handler-bridge), so they must be the Node entry. browserEntry is
|
|
1474
|
+
// what vite serves. Collapsing them is what broke every addon button.
|
|
1475
|
+
const frontendEntry = hasButtonTypes && entryPath !== "" ? entryPath : null
|
|
1476
|
+
const globalServiceEntry =
|
|
1477
|
+
hasGlobalService && entryPath !== "" ? entryPath : null
|
|
1478
|
+
const browserEntry =
|
|
1479
|
+
hasButtonTypes && browserPath !== "" ? browserPath : null
|
|
1480
|
+
out.push({
|
|
1481
|
+
name: manifest.name,
|
|
1482
|
+
types,
|
|
1483
|
+
frontendEntry,
|
|
1484
|
+
browserEntry,
|
|
1485
|
+
publishIntervalMs: manifest.publishIntervalMs ?? null,
|
|
1486
|
+
pollerEntry: null,
|
|
1487
|
+
buttonTypes,
|
|
1488
|
+
deckTypes: {},
|
|
1489
|
+
source: "json",
|
|
1490
|
+
globalServiceEntry,
|
|
1491
|
+
path,
|
|
1492
|
+
internal: false,
|
|
1493
|
+
decks,
|
|
1494
|
+
})
|
|
1495
|
+
}
|
|
1496
|
+
return out
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const mergeAddonByType = (
|
|
1500
|
+
baseByType: Map<string, AddonFrontendRef>,
|
|
1501
|
+
externalScanned: ReadonlyArray<ScannedAddon>,
|
|
1502
|
+
): Map<string, AddonFrontendRef> => {
|
|
1503
|
+
const merged = new Map(baseByType)
|
|
1504
|
+
for (const addon of externalScanned) {
|
|
1505
|
+
for (const type of addon.types) {
|
|
1506
|
+
if (!merged.has(type)) {
|
|
1507
|
+
merged.set(type, {
|
|
1508
|
+
name: addon.name,
|
|
1509
|
+
frontendEntry: addon.frontendEntry,
|
|
1510
|
+
})
|
|
1511
|
+
}
|
|
1512
|
+
if (type === `${addon.name}:${addon.name}` && !merged.has(addon.name)) {
|
|
1513
|
+
merged.set(addon.name, {
|
|
1514
|
+
name: addon.name,
|
|
1515
|
+
frontendEntry: addon.frontendEntry,
|
|
1516
|
+
})
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return merged
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// ponytail: extracted so tests can exercise the addon-spec-to-dir mapping
|
|
1524
|
+
// without spinning up the full pipeline (WS bridge, output client, etc.).
|
|
1525
|
+
// Reads `addons[]` from the parsed config — entries are either a raw string
|
|
1526
|
+
// spec or `{src, enabled?, config?}`. The basename of the resolved path
|
|
1527
|
+
// becomes the addon key for icon resolution; e.g. `{src: /p/chrome-overlay}`
|
|
1528
|
+
// maps `addon://chrome-overlay/assets/foo.svg` to `/p/chrome-overlay/assets/foo.svg`.
|
|
1529
|
+
export const buildExternalAddonDirs = (
|
|
1530
|
+
addonEntries: ReadonlyArray<unknown>,
|
|
1531
|
+
configPath: string,
|
|
1532
|
+
addonEntryPaths: ReadonlyMap<string, string> = new Map(),
|
|
1533
|
+
): Map<string, string> => {
|
|
1534
|
+
const result = new Map<string, string>()
|
|
1535
|
+
// ponytail: prefer the LOADED addons. Deriving the key from the config entry
|
|
1536
|
+
// only works for local paths — an npm specifier like
|
|
1537
|
+
// `@sirenodeck/addon-app-shortcuts` resolved to a non-existent
|
|
1538
|
+
// `<configDir>/@sirenodeck/addon-app-shortcuts` and keyed the map on
|
|
1539
|
+
// "addon-app-shortcuts", while its icons say `addon://app-shortcuts/...`.
|
|
1540
|
+
// Every npm addon's icons therefore failed with "unknown addon dir".
|
|
1541
|
+
// `addonEntryPaths` is keyed by manifest name and points at the real entry
|
|
1542
|
+
// file, whose dirname is the `<pkg>/dist` the assets sit beside.
|
|
1543
|
+
const resolvedKeys = new Set(addonEntryPaths.keys())
|
|
1544
|
+
for (const [name, entryPath] of addonEntryPaths) {
|
|
1545
|
+
result.set(name, dirname(entryPath))
|
|
1546
|
+
}
|
|
1547
|
+
for (const entry of addonEntries) {
|
|
1548
|
+
const source =
|
|
1549
|
+
typeof entry === "string"
|
|
1550
|
+
? entry
|
|
1551
|
+
: typeof entry === "object" &&
|
|
1552
|
+
entry !== null &&
|
|
1553
|
+
typeof (entry as { src?: unknown }).src === "string"
|
|
1554
|
+
? (entry as { src: string }).src
|
|
1555
|
+
: null
|
|
1556
|
+
if (source === null) continue
|
|
1557
|
+
const expanded = source.startsWith("~/")
|
|
1558
|
+
? join(homedir(), source.slice(2))
|
|
1559
|
+
: source
|
|
1560
|
+
const abs = isAbsolute(expanded)
|
|
1561
|
+
? expanded
|
|
1562
|
+
: resolvePath(dirname(configPath), expanded)
|
|
1563
|
+
// ponytail: addon:// asset resolution joins this dir + the icon's
|
|
1564
|
+
// subpath. Addons ship assets in <pkg>/dist/assets (post-build copy),
|
|
1565
|
+
// so prefer dist when present — also prevents this map from
|
|
1566
|
+
// clobbering the correct dirname(frontendEntry) registration in
|
|
1567
|
+
// buildResolverOptions. Source-only addons keep their root.
|
|
1568
|
+
const base = existsSync(join(abs, "dist")) ? join(abs, "dist") : abs
|
|
1569
|
+
// Never let the config-entry guess override a RESOLVED addon's real dir,
|
|
1570
|
+
// but keep last-wins among the guesses themselves (two entries whose paths
|
|
1571
|
+
// share a basename still resolve to the later one, as before).
|
|
1572
|
+
if (!resolvedKeys.has(basename(abs))) result.set(basename(abs), base)
|
|
1573
|
+
}
|
|
1574
|
+
return result
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
export const runPipeline = async (options: RunOptions): Promise<void> => {
|
|
1578
|
+
const { logger } = options
|
|
1579
|
+
|
|
1580
|
+
const xdgConfigHome = resolveXdgConfigHome(options)
|
|
1581
|
+
|
|
1582
|
+
// ponytail: hoist every long-lived resource so the unconditional cleanup
|
|
1583
|
+
// block below runs even when outputClient.init() throws (e.g. Vite can't
|
|
1584
|
+
// bind the frontend port). Without this, the heartbeat setInterval, the
|
|
1585
|
+
// WS bridge, providers, and onConnection listeners all stay alive after
|
|
1586
|
+
// the rejection — the daemon process never exits and the user reports
|
|
1587
|
+
// "the app gets stuck and the service never starts".
|
|
1588
|
+
let loadedConfig: Awaited<ReturnType<typeof validateAndLoadConfig>> | null =
|
|
1589
|
+
null
|
|
1590
|
+
let outputClient: OutputClient | null = null
|
|
1591
|
+
let providers: Awaited<ReturnType<typeof startSystemProviders>> | null = null
|
|
1592
|
+
let addonBundle: Awaited<ReturnType<typeof buildAddonBundle>> | null = null
|
|
1593
|
+
let bridge: Awaited<ReturnType<typeof startWsBridge>> | null = null
|
|
1594
|
+
let addonServices: ReturnType<typeof setupAddonServices> | null = null
|
|
1595
|
+
let runtime: ReturnType<typeof buildRuntime>["runtime"] | null = null
|
|
1596
|
+
let methods: ReturnType<typeof buildRuntime>["methods"] | null = null
|
|
1597
|
+
let decks: ReadonlyArray<RuntimeDeck> | null = null
|
|
1598
|
+
let themeDir: string | null = null
|
|
1599
|
+
let pubSub: PubSub | null = null
|
|
1600
|
+
let store: Store | null = null
|
|
1601
|
+
let descriptor: Awaited<ReturnType<OutputClient["selectDevice"]>> | null =
|
|
1602
|
+
null
|
|
1603
|
+
let addonServicesDispose: (() => void) | null = null
|
|
1604
|
+
let bridgeSignal: AbortController | null = null
|
|
1605
|
+
let statePublisher: StatePublisher | null = null
|
|
1606
|
+
let configWatcher: ConfigWatcher | null = null
|
|
1607
|
+
let outputHandle: OutputHandle | null = null
|
|
1608
|
+
let currentOutputHandle: OutputHandle | null = null
|
|
1609
|
+
let unregisterSignal: (() => void) | null = null
|
|
1610
|
+
let unregisterReload: (() => void) | null = null
|
|
1611
|
+
let unsubscribeLockMode: (() => void) | null = null
|
|
1612
|
+
let removeServiceLogListener: (() => void) | null = null
|
|
1613
|
+
let editorMutationService: ReturnType<
|
|
1614
|
+
typeof createConfigMutationService
|
|
1615
|
+
> | null = null
|
|
1616
|
+
let editorHandler: EditorMessageHandler | null = null
|
|
1617
|
+
let resolveDone: () => void = () => undefined
|
|
1618
|
+
let resolveDoneForCrash: () => void = () => undefined
|
|
1619
|
+
const done = new Promise<void>((resolve) => {
|
|
1620
|
+
resolveDone = resolve
|
|
1621
|
+
resolveDoneForCrash = resolve
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
try {
|
|
1625
|
+
// Validate config first so a broken YAML exits before we ever touch
|
|
1626
|
+
// hardware or spawn an emulator.
|
|
1627
|
+
loadedConfig = await validateAndLoadConfig(options)
|
|
1628
|
+
|
|
1629
|
+
outputClient = selectOutputClient({
|
|
1630
|
+
emulator: options.emulator === true,
|
|
1631
|
+
xdgConfigHome,
|
|
1632
|
+
})
|
|
1633
|
+
const devices = await outputClient.listDevices()
|
|
1634
|
+
const savedDevice = loadDeviceConfig({ xdgConfigHome })
|
|
1635
|
+
descriptor = await outputClient.selectDevice(
|
|
1636
|
+
devices,
|
|
1637
|
+
savedDevice?.serial ?? null,
|
|
1638
|
+
logger,
|
|
1639
|
+
)
|
|
1640
|
+
await outputClient.storeSelection(descriptor)
|
|
1641
|
+
|
|
1642
|
+
const loaded = buildRuntime(options, loadedConfig, descriptor.keyCount)
|
|
1643
|
+
themeDir = loaded.themeDir
|
|
1644
|
+
decks = loaded.decks
|
|
1645
|
+
pubSub = loaded.pubSub
|
|
1646
|
+
runtime = loaded.runtime
|
|
1647
|
+
methods = loaded.methods
|
|
1648
|
+
store = loaded.store
|
|
1649
|
+
|
|
1650
|
+
providers = await startSystemProviders(options, runtime, methods)
|
|
1651
|
+
|
|
1652
|
+
const isCompact = outputClient.kind === "real"
|
|
1653
|
+
|
|
1654
|
+
addonBundle = await buildAddonBundle()
|
|
1655
|
+
|
|
1656
|
+
const remote = options.remote === true
|
|
1657
|
+
const lanAddresses = remote
|
|
1658
|
+
? selectLanAddresses({ networkInterfaces: networkInterfaces })
|
|
1659
|
+
: []
|
|
1660
|
+
const lanHost = remote ? lanAddresses[0]?.address : undefined
|
|
1661
|
+
const bridgeHost: "127.0.0.1" | "0.0.0.0" = remote ? "0.0.0.0" : "127.0.0.1"
|
|
1662
|
+
|
|
1663
|
+
bridge = await startWsBridge({
|
|
1664
|
+
port: 52937,
|
|
1665
|
+
host: bridgeHost,
|
|
1666
|
+
...(lanHost !== undefined ? { lanHost } : {}),
|
|
1667
|
+
expectedToken: process.env["SIRENO_TOKEN"] ?? "",
|
|
1668
|
+
})
|
|
1669
|
+
|
|
1670
|
+
// ponytail: register external addon dirs (from config's `addons:` list) so
|
|
1671
|
+
// `addon://<name>/assets/icon.png` resolves for overlay deck icons. The
|
|
1672
|
+
// addonDirs built from addonBundle.addonByType only contains builtins.
|
|
1673
|
+
const externalAddonDirs = buildExternalAddonDirs(
|
|
1674
|
+
loadedConfig.config.addons ?? [],
|
|
1675
|
+
loadedConfig.configPath,
|
|
1676
|
+
loadedConfig.addonEntryPaths,
|
|
1677
|
+
)
|
|
1678
|
+
|
|
1679
|
+
let resolverOptions = buildResolverOptions(
|
|
1680
|
+
addonBundle.addonByType,
|
|
1681
|
+
[dirname(loadedConfig.configPath)],
|
|
1682
|
+
externalAddonDirs,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
// ponytail: gate the n-1 system button on the session's lock state from
|
|
1686
|
+
// the first frame. The startup injection in buildRuntime ran before the
|
|
1687
|
+
// session provider existed; if the OS is locked at boot, re-inject
|
|
1688
|
+
// without the n-1 so the deck structure is honest. Re-inject on every
|
|
1689
|
+
// subsequent lock transition so the source stays correct.
|
|
1690
|
+
const reInjectedDecks = (isLocked: boolean): ReadonlyArray<RuntimeDeck> =>
|
|
1691
|
+
injectSystemButtons(loaded.sourceDecks, descriptor!.keyCount, {
|
|
1692
|
+
lockActive: isLocked,
|
|
1693
|
+
})
|
|
1694
|
+
const broadcastActiveDeck = (): void => {
|
|
1695
|
+
const activeDeck = runtime!.getActiveDeck()
|
|
1696
|
+
if (activeDeck === undefined) return
|
|
1697
|
+
const msg = buildDeckConfigMessage(
|
|
1698
|
+
activeDeck,
|
|
1699
|
+
runtimeAddonByType,
|
|
1700
|
+
resolverOptions,
|
|
1701
|
+
{
|
|
1702
|
+
navStackDepth: runtime!.navStackDepth(),
|
|
1703
|
+
hasOverlayDeckAvailable: runtime!.hasOverlayDeckAvailable(),
|
|
1704
|
+
inOverlayMode: runtime!.getOverlay() !== null,
|
|
1705
|
+
},
|
|
1706
|
+
descriptor!.keyCount,
|
|
1707
|
+
outputClient!.kind === "real",
|
|
1708
|
+
(fullPath) => getAssetByPath(fullPath)?.id,
|
|
1709
|
+
runtime!.getAvailableOverlayDeckIcon(),
|
|
1710
|
+
runtime!.getAvailableOverlayDeckName(),
|
|
1711
|
+
{ lockActive: runtime!.isLockActive() },
|
|
1712
|
+
)
|
|
1713
|
+
bridge!.broadcast(msg)
|
|
1714
|
+
}
|
|
1715
|
+
if (providers.session.getState() === "locked") {
|
|
1716
|
+
const reInjected = reInjectedDecks(true)
|
|
1717
|
+
runtime.setDecks(reInjected)
|
|
1718
|
+
decks = reInjected
|
|
1719
|
+
broadcastActiveDeck()
|
|
1720
|
+
}
|
|
1721
|
+
unsubscribeLockMode = pubSub.subscribe("runtime:lock-mode", (payload) => {
|
|
1722
|
+
const isLocked =
|
|
1723
|
+
typeof payload === "object" &&
|
|
1724
|
+
payload !== null &&
|
|
1725
|
+
(payload as { active?: unknown }).active === true
|
|
1726
|
+
const reInjected = reInjectedDecks(isLocked)
|
|
1727
|
+
runtime!.setDecks(reInjected)
|
|
1728
|
+
decks = reInjected
|
|
1729
|
+
// ponytail: runtime:setDecks publishes runtime:activeDeck, but the
|
|
1730
|
+
// deck-id-equality dedup in the bridge subscriber skips the
|
|
1731
|
+
// re-broadcast when the active deck id is unchanged. Force a fresh
|
|
1732
|
+
// broadcast so the wire filter sees the lock state on the new decks.
|
|
1733
|
+
broadcastActiveDeck()
|
|
1734
|
+
})
|
|
1735
|
+
|
|
1736
|
+
bridgeSignal = new AbortController()
|
|
1737
|
+
statePublisher = new StatePublisher({
|
|
1738
|
+
bridge,
|
|
1739
|
+
logger,
|
|
1740
|
+
onPublish: () => pubSub?.publish("runtime:invalidate", undefined),
|
|
1741
|
+
})
|
|
1742
|
+
|
|
1743
|
+
const addonRegistryForSystemStatus = new AddonRegistry()
|
|
1744
|
+
registerSystemStatusAddon(addonRegistryForSystemStatus)
|
|
1745
|
+
|
|
1746
|
+
const mainDeck = runtime.getActiveDeck()
|
|
1747
|
+
const externalScanned = buildExternalScannedAddons(
|
|
1748
|
+
loadedConfig.registry,
|
|
1749
|
+
addonBundle.scanned,
|
|
1750
|
+
externalAddonDirs,
|
|
1751
|
+
loadedConfig.addonEntryPaths,
|
|
1752
|
+
loadedConfig.addonBrowserEntryPaths,
|
|
1753
|
+
)
|
|
1754
|
+
publishSIRENO_ADDONS(addonBundle.scanned, externalScanned)
|
|
1755
|
+
options.onAddonsUpdate?.([...addonBundle.scanned, ...externalScanned])
|
|
1756
|
+
// ponytail: ship the merged inventory to the bridge so the emulator's
|
|
1757
|
+
// Addons tab can render it on connect. Builtins are bundled; third-party
|
|
1758
|
+
// addons (from config.yml `addons:`) join here. --emulator mode never
|
|
1759
|
+
// binds the start-mode HTTP API on 3939, so this is the only path.
|
|
1760
|
+
bridge.setAddonInventory(
|
|
1761
|
+
(() => {
|
|
1762
|
+
const defaultConfigs = collectAddonDefaultButtonConfig(
|
|
1763
|
+
loadedConfig!.registry,
|
|
1764
|
+
decks ?? [],
|
|
1765
|
+
logger,
|
|
1766
|
+
)
|
|
1767
|
+
return [...addonBundle!.scanned, ...externalScanned].map(
|
|
1768
|
+
(addon, addonIndex) =>
|
|
1769
|
+
addonInventoryFromScanned(
|
|
1770
|
+
addon,
|
|
1771
|
+
addonIndex,
|
|
1772
|
+
decks ?? [],
|
|
1773
|
+
defaultConfigs.get(addon.name),
|
|
1774
|
+
),
|
|
1775
|
+
)
|
|
1776
|
+
})(),
|
|
1777
|
+
)
|
|
1778
|
+
|
|
1779
|
+
const mainDeckId = mainDeck?.id ?? "main"
|
|
1780
|
+
bridge.setDeckTree(buildDeckTree(decks, mainDeckId))
|
|
1781
|
+
|
|
1782
|
+
// ponytail: addon-triggered deck rebuild (dynamic decks). Mirrors the
|
|
1783
|
+
// config hot-reload decks-only branch: rebuild the runtime deck set,
|
|
1784
|
+
// fall back if the active deck vanished (e.g. a page count shrank), and
|
|
1785
|
+
// rebroadcast deck-config so the frontend sees the new pages.
|
|
1786
|
+
const runtimeAddonByType = mergeAddonByType(
|
|
1787
|
+
addonBundle.addonByType,
|
|
1788
|
+
externalScanned,
|
|
1789
|
+
)
|
|
1790
|
+
const serviceDecks: RuntimeDeck[] = [...decks]
|
|
1791
|
+
let currentLoadedConfig = loadedConfig
|
|
1792
|
+
const nextMainDeckId = (
|
|
1793
|
+
config: typeof currentLoadedConfig.config,
|
|
1794
|
+
): string =>
|
|
1795
|
+
config.decks["main"] !== undefined
|
|
1796
|
+
? "main"
|
|
1797
|
+
: (Object.keys(config.decks)[0] ?? "main")
|
|
1798
|
+
const requestDeckRebuild = (): void => {
|
|
1799
|
+
if (
|
|
1800
|
+
runtime === null ||
|
|
1801
|
+
descriptor === null ||
|
|
1802
|
+
outputClient === null ||
|
|
1803
|
+
bridge === null ||
|
|
1804
|
+
addonBundle === null ||
|
|
1805
|
+
loadedConfig === null ||
|
|
1806
|
+
providers === null
|
|
1807
|
+
) {
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
const rebuilt = buildRuntime(
|
|
1811
|
+
options,
|
|
1812
|
+
loadedConfig,
|
|
1813
|
+
descriptor.keyCount,
|
|
1814
|
+
providers.session.getState() === "locked",
|
|
1815
|
+
).decks
|
|
1816
|
+
decks = rebuilt
|
|
1817
|
+
serviceDecks.splice(0, serviceDecks.length, ...rebuilt)
|
|
1818
|
+
runtime.setDecks(rebuilt)
|
|
1819
|
+
const activeId = runtime.getActiveDeckId()
|
|
1820
|
+
if (!rebuilt.some((d) => d.id === activeId)) {
|
|
1821
|
+
const fallback = rebuilt.some((d) => d.id === "main")
|
|
1822
|
+
? "main"
|
|
1823
|
+
: (rebuilt[0]?.id ?? activeId)
|
|
1824
|
+
runtime.navigateToDeck(fallback, { addToHistory: false })
|
|
1825
|
+
}
|
|
1826
|
+
for (const deck of rebuilt) {
|
|
1827
|
+
registerDeckIcon(deck, resolverOptions, logger)
|
|
1828
|
+
registerIconForDeck(deck.buttons ?? [], resolverOptions, logger)
|
|
1829
|
+
}
|
|
1830
|
+
bridge.setDeckTree(
|
|
1831
|
+
buildDeckTree(rebuilt, nextMainDeckId(currentLoadedConfig.config)),
|
|
1832
|
+
)
|
|
1833
|
+
const activeDeck = runtime.getActiveDeck()
|
|
1834
|
+
const msg = buildDeckConfigMessage(
|
|
1835
|
+
activeDeck,
|
|
1836
|
+
runtimeAddonByType,
|
|
1837
|
+
resolverOptions,
|
|
1838
|
+
{
|
|
1839
|
+
navStackDepth: runtime.navStackDepth(),
|
|
1840
|
+
hasOverlayDeckAvailable: runtime.hasOverlayDeckAvailable(),
|
|
1841
|
+
inOverlayMode: runtime.getOverlay() !== null,
|
|
1842
|
+
},
|
|
1843
|
+
descriptor.keyCount,
|
|
1844
|
+
outputClient.kind === "real",
|
|
1845
|
+
(fullPath) => getAssetByPath(fullPath)?.id,
|
|
1846
|
+
runtime.getAvailableOverlayDeckIcon(),
|
|
1847
|
+
runtime.getAvailableOverlayDeckName(),
|
|
1848
|
+
{ lockActive: runtime.isLockActive() },
|
|
1849
|
+
)
|
|
1850
|
+
bridge.broadcast(msg)
|
|
1851
|
+
logger.info(
|
|
1852
|
+
{
|
|
1853
|
+
deckId: msg.deckId,
|
|
1854
|
+
buttonCount: (
|
|
1855
|
+
msg.surfaces[msg.deckId] as { buttons?: unknown[] } | undefined
|
|
1856
|
+
)?.buttons?.length,
|
|
1857
|
+
},
|
|
1858
|
+
"addon requested deck rebuild",
|
|
1859
|
+
)
|
|
1860
|
+
}
|
|
1861
|
+
methods?.setDeckRebuilder(requestDeckRebuild)
|
|
1862
|
+
|
|
1863
|
+
addonServices = setupAddonServices({
|
|
1864
|
+
runtime,
|
|
1865
|
+
methods,
|
|
1866
|
+
decks: serviceDecks,
|
|
1867
|
+
pubSub,
|
|
1868
|
+
scanned: addonBundle.scanned,
|
|
1869
|
+
externalAddons: externalScanned,
|
|
1870
|
+
// ponytail: addonBundle.addonByType only has builtins — merge in
|
|
1871
|
+
// third-party addons so collectActiveDeckAddonNames can resolve their
|
|
1872
|
+
// types and the state publisher starts their pollers.
|
|
1873
|
+
addonByType: runtimeAddonByType,
|
|
1874
|
+
executor: createActionExecutor({ host: getHostContext() }),
|
|
1875
|
+
statePublisher,
|
|
1876
|
+
bridge,
|
|
1877
|
+
isCompact,
|
|
1878
|
+
resolverOptions,
|
|
1879
|
+
requestDeckRebuild,
|
|
1880
|
+
...(mainDeck !== undefined ? { initialDeck: mainDeck } : {}),
|
|
1881
|
+
signal: bridgeSignal.signal,
|
|
1882
|
+
store,
|
|
1883
|
+
logger,
|
|
1884
|
+
})
|
|
1885
|
+
addonServicesDispose = () => addonServices?.dispose()
|
|
1886
|
+
|
|
1887
|
+
for (const deck of decks) {
|
|
1888
|
+
registerDeckIcon(deck, resolverOptions, logger)
|
|
1889
|
+
registerIconForDeck(deck.buttons ?? [], resolverOptions, logger)
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
bridge.onConnection((socket) => {
|
|
1893
|
+
// Send the full asset bundle to every new connection. The previous
|
|
1894
|
+
// dedupe-by-id approach caused the React frontend to render the
|
|
1895
|
+
// fallback icon after a hot-reload or page refresh: the FIRST
|
|
1896
|
+
// connection consumed the assets, every subsequent connection got
|
|
1897
|
+
// an empty list, and the new client started with an empty cache.
|
|
1898
|
+
// Assets are tiny (a 1.4KB chrome.svg), so re-sending them on
|
|
1899
|
+
// reconnect is cheaper than the bug.
|
|
1900
|
+
const allAssets = getUnsentAssets(new Set())
|
|
1901
|
+
if (allAssets.length > 0) {
|
|
1902
|
+
socket.send(
|
|
1903
|
+
JSON.stringify({
|
|
1904
|
+
type: "assets",
|
|
1905
|
+
deckId: mainDeck?.id ?? "",
|
|
1906
|
+
assets: allAssets.map((a) => ({
|
|
1907
|
+
id: a.id,
|
|
1908
|
+
filename: a.fullPath,
|
|
1909
|
+
src: a.src,
|
|
1910
|
+
})),
|
|
1911
|
+
}),
|
|
1912
|
+
)
|
|
1913
|
+
}
|
|
1914
|
+
const activeDeck = runtime!.getActiveDeck()
|
|
1915
|
+
if (activeDeck !== undefined) {
|
|
1916
|
+
const msg = buildDeckConfigMessage(
|
|
1917
|
+
activeDeck,
|
|
1918
|
+
runtimeAddonByType,
|
|
1919
|
+
resolverOptions,
|
|
1920
|
+
{
|
|
1921
|
+
navStackDepth: runtime!.navStackDepth(),
|
|
1922
|
+
hasOverlayDeckAvailable: runtime!.hasOverlayDeckAvailable(),
|
|
1923
|
+
inOverlayMode: runtime!.getOverlay() !== null,
|
|
1924
|
+
},
|
|
1925
|
+
descriptor!.keyCount,
|
|
1926
|
+
outputClient!.kind === "real",
|
|
1927
|
+
(fullPath) => getAssetByPath(fullPath)?.id,
|
|
1928
|
+
runtime!.getAvailableOverlayDeckIcon(),
|
|
1929
|
+
runtime!.getAvailableOverlayDeckName(),
|
|
1930
|
+
{ lockActive: runtime!.isLockActive() },
|
|
1931
|
+
)
|
|
1932
|
+
logger.info(
|
|
1933
|
+
{
|
|
1934
|
+
deckId: msg.deckId,
|
|
1935
|
+
buttonCount: (
|
|
1936
|
+
msg.surfaces[msg.deckId] as { buttons?: unknown[] } | undefined
|
|
1937
|
+
)?.buttons?.length,
|
|
1938
|
+
},
|
|
1939
|
+
"orchestrator: sending deck-config",
|
|
1940
|
+
)
|
|
1941
|
+
socket.send(JSON.stringify(msg))
|
|
1942
|
+
}
|
|
1943
|
+
})
|
|
1944
|
+
|
|
1945
|
+
const onServiceLog = (entry: {
|
|
1946
|
+
level: string
|
|
1947
|
+
msg: string
|
|
1948
|
+
ts: number
|
|
1949
|
+
component?: string
|
|
1950
|
+
deckId?: string
|
|
1951
|
+
position?: number
|
|
1952
|
+
addonName?: string
|
|
1953
|
+
gesture?: "tap" | "dbl-tap" | "hold"
|
|
1954
|
+
keyIndex?: number
|
|
1955
|
+
}) => {
|
|
1956
|
+
bridge!.broadcast({
|
|
1957
|
+
type: "service-log",
|
|
1958
|
+
level: entry.level as
|
|
1959
|
+
| "debug"
|
|
1960
|
+
| "error"
|
|
1961
|
+
| "fatal"
|
|
1962
|
+
| "info"
|
|
1963
|
+
| "trace"
|
|
1964
|
+
| "warn",
|
|
1965
|
+
msg: entry.msg,
|
|
1966
|
+
ts: entry.ts,
|
|
1967
|
+
...(entry.component !== undefined
|
|
1968
|
+
? { component: entry.component }
|
|
1969
|
+
: {}),
|
|
1970
|
+
...(entry.deckId !== undefined ? { deckId: entry.deckId } : {}),
|
|
1971
|
+
...(entry.position !== undefined ? { position: entry.position } : {}),
|
|
1972
|
+
...(entry.addonName !== undefined
|
|
1973
|
+
? { addonName: entry.addonName }
|
|
1974
|
+
: {}),
|
|
1975
|
+
...(entry.gesture !== undefined ? { gesture: entry.gesture } : {}),
|
|
1976
|
+
...(entry.keyIndex !== undefined ? { keyIndex: entry.keyIndex } : {}),
|
|
1977
|
+
})
|
|
1978
|
+
}
|
|
1979
|
+
process.on("sireno:log", onServiceLog)
|
|
1980
|
+
removeServiceLogListener = () =>
|
|
1981
|
+
process.removeListener("sireno:log", onServiceLog)
|
|
1982
|
+
|
|
1983
|
+
const trackedPids = new Set<number>()
|
|
1984
|
+
|
|
1985
|
+
outputHandle = await outputClient.init({
|
|
1986
|
+
bridge,
|
|
1987
|
+
runtime,
|
|
1988
|
+
pubSub,
|
|
1989
|
+
noAutoOpen: options.noAutoOpen === true,
|
|
1990
|
+
store,
|
|
1991
|
+
decks,
|
|
1992
|
+
theme: {
|
|
1993
|
+
name: loaded.theme.name,
|
|
1994
|
+
apiVersion: loaded.theme.apiVersion,
|
|
1995
|
+
},
|
|
1996
|
+
themeDir,
|
|
1997
|
+
configPath: resolvePath(loadedConfig.configPath),
|
|
1998
|
+
logger,
|
|
1999
|
+
rebuildDecksForKeyCount: (keyCount: number) =>
|
|
2000
|
+
buildRuntime(
|
|
2001
|
+
options,
|
|
2002
|
+
currentLoadedConfig,
|
|
2003
|
+
keyCount,
|
|
2004
|
+
providers?.session.getState() === "locked",
|
|
2005
|
+
).decks,
|
|
2006
|
+
onChildPid: (pid) => {
|
|
2007
|
+
if (trackedPids.has(pid)) return
|
|
2008
|
+
trackedPids.add(pid)
|
|
2009
|
+
options.onChildren?.([...trackedPids])
|
|
2010
|
+
},
|
|
2011
|
+
onChildCrash: () => {
|
|
2012
|
+
logger.fatal(
|
|
2013
|
+
"supervised vite child exhausted its retry budget, shutting down",
|
|
2014
|
+
)
|
|
2015
|
+
resolveDoneForCrash()
|
|
2016
|
+
},
|
|
2017
|
+
...(options.frontendUrl !== undefined
|
|
2018
|
+
? { frontendUrl: options.frontendUrl }
|
|
2019
|
+
: {}),
|
|
2020
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
2021
|
+
...(options.intervalMs !== undefined
|
|
2022
|
+
? { intervalMs: options.intervalMs }
|
|
2023
|
+
: {}),
|
|
2024
|
+
remote: options.remote === true,
|
|
2025
|
+
lanHost,
|
|
2026
|
+
lanAddresses: lanAddresses.map((a) => a.address),
|
|
2027
|
+
})
|
|
2028
|
+
|
|
2029
|
+
currentOutputHandle = outputHandle
|
|
2030
|
+
let refreshQueue = Promise.resolve()
|
|
2031
|
+
|
|
2032
|
+
// Note: do NOT overwrite the accumulated `trackedPids` Set with the initial
|
|
2033
|
+
// `outputHandle.childPids` snapshot — that erased the respawned-PID entries
|
|
2034
|
+
// and orphaned the live vite after the supervisor's respawn. The
|
|
2035
|
+
// `onChildPid` callback above already added every spawned PID (initial +
|
|
2036
|
+
// respawns) into `trackedPids` and reported it via `options.onChildren`,
|
|
2037
|
+
// so the children file stays complete without this second write.
|
|
2038
|
+
|
|
2039
|
+
editorMutationService = createConfigMutationService({
|
|
2040
|
+
configPath: loadedConfig.configPath,
|
|
2041
|
+
})
|
|
2042
|
+
|
|
2043
|
+
const refreshConfig = async (notifyEditor = true): Promise<void> => {
|
|
2044
|
+
const nextLoaded = await validateAndLoadConfig(options)
|
|
2045
|
+
if (!configChanged(currentLoadedConfig.config, nextLoaded.config)) return
|
|
2046
|
+
|
|
2047
|
+
const previousConfig = currentLoadedConfig.config
|
|
2048
|
+
const wasActive = runtime!.getActiveDeckId()
|
|
2049
|
+
const nextRuntime = buildRuntime(
|
|
2050
|
+
options,
|
|
2051
|
+
nextLoaded,
|
|
2052
|
+
descriptor!.keyCount,
|
|
2053
|
+
providers?.session.getState() === "locked",
|
|
2054
|
+
)
|
|
2055
|
+
runtime!.setDecks(nextRuntime.decks)
|
|
2056
|
+
decks = nextRuntime.decks
|
|
2057
|
+
serviceDecks.splice(0, serviceDecks.length, ...nextRuntime.decks)
|
|
2058
|
+
currentLoadedConfig = nextLoaded
|
|
2059
|
+
|
|
2060
|
+
const nextExternal = buildExternalScannedAddons(
|
|
2061
|
+
nextLoaded.registry,
|
|
2062
|
+
addonBundle!.scanned,
|
|
2063
|
+
buildExternalAddonDirs(
|
|
2064
|
+
nextLoaded.config.addons ?? [],
|
|
2065
|
+
nextLoaded.configPath,
|
|
2066
|
+
nextLoaded.addonEntryPaths,
|
|
2067
|
+
),
|
|
2068
|
+
nextLoaded.addonEntryPaths,
|
|
2069
|
+
nextLoaded.addonBrowserEntryPaths,
|
|
2070
|
+
)
|
|
2071
|
+
runtimeAddonByType.clear()
|
|
2072
|
+
for (const [type, ref] of mergeAddonByType(
|
|
2073
|
+
addonBundle!.addonByType,
|
|
2074
|
+
nextExternal,
|
|
2075
|
+
))
|
|
2076
|
+
runtimeAddonByType.set(type, ref)
|
|
2077
|
+
resolverOptions = buildResolverOptions(
|
|
2078
|
+
runtimeAddonByType,
|
|
2079
|
+
[dirname(nextLoaded.configPath)],
|
|
2080
|
+
buildExternalAddonDirs(
|
|
2081
|
+
nextLoaded.config.addons ?? [],
|
|
2082
|
+
nextLoaded.configPath,
|
|
2083
|
+
nextLoaded.addonEntryPaths,
|
|
2084
|
+
),
|
|
2085
|
+
)
|
|
2086
|
+
bridge!.setActiveTheme?.({ name: nextLoaded.theme.name })
|
|
2087
|
+
bridge!.setAddonInventory(
|
|
2088
|
+
[...addonBundle!.scanned, ...nextExternal].map((addon, addonIndex) =>
|
|
2089
|
+
addonInventoryFromScanned(
|
|
2090
|
+
addon,
|
|
2091
|
+
addonIndex,
|
|
2092
|
+
nextRuntime.decks,
|
|
2093
|
+
collectAddonDefaultButtonConfig(
|
|
2094
|
+
nextLoaded.registry,
|
|
2095
|
+
nextRuntime.decks,
|
|
2096
|
+
logger,
|
|
2097
|
+
).get(addon.name),
|
|
2098
|
+
),
|
|
2099
|
+
),
|
|
2100
|
+
)
|
|
2101
|
+
bridge!.setDeckTree(
|
|
2102
|
+
buildDeckTree(nextRuntime.decks, nextMainDeckId(nextLoaded.config)),
|
|
2103
|
+
)
|
|
2104
|
+
for (const deck of nextRuntime.decks) {
|
|
2105
|
+
registerDeckIcon(deck, resolverOptions, logger)
|
|
2106
|
+
registerIconForDeck(deck.buttons ?? [], resolverOptions, logger)
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// setDecks broadcasts when the active projection changed. Otherwise this
|
|
2110
|
+
// is the one normal deck-config frame for the refresh.
|
|
2111
|
+
if (runtime!.getActiveDeckId() === wasActive) broadcastActiveDeck()
|
|
2112
|
+
if (
|
|
2113
|
+
JSON.stringify(previousConfig.theme) !==
|
|
2114
|
+
JSON.stringify(nextLoaded.config.theme) ||
|
|
2115
|
+
JSON.stringify(previousConfig.addons) !==
|
|
2116
|
+
JSON.stringify(nextLoaded.config.addons) ||
|
|
2117
|
+
JSON.stringify(previousConfig.lock) !==
|
|
2118
|
+
JSON.stringify(nextLoaded.config.lock)
|
|
2119
|
+
) {
|
|
2120
|
+
bridge!.broadcast({ type: "iframe-reload" })
|
|
2121
|
+
}
|
|
2122
|
+
if (notifyEditor) editorHandler?.invalidate()
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
const handleConfigChange = (notifyEditor = true): Promise<void> => {
|
|
2126
|
+
const task = refreshQueue.then(
|
|
2127
|
+
() => refreshConfig(notifyEditor),
|
|
2128
|
+
() => refreshConfig(notifyEditor),
|
|
2129
|
+
)
|
|
2130
|
+
refreshQueue = task.then(
|
|
2131
|
+
() => undefined,
|
|
2132
|
+
() => undefined,
|
|
2133
|
+
)
|
|
2134
|
+
return task.catch((err: unknown) => {
|
|
2135
|
+
logger.warn(
|
|
2136
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
2137
|
+
"config refresh failed; keeping previous config",
|
|
2138
|
+
)
|
|
2139
|
+
})
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Hot-reload and editor writes both enter refreshConfig. The mutation
|
|
2143
|
+
// handler waits for this promise, so a watcher cannot race a just-written file.
|
|
2144
|
+
configWatcher = new ConfigWatcher(editorMutationService.sources(), {
|
|
2145
|
+
onChange: () => {
|
|
2146
|
+
void handleConfigChange()
|
|
2147
|
+
},
|
|
2148
|
+
// ponytail: chokidar emits "unlink" when the watched file
|
|
2149
|
+
// disappears — e.g. the worktree holding the config was removed
|
|
2150
|
+
// out from under the daemon. Without this hook the daemon keeps
|
|
2151
|
+
// running with stale state and the next config-touch throws a
|
|
2152
|
+
// confusing `Config file not found`. Shut down cleanly so the
|
|
2153
|
+
// operator gets one obvious message instead of a flood.
|
|
2154
|
+
onUnlink: (path) => {
|
|
2155
|
+
logger.fatal(
|
|
2156
|
+
{ configPath: path },
|
|
2157
|
+
"config file was deleted (worktree removed?) — shutting down daemon; restart with a valid config",
|
|
2158
|
+
)
|
|
2159
|
+
resolveDoneForCrash()
|
|
2160
|
+
},
|
|
2161
|
+
})
|
|
2162
|
+
editorHandler = createEditorMessageHandler({
|
|
2163
|
+
mutationService: editorMutationService,
|
|
2164
|
+
getState: () => ({
|
|
2165
|
+
config: currentLoadedConfig.config,
|
|
2166
|
+
sources: editorMutationService!.sources(),
|
|
2167
|
+
sourceContents: Object.fromEntries(
|
|
2168
|
+
editorMutationService!
|
|
2169
|
+
.sources()
|
|
2170
|
+
.map((source) => [source, readFileSync(source, "utf8")]),
|
|
2171
|
+
),
|
|
2172
|
+
themes: (
|
|
2173
|
+
currentLoadedConfig.registry.listThemes?.() ?? [
|
|
2174
|
+
{ name: currentLoadedConfig.theme.name },
|
|
2175
|
+
]
|
|
2176
|
+
).map((theme) => ({
|
|
2177
|
+
name: theme.name,
|
|
2178
|
+
active: theme.name === currentLoadedConfig.theme.name,
|
|
2179
|
+
})),
|
|
2180
|
+
buttonSchemas: serializeButtonSchemas(currentLoadedConfig.registry),
|
|
2181
|
+
surfaces: runtime!.getEditorSurfaces({
|
|
2182
|
+
keyCount: descriptor!.keyCount,
|
|
2183
|
+
}),
|
|
2184
|
+
}),
|
|
2185
|
+
validateConfig: (buttonType, config) =>
|
|
2186
|
+
validateButton(
|
|
2187
|
+
{ type: buttonType, config: config as Record<string, unknown> },
|
|
2188
|
+
currentLoadedConfig.registry,
|
|
2189
|
+
"button",
|
|
2190
|
+
).issues.map((issue) => issue.message),
|
|
2191
|
+
onChanged: () => handleConfigChange(false),
|
|
2192
|
+
broadcast: (message) => bridge!.broadcast(message),
|
|
2193
|
+
})
|
|
2194
|
+
bridge.onMessage(editorHandler.onMessage)
|
|
2195
|
+
bridge.onMessage((message) => {
|
|
2196
|
+
if (message.type !== "select-deck") return
|
|
2197
|
+
const selected = decks?.find((deck) => deck.id === message.deckId)
|
|
2198
|
+
if (selected === undefined) return
|
|
2199
|
+
if (selected.isOverlay === true) {
|
|
2200
|
+
runtime?.setOverlay(selected.id, { source: "manual" })
|
|
2201
|
+
} else {
|
|
2202
|
+
runtime?.navigateToDeck(selected.id)
|
|
2203
|
+
}
|
|
2204
|
+
})
|
|
2205
|
+
bridge.onConnection(editorHandler.onConnection)
|
|
2206
|
+
await configWatcher.start({ ignoreInitial: true })
|
|
2207
|
+
|
|
2208
|
+
const signals = options.signals ?? defaultSignals
|
|
2209
|
+
unregisterSignal = signals.onSignal(() => {
|
|
2210
|
+
logger.info("received signal, shutting down")
|
|
2211
|
+
resolveDone()
|
|
2212
|
+
})
|
|
2213
|
+
// ponytail: SIGUSR1 is the in-place-reload signal. The reload command
|
|
2214
|
+
// (CLI side) and `systemctl reload-or-restart` both send it. The
|
|
2215
|
+
// handler triggers runtime.invalidate() which publishes
|
|
2216
|
+
// `runtime:invalidate` on the pubsub — the BrowserRenderer subscribes
|
|
2217
|
+
// to that and triggers a screenshot tick, so connected frontends
|
|
2218
|
+
// re-render on demand without a full preflight re-run.
|
|
2219
|
+
unregisterReload = signals.onReload(() => {
|
|
2220
|
+
logger.info("runtime: reloaded via SIGUSR1")
|
|
2221
|
+
// runtime is non-null here: buildRuntime assigned it at line ~1379,
|
|
2222
|
+
// and the reload handler only fires after this point.
|
|
2223
|
+
if (runtime !== null) runtime.invalidate()
|
|
2224
|
+
})
|
|
2225
|
+
|
|
2226
|
+
await done
|
|
2227
|
+
} finally {
|
|
2228
|
+
if (unregisterSignal !== null) unregisterSignal()
|
|
2229
|
+
if (unregisterReload !== null) unregisterReload()
|
|
2230
|
+
if (bridgeSignal !== null) bridgeSignal.abort()
|
|
2231
|
+
if (addonServicesDispose !== null) addonServicesDispose()
|
|
2232
|
+
if (unsubscribeLockMode !== null) unsubscribeLockMode()
|
|
2233
|
+
if (statePublisher !== null) statePublisher.stopAll()
|
|
2234
|
+
if (
|
|
2235
|
+
options.emulator !== true &&
|
|
2236
|
+
currentOutputHandle !== null &&
|
|
2237
|
+
typeof currentOutputHandle.pushBlackFrame === "function"
|
|
2238
|
+
) {
|
|
2239
|
+
try {
|
|
2240
|
+
await currentOutputHandle.pushBlackFrame()
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
logger.warn({ err: (err as Error).message }, "pushBlackFrame failed")
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
if (removeServiceLogListener !== null) removeServiceLogListener()
|
|
2246
|
+
if (configWatcher !== null) {
|
|
2247
|
+
try {
|
|
2248
|
+
await configWatcher.close()
|
|
2249
|
+
} catch {
|
|
2250
|
+
// already closed or failed during init — ignore
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
await Promise.allSettled([
|
|
2254
|
+
currentOutputHandle !== null
|
|
2255
|
+
? currentOutputHandle.stop()
|
|
2256
|
+
: Promise.resolve(),
|
|
2257
|
+
runtime !== null ? runtime.stopActiveAppPolling() : Promise.resolve(),
|
|
2258
|
+
providers !== null ? providers.activeApp.stop() : Promise.resolve(),
|
|
2259
|
+
providers !== null ? providers.session.stop() : Promise.resolve(),
|
|
2260
|
+
providers !== null ? providers.keyMacro.stop() : Promise.resolve(),
|
|
2261
|
+
bridge !== null ? bridge.close() : Promise.resolve(),
|
|
2262
|
+
])
|
|
2263
|
+
logger.info("shutdown complete")
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
void resolveXdgConfigHome
|
|
2268
|
+
void exec
|
|
2269
|
+
|
|
2270
|
+
export const run = runPipeline
|