Files
nexu-io-open-design/apps/desktop/src/main/runtime.ts
Tom Huang 29b138f7a3 feat(brands): turn any brand into a reusable design system (#4691)
* Implement brand management routes and CLI support

- Added `brand-routes.ts` to handle HTTP endpoints for brand operations: listing, extracting, retrieving details, deleting, and serving logos.
- Introduced `brands-cli-help.ts` for CLI commands related to brand management, including usage instructions for listing, creating, retrieving, and deleting brands.
- Updated `cli.ts` to integrate brand commands into the existing CLI structure, allowing headless management of brands via the command line.
- Created supporting files for brand metadata handling, including `design-md.ts` for rendering brand information in markdown format and `index.ts` for the brand engine API.
- Implemented `prefetch.ts` to fetch and process brand material from specified URLs, ensuring a streamlined extraction process.
- Enhanced server setup in `server.ts` to register brand routes and manage brand-related data effectively.

This commit establishes a comprehensive framework for managing brands within the application, facilitating both HTTP and CLI interactions.

* Enhance memory management and onboarding experience

- Introduced canonical profile labels to ensure consistent handling of user input in profile forms, preventing duplicate entries.
- Updated the `parseProfileBody` and `captureProfileFromForm` functions to utilize the new canonical label matching.
- Added a memory callout section in the onboarding view to highlight the benefits of memory usage, including personalized responses and reduced setup questions.
- Implemented new UI elements in the onboarding view to improve user engagement with memory features.
- Expanded i18n support for new onboarding messages related to memory benefits across multiple languages.

* Refactor onboarding flow and enhance design system integration

- Updated the onboarding process to include a new brand extraction step, replacing the previous newsletter step.
- Adjusted the tracking logic to reflect the new onboarding steps, ensuring accurate analytics for user progress.
- Improved the UI for the onboarding view, including new input fields for email collection during the brand extraction phase.
- Refined the EntryShell component to remove outdated comments and clarify the onboarding renderer's purpose.
- Enhanced CSS styles for the onboarding steps to improve layout and user experience.
- Updated internationalization strings across multiple languages to reflect changes in the onboarding flow and brand extraction messaging.

* Add brand management features and enhance font handling

- Introduced new modules for managing brand assets, including `chrome.ts` for headless Chrome operations and `fonts.ts` for self-hosting web fonts.
- Implemented `prefetch.ts` to streamline the brand material extraction process, allowing for efficient harvesting of colors, fonts, and logos.
- Enhanced the brand system with new schema definitions in `schema.ts` to support brand color and font management.
- Developed the `engine` module to integrate brand building and rendering processes, including token derivation and artifact generation.
- Improved the overall structure and organization of brand-related files for better maintainability and scalability.

* Enhance brand extraction and project management features

- Updated `brand-routes.ts` to include new dependencies for project management, allowing for the registration of brand-related projects.
- Modified the `extractBrand` function to support project ID and system files, improving the brand extraction process.
- Enhanced the CLI commands in `cli.ts` to handle project IDs during brand creation, enabling better tracking of brand projects.
- Updated the server setup in `server.ts` to register new project-related routes.
- Improved the UI components to display project information associated with brands, including buttons for opening projects in the `BrandDetailView` and `BrandsTab`.
- Added new metadata fields in the contracts to support project tracking and management for brands.

This commit establishes a more robust framework for managing brand projects, enhancing both backend and frontend functionalities.

* Enhance onboarding profile management and memory persistence

- Added new canonical profile labels for 'Organization size', 'Use cases', and 'Discovery source' to improve user input consistency.
- Introduced `OnboardingProfileState` type to manage onboarding profile data more effectively.
- Implemented functions to build and persist the onboarding profile body to memory, ensuring user selections are saved accurately.
- Updated the `OnboardingView` component to handle profile persistence during navigation and submission steps.
- Enhanced tests to verify that user selections are correctly persisted to the memory profile.

This commit improves the onboarding experience by ensuring that user inputs are consistently captured and stored, enhancing overall user engagement with the application.

* Reflow brand extraction into an agent-driven, live flow

Replace the deterministic SSE prefetch/preview/system pipeline with an
agent-driven extraction: POST /api/brands now reserves the brand and stands
up a backing project with the target site open in an in-app browser tab plus
a seeded prompt, so the agent measures, synthesizes brand.json incrementally,
and the user can clear anti-bot walls by hand. New /preview and /finalize
routes let the agent render the kit page live and register the resulting
user:<id> design system, so extracted brand facts persist as a structured,
reusable brand kit instead of a one-shot deterministic guess.

Adds the brand-extract skill (SKILL.md + brand-kit.html template), kit-render
engine, brand-extraction-engine tests, brand project covers in the Designs
tab, onboarding extract handoff, and the matching od brand extract/preview/
finalize CLI subcommands and contract updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Sediment finalized brands into structured memory

Reflow a finalized brand into the memory store (brandToMemoryEntries +
reflowBrandToMemory) so future chats can ground vague requests in the
brand's palette, type, voice and rules. finalizeBrand now wires through
the runtime dataDir and best-effort persists the brand, MemoryChangeEvent
gains a 'brand' source, and the brand kit render hardens its inline JSON
escaping. Adds brand.previewEmpty / brand.viewDetails across all locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Implement logo fallback and imagery support in brand extraction

- Introduced a deterministic logo fallback mechanism to ensure that brand extraction processes can retrieve and save site logos, even when the agent fails to do so.
- Enhanced the `startBrandExtraction` and `finalizeBrand` functions to utilize the new logo fallback, allowing for better handling of logo assets.
- Added support for imagery samples in brand validation, enabling the inclusion of representative images in the brand kit.
- Updated the brand kit rendering to include self-hosted fonts and imagery, improving the overall presentation of brand assets.

This commit strengthens the brand extraction workflow by ensuring that logos and imagery are reliably captured and displayed, enhancing the user experience in brand management.

* Enhance memory management with rule proposal and verification features

- Introduced new functionality for distilling annotations into rule proposals, allowing users to suggest rules based on in-canvas annotations through the `od memory rule suggest` command.
- Implemented a verification system that programmatically enforces compliance with active rules during artifact generation, ensuring that all active rules are covered in the self-verify scorecard.
- Added endpoints for managing verification outcomes, including listing, removing, and clearing verification records, enhancing the transparency of the verification process.
- Updated the memory management system to support the retrieval of active rule entries, ensuring that only linked rules are considered during verification.
- Enhanced tests for both rule proposal generation and verification processes to ensure reliability and correctness.

This commit strengthens the memory management capabilities by integrating rule proposals and verification, improving the overall user experience in managing design rules and ensuring compliance.

* Distill review annotations into memory and enforce self-verify scorecard

Add distillAnnotationsToMemory to mine inline preview comments/highlights/
marks into durable feedback + rule memory via a dedicated distiller prompt,
threaded through the existing extract pipeline with an 'annotation' change
source. Tighten the self-verify prompt (daemon + contracts) to state the
daemon programmatically checks the scorecard, so a missing or uncovered
scorecard on an artifact turn is an enforcement failure. Cover the rule
suggest and verification-history routes with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Apply brand design system through web config on "Use in new chat"

Thread onApplyDesignSystem from the entry shell into BrandsTab so the brand's
registered design system is applied via the web config channel instead of a
bare daemon PATCH that left the Home composer stale. Add a transient
home-intent latch + event so the Brands tab can request the Prototype chip on
the already-mounted HomeView, which consumes it once the plugin catalog loads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wire annotation distillation into background memory extraction

Add a background distill pass that mines inline review annotations
(comments / highlights / drawn marks) from a turn into durable memory
alongside the general LLM extraction, surface an `annotation` memory
toast source in the web UI, and cover the flow with a unit test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix brand design system not applying to composer on "Use in new chat"

Selecting a brand's "Use in new chat" applies the brand's design system as
the default and fires the Prototype chip intent in the same synchronous click
handler. HomeView consumed that intent inside the event listener, so `pickChip`
ran before React committed the config change and seeded the composer's
design-system field from the stale (empty) default — the composer showed
"No design system" instead of the brand until a reload.

Split the intent handling: the listener now only bumps a tick, and a separate
effect consumes the chip after the re-render lands, so the seeded design system
reflects the freshly-applied brand. Add the previously-untracked home-intent
latch test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): rework Brands into Brand Kit and add Home create entry

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(brands): harvest real cover/hero images for the Images module

The brand kit's Images gallery only populated when the extraction agent
remembered to save imagery — so a forgetful or bot-blocked agent (and the
pre-imagery "Open Design" brand) left it empty. Add a deterministic,
server-side imagery fallback (imagery-fallback.ts), mirroring the logo
fallback: it parses og:image/twitter:image, large <img> (highest-res
srcset/<picture>), <link rel=preload as=image>, and CSS background-image
hero blocks, fetches candidates with browser-shaped headers, decodes
PNG/GIF/JPEG/WebP dimensions to keep only big representative images
(dropping icons/sprites/logos/tracking pixels), dedupes by content hash,
and saves up to 8 of the largest into imagery/ with labeled samples.

finalizeBrand runs it as a timeout-bounded, failure-tolerant safety net
(injectable so tests stay offline) when the agent captured too few
samples, first adopting any on-disk images. The extraction prompt and
brand-extract SKILL now explicitly direct the agent to harvest the site's
large/cover/hero images, filtered by rendered size.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(qa): implement deck layout validation and safety checks

Add a new QA module for validating the layout of generated brand decks to ensure robustness against clipping and truncation issues. The `analyseDeckLayout` function checks for critical layout invariants, including the presence of `.slide` sections, correct container types, and necessary runtime layers. Introduce `assertDeckLayoutSafe` to enforce these checks during brand system rebuilds, preventing the deployment of decks that fail validation. Additionally, create comprehensive tests to verify the functionality of the new layout validation features.

* fix(brands): apply deck shrink-to-fit synchronously so slides never clip

The no-clip runtime scheduled its fit pass through requestAnimationFrame,
whose callbacks are throttled while the deck is offscreen or occluded. A
slide could therefore stay unscaled — and clip its content — until first
paint. Fit synchronously on resize/load/fonts-ready with a trailing
setTimeout settle pass for late reflow, removing the rAF dependency.

Verified at the previously-broken 1024x620 viewport: container-type:size,
zero truncations, runtime auto-applies scale (Problem 0.71, ASK 0.87,
Product 0.97, Competition 0.97) and frame clip count drops to 0.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): let New Brand modal embed scrollable brand reference picker

Add a fillHeight mode to BrandReferencePicker so the heading, quick-pick
row and controls stay pinned while only the gallery scrolls inside a
bounded-height parent. Wire it into NewBrandModal with a stable, spacious
dialog and refresh the related newBrand/brandPicker copy across all 18
locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(brands): enhance brand extraction with deterministic seed harvesting

Introduce a new `seed-fallback` module to provide a server-side deterministic palette and typography seed during brand extraction. This ensures that the brand kit's initial display includes a harvested logo, an approximate color palette, and font families, improving the user experience by reducing the all-skeleton appearance during the first paint. Update the `startBrandExtraction` function to utilize this new module, allowing for a more seamless and visually appealing brand extraction process.

Additionally, enhance the `BrandReferencePicker` component to reflect loading states and errors during brand extraction, ensuring users receive immediate feedback on their actions. Update related tests to verify the idempotency of the `finalizeBrand` function, ensuring that re-finalizing a brand correctly reuses the existing design system without duplication.

* feat(brand-extract): enhance BrandReferencePicker and localization updates

Updated the BrandReferencePicker component to reflect loading states and errors during brand extraction, improving user feedback. Added a new localization key for the brand extraction process and updated existing translations in English, Simplified Chinese, and Traditional Chinese to enhance clarity and user experience. Additionally, introduced new styles for better interaction with brand assets in the brand kit template.

* feat(brands): wire in-page lightbox/masonry/asset preview + refine seed

Brand-kit preview improvements for the live extraction kit:
- brand-kit.html: add in-page overlay system (sandboxed iframe has no
  top-nav) — clickable image lightbox with prev/next, a "view all"
  masonry modal, and a full-page asset preview modal that loads
  system/artifacts/<kind>.html in an iframe. Defer auto-reload while an
  overlay is open so it never yanks the modal out mid-interaction.
- seed-fallback.ts: prefer vivid mid-luminance hues for the seeded
  accent/accent-secondary, and drop icon/symbol faces (Remix Icon etc.)
  from the typography seed so specimens never render glyph soup.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(brands): wire in-page lightbox/masonry/asset preview + refine seed

Brand-kit preview improvements for the live extraction kit:
- brand-kit.html: add in-page overlay system (sandboxed iframe has no
  top-nav) — clickable image lightbox with prev/next, a "view all"
  masonry modal, and a full-page asset preview modal that loads
  system/artifacts/<kind>.html in an iframe. Defer auto-reload while an
  overlay is open so it never yanks the modal out mid-interaction.
- seed-fallback.ts: prefer vivid mid-luminance hues for the seeded
  accent/accent-secondary, and drop icon/symbol faces (Remix Icon etc.)
  from the typography seed so specimens never render glyph soup.

Co-authored-by: Cursor <cursoragent@cursor.com>

* i18n(web): add brandPicker.opening across remaining locales + picker test

Completes the brand-reference picker i18n key that was committed only for
en/zh-CN/zh-TW, so every locale satisfies the typed Dict, and lands the
BrandReferencePicker extraction-feedback test left untracked by the
concurrent worker.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(EntryShell): enhance AMR cloud card visibility post-detection

Updated the EntryShell component to ensure the AMR cloud card remains visible after detection settles, even when the AMR runtime is unavailable. This change prevents the card from disappearing and allows it to degrade gracefully to fallback content and sign-in flow. Additionally, added tests to verify the new behavior, ensuring a better user experience during onboarding.

* feat(library): OD Library asset registry + OD Clipper extension

Add a global, cross-project asset registry (OD Library) and a Chrome MV3
capture extension (OD Clipper), wiring the full HTTP + CLI + Web UI three-track
loop per specs/od-clipper.md.

- contracts: LibraryAsset/Source/Kind, ingest, search, pairing, task DTOs
- daemon: 6 additive SQLite tables, content-addressed owned storage, the
  idempotent registerLibraryAsset hook (hash dedup + append-source),
  programmatic enrichment (mime/size/image dims/domain/tags), pairing tokens
  with a persisted extension-origin allowlist, /api/library/* routes, and
  /api/tools/library/{search,apply} for in-task agent reuse
- cli: `od library list|get|rm|search|import|pair`
- web: Library tab (grid, source badges, filters, search, live SSE updates,
  extension pairing affordance)
- clipper/: standalone MV3 extension (background SW, content toolbar, popup)
- skills/library-curator: utility skill for agent-driven asset reuse

Origin middleware now honors paired chrome-extension:// origins (seeded from
SQLite on boot) and exempts the pairing-confirm handshake. Enrichment AI stages
(caption/OCR/embedding) are recorded as skipped pending a configured model.

* feat(brands): programmatic-first design system extraction + rename

Make brand extraction two-phase so a usable design system is ready the
moment the user enters a URL — the instant "aha" — instead of waiting on
the AI agent:

1. PROGRAMMATIC-FIRST (synchronous): startBrandExtraction now harvests the
   site deterministically (logo, palette, typography, one-line description,
   cover imagery, source URL) via prefetchBrand, synthesizes a valid design
   system with brandFromMaterial (no LLM), and finalizes + registers it
   before returning. finalizeBrand is refactored into a reusable
   finalizeBrandCore shared by both the programmatic path and the agent path.
2. ASYNC AI ENRICHMENT: the seeded agent prompt is reframed to enrich the
   already-usable design system and re-finalize in place (same user:<id>),
   updating every artifact/template.

Bounded + best-effort: a blocked/unreachable origin skips phase 1 and stays
`extracting` for the agent to drive. Gated on userDesignSystemsRoot so the
legacy agent-only path stays intact for tests.

Also rename the user-facing "Brand Kit" surface to "Design System" across
en + zh-CN strings, project names, and the enrichment prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(library): enhance asset import and management features

- Updated the `import` command to allow multiple local files and remote URLs, with restrictions on supported formats.
- Added new commands: `apply` for copying assets into project design files, `edit-as-page` for converting HTML assets into editable projects, and `figma` for exporting Figma captures.
- Introduced sidecar functionality for storing derived data alongside owned assets, including Figma capture IR and element HTML.
- Enhanced server configuration to support larger ingest payloads for asset captures.
- Improved error handling and user feedback during asset import and application processes.

* feat(asset-management): enhance asset dropzone and introduce chat-to-design feature

- Updated the DesignSystemAssetDropzone component to improve file preview handling with new functions for creating and revoking object URLs.
- Adjusted CSS for better layout and spacing in the asset dropzone.
- Added a new "Chat to design" button in the LibrarySection component, allowing users to send selected assets to the Home chat composer for project creation.
- Updated localization strings across multiple languages to reflect changes in asset import terminology.
- Enhanced the HomeView component to handle asset staging from the chat composer.

* feat(library): enhance asset application with element markup support

- Updated the `applyLibraryAsset` function to include an `includeElement` option, allowing the capture of element markup alongside assets.
- Modified related components (e.g., `ChatComposer`, `LibrarySection`, `FileWorkspace`) to handle the new element markup feature, ensuring both asset paths and optional element paths are returned and processed.
- Introduced a new function, `fetchLibraryAssetElementHtml`, to retrieve the captured HTML for element-pick assets.
- Enhanced the UI to display element markup inline within the chat composer, improving user interaction with captured elements.
- Updated API contracts to reflect changes in asset application responses, including optional element markup paths.

* feat(library): enhance asset filtering and preview handling

- Updated the LibraryPicker and LibrarySection components to implement a badge-aware kind filter, allowing for more precise asset filtering based on badge kind.
- Introduced a new `matchesKindFilter` function to streamline the filtering logic across components.
- Enhanced the DesignSystemAssetDropzone to ensure proper handling of image previews, addressing issues with broken thumbnails under React StrictMode.
- Added CSS styles for kind badges to improve asset representation in the UI.
- Implemented tests for the DesignSystemAssetDropzone to ensure correct preview lifecycle management.

* feat(library): hydrate single asset on SSE ingest

Add fetchLibraryAsset(id) so the Library grid can merge just the one
asset an `ingest` SSE event references instead of refetching the whole
list on every capture. Returns null on miss/error.

* feat(clipper): richer in-page image picker

Collect CSS background-image url()s in addition to <img> (so hero/section
art painted as backgrounds is no longer silently missed), defer thumbnail
decode to visible cells via IntersectionObserver, draw downscaled canvas
thumbnails instead of second full-res decodes, and add locate-on-page
highlighting so a picked image can be traced back to its DOM source.

* feat(library): implement lazy loading for thumbnails and enhance asset filtering

- Introduced a `LibraryThumb` component to lazily load heavy content (images, videos, iframes) only when they are near the viewport, improving performance.
- Added a debounced search feature to optimize asset filtering, reducing unnecessary network requests during rapid input.
- Enhanced the asset filtering logic to track active filters using a ref, ensuring efficient updates during live events.
- Updated the `snapshotCardRects` and `cardIdsInBand` functions to support improved hit-testing for drag-and-drop interactions.

* feat(library): lazy picker thumbnails + debounced search

Extend the Library grid's lazy-thumbnail + 250ms debounced-search pattern
to the composer LibraryPicker so opening it no longer fires one full-bytes
request per asset, and tidy the clipper content-script image collection.

* feat(clipper): compress and budget capture inlining

Re-encode large raster images to downscaled WebP and inline smallest-first
within a fixed budget, dropping only the secondary Figma IR past a safe body
size, so an image-heavy page (e.g. a news front page) always saves as an
editable HTML capture instead of 413-failing the ingest.

* test(library): LibraryPicker debounce + lazy-thumbnail coverage

Cover the composer picker's 250ms debounced search and its lazy <img>
mount (deferred until the card is in view), matching the grid's perf test.

* feat(design-system): enhance asset handling and UI for design systems

- Updated the CLI to support additional asset kinds, including 'design-system'.
- Enhanced the DesignSystemProvenance type to include source URLs, improving provenance tracking.
- Modified the design system generation jobs to correctly summarize source links and GitHub repositories.
- Updated UI components to reflect changes in asset handling, including new source link management in the DesignSystemFlow.
- Improved tests to cover new functionality for adding source links and ensuring proper handling of design system assets.

* refactor(library): rename 'design-system' to 'brand kit' and enhance thumbnail loading

- Updated labels and filters in Library components to replace 'design-system' with 'brand kit'.
- Introduced a shimmer skeleton for lazy-loaded thumbnails in the LibraryPicker to improve user experience during asset loading.
- Enhanced the PickerCard component for better performance by memoizing individual asset cards.
- Updated tests to ensure proper handling of brand kit assets and their visibility in the LibraryPicker.

* feat(clipper): implement internationalization for toolbar and popup

- Added i18n support to the clipper, enabling localization of UI elements and tooltips.
- Introduced a new i18n.js file to manage translations for various languages.
- Updated content.js and popup.js to utilize the i18n functions for dynamic text rendering.
- Enhanced accessibility by ensuring aria-labels and tooltips are also localized.
- Improved user experience by providing localized messages for actions and statuses.

* feat(clipper): enhance brand kit extraction and localization support

- Updated the brand kit extraction process to include improved handling of assets and localization for various UI elements.
- Added internationalization support for the brand kit feature, allowing for dynamic text rendering based on user locale.
- Enhanced the user experience by ensuring that all relevant messages and tooltips are localized.
- Updated tests to cover new localization features and ensure proper functionality of the brand kit extraction process.

* feat(clipper): enhance brand color derivation and update localization

- Introduced new functions for color manipulation, including linear interpolation and clamping, to improve brand color derivation.
- Updated the deriveBrandColors function to better map observed palettes to semantic roles, ensuring consistent brand representation.
- Revised localization strings in i18n.js to reflect changes from 'brand kit' to 'design system', enhancing clarity and user experience.
- Improved overall code organization and readability by refactoring existing functions and adding new utility methods.

* refactor(clipper): update terminology from 'brand kit' to 'design system'

- Replaced all instances of 'brand kit' with 'design system' across various components and localization files for consistency.
- Updated UI elements, tooltips, and documentation to reflect the new terminology.
- Enhanced user experience by ensuring clarity in the design system extraction process and related functionalities.
- Adjusted localization strings in multiple languages to align with the updated terminology.

* feat(clipper): enhance image fill handling and normalization

- Introduced functions to normalize image fills by converting non-PNG/JPEG formats (SVG, WebP, GIF, AVIF) to PNG before import, ensuring all images are properly rendered in Figma.
- Updated the UI to report the number of images converted and dropped during the import process, improving user feedback.
- Enhanced the overall image processing workflow to prevent silent failures when unsupported formats are encountered.
- Revised documentation to reflect the new image handling capabilities and supported formats.

* feat(clipper): enhance UI kit and busy state feedback

- Updated the UI kit to include new components such as inputs, selection, and overlays, improving the overall design system representation.
- Enhanced the busy state feedback during capture processes with localized messages and a step-by-step progress indicator, providing users with clearer status updates.
- Revised localization strings to support new UI elements and improve user experience across multiple languages.
- Improved documentation to reflect changes in the UI kit and busy state handling.

* fix(brands): restore design-systems nav entry + reconcile BrandsTab on re-activation

Address review feedback on PR #4260:

1. EntryNavRail dropped the only control that reached view==='design-systems'
   when Brands replaced it in the rail, leaving the still-rendered/routed
   design-system manager deep-link only (the entry-nav-design-systems e2e
   specs assert this). Restore a reachable rail entry (blocks icon, existing
   navDesignSystems key) alongside Brands.

2. BrandsTab only fetched once on mount, but EntryShell keeps sub-views
   mounted and toggles visibility, so a brand finishing extraction in its
   backing project never reconciled until a full reload. Refresh whenever the
   Brands view becomes active again, and poll while any brand is extracting
   (torn down once settled / when hidden).

Red spec: tests/components/BrandsTab.refresh.test.tsx (fails pre-fix:
fetchBrands called once, not twice).

* Update clipper/brand-capture.js

* fix(clipper): improve busy state handling and UI feedback

- Adjusted the spinner CSS to use flex properties for better layout control.
- Enhanced the reclampIfMoved function to preserve user position during busy state transitions.
- Added loading toast notifications for popup-launched captures to ensure progress visibility even when the on-page bar is hidden.

* feat(daemon): add kiwi-schema dependency and enhance Figma API integration

- Added kiwi-schema package to the daemon for improved schema handling.
- Updated FigmaApiNode interface and related functions to support shared functionality with the offline decoder, ensuring consistency in node processing.
- Refactored capture functions in clipper to maintain UI visibility during DOM/IR snapshots, enhancing user experience during capture operations.

* fix(web): surface missing backing projects

* fix(web): re-enable brand actions after use

* fix(daemon): serve brand logos from data roots

* fix(brands): reconcile failed extractions

* feat(daemon): implement offline Figma import and decoding functionality

- Added support for importing `.fig` files directly into the daemon, enabling offline processing without requiring a Figma account.
- Introduced a new `fig-decode.ts` module for decoding `.fig` files, handling both ZIP-wrapped and raw formats.
- Created `figma-import.ts` to orchestrate the import process, generating a canonical snapshot and associated metadata.
- Enhanced the server to handle Figma file uploads and integrate with the new decoding logic.
- Updated package dependencies to include `kiwi-schema`, `html2canvas`, and `jspdf` for improved functionality.
- Added tests for the new Figma import features to ensure reliability and correctness.

* feat(clipper): reload-proof capture progress badge on the extension icon

The on-page progress strip dies if a page reloads itself mid-capture
(aggressive paywall sites like economist.com do this), leaving no
loading signal. Add a per-tab '•••' badge on the extension icon for the
lifetime of any capture message — it lives on the action icon, so a page
navigation can't take it down. Verified end-to-end via a real loaded
extension.

* feat(daemon): add export functionality for Figma and enhance PDF export process

- Introduced `runFigma` command for importing Figma designs, supporting both local `.fig` files and Figma URLs.
- Added detailed usage instructions for the `od figma import` command.
- Implemented `runExport` command for programmatic export of HTML/deck artifacts to PDF, PPTX, or image formats.
- Enhanced error handling and user feedback during export processes.
- Removed obsolete `build-pptx-export-prompt` module and related tests to streamline the codebase.

* feat(daemon): enhance library synchronization and export capabilities

- Implemented `reconcileLibrary` to mirror design systems and agent-produced project deliverables into the Library as referenced assets.
- Added support for programmatic export of artifacts via the `od export` command, including detailed usage instructions.
- Introduced new functions for handling Figma imports and exports, improving integration with design workflows.
- Enhanced error handling and user feedback during synchronization and export processes.
- Added tests for new features to ensure reliability and correctness.

* feat(web): PPTX export for any shareable artifact + Library toolbar tooltips

* chore(nix): refresh pnpm deps hash

* refactor(web): enhance onboarding view and file export progress indicators

- Updated the onboarding view layout for improved accessibility and visual hierarchy, including adjustments to spacing, typography, and button styles.
- Introduced a loading toast for file export progress, displaying elapsed time and estimated time remaining for slide exports.
- Added new translation keys for export progress messages in multiple languages.
- Refactored the export progress handling to provide real-time updates during the export process, improving user feedback and experience.

* refactor(web): streamline export capture bridge and update connector styles

- Removed unused loading logic for html2canvas in the export capture bridge, simplifying the code.
- Updated CSS for the onboarding view connector to improve visual clarity and ensure it does not overlap with node numbers.

* refactor(web): remove html2canvas dependency and enhance Figma URL handling

- Removed the html2canvas package from the project, including its references in the lock file and related components.
- Added functionality to manage Figma URLs within the Design System flow, allowing users to add, remove, and validate Figma file links.
- Improved drag-and-drop handling to prevent unintended file navigation when dropping files outside designated areas.
- Updated UI components to accommodate new Figma URL features, enhancing user experience and accessibility.

* refactor(web): unify brand and design system flows

- Merged the brand extraction process into the design system creation workflow, allowing users to start from a brand directly within the design system wizard.
- Updated routing to redirect legacy brand links to the unified design systems tab.
- Enhanced the onboarding experience by removing the separate Brand Kit tab and integrating brand selection into the design system creation process.
- Improved UI components to reflect these changes, ensuring a seamless user experience across the application.

* feat(web): introduce brand enrichment banner and picker modal

- Added a new BrandEnrichmentBanner component to allow users to refine programmatically-extracted design systems with AI by selecting design-system skills.
- Implemented a BrandPickerModal for selecting brands from a searchable gallery, enhancing the design system creation flow.
- Updated ChatPane to conditionally display the enrichment banner for eligible brand projects, improving user engagement.
- Enhanced the design system flow to support the new brand enrichment features, ensuring a seamless experience for users.

* feat(web): enhance BrandPickerModal and DesignSystemAssetDropzone

- Updated the BrandPickerModal to allow scrolling of the entire picker area, improving user experience by creating a unified scrolling surface.
- Added new props to the BrandReferencePicker for action labels and scroll root reference, enhancing flexibility in brand selection.
- Introduced a new DesignKitView component for rendering design kits consistently across different surfaces.
- Enhanced the DesignSystemAssetDropzone to support a wider variety of file types with appropriate previews, improving asset management during design system creation.
- Updated styles for better visual clarity and responsiveness across components.

* feat(web): update Design Systems tab actions and enhance localization

- Changed the button label in the DesignSystemsTab from "Edit" to "Open" for better clarity in user actions.
- Added a new translation key for 'dsManager.openSystem' across multiple languages to support the updated button label.
- Enhanced the FileWorkspace component to ensure the Design Files tab aligns correctly with the Design System tab, improving UI consistency.
- Implemented a new design system editing feature that allows users to fetch and save design system content from DESIGN.md, enhancing the design workflow.

* fix(merge): repair post-merge regressions after origin/main integration

Follow-up fixes on top of the origin/main merge (886f925cd) addressing
regressions the conflict resolution introduced. main's web suite is the
oracle (100% green); resolution principle was main's engine/backend +
HEAD's UI, unioned.

daemon:
- library-sync.ts: correct design-systems import to ./design-systems/index.js
  (design-systems became a directory module on main).
- tests/server-bootstrap-regression: add LIBRARY_DIR to the PathDeps fixture
  (main-added test x HEAD-added LIBRARY_DIR field).

web:
- WorkspaceTabsBar: union — restore main's onboarding Search-popover close
  behaviour + guards, keep HEAD's library/brands nav entries.
- HomeView: restore main's composer sending-state (await onSubmit, widen its
  return type to Promise<boolean>|boolean|void, pass submitting to HomeHero).
- MemorySection.test: take main's version to match main's two-loop memory
  component.
- i18n: restore dropped settings.onboardingRoleMarketing key across types.ts
  and all locales.
- App/BrandsTab/EntryNavRail/router/home-intent: union fixes restoring main
  features dropped during conflict resolution (needs_input handling, etc.).

Validation: pnpm guard + full pnpm typecheck (all 23 packages) green.
Known-red: EntryShell onboarding step 3 intentionally retains HEAD's "build"
step rather than main's brand-extract step; 8 EntryShell.onboarding /
App.onboarding-amr-e2e tests stay red pending that onboarding decision.

* fix(merge): keep HEAD's unified brand flow (revert main's separate Brands tab)

Follow-up to 688544ff7. Per the chosen product direction (brand creation
unified into the design-system create wizard, not a standalone Brands tab),
revert the brand-flow routing/nav that the post-merge repair had restored
from main:

- router.ts: keep HEAD's brand routing (brands folded into design-systems),
  drop main's standalone /brands and /brands/:id view routing.
- EntryNavRail.tsx: drop main's standalone "Brands" nav button.
- runtime/home-intent.ts: drop main's brand "Use in new chat" confirmation
  notice plumbing (tied to the separate Brands flow).

Kept from the repair commit (additive, non-conflicting): App.tsx
loadedActiveProject correctness, composer Sending… state, WorkspaceTabsBar
onboarding popover behaviour, two-loop memory test, restored i18n keys,
brand needs_input STATUS handling, server.ts plugin-route infrastructure.

* feat(library-ui): implement conditional rendering based on LIBRARY_UI_VISIBLE

- Updated router.ts to conditionally render the library view based on the LIBRARY_UI_VISIBLE flag.
- Modified ComposerPlusMenu.tsx, DesignFilesPanel.tsx, and DesignSystemAssetDropzone.tsx to show the "Select from library" button only when LIBRARY_UI_VISIBLE is true.
- Adjusted EntryNavRail.tsx and EntryShell.tsx to include the library navigation button and section conditionally based on the LIBRARY_UI_VISIBLE state.
- Enhanced HomeHero.tsx to allow starting a blank project directly, improving user experience by providing more options for project creation.

This commit introduces a feature toggle for the library UI, allowing for better control over its visibility during development and testing.

* feat(home-hero): implement edge auto-scroll for horizontal overflow

- Introduced `useEdgeAutoScroll` hook to manage auto-scrolling behavior for horizontally overflowing components in HomeHero.
- Updated `PluginPromptPresets` and `RailGroup` components to utilize the new auto-scroll functionality, enhancing accessibility for users without trackpads.
- Added `EdgeScrollZones` component to provide interactive edge zones for scrolling.
- Enhanced CSS styles to support the new scrolling layout and ensure proper positioning of elements.

This commit improves user experience by making overflow content more accessible and easier to navigate.

* feat(design-systems): add project creation from design system and enhance UI components

- Implemented `handleCreateProjectFromDesignSystem` function in `AppInner` to facilitate project creation directly from a selected design system.
- Updated `DesignKitView` to wrap the iframe in a span for better layout control.
- Refactored CSS for `BrandPreviewCard` and `DesignSystemsTab` to improve styling and responsiveness.
- Introduced a new `TemplatePicker` component in `HomeHero` for selecting project-type templates, enhancing user experience.
- Updated various components to support asynchronous handlers for design system actions, improving overall functionality.

This commit enhances the design system integration and user interface, making project creation more intuitive and accessible.

* feat(brand-routes): enhance brand reservation API and add DESIGN.md support

- Updated the POST /api/brands endpoint to accept optional fields: description and designMd, allowing for more flexible brand reservations.
- Modified validation to require either a URL or designMd for brand extraction.
- Introduced a new design-md-input module to handle parsing and validation of DESIGN.md content.
- Enhanced startBrandExtraction function to support processing of DESIGN.md, improving integration with design systems.
- Added utility functions for managing DESIGN.md input and output, streamlining the brand creation process.

This commit improves the brand extraction workflow by integrating DESIGN.md support, making it easier for users to create and manage brands.

* feat(chat-pane, design-kit-view): enhance chat functionality and design preview features

- Added `handleNextStepPromptAction` to `ChatPane` for setting draft prompts, improving user interaction.
- Introduced `nextStepVariant` to differentiate design system projects in `ChatPane`.
- Updated `DesignKitView` to include a button for previewing design kit covers, enhancing user experience.
- Implemented a modal for displaying design kit previews, allowing users to view content in a dedicated space.

These changes improve the chat interface and design kit interactions, making the application more intuitive and user-friendly.

* feat(brand-extraction): enhance DESIGN.md support and testing

- Added a new test case to validate brand extraction from DESIGN.md input without requiring a website.
- Implemented functionality to register brands directly from DESIGN.md, improving the brand creation workflow.
- Updated the `ChatPane` and `NextStepActions` components to handle design system-specific actions for projects, enhancing user experience.
- Enhanced localization files with new carousel hints and project brief options across multiple languages.

These changes streamline the brand extraction process and improve the overall functionality of the design system integration.

* feat(wireframe-examples): add annotated and greybox wireframe examples

- Introduced new wireframe examples for annotated and greybox styles, enhancing design system capabilities.
- Added HTML and JSON files for both wireframe types, providing templates for low-fidelity design mockups.
- Implemented SKILL.md documentation for each wireframe example, detailing usage and design specifications.

These additions improve the design toolkit, offering users more options for creating wireframes in various styles.

* feat(brand-extraction): refine Chrome fallback and enhance error handling

- Updated the Chrome fallback logic in the prefetch pipeline to clarify its purpose and usage as a diagnostic tool.
- Introduced environment variable checks to enable or disable system Chrome usage, improving control over the extraction process.
- Enhanced error messages in the DesignSystemCreationFlow component to provide clearer guidance on required inputs for creating a design system.
- Added regression tests to ensure that prompts do not instruct the agent to invoke a non-existent `brand-extract` skill, preventing potential failures during brand extraction.

These changes improve the robustness of the brand extraction process and enhance user experience by providing clearer instructions and error handling.

* feat(brand-extraction): enhance DESIGN.md input handling and introduce brand ready prompt

- Updated the BrandFromDesignMdInput interface to explicitly define the description property as optional with undefined.
- Enhanced the brand extraction prompts to clarify the inline brand-extract workflow, preventing confusion during the extraction process.
- Added a new BrandReadyPrompt component to notify users when a design system is ready for preview, improving user experience.
- Introduced CSS styles for the BrandReadyPrompt to ensure a visually appealing and user-friendly interface.
- Updated localization files to support new strings related to the brand ready prompt across multiple languages.

These changes improve the clarity and usability of the brand extraction process, providing users with timely feedback and a more intuitive interface.

* feat(brand-extraction): improve design system focus handling and localization updates

- Refactored the handling of browser tabs in the brand extraction tests to ensure proper validation of tab states.
- Enhanced the AppInner component to refresh design systems alongside templates, ensuring users see the latest updates without page reloads.
- Introduced a pending focus state in the DesignSystemsTab to manage design system selection more effectively after brand extraction.
- Added a BrandReadyPrompt in the ProjectView to notify users when a design system is ready for preview, improving user engagement.
- Updated localization files for Chinese (Simplified and Traditional) to reflect changes in terminology related to design systems.

These changes enhance the user experience by providing timely feedback and ensuring that the design system selection process is seamless and intuitive.

* fix(styles): adjust letter-spacing and enhance plus-menu trigger styles

- Set letter-spacing to 0 in design-system-flow.css for improved text clarity.
- Added styles for plus-menu trigger in plus-menu.css, including background, border, and hover effects to enhance user interaction and visual consistency.

These changes refine the design aesthetics and improve the usability of the plus-menu component.

* feat(tests): add design-system focus handoff tests

- Introduced a new test suite for validating the design-system focus handoff functionality.
- Implemented tests to ensure that the focus ID is correctly set, read, and cleared from session storage, preventing user selection hijacking.
- Added checks for scenarios where no focus ID is pending, enhancing test coverage for the design system's behavior.

These tests ensure the reliability of the design-system focus handling, contributing to a more robust user experience.

* feat(export): restrict image format options to PNG and JPEG

- Updated the image format options in the export functionality to only allow PNG and JPEG, removing WebP to prevent silent downgrades.
- Enhanced error handling to provide clear feedback when an unsupported image format is specified.
- Adjusted related documentation and comments to reflect the changes in supported formats across the application.

These changes ensure consistency in image export behavior and improve user experience by providing immediate validation errors for unsupported formats.

* feat(origin-validation): implement zero-config OD Clipper bypass for library requests

- Added a new function `isZeroConfigClipperLibraryRequest` to validate requests from locally-installed browser extensions targeting the `/library/` path.
- Updated the origin validation middleware to utilize this function, allowing unpaired browser extensions to access the `/api/library/ingest` endpoint while blocking other cross-origin requests.
- Enhanced tests to cover the new bypass functionality, ensuring correct behavior for both valid and invalid origins.

These changes improve the integration of browser extensions with the local daemon, enhancing user experience while maintaining security.

* feat(design-systems): add download functionality for design systems

- Implemented a new command `od design-systems download <id>` to allow users to download design systems as a .zip file, including all system files and a generated SKILLS.md usage guide.
- Updated the CLI help documentation to include usage instructions for the new download command.
- Enhanced the design systems API to support the download feature, ensuring only user design systems are accessible while handling errors for non-existent presets.
- Added localization strings for the new download functionality across multiple languages.

These changes enhance the usability of design systems by providing a straightforward method for users to obtain and share their design assets.

* feat(design-systems): enhance design system management and localization

- Introduced new UI components and styles for managing design systems, including buttons for downloading, refreshing, and resetting edits.
- Updated the DesignKitView to support direct actions for DESIGN.md editing, improving user interaction with design systems.
- Enhanced the DesignSystemDetail component to include download functionality and improved state management for design system edits.
- Added localization strings for new features, ensuring consistent user experience across multiple languages.
- Improved error handling and user feedback for design system operations, including download failures.

These changes streamline the design system management process, making it more intuitive and user-friendly while ensuring robust localization support.

* feat(tests): add comprehensive tests for design system archive functionality

- Introduced a new test suite for validating the `buildUserDesignSystemArchive` and `buildDesignSystemSkillsMarkdown` functions.
- Implemented tests to ensure correct packing of design system files, including the generation of a `SKILLS.md` guide and exclusion of internal metadata.
- Added checks for handling non-user IDs and scenarios where a design system already includes its own `SKILLS.md`.
- Enhanced the overall test coverage for design system functionalities, ensuring reliability and correctness in the design system archive process.

These changes improve the robustness of the design system features by ensuring thorough testing of critical functionalities.

* feat(figma-import): enhance CLI output and add Figma import endpoint

- Updated the CLI to conditionally log detailed import information based on the `--json` flag, improving usability for users who prefer JSON output.
- Introduced a new API endpoint for importing Figma files, handling file uploads and validating project existence, with appropriate error responses for missing files or invalid URLs.
- Added a dedicated route for the Figma import functionality, ensuring seamless integration with existing project workflows.

These changes improve the Figma import experience by providing clearer output options and robust error handling, enhancing overall user interaction with the CLI and API.

* feat(design-files): enhance DesignFilesPanel with new actions and styles

- Added new action buttons for opening a browser and creating a design system in the DesignFilesPanel, improving user interaction in the empty state.
- Updated styles for action buttons to enhance visual distinction and usability.
- Enhanced tests to verify the functionality of new actions in the DesignFilesPanel, ensuring they trigger correctly.

These changes improve the user experience by providing additional functionality and clearer visual cues in the design files interface.

* fix(ci): restore new project modal flow

* fix(ci): align design kit and onboarding checks

* fix(ci): sync bake preview workflow action

* fix(ci): include plugin preview helper scripts

* fix(review): harden brand source and preview flows

* fix(ci): stabilize web workspace tests

* fix(review): address latest blocking feedback

* chore(ci): retrigger validation after label update

* chore: re-trigger CI on updated main — needs-validation gate moved to merge_group (#4714)

* refactor(lightbox): implement portal for overlays to resolve z-index issues

- Updated the lightbox component to use React's createPortal for rendering overlays directly to the <body>, ensuring proper z-index stacking.
- Removed session mode toggle from HomeHero and adjusted related styles and tests accordingly.
- Cleaned up CSS by removing unused styles related to session mode toggle.
- Updated tests to reflect changes in the HomeHero component and its interaction with the design router.

* style(home-hero): remove focus halo from template search input

- Updated CSS to eliminate the global input focus outline and box-shadow for the template search field in the HomeHero component.
- Added a test to verify that the template picker search field maintains a clean appearance when focused.

* feat(design-system): add create design CTA and enhance design kit functionality

- Introduced a new `DesignSystemCreateCta` component to facilitate creating new designs from an active design system, enhancing user experience in the chat interface.
- Updated `ChatPane` to include the new CTA, allowing users to create designs directly from the chat.
- Enhanced `DesignKitView` with sticky header functionality for better accessibility while scrolling.
- Added new CSS styles for the `DesignSystemCreateCta` component to ensure a visually appealing and consistent design.
- Updated internationalization files to include new strings for the design system creation feature.

* feat(upload): enhance file upload handling and error recovery

- Introduced `sanitizePath` to preserve directory structures during file uploads, preventing issues with subdirectory paths.
- Updated `DesignKitView` and related components to utilize the new `sanitizePath` function for improved file name resolution.
- Added `KitErrorBoundary` component to gracefully handle rendering errors in the design kit, providing a user-friendly fallback.
- Implemented internationalization updates for new error messages and action confirmations related to uploads and error handling.
- Enhanced CSS styles for better visual feedback during error states and improved user experience.

* feat(design-kit): add keyboard shortcuts hint and enhance key handling

- Introduced a new keyboard shortcuts hint in the DesignKitView, providing users with quick access to essential actions (E edit, C copy, U upload, R refresh, ⌫ delete logo).
- Implemented a keydown event handler to manage keyboard shortcuts contextually within the design kit, improving user interaction and accessibility.
- Updated CSS for the shortcuts hint to ensure it remains low-contrast until hovered, enhancing the UI experience.
- Added internationalization support for new shortcut labels and hints across multiple languages.
- Adjusted DesignSystemsTab to prefer user logos for their systems, improving visual consistency.

* feat(design-system): introduce DesignSystemExtractionPanel and enhance design system interactions

- Added the `DesignSystemExtractionPanel` component to facilitate user interactions during design system extraction, providing a synthesized conversation view and next steps.
- Updated `ChatPane` to render the new extraction panel when a design system is active, enhancing user guidance.
- Introduced a new utility function `designSystemExtractionSource` to derive human-readable labels for design system sources.
- Enhanced internationalization support with new strings for extraction-related actions and prompts across multiple languages.
- Updated various components and tests to reflect changes in terminology and functionality, improving overall user experience.

* feat(project): add project deletion functionality and enhance design system interactions

- Introduced `onDeleteProject` prop in `ProjectView` to handle project deletion, improving project management capabilities.
- Updated `AppInner` to include the new delete project handler, enhancing user experience in project interactions.
- Enhanced `DesignKitView` and `DesignSystemsTab` with loading states and improved visual feedback during design system resolution.
- Removed deprecated `DesignSystemCreateCta` component and associated styles, streamlining the codebase.
- Updated internationalization files to reflect changes in project management terminology and actions.

* feat(design-kit): enhance internationalization and user feedback in DesignKitView

- Updated various labels and error messages in the DesignKitView to utilize internationalization functions, improving accessibility and user experience.
- Enhanced color input validation messages and added confirmation prompts for design system deletions in DesignSystemsTab and FileWorkspace.
- Introduced new props for handling design system project deletions, streamlining project management.
- Updated internationalization files to reflect new strings and translations for improved user guidance across multiple languages.

* refactor(design-kit): remove keyboard shortcuts hint and streamline header menu

- Eliminated the keyboard shortcuts hint from the DesignKitView, simplifying the header menu.
- Updated the sticky-header overflow menu to exclude upload, full-system preview, and shortcut help actions, focusing on essential project operations.
- Adjusted related tests to reflect the removal of the shortcuts hint and ensure accurate menu item visibility.

* feat(brand-routes): add extract-from-html endpoint for brand extraction

- Introduced a new POST endpoint `/api/brands/:id/extract-from-html` to re-run brand extraction using HTML rendered from the in-app browser after clearing anti-bot walls.
- Implemented error handling for missing HTML and brand not found scenarios.
- Enhanced the `extractBrandFromHtml` function to process the provided HTML and optional CSS, integrating it into the existing brand extraction workflow.
- Updated `prefetch` functionality to support extraction from pre-rendered HTML, improving the overall brand data retrieval process.

* chore(nix): refresh pnpm deps hash

* feat(brand-cli): add extract-from-html command for brand extraction

- Introduced a new CLI command `od brand extract-from-html` to facilitate brand extraction from pre-captured rendered HTML, allowing users to bypass anti-bot walls.
- Enhanced the command to accept optional CSS and base URL parameters, improving flexibility in extraction scenarios.
- Implemented error handling for missing HTML input and invalid brand IDs, ensuring robust user feedback.
- Updated the `BRAND_USAGE` documentation to reflect the new command and its usage details.
- Adjusted server configuration to accommodate larger payloads for the new extraction endpoint.

* feat(design-system): enhance design system extraction and browser tools

- Added a new script to collect CSS styles from rendered pages, improving brand extraction capabilities by capturing computed styles from cross-origin stylesheets.
- Removed the `DesignSystemExtractionPanel` component and its associated styles, streamlining the codebase.
- Updated `ProjectView` and `FileWorkspace` components to enhance design system interactions and improve user experience.
- Introduced new internationalization strings for design system phases and actions, ensuring better user guidance across multiple languages.

* feat(brand-assist): implement browser assist for brand extraction

- Added support for a client-side confirmation mechanism for the brand-browser-assist od-card, allowing users to extract brand information from the unblocked browser DOM.
- Enhanced the `ProjectView`, `ChatPane`, and `AssistantMessage` components to handle the new assist functionality, improving user interaction during brand extraction.
- Introduced new internationalization strings for browser assist prompts and messages, ensuring clarity and guidance across multiple languages.
- Updated the `useBrandReadyPrompt` hook to manage the state of the browser assist, providing a seamless user experience when dealing with anti-bot walls.

* feat(brand-prompt): enhance BrandReadyPrompt with refinement options

- Updated the BrandReadyPrompt component to include options for AI optimization and manual editing, allowing users to refine extracted brand systems.
- Added a new refinement nudge to inform users that automatic extraction may miss details, improving user guidance.
- Adjusted styles for the prompt and dismiss button for better alignment and visual consistency.
- Introduced new internationalization strings for the refinement features, ensuring clarity across multiple languages.
- Removed deprecated PPTX export functionality from the FileViewer component, streamlining the export options.

* refactor(export): remove PPTX export functionality and streamline export options

- Eliminated PPTX export support across various components, including CLI, desktop, and web, to simplify export formats.
- Updated documentation and help messages to reflect the removal of PPTX, ensuring clarity for users.
- Adjusted export-related types and constants to focus on PDF and image formats only, enhancing code maintainability.
- Improved user experience by refining export options and related UI elements.

* refactor(export): remove PPTX references and update export functionality

- Removed all instances of PPTX export functionality from the codebase, including related dependencies and comments.
- Updated export options to focus solely on PDF and image formats, enhancing clarity and maintainability.
- Adjusted UI components and tests to reflect the removal of PPTX, ensuring a streamlined user experience.
- Improved internationalization strings and documentation to align with the new export capabilities.

* chore(nix): refresh pnpm deps hash

* fix(onboarding): preserve selected runtime

* fix(brand): localize generated kit copy

* fix(onboarding): align first-run flow with main

* fix(nav): use palette icon for design systems

* fix(analytics): use design system onboarding step

* fix(ui): remove design system guide toggle

* fix(ui): position design system ready prompt

* fix(ui): space plugin task notice

* fix(web): restore home ask mode and design kit preview

* test(e2e): align onboarding visual capture

* test(e2e): align amr onboarding checks

* fix(brand): remove blocked reference brands

* feat(onboarding): show profile choices as chips

* fix(home): prefer design system cover art in recents

* test(e2e): select onboarding profile chips

* feat(brand-extraction): implement programmatic extraction transcript and UI enhancements for design systems

* feat(brand-extraction): enhance programmatic extraction with transcript agent support and UI improvements

* feat(brand-extraction): add transcript agent resolution and improve message handling in brand extraction

* fix(design-systems): stabilize loading state coverage

* test(e2e): align design system detail visual

* fix(brand-extraction): backfill programmatic transcripts

* fix(web): refresh ready brand design systems

* fix(brands): stabilize extraction handoff and seed colors

* fix(brands): return extraction transcript immediately

* fix(web): open new project modal from entry rail

* fix(editing): expose content edits for plain targets

* feat(file-viewer): implement manual edit draft dirty state tracking and reset logic

* feat(design-system): enhance project creation flow with conversation ID handling

* feat(brands): implement light theme handling for color extraction and seed generation

* feat(brands): add finalizeBrandProject function for brand project completion

* feat(file-workspace): add designSystemBrandId prop and update DesignSystemProjectPanel to use it

* Fix manual editing for brand kits

* fix(design-system): wait for project refreshes

* fix(web): open new project modal from rail

* fix(web): restore home ask mode toggle

* fix(web): sync brand color edits with seeds

* fix(web): stabilize design system workspace tests

* test(tools-pack): relax Windows resource cache timeout

* chore(pr): retrigger review after validation

* fix(web): surface design kit action progress

* fix(web): clarify brand next-step actions

* fix(web): cancel programmatic brand extraction

* fix(web): add design systems tab action feedback

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: xne998808-ai <xne998808@gmail.com>
Co-authored-by: PerishCode <perishcode@gmail.com>
Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-06-25 03:56:14 +00:00

2352 lines
92 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHmac, randomBytes } from "node:crypto";
import { appendFile, mkdir, realpath, stat, writeFile } from "node:fs/promises";
import { release } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { BrowserWindow, app, dialog, ipcMain, nativeImage, screen, session, shell } from "electron";
import {
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_MODES,
DESKTOP_UPDATE_STATES,
type DesktopExportArtifactInput,
type DesktopExportArtifactResult,
type DesktopExportPdfInput,
type DesktopExportPdfResult,
type DesktopUpdateStatusSnapshot,
} from "@open-design/sidecar-proto";
import type { OpenDesignHostActionResult, OpenDesignHostCaptureResult, OpenDesignHostUpdaterActionOptions } from "@open-design/host";
import { openValidatedDirectory } from "./open-path.js";
import { exportArtifact as exportArtifactFromHtml } from "./artifact-export.js";
import { createElectronPdfTarget, exportPdfFromHtml, savePrintReadyDocumentAsPdf } from "./pdf-export.js";
import { SPLASH_VIDEO_DATA_URL } from "./splash-video.js";
import type { PrintReadyPdfOptions } from "./pdf-export.js";
import type { DesktopUpdater } from "./updater.js";
const execFileAsync = promisify(execFile);
/**
* Result of validating a candidate path before exposing it to a
* privileged shell operation.
*/
export type PathValidationResult =
| { ok: true; resolved: string }
| { ok: false; reason: string };
/**
* Validates that a path points at an existing absolute directory
* that is *not* a macOS application bundle. Returns the
* realpath-resolved canonical path on success so symlink games can't
* be used to escape into another location.
*
* The `.app` rejection is load-bearing on macOS: `.app` bundles are
* directories, so a plain "isDirectory" check would let the path
* gate forward `/Applications/Safari.app` (or any other installed
* app) into `shell.openPath`, which would *launch* the application
* rather than reveal it in Finder. Since the only legitimate use of
* the openPath bridge is "show the project folder," rejecting `.app`
* keeps the bridge limited to the actual feature surface.
*/
export async function validateExistingDirectory(p: string): Promise<PathValidationResult> {
if (typeof p !== "string" || p.length === 0) {
return { ok: false, reason: "path must be a non-empty string" };
}
if (!isAbsolute(p)) {
return { ok: false, reason: "path must be absolute" };
}
let resolvedReal: string;
try {
resolvedReal = await realpath(p);
} catch {
return { ok: false, reason: "path does not exist" };
}
let st;
try {
st = await stat(resolvedReal);
} catch {
return { ok: false, reason: "path could not be stat'd" };
}
if (!st.isDirectory()) {
return { ok: false, reason: "path is not a directory" };
}
// macOS app bundles are directories; treat them as opaque files
// because shell.openPath on a `.app` *launches* the application.
if (resolvedReal.toLowerCase().endsWith(".app")) {
return { ok: false, reason: "application bundles are not project directories" };
}
return { ok: true, resolved: resolvedReal };
}
/**
* Shape returned to the desktop's `shell:open-path` handler. The handler
* needs both the canonical resolved directory (to forward into
* `shell.openPath`) and a couple of metadata signals so it can enforce
* mrcfps's PR #974 follow-up requirement: only allow `openPath(projectId)`
* for projects whose `resolvedDir` came from the trusted picker flow.
*
* `hasBaseDir` distinguishes folder-imported projects (where the
* resolvedDir is a user-controlled location) from native projects (where
* the resolvedDir is daemon-owned `<projectsRoot>/<id>` and is therefore
* always safe to open). Folder-imported projects must additionally
* carry `fromTrustedPicker: true` from the HMAC-gated import flow.
*/
export type ResolvedProjectDirContext = {
fromTrustedPicker: boolean;
hasBaseDir: boolean;
resolvedDir: string;
};
/**
* Decide whether `shell.openPath` may forward this project's
* `resolvedDir` to the OS file manager. PR #974 mrcfps follow-up:
* folder-imported projects (`hasBaseDir: true`) must additionally
* carry `fromTrustedPicker: true`, the marker stamped by the daemon's
* HMAC-gated import flow. Native projects (no `baseDir`,
* `resolvedDir` lives under the daemon-owned projects root) are
* always safe to open. Returned as a structured result so the IPC
* handler can prefix the rejection reason with "open-path: " to
* match the rest of its error envelope shape.
*/
export function isOpenPathAllowedForProject(
context: ResolvedProjectDirContext,
): { ok: true } | { ok: false; reason: string } {
if (context.hasBaseDir && !context.fromTrustedPicker) {
return { ok: false, reason: "project did not come from the trusted picker flow" };
}
return { ok: true };
}
/**
* Resolves a project ID to its canonical working directory by asking
* the daemon. The web sidecar proxies `/api/*` to the daemon, so the
* desktop main process can reach the daemon's project-detail endpoint
* via the web URL we already discover for the BrowserWindow load.
*
* Used as the trust boundary for the `shell:open-path` IPC handler:
* the renderer hands the main process a project ID (something it knows
* the daemon registered), and the main process derives the path itself
* from the daemon's authoritative response. A compromised renderer
* cannot synthesize an arbitrary path because it never gets to name
* the path — it only names the project. The handler additionally
* inspects `metadata.baseDir` and `metadata.fromTrustedPicker` so it
* can refuse folder-imported projects that did not come through the
* desktop HMAC-gated import flow (PR #974).
*/
export async function fetchResolvedProjectDir(
apiBaseUrl: string,
projectId: string,
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
): Promise<{ ok: true; context: ResolvedProjectDirContext } | { ok: false; reason: string }> {
if (typeof projectId !== "string" || projectId.length === 0) {
return { ok: false, reason: "project id must be a non-empty string" };
}
// Reject obviously malformed ids before sending — the daemon enforces
// its own isSafeId check, but the floor here keeps URL construction
// honest and short-circuits trivial malicious input. The regex mirrors
// `apps/daemon/src/projects.ts#isSafeId` and `POST /api/projects`'s
// `[A-Za-z0-9._-]{1,128}` shape (round-4 mrcfps): legitimate dotted
// ids like `my-project.v2` would otherwise be rejected here even
// though the backend accepted them at create time, regressing
// Continue in CLI / Finalize on those projects.
if (!/^[A-Za-z0-9._-]{1,128}$/.test(projectId)) {
return { ok: false, reason: "project id contains disallowed characters" };
}
let resp: Response;
try {
resp = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/projects/${encodeURIComponent(projectId)}`);
} catch (err) {
return { ok: false, reason: `daemon fetch failed: ${err instanceof Error ? err.message : String(err)}` };
}
if (!resp.ok) {
return { ok: false, reason: `daemon returned HTTP ${resp.status}` };
}
let body: unknown;
try {
body = await resp.json();
} catch {
return { ok: false, reason: "daemon response was not JSON" };
}
const resolvedDir =
body && typeof body === "object" && "resolvedDir" in body
? (body as { resolvedDir: unknown }).resolvedDir
: undefined;
if (typeof resolvedDir !== "string" || resolvedDir.length === 0) {
return { ok: false, reason: "daemon response did not include resolvedDir" };
}
const project =
body && typeof body === "object" && "project" in body
? (body as { project: unknown }).project
: undefined;
const metadata =
project && typeof project === "object" && "metadata" in project
? (project as { metadata: unknown }).metadata
: undefined;
const hasBaseDir =
metadata != null &&
typeof metadata === "object" &&
typeof (metadata as { baseDir?: unknown }).baseDir === "string" &&
((metadata as { baseDir: string }).baseDir.length > 0);
const fromTrustedPicker =
metadata != null &&
typeof metadata === "object" &&
(metadata as { fromTrustedPicker?: unknown }).fromTrustedPicker === true;
return { ok: true, context: { fromTrustedPicker, hasBaseDir, resolvedDir } };
}
// Mirror of the daemon's token field separator. We avoid `.` because
// ISO 8601 expiry strings already contain dots (`...:00.000Z`). `~`
// appears in neither base64url nor ISO 8601, so the three fields are
// unambiguous when the daemon splits them. Drift between the two
// constants would silently invalidate every minted token, so the
// packaged workspace's vitest pins the produced shape.
const DESKTOP_IMPORT_TOKEN_FIELD_SEP = "~";
/**
* Pure-function HMAC mint for the `X-OD-Desktop-Import-Token` header.
* Mirrors `signDesktopImportToken` on the daemon side (PR #974). Kept in
* a small exported helper so the packaged workspace's vitest suite can
* pin token-shape contract drift without booting Electron.
*/
export function signDesktopImportToken(
secret: Buffer,
baseDir: string,
options: { nonce: string; exp: string },
): string {
const signature = createHmac("sha256", secret)
.update(`${baseDir}\n${options.nonce}\n${options.exp}`)
.digest("base64url");
return [options.nonce, options.exp, signature].join(DESKTOP_IMPORT_TOKEN_FIELD_SEP);
}
const PENDING_POLL_MS = 120;
const RUNNING_POLL_MS = 2000;
// Minimum time the light splash window stays on screen before we reveal the main
// window. It is sized to outlast the ~1.7s clip so the brand animation always
// plays through. The splash is shown immediately and in parallel with the
// daemon/web boot (see the packaged entry), so this time overlaps startup rather
// than adding to it; the <video> holds on its final frame (it does not loop)
// while the runtime finishes coming up. See `createSplashWindow`.
const MIN_SPLASH_MS = 2000;
// While the splash is up, the real web app loads in a hidden main window. We
// reveal it only once the web bundle reports it has actually mounted (it sets
// `data-od-app-mounted="1"` on first paint of the real UI), so the user never
// sees the web's own "Loading Open Design…" shell flash between the splash and
// the app. Poll cadence + a hard ceiling so a missing mount signal can never
// strand the user on the splash forever.
const WEB_MOUNT_POLL_MS = 80;
const WEB_MOUNT_REVEAL_TIMEOUT_MS = 15000;
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const summarizeExpression = (expression: string): Record<string, unknown> => ({
expressionLength: expression.length,
expressionPreview: expression.length > 120 ? `${expression.slice(0, 120)}...` : expression,
});
const MAX_CONSOLE_ENTRIES = 200;
const DESKTOP_PET_WINDOW_WIDTH = 360;
const DESKTOP_PET_WINDOW_HEIGHT = 300;
const DESKTOP_PET_WINDOW_MARGIN = 24;
const UPDATER_STATUS_EVENT = "od:update:status-changed";
const DESIGN_BROWSER_PARTITION = "persist:open-design-design-browser";
const UPDATER_IPC_CHANNELS = [
"od:update:status",
"od:update:check",
"od:update:download",
"od:update:install",
"od:update:quit",
] as const;
export type DesktopEvalInput = {
expression: string;
};
export type DesktopEvalResult = {
error?: string;
ok: boolean;
value?: unknown;
};
export type DesktopScreenshotInput = {
path: string;
};
export type DesktopScreenshotResult = {
path: string;
};
export type DesktopConsoleEntry = {
level: string;
text: string;
timestamp: string;
};
export type DesktopConsoleResult = {
entries: DesktopConsoleEntry[];
};
type DesktopBrowserStorageType =
| "cachestorage"
| "cookies"
| "filesystem"
| "indexdb"
| "localstorage"
| "serviceworkers"
| "shadercache"
| "websql";
export type DesktopClickInput = {
selector: string;
};
export type DesktopClickResult = {
clicked: boolean;
found: boolean;
};
export type DesktopStatusSnapshot = {
pid?: number;
state: "idle" | "running" | "unknown";
title?: string | null;
updatedAt?: string;
url?: string | null;
windowVisible?: boolean;
};
export type DesktopRuntime = {
close(): Promise<void>;
click(input: DesktopClickInput): Promise<DesktopClickResult>;
console(): DesktopConsoleResult;
eval(input: DesktopEvalInput): Promise<DesktopEvalResult>;
exportArtifact(input: DesktopExportArtifactInput): Promise<DesktopExportArtifactResult>;
exportPdf(input: DesktopExportPdfInput): Promise<DesktopExportPdfResult>;
screenshot(input: DesktopScreenshotInput): Promise<DesktopScreenshotResult>;
show(): void;
status(): DesktopStatusSnapshot;
};
export type DesktopRuntimeOptions = {
// Per-process secret shared with the daemon at startup (over its
// sidecar IPC) so the main process can mint HMAC tokens for the
// `dialog:pick-and-import` flow. The secret stays in main-process
// memory for the runtime lifetime even if the initial registration
// missed its window — round-5 (lefarcen P1, mrcfps) added a lazy
// re-registration path on `DESKTOP_AUTH_PENDING` that needs the same
// secret to mint a fresh token after re-handshaking with the daemon.
desktopAuthSecret?: Buffer | null;
discoverUrl(): Promise<string | null>;
/**
* Round-7 (lefarcen P2 @ runtime.ts:336): packaged desktop loads the
* renderer from `od://app/`, which only resolves through Electron's
* registered protocol handler in the renderer context. Main-process
* `globalThis.fetch` (Node/undici) ignores that handler, so any
* `fetch(webUrl + '/api/...')` from main fails in packaged builds.
* `discoverDaemonUrl` returns the real `http://127.0.0.1:<port>` URL
* the sidecar daemon reported over STATUS IPC, so main-process API
* calls bypass the protocol handler entirely. Optional so tools-dev
* (where webUrl IS an http:// URL Node fetch can hit) can omit it
* and the runtime falls back to `discoverUrl` for API calls too.
*/
discoverDaemonUrl?: () => Promise<string | null>;
/**
* BCP-47 locale string read from the OS by main process, forwarded
* to the preload via `webPreferences.additionalArguments` so the
* renderer can mirror it onto `__od__.client.osLocale`. Optional;
* when omitted the renderer falls back to navigator/localStorage.
*/
osLocale?: string;
preloadPath?: string;
/**
* User-visible app/window name. Packaged release channels pass their
* channel-specific product name here so concurrent installs remain
* distinguishable in the OS window switcher.
*/
windowTitle?: string;
/**
* Round-5 (lefarcen P1, mrcfps): lazy re-handshake hook. The runtime
* calls this when the daemon answers `503 DESKTOP_AUTH_PENDING` so a
* daemon-restart-mid-session, or a missed startup-window race, no
* longer permanently breaks folder import. Returns `true` when
* registration succeeded so the runtime can mint a fresh token and
* retry once. Optional so test runtimes and web-only deployments can
* skip it (the lazy retry then collapses into a single attempt).
*/
registerDesktopAuthWithDaemon?: () => Promise<boolean>;
/**
* Optional file path to append renderer-process error/warning console
* messages to. Lets the diagnostics export pick up UI errors that would
* otherwise only live in DevTools.
*/
rendererLogPath?: string | null;
requestQuit?: () => void;
/**
* Optional pre-created splash window. The packaged entry creates the splash
* BEFORE awaiting the daemon/web sidecars so the brand animation is on screen
* in parallel with startup (no black no-window gap). When omitted (tools-dev,
* tests) the runtime creates its own splash — dev boots fast enough that the
* window-then-splash ordering is imperceptible. The runtime owns closing it
* once the main window is revealed.
*/
splashWindow?: BrowserWindow | null;
/**
* Wall-clock instant the pre-created `splashWindow` first appeared (from
* {@link SplashWindowHandle.startedAt}). The minimum-hold timer is measured
* from here, so when packaged creates the splash before the sidecars boot the
* hold overlaps the boot instead of being added after it. Ignored when
* `splashWindow` is omitted (the runtime stamps its own splash at creation).
*/
splashStartedAt?: number;
updater?: DesktopUpdater;
};
const DESKTOP_IMPORT_TOKEN_HEADER = "X-OD-Desktop-Import-Token";
const DESKTOP_IMPORT_TOKEN_TTL_MS = 60_000;
export function mintImportToken(secret: Buffer, baseDir: string): string {
const nonce = randomBytes(16).toString("base64url");
const exp = new Date(Date.now() + DESKTOP_IMPORT_TOKEN_TTL_MS).toISOString();
return signDesktopImportToken(secret, baseDir, { nonce, exp });
}
/**
* Pure helper for the `dialog:pick-and-import` IPC handler. Extracted
* from `createDesktopRuntime` so vitest can pin the round-5 lazy-retry
* branch (lefarcen P1, mrcfps) without booting Electron. Mirrors the
* pattern of `fetchResolvedProjectDir` next door — the IPC wrapper
* stays a thin adapter that supplies the picker output and forwards
* the structured result to the renderer.
*
* Round-5 contract:
* - Always pass `desktopAuthSecret` (no early-return on null secret).
* A startup registration that missed its window keeps the secret in
* memory; the first user-initiated import triggers the lazy retry.
* - On `503 DESKTOP_AUTH_PENDING` from the daemon, call the injected
* `registerDesktopAuth()` once. If it succeeds, mint a FRESH token
* (new nonce, new exp — replay protection still works) and POST
* once more. Single retry only, no infinite loop.
* - On any other failure (4xx, network error, second 503), return the
* structured failure to the renderer. The Toast surfaces the reason.
*/
export type PickAndImportFolderDeps = {
/**
* Round-7 (lefarcen P2 @ runtime.ts:336): the helper now POSTs to the
* sidecar daemon's real `http://127.0.0.1:<port>` URL rather than the
* renderer-only `od://app/` webUrl. Renamed from `webUrl` to make the
* boundary explicit — main-process Node fetch must hit a real http
* URL, never a custom Electron protocol scheme. tools-dev callers
* pass the same value they used to pass for `webUrl` (its web URL is
* already http://127.0.0.1:...).
*/
apiBaseUrl: string;
baseDir: string;
desktopAuthSecret: Buffer;
fetchImpl?: typeof globalThis.fetch;
init?: { name?: string; skillId?: string | null; designSystemId?: string | null };
/** Round-5: lazy re-registration hook. Called once on 503. */
registerDesktopAuth?: () => Promise<boolean>;
/** Injected for tests; defaults to the production HMAC mint. */
mintToken?: (secret: Buffer, baseDir: string) => string;
};
export type PickAndImportFolderResult =
| { ok: true; response: unknown }
| { ok: false; canceled?: boolean; details?: unknown; reason?: string };
export async function pickAndImportFolder(
deps: PickAndImportFolderDeps,
): Promise<PickAndImportFolderResult> {
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
const mint = deps.mintToken ?? mintImportToken;
const importUrl = `${deps.apiBaseUrl.replace(/\/+$/, "")}/api/import/folder`;
const requestBody = JSON.stringify({
baseDir: deps.baseDir,
...(deps.init?.name == null ? {} : { name: deps.init.name }),
...(deps.init?.skillId === undefined ? {} : { skillId: deps.init.skillId }),
...(deps.init?.designSystemId === undefined ? {} : { designSystemId: deps.init.designSystemId }),
});
async function postOnce(): Promise<Response | { ok: false; reason: string }> {
const headerValue = mint(deps.desktopAuthSecret, deps.baseDir);
try {
return await fetchImpl(importUrl, {
body: requestBody,
headers: {
"Content-Type": "application/json",
[DESKTOP_IMPORT_TOKEN_HEADER]: headerValue,
},
method: "POST",
});
} catch (err) {
return { ok: false, reason: `daemon fetch failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
let resp = await postOnce();
if ("reason" in resp) {
return { ok: false, reason: resp.reason };
}
// Round-5 (lefarcen P1, mrcfps): lazy retry on DESKTOP_AUTH_PENDING.
// Daemon body shape from server.ts sendApiError: `{ error: { code,
// message, details, retryable } }`. The daemon-import-token-gate test
// pins `body.error?.code === 'DESKTOP_AUTH_PENDING'` (line 215-216),
// so we read the same path here — no new wire shape.
if (resp.status === 503 && deps.registerDesktopAuth != null) {
let body: unknown;
try {
body = await resp.clone().json();
} catch {
body = null;
}
const code =
body != null && typeof body === "object" && "error" in body && body.error != null && typeof body.error === "object" && "code" in body.error
? (body.error as { code?: unknown }).code
: undefined;
if (code === "DESKTOP_AUTH_PENDING") {
const reregistered = await deps.registerDesktopAuth();
if (reregistered) {
const retry = await postOnce();
if ("reason" in retry) {
return { ok: false, reason: retry.reason };
}
resp = retry;
}
}
}
let body: unknown;
try {
body = await resp.json();
} catch {
body = null;
}
if (!resp.ok) {
return {
ok: false,
reason: `daemon returned HTTP ${resp.status}`,
...(body == null ? {} : { details: body }),
};
}
return { ok: true, response: body };
}
/**
* Pure helper for the `dialog:pick-and-replace-working-dir` IPC handler.
* Mirrors `pickAndImportFolder` but targets the endpoint that re-points
* an existing project at a new local folder.
*/
export type PickAndReplaceWorkingDirDeps = {
apiBaseUrl: string;
baseDir: string;
desktopAuthSecret: Buffer;
fetchImpl?: typeof globalThis.fetch;
/** Injected for tests; defaults to the production HMAC mint. */
mintToken?: (secret: Buffer, baseDir: string) => string;
projectId: string;
registerDesktopAuth?: () => Promise<boolean>;
};
export type PickAndReplaceWorkingDirResult =
| { ok: true; response: unknown }
| { ok: false; canceled?: boolean; details?: unknown; reason?: string };
export async function pickAndReplaceWorkingDir(
deps: PickAndReplaceWorkingDirDeps,
): Promise<PickAndReplaceWorkingDirResult> {
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
const mint = deps.mintToken ?? mintImportToken;
if (typeof deps.projectId !== "string" || deps.projectId.length === 0) {
return { ok: false, reason: "project id must be a non-empty string" };
}
if (!/^[A-Za-z0-9._-]{1,128}$/.test(deps.projectId)) {
return { ok: false, reason: "project id contains disallowed characters" };
}
const workingDirUrl = `${deps.apiBaseUrl.replace(/\/+$/, "")}/api/projects/${encodeURIComponent(deps.projectId)}/working-dir`;
const requestBody = JSON.stringify({ baseDir: deps.baseDir });
async function postOnce(): Promise<Response | { ok: false; reason: string }> {
const headerValue = mint(deps.desktopAuthSecret, deps.baseDir);
try {
return await fetchImpl(workingDirUrl, {
body: requestBody,
headers: {
"Content-Type": "application/json",
[DESKTOP_IMPORT_TOKEN_HEADER]: headerValue,
},
method: "POST",
});
} catch (err) {
return { ok: false, reason: `daemon fetch failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
let resp = await postOnce();
if ("reason" in resp) {
return { ok: false, reason: resp.reason };
}
if (resp.status === 503 && deps.registerDesktopAuth != null) {
let body: unknown;
try {
body = await resp.clone().json();
} catch {
body = null;
}
const code =
body != null && typeof body === "object" && "error" in body && body.error != null && typeof body.error === "object" && "code" in body.error
? (body.error as { code?: unknown }).code
: undefined;
if (code === "DESKTOP_AUTH_PENDING") {
const reregistered = await deps.registerDesktopAuth();
if (reregistered) {
const retry = await postOnce();
if ("reason" in retry) {
return { ok: false, reason: retry.reason };
}
resp = retry;
}
}
}
let body: unknown;
try {
body = await resp.json();
} catch {
body = null;
}
if (!resp.ok) {
return {
ok: false,
reason: `daemon returned HTTP ${resp.status}`,
...(body == null ? {} : { details: body }),
};
}
return { ok: true, response: body };
}
/**
* Pure helper for the `dialog:pick-working-dir` IPC handler (the Home,
* pre-create flow). Unlike `pickAndImportFolder` / `pickAndReplaceWorkingDir`,
* there is no project yet, so we cannot POST to the daemon to discover a
* `503 DESKTOP_AUTH_PENDING` and self-heal. The token we mint here is spent
* LATER, by the renderer, on `POST /api/projects/:id/working-dir` once the
* project exists.
*
* If the daemon missed its startup auth-registration window, that deferred
* POST is guaranteed to be rejected with `DESKTOP_AUTH_PENDING` — and the
* renderer's create flow surfaces that as a confusing late failure. To keep
* the Home picker on par with the import/replace flows' self-healing, we
* proactively run the desktop-auth handshake (`registerDesktopAuth`) BEFORE
* minting and returning the token, so the daemon already knows the secret by
* the time the renderer spends the token.
*
* Extracted from `createDesktopRuntime` so vitest can pin the
* DESKTOP_AUTH_PENDING re-registration path without booting Electron.
*/
export type MintHomeWorkingDirTokenDeps = {
baseDir: string;
desktopAuthSecret: Buffer;
/** Lazy desktop-auth handshake. Mirrors the import/replace flows. */
registerDesktopAuth?: () => Promise<boolean>;
/** Injected for tests; defaults to the production HMAC mint. */
mintToken?: (secret: Buffer, baseDir: string) => string;
};
export type MintHomeWorkingDirTokenResult =
| { baseDir: string; ok: true; token: string }
| { ok: false; reason: string };
export async function mintHomeWorkingDirToken(
deps: MintHomeWorkingDirTokenDeps,
): Promise<MintHomeWorkingDirTokenResult> {
const mint = deps.mintToken ?? mintImportToken;
const baseDir = deps.baseDir.trim();
if (baseDir.length === 0) {
return { ok: false, reason: "picker returned an empty path" };
}
// Ensure the daemon has the desktop-auth secret registered before we hand
// the renderer a token bound to it. A failed handshake here means the
// deferred working-dir POST would fail anyway, so report it now while the
// user is still in the picker rather than as a silent late create failure.
if (deps.registerDesktopAuth != null) {
const registered = await deps.registerDesktopAuth();
if (!registered) {
return {
ok: false,
reason: "desktop auth handshake with the daemon failed; please retry",
};
}
}
return { baseDir, ok: true, token: mint(deps.desktopAuthSecret, baseDir) };
}
const MAC_WINDOW_CHROME =
process.platform === "darwin"
? ({
titleBarStyle: "hiddenInset" as const,
trafficLightPosition: { x: 12, y: 10 },
})
: {};
const MAC_WINDOW_CHROME_CSS = `
.app-chrome-header {
--app-chrome-traffic-space: 96px !important;
--app-chrome-traffic-margin: 12px !important;
-webkit-app-region: drag;
}
.app-chrome-traffic-space {
flex: 0 0 96px !important;
width: 96px !important;
}
.app-chrome-header button,
.app-chrome-header a,
.app-chrome-header [role="button"],
.app-chrome-header [contenteditable],
.app-chrome-actions,
.app-chrome-actions *,
.avatar-popover,
.avatar-popover *,
.inline-switcher__popover,
.inline-switcher__popover *,
.workspace-tabs-popover,
.workspace-tabs-popover * {
-webkit-app-region: no-drag;
}
.app-chrome-drag {
-webkit-app-region: drag;
}
.modal-backdrop,
.modal-backdrop *,
.modal,
.modal *,
.new-project-modal-backdrop,
.new-project-modal-backdrop *,
.automation-modal-backdrop,
.automation-modal-backdrop *,
.use-everywhere-modal-backdrop,
.use-everywhere-modal-backdrop *,
.plugin-details-modal-backdrop,
.plugin-details-modal-backdrop *,
.plugins-import-modal__backdrop,
.plugins-import-modal__backdrop *,
.ds-modal-backdrop,
.ds-modal-backdrop *,
.ds-modal,
.ds-modal *,
.prompt-template-modal-backdrop,
.prompt-template-modal-backdrop *,
.prompt-template-modal,
.prompt-template-modal *,
.prompt-template-lightbox-backdrop,
.prompt-template-lightbox-backdrop *,
.project-instructions-modal-backdrop,
.project-instructions-modal-backdrop *,
.home-hero-confirm__backdrop,
.home-hero-confirm__backdrop *,
.project-ds-picker-fullscreen,
.project-ds-picker-fullscreen *,
.staged-preview-modal,
.staged-preview-modal *,
.qs-overlay,
.qs-overlay * {
-webkit-app-region: no-drag;
}
.modal-backdrop::before,
.new-project-modal-backdrop::before,
.automation-modal-backdrop::before,
.use-everywhere-modal-backdrop::before,
.plugin-details-modal-backdrop::before,
.plugins-import-modal__backdrop::before,
.ds-modal-backdrop::before,
.prompt-template-modal-backdrop::before,
.prompt-template-lightbox-backdrop::before,
.project-instructions-modal-backdrop::before,
.home-hero-confirm__backdrop::before,
.project-ds-picker-fullscreen::before,
.staged-preview-modal::before,
.qs-overlay::before {
content: "";
position: absolute;
top: 0;
right: 0;
left: 0;
height: 56px;
pointer-events: auto;
-webkit-app-region: drag !important;
}
.entry-brand {
-webkit-app-region: drag;
padding-top: 32px !important;
}
.entry-header {
-webkit-app-region: drag;
}
.entry-brand button,
.entry-brand [role="button"],
.entry-header button,
.entry-header [role="button"],
.entry-tabs,
.entry-tabs *,
.viewer-toolbar,
.viewer-toolbar *,
.deck-nav,
.deck-nav *,
.share-menu-popover,
.share-menu-popover *,
.entry-side-resizer,
.inline-switcher__popover,
.inline-switcher__popover *,
.avatar-popover,
.avatar-popover *,
.workspace-tabs-popover,
.workspace-tabs-popover * {
-webkit-app-region: no-drag;
}
`;
// Light-background startup splash shown while the web runtime boots. It plays
// the brand intro clip once and then holds on its final settled logo frame until
// the main window is ready. The clip is embedded as a base64 data URL so it
// renders identically in dev and in packaged builds (see `splash-video.ts`).
function createPendingHtml(): string {
const start = splashStagePayload("starting");
const initialPct = Math.max(0, Math.min(100, Math.round((start.step / start.total) * 100)));
return `data:text/html;charset=utf-8,${encodeURIComponent(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Open Design</title>
<style>
html,
body {
background: #f2f4f5;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
align-items: center;
display: flex;
justify-content: center;
}
video {
background: #f2f4f5;
height: auto;
max-height: 100%;
max-width: 100%;
width: auto;
}
.boot-stage {
bottom: 56px;
color: #7a838a;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 13px;
left: 0;
letter-spacing: 0.02em;
position: fixed;
right: 0;
text-align: center;
transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
user-select: none;
}
.boot-stage-swapping {
opacity: 0;
transition-duration: 140ms;
}
.boot-stage-step {
color: #9aa2a8;
font-variant-numeric: tabular-nums;
margin-right: 7px;
}
.boot-progress {
background: rgba(122, 131, 138, 0.18);
border-radius: 999px;
bottom: 84px;
height: 3px;
left: 50%;
overflow: hidden;
position: fixed;
transform: translateX(-50%);
width: 200px;
}
.boot-progress-fill {
background: #7a838a;
border-radius: 999px;
height: 100%;
transition: width 320ms cubic-bezier(0.23, 1, 0.32, 1);
}
.boot-dots .dot {
animation: boot-dot 1.4s cubic-bezier(0.23, 1, 0.32, 1) infinite;
display: inline-block;
}
.boot-dots .dot:nth-child(2) { animation-delay: 0.2s; }
.boot-dots .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes boot-dot {
0%, 60%, 100% { opacity: 0.25; }
30% { opacity: 1; }
}
</style>
</head>
<body>
<video
id="splash"
autoplay
muted
playsinline
disablepictureinpicture
src="${SPLASH_VIDEO_DATA_URL}"
></video>
<div class="boot-progress" aria-hidden="true">
<div class="boot-progress-fill" id="boot-progress-fill" data-pct="${initialPct}" style="width: ${initialPct}%;"></div>
</div>
<div class="boot-stage" id="boot-stage" aria-live="polite">
<span class="boot-stage-step" id="boot-stage-step">${start.step}/${start.total}</span><span id="boot-stage-text">${start.label}</span><span class="boot-dots" aria-hidden="true"><span class="dot">.</span><span class="dot">.</span><span class="dot">.</span></span>
</div>
<script>
(function () {
var video = document.getElementById("splash");
if (!video) return;
var play = function () {
var attempt = video.play();
if (attempt && typeof attempt.catch === "function") attempt.catch(function () {});
};
video.addEventListener("loadedmetadata", function () { video.currentTime = 0; });
video.addEventListener("loadeddata", play);
play();
})();
// Accepts the structured { step, total, label } payload (and tolerates a
// bare label string for back-compat). The step counter + progress bar give
// a slow cold boot a sense of how far along it is; the bar only ever grows
// so a re-asserted earlier stage cannot make it lurch backwards.
window.__odSplashSetStage = function (info) {
var data = (typeof info === "string") ? { label: info } : (info || {});
var wrap = document.getElementById("boot-stage");
var text = document.getElementById("boot-stage-text");
var stepEl = document.getElementById("boot-stage-step");
var fill = document.getElementById("boot-progress-fill");
if (!wrap || !text) return;
var step = (typeof data.step === "number") ? data.step : null;
var total = (typeof data.total === "number" && data.total > 0) ? data.total : null;
if (fill && step != null && total != null) {
var pct = Math.max(0, Math.min(100, Math.round((step / total) * 100)));
var prev = parseFloat(fill.getAttribute("data-pct")) || 0;
if (pct >= prev) {
fill.style.width = pct + "%";
fill.setAttribute("data-pct", String(pct));
}
}
var label = (typeof data.label === "string") ? data.label : null;
var stepText = (step != null && total != null) ? (step + "/" + total) : null;
var labelSame = (label == null) || text.textContent === label;
var stepSame = (stepText == null) || !stepEl || stepEl.textContent === stepText;
if (labelSame && stepSame) return;
wrap.classList.add("boot-stage-swapping");
setTimeout(function () {
if (label != null) text.textContent = label;
if (stepEl && stepText != null) stepEl.textContent = stepText;
wrap.classList.remove("boot-stage-swapping");
}, 140);
};
</script>
</body>
</html>`)}`;
}
/**
* Boot phases surfaced as a muted status line under the splash logo. The cold
* boot on a slow machine can hold the splash's settled final frame for many
* seconds; the stage text, the step counter ("3/7"), the filling progress bar,
* and the continuously pulsing dots are what tell the user the app is working,
* not hung. Stage transitions follow the repo animation philosophy: 140ms
* ease-out fade out, 200ms ease-out fade in.
*
* The set is intentionally fine-grained: a slow first run spends most of its
* time in the two long native waits (daemon coming online, web server coming
* online), so we mark BOTH the "starting X" edge and the "X ready" edge of each
* so the counter visibly advances right after each long wait clears. More steps
* = the wait reads as forward motion instead of one frozen label.
*/
export type SplashBootStage =
| "starting"
| "engine"
| "engineReady"
| "interface"
| "interfaceReady"
| "workspace"
| "finishing";
/**
* Canonical boot order. The index in this array drives the "N/total" step
* counter and the progress-bar fill, so keep it in the real chronological order
* the stages fire. `setSplashStage` clamps progress so a re-asserted earlier
* stage (e.g. the idempotent "workspace" re-fire at the reveal gate) can never
* make the bar jump backwards.
*/
const SPLASH_STAGE_SEQUENCE: readonly SplashBootStage[] = [
"starting",
"engine",
"engineReady",
"interface",
"interfaceReady",
"workspace",
"finishing",
];
const SPLASH_STAGE_LABELS: Record<SplashBootStage, string> = {
starting: "Starting Open Design",
engine: "Starting the local engine",
engineReady: "Local engine ready",
interface: "Preparing the interface",
interfaceReady: "Interface ready",
workspace: "Opening your workspace",
finishing: "Almost ready",
};
const SPLASH_STAGE_TOTAL = SPLASH_STAGE_SEQUENCE.length;
/** Step/label payload handed to the renderer's `__odSplashSetStage`. */
function splashStagePayload(stage: SplashBootStage): { step: number; total: number; label: string } {
const index = SPLASH_STAGE_SEQUENCE.indexOf(stage);
return {
step: index < 0 ? 1 : index + 1,
total: SPLASH_STAGE_TOTAL,
label: SPLASH_STAGE_LABELS[stage],
};
}
/**
* Narrow view of the splash window that the stage updater needs. A real
* `BrowserWindow` satisfies this structurally; tests pass a mock so the
* load-ready/replay logic is exercisable without a live Electron renderer.
*/
export type SplashStageSurface = {
isDestroyed(): boolean;
webContents: {
executeJavaScript(code: string, userGesture?: boolean): Promise<unknown>;
once(event: "did-finish-load", listener: () => void): void;
};
};
type SplashStageState = { ready: boolean; pending: SplashBootStage | null };
// Per-splash readiness + the latest stage requested before the page finished
// loading. Keyed weakly so a closed splash is collected without bookkeeping.
const splashStageState = new WeakMap<SplashStageSurface, SplashStageState>();
function applySplashStage(splash: SplashStageSurface, stage: SplashBootStage): void {
void splash.webContents
.executeJavaScript(
`window.__odSplashSetStage && window.__odSplashSetStage(${JSON.stringify(splashStagePayload(stage))});`,
true,
)
.catch(() => undefined);
}
/**
* Arm load-ready tracking for a freshly created splash. MUST be called before
* `loadURL` so the `did-finish-load` listener cannot miss the event. Until the
* splash data-URL has loaded (and defined `window.__odSplashSetStage`), stage
* updates are stashed rather than executed against a renderer that has no
* setter yet — otherwise the first update (the daemon phase, fired right after
* window creation on a cold boot) is silently dropped. The latest stashed
* stage is replayed once the page reports it has loaded.
*/
export function registerSplashStageTracking(splash: SplashStageSurface): void {
const state: SplashStageState = { ready: false, pending: null };
splashStageState.set(splash, state);
splash.webContents.once("did-finish-load", () => {
state.ready = true;
if (state.pending != null) {
const stage = state.pending;
state.pending = null;
applySplashStage(splash, stage);
}
});
}
/**
* Update the splash status line. Safe to call with a destroyed/absent window
* and idempotent for repeated stages, so callers can fire-and-forget at each
* boot phase boundary (packaged sidecar spawns, runtime reveal gate). Stage
* updates that arrive before the splash page has loaded are deferred and
* replayed on load (see `registerSplashStageTracking`); a window with no
* tracking registered (e.g. an unmanaged test surface) applies immediately.
*/
export function setSplashStage(splash: SplashStageSurface | null, stage: SplashBootStage): void {
if (splash == null || splash.isDestroyed()) return;
const state = splashStageState.get(splash);
if (state == null || state.ready) {
applySplashStage(splash, stage);
return;
}
state.pending = stage;
}
export type SplashWindowHandle = {
/**
* Wall-clock instant the splash window was created. Carried alongside the
* window so the minimum-hold calculation measures how long the splash has
* ACTUALLY been on screen — in packaged builds the window is created before
* the sidecars boot, so the hold overlaps the boot instead of being added on
* top of it. (Originally this clock started inside `createDesktopRuntime`,
* after the sidecars had already finished — re-adding the full delay.)
*/
startedAt: number;
window: BrowserWindow;
};
/**
* Create and immediately show the light brand-splash window. The packaged entry
* calls this BEFORE awaiting the daemon/web sidecars so the animation masks the
* whole cold boot (no black no-window gap); the desktop runtime then adopts it
* via `DesktopRuntimeOptions.splashWindow` + `splashStartedAt` and closes it
* once the real app has mounted in the (initially hidden) main window. Frameless
* + matching size so the reveal swap reads as a single window, never a flash.
*/
export function createSplashWindow(): SplashWindowHandle {
// Stamp creation time at the instant the window appears (see SplashWindowHandle).
const startedAt = Date.now();
const splash = new BrowserWindow({
autoHideMenuBar: true,
backgroundColor: "#f2f4f5",
frame: false,
height: 900,
resizable: false,
show: true,
title: "Open Design",
width: 1280,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// Arm stage tracking before loadURL so a stage update fired before the
// page loads is deferred and replayed rather than dropped (see
// `registerSplashStageTracking`).
registerSplashStageTracking(splash);
void splash.loadURL(createPendingHtml());
return { startedAt, window: splash };
}
function resolveDesktopIconPath(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "../../../web/public/app-icon.png");
}
function applyDockIcon(): void {
if (process.platform !== "darwin" || !app.dock) return;
const icon = nativeImage.createFromPath(resolveDesktopIconPath());
if (icon.isEmpty()) return;
app.dock.setIcon(icon);
}
function normalizeScreenshotPath(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
}
function mapConsoleLevel(level: number): string {
switch (level) {
case 0:
return "debug";
case 1:
return "info";
case 2:
return "warn";
case 3:
return "error";
default:
return "log";
}
}
async function applyWindowChromeCss(window: BrowserWindow): Promise<void> {
if (process.platform !== "darwin" || window.isDestroyed()) return;
await window.webContents.insertCSS(MAC_WINDOW_CHROME_CSS, { cssOrigin: "user" });
}
// Exported for unit tests in `apps/packaged/tests/desktop-url-allowlist.test.ts`
// — these are pure URL-policy helpers and `apps/desktop` itself has no
// vitest setup, so the packaged workspace hosts the coverage. Keep them
// pure and side-effect-free.
export function isHttpUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export function isAllowedChildWindowUrl(url: string): boolean {
try {
const parsed = new URL(url);
// `blob:` covers in-renderer generated downloads / object URLs.
// `od:` is the packaged Electron entry's privileged scheme
// registered by `apps/packaged/src/protocol.ts` and proxied to the
// local web sidecar. Without this branch, any in-app
// `<a target="_blank" href="/api/...">` resolves to `od://app/...`
// in packaged builds, falls through `setWindowOpenHandler` to
// `{ action: "deny" }`, and the click is silently dropped — that
// was the Orbit "Open artifact" no-op reported in #911. Allowing
// `od:` here lets Electron open the link in a child BrowserWindow
// that inherits the same protocol registration + preload, so the
// live artifact preview renders normally. Dev mode is unaffected:
// its links resolve to `http://127.0.0.1:.../...`, which is gated
// by the separate `isHttpUrl` branch and continues to open in the
// user's external browser via `shell.openExternal`.
// `about:blank` is used by the renderer's PDF export fallback path:
// `window.open('', '_blank')` opens a blank window that is then
// navigated to a Blob URL. Without this, the empty URL is denied
// and the user sees a "Popup blocked" alert.
return (
parsed.protocol === "blob:" ||
parsed.protocol === "od:" ||
(parsed.protocol === "about:" && parsed.pathname === "blank")
);
} catch {
return false;
}
}
export function isAllowedEmbeddedBrowserUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Security boundary for the design-browser webview. Keep this to remote
// references and project-served content only. `file:` is deliberately
// excluded: the same surface can capture the webview region and persist
// the PNG into the project, so allowing `file://` would let a compromised
// renderer or a pasted address load and exfiltrate arbitrary local files
// (e.g. `/etc/passwd`). The reference board only needs http(s) and
// about:blank.
return (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
(parsed.protocol === "about:" && parsed.pathname === "blank")
);
} catch {
return false;
}
}
export function resolveDesktopStatusUrl(currentUrl: string | null, pendingUrl: string | null): string | null {
return pendingUrl ?? currentUrl;
}
function installWindowChromeCssHook(window: BrowserWindow): void {
window.webContents.on("did-finish-load", () => {
void applyWindowChromeCss(window).catch((error: unknown) => {
console.error("desktop window chrome CSS injection failed", error);
});
});
}
function desktopPetUrl(baseUrl: string): string {
const url = new URL(baseUrl);
url.pathname = "/desktop-pet";
url.search = "";
url.hash = "";
return url.toString();
}
// Encode the OS locale before stuffing it into a Chromium argv value
// — BCP-47 region tags shouldn't contain `;` or `=`, but the renderer's
// `process.argv` parser is happier if we never have to worry about it.
function osLocaleAdditionalArguments(osLocale: string | undefined): string[] | undefined {
return osLocale ? [`--od-os-locale=${encodeURIComponent(osLocale)}`] : undefined;
}
function createDesktopPetWindow(preloadPath: string, osLocale: string | undefined): BrowserWindow {
const { workArea } = screen.getPrimaryDisplay();
const petWindow = new BrowserWindow({
width: DESKTOP_PET_WINDOW_WIDTH,
height: DESKTOP_PET_WINDOW_HEIGHT,
x: workArea.x + workArea.width - DESKTOP_PET_WINDOW_WIDTH - DESKTOP_PET_WINDOW_MARGIN,
y: workArea.y + workArea.height - DESKTOP_PET_WINDOW_HEIGHT - DESKTOP_PET_WINDOW_MARGIN,
show: false,
frame: false,
transparent: true,
backgroundColor: "#00000000",
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
hasShadow: false,
focusable: false,
webPreferences: {
additionalArguments: osLocaleAdditionalArguments(osLocale),
contextIsolation: true,
nodeIntegration: false,
preload: preloadPath,
sandbox: true,
},
});
petWindow.setAlwaysOnTop(true, "floating");
// `skipTransformProcessType: true` is load-bearing, not an
// optimization. By default Electron's macOS `setVisibleOnAllWorkspaces`
// transforms the whole *process* type between `UIElementApplication`
// and `ForegroundApplication` to apply the all-Spaces behavior — the
// Electron docs note this "will hide the window and dock for a short
// time". That round-trip races during the launch burst (the pet
// window is created alongside the main window) and on Electron 41 /
// macOS 26 the process can stay stuck as an accessory app: no Dock
// icon, no menu bar, even though the windows render fine (issue
// #2394). The desktop pet is a cosmetic companion window; it must
// never decide the app's Dock identity — the main window does.
// Skipping the transform keeps the app a regular Dock app; the pet
// still floats on every Space via its `alwaysOnTop` floating level.
petWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true,
});
petWindow.webContents.setWindowOpenHandler(({ url }) => {
if (isHttpUrl(url)) void shell.openExternal(url);
return { action: "deny" };
});
petWindow.webContents.on("will-navigate", (event, url) => {
if (!url.includes("/desktop-pet")) event.preventDefault();
});
return petWindow;
}
function showWindowButtons(window: BrowserWindow): void {
if (process.platform !== "darwin" || window.isDestroyed()) return;
window.setWindowButtonVisibility(true);
}
// Windows focus-stealing prevention can leave a detached-spawned GUI
// window minimized or hidden even when constructed with show:true,
// leaving users unable to locate the window. Cross-platform safe: only
// acts when the window is actually minimized or hidden, preserving any
// user-adjusted window state.
function ensureWindowVisible(window: BrowserWindow): void {
if (window.isDestroyed()) return;
if (window.isMinimized()) window.restore();
if (!window.isVisible()) window.show();
window.focus();
}
/**
* Surface of {@link BrowserWindow} consumed by
* {@link hideWindowExitingFullscreen} — declared structurally so the
* helper can be exercised with a plain mock in unit tests without
* standing up an actual Electron window.
*
* `isEnteringFullscreen` covers the Electron-asynchronous gap between
* the renderer asking for fullscreen and the OS confirming via
* 'enter-full-screen': the caller is expected to track this from the
* window's enter/leave listeners (see the close handler in
* {@link configureWindow}) and surface it here.
*/
export type WindowFullscreenSurface = {
hide: () => void;
isFullScreen: () => boolean;
isSimpleFullScreen: () => boolean;
isEnteringFullscreen: () => boolean;
setFullScreen: (flag: boolean) => void;
setSimpleFullScreen: (flag: boolean) => void;
once: (event: 'enter-full-screen' | 'leave-full-screen', listener: () => void) => unknown;
};
export type MainWindowCloseSurface = {
on: (event: 'closed', listener: () => void) => unknown;
};
export function attachNonDarwinMainWindowCloseShutdown(
window: MainWindowCloseSurface,
options: {
isStopped: () => boolean;
requestQuit?: () => void;
},
): void {
window.on("closed", () => {
if (options.isStopped()) return;
options.requestQuit?.();
});
}
/**
* Hide the window, first leaving any active fullscreen so macOS doesn't
* orphan the fullscreen Space as a black screen. The hide is deferred
* until 'leave-full-screen' fires; if the Space transition is still
* flipping in (`isEnteringFullscreen`), defer further until
* 'enter-full-screen' settles before starting the exit. Plain hides
* race the OS Space teardown and leave the user staring at a black
* desktop until they switch Spaces by hand.
*/
export function hideWindowExitingFullscreen(window: WindowFullscreenSurface): void {
if (window.isSimpleFullScreen()) {
window.once('leave-full-screen', () => window.hide());
window.setSimpleFullScreen(false);
return;
}
if (window.isFullScreen()) {
window.once('leave-full-screen', () => window.hide());
window.setFullScreen(false);
return;
}
if (window.isEnteringFullscreen()) {
window.once('enter-full-screen', () => {
window.once('leave-full-screen', () => window.hide());
window.setFullScreen(false);
});
return;
}
window.hide();
}
// Some image exports reach the renderer through a normal `<a download>` link.
// Without this hook Electron writes the bytes straight to the OS Downloads
// folder, so the user never gets to pick a destination. setSaveDialogOptions
// makes Electron show the native Save As panel before the download starts.
const IMAGE_SAVE_AS_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
function attachDownloadSaveAsDialog(window: BrowserWindow): void {
window.webContents.session.on("will-download", (_event, item) => {
const filename = item.getFilename();
const dot = filename.lastIndexOf(".");
const ext = dot >= 0 ? filename.slice(dot).toLowerCase() : "";
if (!IMAGE_SAVE_AS_EXTENSIONS.has(ext)) return;
item.setSaveDialogOptions({
title: "Save As",
defaultPath: filename,
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp"] },
{ name: "All Files", extensions: ["*"] },
],
});
});
}
function parsePrintReadyPdfOptions(value: unknown): PrintReadyPdfOptions {
if (value == null) return {};
if (typeof value !== "object" || Array.isArray(value)) {
throw new Error("Invalid print payload: expected options object");
}
const deck = (value as { deck?: unknown }).deck;
if (deck !== undefined && typeof deck !== "boolean") {
throw new Error("Invalid print payload: expected deck option to be boolean");
}
return deck === true ? { deck: true } : {};
}
// Parses the optional renderer-supplied capture clip into an Electron
// Rectangle. Returns undefined (capture the full page) when the payload is
// missing, not an object, or carries an invalid clip; valid clips are
// rounded and clamped so x/y stay >= 0 and width/height stay >= 1.
function parseCaptureClip(value: unknown): Electron.Rectangle | undefined {
if (value == null || typeof value !== "object" || Array.isArray(value)) return undefined;
const clip = (value as { clip?: unknown }).clip;
if (clip == null || typeof clip !== "object" || Array.isArray(clip)) return undefined;
const { x, y, width, height } = clip as {
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
};
if (
typeof x !== "number" || !Number.isFinite(x) ||
typeof y !== "number" || !Number.isFinite(y) ||
typeof width !== "number" || !Number.isFinite(width) ||
typeof height !== "number" || !Number.isFinite(height)
) {
return undefined;
}
return {
x: Math.max(0, Math.round(x)),
y: Math.max(0, Math.round(y)),
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
};
}
function unavailableUpdaterStatus(): DesktopUpdateStatusSnapshot {
return {
arch: process.arch,
capabilities: {
canApplyInPlace: false,
canDownload: false,
canOpenInstaller: false,
requiresManualInstall: false,
},
channel: DESKTOP_UPDATE_CHANNELS.BETA,
currentVersion: "0.0.0",
enabled: false,
error: {
code: "updater-unavailable",
message: "Desktop updater is not available.",
},
mode: DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER,
platform: process.platform,
state: DESKTOP_UPDATE_STATES.UNSUPPORTED,
supported: false,
};
}
function checkOptionsFromHost(options: unknown): { autoDownload?: boolean } | undefined {
const input = options as OpenDesignHostUpdaterActionOptions | null | undefined;
const payload = input?.payload;
if (payload == null || typeof payload.autoDownload !== "boolean") return undefined;
return { autoDownload: payload.autoDownload };
}
async function reportRendererCrash(
options: DesktopRuntimeOptions,
properties: { reason: string; exit_code: number | null },
): Promise<void> {
try {
// discoverDaemonUrl returns the real http://127.0.0.1:<port> URL the
// sidecar daemon listens on. In tools-dev callers omit it and fall back
// to discoverUrl (which is also http in dev). In packaged builds it's
// mandatory because the renderer-only `od://app/` scheme isn't
// reachable from main-process Node fetch.
const baseUrl = (await (options.discoverDaemonUrl?.() ?? options.discoverUrl())) ?? null;
if (!baseUrl) return;
const url = new URL("/api/observability/event", baseUrl).toString();
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event: "desktop_renderer_crash",
properties: {
reason: properties.reason,
exit_code: properties.exit_code,
},
}),
});
} catch {
// Best-effort. The user is already in a degraded state — failing to
// report the crash must not cascade into another failure path.
}
}
/**
* Native directory picker, parented to the renderer window that initiated
* the IPC call. Parenting makes the dialog window-modal and hands it
* keyboard focus (most visibly on Windows): without a parent the focus
* stays on the Electron window, so pressing Esc falls through to the web
* app and closes the in-app modal *behind* the still-open native picker.
* With a parent the picker owns Esc and cancels itself.
*/
async function showDirectoryPickerForSender(
sender: Electron.WebContents,
): Promise<Electron.OpenDialogReturnValue> {
const parent =
BrowserWindow.fromWebContents(sender) ?? BrowserWindow.getFocusedWindow();
const pickerOptions: Electron.OpenDialogOptions = {
properties: ["openDirectory", "createDirectory"],
};
return parent
? dialog.showOpenDialog(parent, pickerOptions)
: dialog.showOpenDialog(pickerOptions);
}
export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise<DesktopRuntime> {
const preloadPath = options.preloadPath ?? join(dirname(fileURLToPath(import.meta.url)), "preload.cjs");
applyDockIcon();
// ipcMain.handle() registers a handler in an internal map that is *not*
// surfaced via eventNames(); the previous `!eventNames().includes(...)`
// check was therefore always true and would throw "Attempted to register
// a second handler" on the second createDesktopRuntime() call (e.g. dev
// hot-reload). removeHandler is a no-op when nothing is registered.
ipcMain.removeHandler("dialog:pick-folder");
ipcMain.removeHandler("dialog:pick-and-import");
ipcMain.removeHandler("dialog:pick-and-replace-working-dir");
ipcMain.removeHandler("dialog:pick-working-dir");
ipcMain.removeHandler("shell:open-external");
ipcMain.removeHandler("shell:open-path");
ipcMain.removeHandler("browser:clear-data");
for (const channel of UPDATER_IPC_CHANNELS) {
ipcMain.removeHandler(channel);
}
ipcMain.handle("shell:open-external", async (_event, url: string) => {
if (!isHttpUrl(url)) return false;
try {
await shell.openExternal(url);
return true;
} catch {
return false;
}
});
// PR #974: the renderer no longer receives a raw filesystem path from
// the main process. The previous `dialog:pick-folder` IPC returned the
// chosen path string, the renderer then POSTed `/api/import/folder`
// itself, and a compromised renderer could substitute an arbitrary
// baseDir at the second step (or skip the picker entirely and call
// `/api/import/folder` directly via fetch). The new
// `dialog:pick-and-import` IPC binds the picker and the import into
// a single main-process transaction: we show the dialog, mint an
// HMAC token for the chosen path, POST `/api/import/folder` with that
// token, and hand the renderer back only the daemon's response shape.
// The daemon's HTTP handler verifies the token and rejects any
// import request without one whenever a desktop secret has been
// registered, closing the renderer→arbitrary-baseDir bypass at the
// import boundary while leaving web-only deployments untouched.
ipcMain.handle(
"dialog:pick-and-import",
async (event, init?: { name?: string; skillId?: string | null; designSystemId?: string | null }) => {
// Defensive failsafe for non-production runtimes (test harnesses
// that construct createDesktopRuntime without a secret). Round-5
// production wiring in runDesktopMain ALWAYS passes the per-process
// secret regardless of whether the startup handshake succeeded —
// the lazy retry inside pickAndImportFolder is the recovery
// mechanism for the "startup registration missed its window"
// case (lefarcen P1, mrcfps), not this branch.
if (options.desktopAuthSecret == null) {
return { ok: false, reason: "desktop auth secret not registered" };
}
// Round-7 (lefarcen P2): packaged builds report the renderer URL
// (`od://app/`) over `discoverUrl`, but Node-side fetch can't
// resolve a custom Electron protocol scheme. Prefer the daemon
// sidecar's real http URL when packaged exposes it; tools-dev
// omits `discoverDaemonUrl` and we fall back to the web URL
// (which is itself an http://127.0.0.1 URL in dev).
const apiBaseUrl =
(options.discoverDaemonUrl ? await options.discoverDaemonUrl() : null) ??
(await options.discoverUrl());
if (!apiBaseUrl) {
return { ok: false, reason: "daemon API URL not available" };
}
const result = await showDirectoryPickerForSender(event.sender);
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
// PR #974 round-5 (lefarcen P3): trim ONCE on the desktop side so the
// HMAC and the request body bind to the exact same string the daemon
// realpath()s. The daemon used to verify raw `baseDir` and then trim
// before resolution — a `/tmp/foo ` selection could authorize an
// import of `/tmp/foo`. Doing the trim here keeps desktop as the
// source of truth for the canonical path it picked, signs that, and
// sends that — daemon then verifies and imports the same string.
const baseDir = result.filePaths[0].trim();
if (baseDir.length === 0) {
return { ok: false, reason: "picker returned an empty path" };
}
return await pickAndImportFolder({
apiBaseUrl,
baseDir,
desktopAuthSecret: options.desktopAuthSecret,
init,
registerDesktopAuth: options.registerDesktopAuthWithDaemon,
});
},
);
// Atomic counterpart to dialog:pick-and-import for replacing a
// project's working directory. The picker, HMAC mint, and daemon
// POST are a single main-process transaction.
ipcMain.handle(
"dialog:pick-and-replace-working-dir",
async (event, init?: { projectId?: string }) => {
if (options.desktopAuthSecret == null) {
return { ok: false, reason: "desktop auth secret not registered" };
}
const projectId = typeof init?.projectId === "string" ? init.projectId : "";
if (projectId.length === 0) {
return { ok: false, reason: "project id is required" };
}
const apiBaseUrl =
(options.discoverDaemonUrl ? await options.discoverDaemonUrl() : null) ??
(await options.discoverUrl());
if (!apiBaseUrl) {
return { ok: false, reason: "daemon API URL not available" };
}
const result = await showDirectoryPickerForSender(event.sender);
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
const baseDir = result.filePaths[0].trim();
if (baseDir.length === 0) {
return { ok: false, reason: "picker returned an empty path" };
}
return await pickAndReplaceWorkingDir({
apiBaseUrl,
baseDir,
desktopAuthSecret: options.desktopAuthSecret,
projectId,
registerDesktopAuth: options.registerDesktopAuthWithDaemon,
});
},
);
// Home-flow counterpart: the project does not exist yet, so we only show
// the native picker and mint a token bound to the chosen folder. The
// renderer threads { baseDir, token } back through project creation and
// spends the token on POST /api/projects/:id/working-dir once the project
// exists. Main remains the single source of filesystem paths crossing into
// the daemon (same trust boundary as dialog:pick-and-replace-working-dir).
ipcMain.handle("dialog:pick-working-dir", async (event) => {
if (options.desktopAuthSecret == null) {
return { ok: false, reason: "desktop auth secret not registered" };
}
const result = await showDirectoryPickerForSender(event.sender);
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
return await mintHomeWorkingDirToken({
baseDir: result.filePaths[0],
desktopAuthSecret: options.desktopAuthSecret,
registerDesktopAuth: options.registerDesktopAuthWithDaemon,
});
});
// shell.openPath opens an absolute filesystem path in the OS file
// manager (Finder / Explorer / Files). It resolves to '' on success
// and to a non-empty error string on failure (per Electron's
// contract). The web caller uses that empty/non-empty distinction
// to decide between the success toast and the manual fallback toast.
//
// The renderer hands us a *project ID*, not a path. The main
// process then asks the daemon (via the web sidecar proxy) for the
// canonical resolvedDir, then validates it (absolute, exists,
// is-directory, not an .app bundle) before forwarding to
// shell.openPath. This makes the path allowlist daemon-controlled:
// a compromised renderer cannot synthesize an arbitrary path
// because it never names the path, only the project ID. The daemon
// is the single source of truth for what counts as a project root.
//
// PR #974 defense in depth: when the project is folder-imported
// (resolvedDir comes from a user-controlled `metadata.baseDir`), we
// additionally require `metadata.fromTrustedPicker === true`, the
// marker stamped by the daemon's HMAC-gated import handler. Native
// projects (no `metadata.baseDir`, resolvedDir under the daemon's
// own projects root) are always safe to open. This is the literal
// interpretation of mrcfps's round-3 review: "only allowing
// openPath(projectId) for projects whose resolvedDir came from that
// trusted flow."
ipcMain.handle("shell:open-path", async (_event, projectId: string) => {
// Round-7 (lefarcen P2): same packaged od:// → daemon URL pivot as
// the dialog:pick-and-import handler above.
const apiBaseUrl =
(options.discoverDaemonUrl ? await options.discoverDaemonUrl() : null) ??
(await options.discoverUrl());
if (!apiBaseUrl) {
return "open-path: daemon API URL not available";
}
const resolved = await fetchResolvedProjectDir(apiBaseUrl, projectId);
if (!resolved.ok) return `open-path: ${resolved.reason}`;
const allowed = isOpenPathAllowedForProject(resolved.context);
if (!allowed.ok) return `open-path: ${allowed.reason}`;
const validated = await validateExistingDirectory(resolved.context.resolvedDir);
if (!validated.ok) return `open-path: ${validated.reason}`;
try {
return await openValidatedDirectory(validated.resolved, {
release,
execFile: async (cmd, args) => {
const { stdout } = await execFileAsync(cmd, [...args]);
return { stdout };
},
openPath: (p) => shell.openPath(p),
});
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
});
let currentUrl: string | null = null;
let currentPetUrl: string | null = null;
let pendingUrl: string | null = null;
let stopped = false;
let timer: NodeJS.Timeout | null = null;
// Set when the main-frame load fails or the renderer process is gone. The
// poll loop reloads the current URL to recover instead of leaving a blank app.
let rendererFailed = false;
// True while a `tick()` is mid-flight, so load failures do not schedule two
// independent polling loops.
let ticking = false;
const consoleEntries: DesktopConsoleEntry[] = [];
const petWindow = createDesktopPetWindow(preloadPath, options.osLocale);
const windowTitle = options.windowTitle ?? "Open Design";
const window = new BrowserWindow({
height: 900,
icon: resolveDesktopIconPath(),
// Below this size the project page's left/right split (chat
// composer + designs panel + preview pane) overlaps and the top
// navigation clips, so prevent Electron from honoring user drags
// that would shrink the window past the usable breakpoint.
minHeight: 600,
minWidth: 900,
// Starts hidden: the splash window is what the user sees while the real web
// app loads in here. We reveal this window only once the app has actually
// mounted (see `revealWhenReady` below), so there is never a flash of the
// web's own "Loading Open Design…" shell.
show: false,
title: windowTitle,
autoHideMenuBar: true,
...MAC_WINDOW_CHROME,
webPreferences: {
additionalArguments: osLocaleAdditionalArguments(options.osLocale),
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
preload: preloadPath,
sandbox: true,
webviewTag: true,
},
width: 1280,
});
installWindowChromeCssHook(window);
showWindowButtons(window);
attachDownloadSaveAsDialog(window);
window.on("page-title-updated", (event) => {
event.preventDefault();
window.setTitle(windowTitle);
});
window.webContents.on("did-start-loading", () => {
console.info("[open-design desktop] main window did-start-loading", {
pendingUrl,
url: window.webContents.getURL(),
});
});
window.webContents.on("dom-ready", () => {
console.info("[open-design desktop] main window dom-ready", {
title: window.getTitle(),
url: window.webContents.getURL(),
});
});
window.webContents.on("did-finish-load", () => {
console.info("[open-design desktop] main window did-finish-load", {
title: window.getTitle(),
url: window.webContents.getURL(),
});
});
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
console.error("[open-design desktop] main window did-fail-load", {
errorCode,
errorDescription,
isMainFrame,
pendingUrl,
validatedURL,
url: window.webContents.getURL(),
});
});
window.on("unresponsive", () => {
console.error("[open-design desktop] main window unresponsive", {
pendingUrl,
url: window.webContents.getURL(),
});
});
window.on("responsive", () => {
console.info("[open-design desktop] main window responsive", {
pendingUrl,
url: window.webContents.getURL(),
});
});
// Renderer-process crashes are completely invisible to the web bundle's
// own analytics surface (the renderer is dead — no JS can run, no
// window.error fires). The main process is the last layer that can
// observe them, so we forward the event to the daemon's safety-event
// bridge (`POST /api/observability/event`), which posts directly to
// PostHog with `device_id = installationId`. Best-effort: a failure to
// reach the daemon must not block the crash recovery flow.
window.webContents.on("render-process-gone", (_event, details) => {
console.error("[open-design desktop] main window render-process-gone", {
exitCode: details.exitCode,
reason: details.reason,
url: window.webContents.getURL(),
});
void reportRendererCrash(options, {
reason: details.reason,
exit_code: typeof details.exitCode === "number" ? details.exitCode : null,
});
// A clean-exit is intentional teardown; a crash / OOM / OS kill of a
// backgrounded renderer leaves the window blank, so flag it for the poll
// loop to reload the app.
if (details.reason !== "clean-exit") markRendererFailed();
});
// A failed main-frame navigation parks the renderer on chrome-error:// (blank
// white) with no auto-retry. errorCode -3 (ABORTED) is a normal navigation
// cancel (a new load started), so ignore it and sub-frame failures; anything
// else means the load to the web server failed and needs a retry.
window.webContents.on("did-fail-load", (_event, errorCode, _description, _url, isMainFrame) => {
if (isMainFrame && errorCode !== -3) markRendererFailed();
});
const sendUpdaterStatus = (status = options.updater?.snapshot() ?? unavailableUpdaterStatus()) => {
if (window.isDestroyed()) return;
window.webContents.send(UPDATER_STATUS_EVENT, status);
};
const unsubscribeUpdater = options.updater?.subscribe(() => sendUpdaterStatus()) ?? (() => undefined);
const requireMainWindowSender = (event: Electron.IpcMainInvokeEvent): void => {
if (event.sender !== window.webContents) {
throw new Error("host IPC is only available to the main Open Design window");
}
};
window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
const src = typeof params.src === "string" ? params.src : "";
const partition = typeof params.partition === "string" ? params.partition : "";
if (!isAllowedEmbeddedBrowserUrl(src) || partition !== DESIGN_BROWSER_PARTITION) {
event.preventDefault();
return;
}
delete webPreferences.preload;
webPreferences.contextIsolation = true;
webPreferences.nodeIntegration = false;
webPreferences.sandbox = true;
});
// `will-attach-webview` only vets the initial `src`. The design-browser panel
// navigates an already-attached guest with `<webview>.loadURL(...)`, which does
// not re-trigger attach, so the same allowlist has to gate every guest
// navigation too — otherwise a compromised renderer or pasted address could
// `loadURL("file:///etc/passwd")` after the first http(s) load and exfiltrate
// its pixels through the host capture bridge.
window.webContents.on("did-attach-webview", (_event, guestWebContents) => {
const blockDisallowed = (navEvent: Electron.Event, url: string): void => {
if (!isAllowedEmbeddedBrowserUrl(url)) {
navEvent.preventDefault();
}
};
guestWebContents.on("will-navigate", blockDisallowed);
guestWebContents.on("will-redirect", blockDisallowed);
guestWebContents.setWindowOpenHandler(() => ({ action: "deny" }));
});
ipcMain.handle("browser:clear-data", async (event, rawOptions: unknown): Promise<OpenDesignHostActionResult> => {
requireMainWindowSender(event);
const optionsRecord = rawOptions != null && typeof rawOptions === "object"
? rawOptions as { cookies?: unknown; storage?: unknown }
: {};
const clearCookies = optionsRecord.cookies !== false;
const clearStorage = optionsRecord.storage !== false;
const storages: DesktopBrowserStorageType[] = [];
if (clearCookies) storages.push("cookies");
if (clearStorage) {
storages.push(
"cachestorage",
"filesystem",
"indexdb",
"localstorage",
"shadercache",
"websql",
"serviceworkers",
);
}
try {
if (storages.length > 0) {
await session.fromPartition(DESIGN_BROWSER_PARTITION).clearStorageData({ storages });
}
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
});
ipcMain.handle("od:update:status", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.status() ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:check", async (event, updaterOptions: unknown) => {
requireMainWindowSender(event);
const status = await (options.updater?.checkForUpdates(checkOptionsFromHost(updaterOptions)) ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:download", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.downloadUpdate() ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:install", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.installUpdate() ?? unavailableUpdaterStatus());
sendUpdaterStatus(status);
return status;
});
ipcMain.handle("od:update:quit", async (event): Promise<OpenDesignHostActionResult> => {
requireMainWindowSender(event);
const status = await (options.updater?.status() ?? unavailableUpdaterStatus());
if (status.installResult == null) {
return { ok: false, reason: "installer has not been opened" };
}
if (options.requestQuit == null) {
return { ok: false, reason: "desktop quit is not available" };
}
setTimeout(() => options.requestQuit?.(), 0);
return { ok: true };
});
ipcMain.removeAllListeners("desktop-pet:set-visible");
ipcMain.on("desktop-pet:set-visible", (event, visible: unknown) => {
if (petWindow.isDestroyed() || event.sender !== petWindow.webContents) return;
if (visible) petWindow.showInactive();
else petWindow.hide();
});
ipcMain.removeHandler('od:print-pdf');
ipcMain.handle('od:print-pdf', async (_event, html: unknown, nonce: unknown, options: unknown): Promise<void> => {
if (typeof html !== 'string') {
throw new Error('Invalid print payload: expected HTML string');
}
const printNonce = typeof nonce === 'string' ? nonce : '';
const printOptions = parsePrintReadyPdfOptions(options);
// Issue #1774: the renderer's `printPdf()` bridge runs the direct
// Save-as-PDF flow (showSaveDialog -> printToPDF -> write), never
// `webContents.print()` — the printer-first OS dialog. The renderer
// (apps/web/src/runtime/exports.ts#exportAsPdf) only reacts to a
// rejection: it shows a "Print failed" alert. A resolved call —
// including a user-canceled Save dialog — is silent, matching the
// pre-#1774 behavior where canceling the OS dialog was a no-op.
const result = await savePrintReadyDocumentAsPdf(
html,
printNonce,
createElectronPdfTarget(),
printOptions,
);
if (!result.ok) {
throw new Error(result.error ?? 'PDF export failed');
}
});
ipcMain.removeHandler('od:capture-page');
ipcMain.handle('od:capture-page', async (event, rawOptions: unknown): Promise<OpenDesignHostCaptureResult> => {
if (event.sender !== window.webContents) {
return { ok: false, reason: 'capture sender not allowed' };
}
try {
const clip = parseCaptureClip(rawOptions);
const image = clip
? await window.webContents.capturePage(clip)
: await window.webContents.capturePage();
const size = image.getSize();
return { ok: true, dataUrl: image.toDataURL(), w: size.width, h: size.height };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
}
});
window.on("focus", () => showWindowButtons(window));
window.on("blur", () => showWindowButtons(window));
window.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedChildWindowUrl(url)) return { action: "allow" };
if (isHttpUrl(url)) void shell.openExternal(url);
return { action: "deny" };
});
window.webContents.on("will-navigate", (event, url) => {
if (!isHttpUrl(url) || url === currentUrl) return;
const currentOrigin = currentUrl ? new URL(currentUrl).origin : null;
const nextOrigin = new URL(url).origin;
if (currentOrigin === nextOrigin) return;
event.preventDefault();
void shell.openExternal(url);
});
if (process.platform === "darwin") {
// Track the in-flight fullscreen-enter window so the close handler can
// tell mid-transition apart from "definitely not fullscreen". HTML
// requestFullscreen() emits enter-html-full-screen on webContents
// before the OS Space transition completes; the BrowserWindow
// enter-full-screen event fires once the OS confirms.
let enteringFullscreen = false;
window.webContents.on("enter-html-full-screen", () => {
enteringFullscreen = true;
});
window.webContents.on("leave-html-full-screen", () => {
enteringFullscreen = false;
});
window.on("enter-full-screen", () => {
enteringFullscreen = false;
});
window.on("leave-full-screen", () => {
enteringFullscreen = false;
});
window.on("close", (event) => {
if (stopped) return;
event.preventDefault();
hideWindowExitingFullscreen({
hide: () => window.hide(),
isFullScreen: () => window.isFullScreen(),
isSimpleFullScreen: () => window.isSimpleFullScreen(),
isEnteringFullscreen: () => enteringFullscreen,
setFullScreen: (flag) => window.setFullScreen(flag),
setSimpleFullScreen: (flag) => window.setSimpleFullScreen(flag),
// BrowserWindow.once is heavily overloaded; both event names are
// valid (BrowserWindow emits enter-full-screen and
// leave-full-screen on macOS) but TypeScript can't pick a single
// overload for the union, so narrow at the call site.
once: (event, listener) =>
event === 'enter-full-screen'
? window.once('enter-full-screen', listener)
: window.once('leave-full-screen', listener),
});
});
} else {
attachNonDarwinMainWindowCloseShutdown(window, {
isStopped: () => stopped,
requestQuit: options.requestQuit,
});
}
const rendererLogPath = options.rendererLogPath ?? null;
let rendererLogReady: Promise<void> | null = null;
const ensureRendererLogDir = async (): Promise<void> => {
if (rendererLogPath == null) return;
if (rendererLogReady == null) {
rendererLogReady = mkdir(dirname(rendererLogPath), { recursive: true }).then(() => undefined);
}
await rendererLogReady;
};
const persistRendererEntry = async (entry: DesktopConsoleEntry): Promise<void> => {
if (rendererLogPath == null) return;
if (entry.level !== "error" && entry.level !== "warn") return;
try {
await ensureRendererLogDir();
const line = `${JSON.stringify({ timestamp: entry.timestamp, level: entry.level, text: entry.text })}\n`;
await appendFile(rendererLogPath, line, "utf8");
} catch (error) {
console.error("desktop renderer log append failed", error);
}
};
(window.webContents as any).on("console-message", (event: { level?: number | string; message?: string }) => {
const level = typeof event.level === "number" ? mapConsoleLevel(event.level) : (event.level ?? "log");
const entry: DesktopConsoleEntry = {
level,
text: event.message ?? "",
timestamp: new Date().toISOString(),
};
consoleEntries.push(entry);
if (consoleEntries.length > MAX_CONSOLE_ENTRIES) {
consoleEntries.splice(0, consoleEntries.length - MAX_CONSOLE_ENTRIES);
}
void persistRendererEntry(entry);
});
// The splash window carries the light brand animation. In packaged builds the
// entry hands us one it created BEFORE the sidecars booted (so it overlaps the
// whole cold start); otherwise we create our own. The main window above stays
// hidden behind it until the real app has mounted.
// When the caller (packaged) hands us a pre-created splash, honour the
// creation time it captured so the minimum hold is measured from when the
// animation actually appeared — before the sidecar boot — not from now (which
// in packaged is already post-boot). When we create our own splash (tools-dev)
// its handle carries a fresh, correct timestamp.
let splash: BrowserWindow | null = options.splashWindow ?? null;
let splashStartedAt = options.splashStartedAt ?? Date.now();
if (splash == null) {
const created = createSplashWindow();
splash = created.window;
splashStartedAt = created.startedAt;
}
let revealed = false;
let revealing = false;
const revealMainWindow = (): void => {
if (revealed || window.isDestroyed()) return;
revealed = true;
showWindowButtons(window);
window.show();
window.focus();
ensureWindowVisible(window);
if (splash != null && !splash.isDestroyed()) splash.close();
};
// Hold the splash until BOTH (a) the web bundle reports it has mounted — it
// sets `data-od-app-mounted="1"` on first paint of the real UI — so we never
// reveal the web's own dark "Loading Open Design…" shell, and (b) the splash
// has been up at least MIN_SPLASH_MS so the brand clip plays through. A hard
// ceiling guarantees the user is never stranded on the splash if the mount
// signal never arrives.
const revealWhenReady = async (): Promise<void> => {
if (revealing || revealed) return;
revealing = true;
// The web bundle is loading in the hidden main window from here on; let
// the splash status line reflect that final phase while we poll for mount.
setSplashStage(splash, "workspace");
const deadline = Date.now() + WEB_MOUNT_REVEAL_TIMEOUT_MS;
while (!stopped && !window.isDestroyed() && Date.now() < deadline) {
const mounted = await window.webContents
.executeJavaScript(`document.documentElement.getAttribute("data-od-app-mounted") === "1"`, true)
.catch(() => false);
if (mounted === true) break;
await delay(WEB_MOUNT_POLL_MS);
}
// The real UI has mounted behind the splash; the only thing left is the
// minimum-hold so the brand clip plays through. Advance the counter to its
// final step so the user sees the boot reach completion, not stall at
// "Opening your workspace".
setSplashStage(splash, "finishing");
const remaining = MIN_SPLASH_MS - (Date.now() - splashStartedAt);
if (remaining > 0) await delay(remaining);
revealMainWindow();
};
const schedule = (delayMs: number) => {
if (stopped) return;
timer = setTimeout(() => {
void tick();
}, delayMs);
};
// Flag the renderer as needing a reload and poll again promptly, rather than
// waiting up to RUNNING_POLL_MS. The next `tick` re-loads the current URL (see
// the `rendererFailed` branch) and clears the flag once the load succeeds. If
// the web server is still unreachable, discovery returns null and the loop
// naturally backs off to RUNNING_POLL_MS until it returns.
const markRendererFailed = () => {
if (stopped || window.isDestroyed()) return;
rendererFailed = true;
// Mid-tick failures (a rejecting loadURL) are rescheduled by the tick's own
// catch/success path; scheduling here too would spawn a second poll loop.
if (ticking) return;
if (timer) {
clearTimeout(timer);
timer = null;
}
schedule(PENDING_POLL_MS);
};
const tick = async () => {
if (stopped || window.isDestroyed()) return;
ticking = true;
try {
const url = await options.discoverUrl();
// Reload when the discovered URL changes, OR when the renderer is in a
// failed/blank state (URL unchanged but the page died), so a window
// restored from the background recovers instead of staying blank.
if (url != null && (url !== currentUrl || rendererFailed)) {
pendingUrl = url;
// Load the web app into the still-hidden main window as soon as it is
// discovered; it mounts behind the splash so the swap is instant.
console.info("[open-design desktop] main window loadURL start", { currentUrl, url });
await window.loadURL(url);
console.info("[open-design desktop] main window loadURL success", { url });
currentUrl = url;
rendererFailed = false;
pendingUrl = null;
const nextPetUrl = desktopPetUrl(url);
if (!petWindow.isDestroyed() && nextPetUrl !== currentPetUrl) {
await petWindow.loadURL(nextPetUrl);
currentPetUrl = nextPetUrl;
}
if (!revealed) {
void revealWhenReady();
} else {
showWindowButtons(window);
}
} else if (url == null) {
pendingUrl = null;
}
schedule(currentUrl == null ? PENDING_POLL_MS : RUNNING_POLL_MS);
} catch (error) {
pendingUrl = null;
console.error("desktop web discovery failed", error);
schedule(PENDING_POLL_MS);
} finally {
ticking = false;
}
};
void tick();
return {
async click(input) {
if (window.isDestroyed()) return { clicked: false, found: false };
const selector = JSON.stringify(input.selector);
return await window.webContents.executeJavaScript(
`(() => {
const element = document.querySelector(${selector});
if (!element) return { found: false, clicked: false };
if (typeof element.click === "function") element.click();
return { found: true, clicked: true };
})()`,
true,
);
},
async close() {
stopped = true;
if (timer != null) {
clearTimeout(timer);
timer = null;
}
unsubscribeUpdater();
ipcMain.removeAllListeners("desktop-pet:set-visible");
for (const channel of UPDATER_IPC_CHANNELS) {
ipcMain.removeHandler(channel);
}
ipcMain.removeHandler("browser:clear-data");
if (splash != null && !splash.isDestroyed()) splash.close();
if (!petWindow.isDestroyed()) petWindow.close();
if (!window.isDestroyed()) window.close();
},
console() {
return { entries: [...consoleEntries] };
},
async eval(input) {
if (window.isDestroyed()) return { error: "desktop window is destroyed", ok: false };
const startedAt = Date.now();
console.info("[open-design desktop] eval executeJavaScript start", {
...summarizeExpression(input.expression),
statusUrl: resolveDesktopStatusUrl(currentUrl, pendingUrl),
webContentsUrl: window.webContents.getURL(),
});
try {
const value = await window.webContents.executeJavaScript(input.expression, true);
console.info("[open-design desktop] eval executeJavaScript success", {
durationMs: Date.now() - startedAt,
statusUrl: resolveDesktopStatusUrl(currentUrl, pendingUrl),
valueType: typeof value,
webContentsUrl: window.webContents.getURL(),
});
return { ok: true, value };
} catch (error) {
console.error("[open-design desktop] eval executeJavaScript failed", {
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
statusUrl: resolveDesktopStatusUrl(currentUrl, pendingUrl),
webContentsUrl: window.webContents.getURL(),
});
return { error: error instanceof Error ? error.message : String(error), ok: false };
}
},
exportArtifact(input) {
return exportArtifactFromHtml(input);
},
exportPdf(input) {
return exportPdfFromHtml(input);
},
async screenshot(input) {
if (window.isDestroyed()) throw new Error("desktop window is destroyed");
const outputPath = normalizeScreenshotPath(input.path);
const image = await window.webContents.capturePage();
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, image.toPNG());
return { path: outputPath };
},
show() {
if (window.isDestroyed()) return;
// Before the splash reveal gate has fired (revealWhenReady), the main
// window is still hidden and surfacing it here would show the half-loaded
// web shell and bypass the gate — reintroducing the startup flash this is
// meant to remove (e.g. a packaged second-instance focus arriving mid
// boot). Bring the splash forward instead; the main window is revealed on
// its own once the app has mounted.
if (!revealed) {
if (splash != null && !splash.isDestroyed()) {
splash.show();
splash.focus();
}
return;
}
window.show();
window.focus();
},
status() {
return {
pid: process.pid,
state: window.isDestroyed() ? "unknown" : "running",
title: window.isDestroyed() ? null : window.getTitle(),
updatedAt: new Date().toISOString(),
url: resolveDesktopStatusUrl(currentUrl, pendingUrl),
windowVisible: !window.isDestroyed() && window.isVisible(),
};
},
};
}