Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

28 KiB

Desktop Layering

This document defines the layering rules for apps/desktop.

Document Authority

This is the authoritative document for the durable directory shape and ownership model inside apps/desktop.

Other documents should stay shorter:

If the durable desktop directory shape or ownership model changes, update this document first, then update the summaries that point to it.

Purpose

The desktop app is an Electron shell around tuttid.

Its job is to provide:

  • window lifecycle
  • preload bridge exposure
  • Electron and OS integration
  • local daemon supervision

It must not become a second business layer.

Current Layout

apps/desktop/
  src/
    main/
    preload/
    renderer/
    shared/

Current concrete shape is still intentionally small:

  • main contains bootstrap composition, daemon supervision, host access, transport, IPC, update, generated defaults, logging, and window modules
  • preload uses one business entrypoint plus a capability-minimal minimum-version admission entry
  • renderer resolves dashboard and workspace shells from one business bundle and keeps minimum-version admission in an isolated bundle
  • renderer organizes growing UI behavior as feature modules with service-owned state
  • shared is used for narrow desktop-local contracts and i18n resources shared across main, preload, and renderer

The rules below describe both the current implementation and the allowed growth path.

The desktop tree should be read by ownership, not as a checklist of required files. Concrete filenames may evolve; preserve the responsibilities and dependency direction below.

apps/desktop/src/
  main/
    daemon/
    generated/
    host/
    ipc/
    desktopAppLifecycle.ts
    desktopAppServices.ts
    desktopDaemonRuntime.ts
    desktopHostServices.ts
    transport/
    update/
    windows/
  preload/
    entries/
    api/
  renderer/
    src/
      app/
        windows/
          dashboard/
          workspace/
      features/
        <feature>/
          services/
            <feature>Service.interface.ts
            <feature>Types.ts
            register<Feature>Services.ts
            internal/
              adapters/
          ui/
      i18n/
      lib/
  shared/
    contracts/
    errors/
    i18n/

Reading rules:

  • main/host/* owns host-side desktop capabilities such as file dialogs, workspace window handoff, and host-assisted workspace file access
  • main/desktopDaemonRuntime.ts owns daemon endpoint, client, and managed-process runtime composition
  • main/desktopHostServices.ts owns host-side service composition such as preferences, file dialogs, and workspace launch wiring
  • main/desktopAppServices.ts composes daemon runtime, host services, and updater into the app-facing service set used by bootstrap
  • main/desktopAppLifecycle.ts owns Electron application lifecycle event registration
  • main/windows/* are Electron window shells, not business modules
  • main/windows/* owns native window intents such as close and development reload shortcuts; renderer should respond to typed close requests instead of inferring native lifecycle from beforeunload
  • preload/entries/* are capability-entry assembly points, even if only one entry exists today
  • renderer/src/app/windows/* are renderer window composition shells such as dashboard and workspace
  • renderer/src/features/* are reusable renderer feature modules
  • renderer/src/features/*/services/internal/** is private implementation for the owning feature
  • renderer/src/features/workspace-file-preview owns the workspace-scoped preview presenter registry and fallback policy; file-manager, Workbench, and Agent surfaces consume its public service contract directly instead of owning parallel routing or adding preview-open proxy methods to another feature service
  • desktop business-facing capabilities should usually surface through ipc/* and preload/api/*
  • shared/contracts/* is for stable desktop-local bridge contracts, not a general utility bucket
  • shared/errors/* owns desktop-local error codes and non-Electron error classification shared across desktop layers
  • shared/i18n/* owns desktop-local i18n resources, typed translation helpers, and app-level i18n-runtime composition shared across desktop layers

When adding or moving a desktop directory, update this ownership shape only when the new directory creates a durable boundary. Do not add every new implementation file to this document.

Layer Responsibilities

Desktop I18n Ownership Rule

Desktop i18n resources should be organized by ownership, not by where they are rendered.

Rules:

  • keep desktop-owned product i18n resources in src/shared/i18n/*
  • keep reusable package default i18n resources in the owning package instead of copying those strings into apps/desktop
  • compose the renderer app-level i18n runtime from desktop-owned resources plus reusable package default i18n resources
  • let host overrides win over package defaults during runtime merge
  • keep user-facing error i18n resources under the desktop i18n layer, even when the error semantics originate from shared protocol clients

Current ownership model:

  • apps/desktop/src/shared/i18n/* owns desktop launcher i18n resources, workspace host i18n resources, desktop workbench product i18n resources, and desktop error i18n resources
  • reusable package defaults such as file-manager i18n resources and generic workbench window-chrome i18n resources stay in their owning packages/* modules
  • apps/desktop/src/renderer/src/i18n/index.tsx merges desktop-owned i18n resources with package defaults into one app-level i18n runtime

Do not duplicate package default strings inside apps/desktop/src/shared/i18n/* unless the desktop app is intentionally taking ownership of that wording as a product override.

src/main

main owns Electron-specific capabilities and the controlled bridge to tuttid.

Current responsibilities include:

  • app bootstrap
  • app-service composition
  • window creation
  • daemon start and stop
  • transport endpoint resolution
  • IPC registration
  • desktop main-process logging
  • desktop update integration
  • generated desktop defaults consumption

main may:

  • know managed loopback backend details
  • supervise tuttid
  • call Electron-native APIs

main must not:

  • implement business rules
  • reinterpret business workflows
  • hold complex business state that belongs in tuttid

If logic is not Electron-specific, it should usually move out of apps/desktop.

Main Composition Rule

Keep main composition layered and explicit.

Preferred shape:

  • bootstrap.ts coordinates startup order and top-level error handling
  • desktopAppServices.ts assembles the service set that the app needs at runtime
  • desktopDaemonRuntime.ts assembles managed-local-backend concerns
  • desktopHostServices.ts assembles host-facing service concerns
  • desktopAppLifecycle.ts registers Electron app lifecycle listeners
  • ipc/* adapts typed host and runtime services into preload-facing handlers

Do not collapse these responsibilities back into one long bootstrap file once the boundaries exist.

Minimum-version admission is a lifecycle boundary:

  • @tutti-os/desktop-update-admission owns the product-neutral controller, response validation, mandatory updater lease, preload API, shared React presentation, and default i18n resources
  • bootstrap supplies product transport, updater adapter, window assets, download URL, logging sink, and business-window enumeration to that package
  • bootstrap may create the updater and the shared minimum-version controller before daemon, host-service, IPC, menu, or business-window composition
  • a blocked startup must not construct business services or start the managed daemon
  • the admission window uses its own renderer HTML entry and preload entry
  • the admission preload exposes only minimum-version commands; the normal preload must not duplicate those commands
  • mandatory update execution owns the updater through an exclusive session and restores the prior normal update configuration only when the policy block is released
  • host apps must not copy the controller, validation, lease, preload, renderer, or default-copy implementation back into app-local modules

Lifecycle-specific rule:

  • when desktop shutdown depends on daemon cleanup, before-quit should act as an async gate instead of firing cleanup and exiting immediately
  • managed tuttid shutdown, listener-info cleanup, and similar teardown work should finish before the final app quit path resumes
  • lifecycle tests should cover the quit gate whenever desktop changes alter daemon-stop timing

Main IPC Rule

Keep main/ipc/* thin.

Preferred shape:

  • ipc/* binds channel names to typed handlers
  • ipc/handle.ts owns the shared ipcMain.handle(...)->toDesktopIpcResult(...) wrapper
  • main/host/* owns host-side payload normalization, window broadcast, file dialog use, and other native side effects
  • main/update/* owns updater-specific payload normalization and updater-facing access helpers
  • shared/contracts/* may own the typed channel -> payload/result mapping used by both preload invoke helpers and main IPC registration

Do not grow register*Ipc files into ad hoc business or host orchestration surfaces once a typed access/helper layer exists.

Normalization-specific rule:

  • host access layers should return durable product-shaped values, not raw Electron dialog outputs
  • updater access layers should validate the full preload contract instead of silently coercing unsupported fields
  • examples include deriving a usable workspace name from selected local directories and rejecting unsupported update channel values at the access boundary

src/preload

preload is the renderer-facing desktop SDK layer.

Current state:

  • the business preload composes the normal typed API surface
  • the minimum-version preload is a separate, capability-minimal entry

It should:

  • expose typed APIs such as window.tutti.runtime.getBackendConfig()
  • hide IPC channel names
  • define capability surfaces per window when needed

It should not:

  • expose a generic invoke(channel, payload) surface to renderer code
  • own business orchestration
  • know daemon endpoint details

src/renderer

renderer is a consumer of desktop capabilities.

Current state:

  • business windows use one React bundle and resolve their window shells by startup view
  • the minimum-version admission window uses a separate renderer entry and must not import business composition
  • renderer window shells create window-scoped dependency containers and assemble features
  • feature services own renderer-local state, commands, and preload adapters

It should:

  • keep window shells under renderer/src/app/windows/*
  • organize reusable product UI by feature where practical as the workspace interior grows
  • keep feature UI under renderer/src/features/<feature>/ui/*
  • keep feature service public surfaces under renderer/src/features/<feature>/services/*
  • keep feature service implementation under renderer/src/features/<feature>/services/internal/**
  • use @tutti-os/infra/di for renderer feature service tokens and window-scoped composition
  • expose feature stores to UI as readonly state and mutate them only inside the owning service
  • consume shared tokens, icons, and primitives from @tutti-os/ui-system
  • prefer the @tutti-os/ui-system root export for runtime imports instead of reaching into package internals
  • load @tutti-os/ui-system/styles.css once from the renderer style entrypoint
  • call typed preload APIs
  • consume a typed tuttid client created by renderer window or platform composition from preload-provided runtime config

It should not:

  • import Electron APIs directly
  • let feature UI or feature services construct daemon clients
  • discover or derive daemon transport endpoints
  • import another feature's services/internal/**
  • mutate another module's feature store directly from UI
  • recreate a second design system inside apps/desktop
  • deep-import from @tutti-os/ui-system internal source files or per-file component paths

Renderer Feature Rule

Renderer features are organized around a feature-owned service.

The preferred feature shape is:

renderer/src/features/<feature>/
  index.ts
  services/
    <feature>Service.interface.ts
    <feature>Types.ts
    register<Feature>Services.ts
    internal/
      <feature>Service.ts
      <feature>Store.ts
      <feature>Model.ts
      adapters/
  ui/
    use<Feature>Service.ts
    <Feature>.tsx

Rules:

  • services/*.interface.ts declares the DI token and public service interface.
  • services/*Types.ts contains public state and view-facing types.
  • services/register*Services.ts registers the feature service for a window container.
  • services/internal/** contains concrete service, store, model, and adapters.
  • ui/* subscribes to the feature service store and calls service commands.
  • index.ts exports only the stable feature surface needed by window composition or other features.

Do not create empty template files. Add optional files such as <feature>Dependencies.ts only when a feature has real external dependencies that are not already represented by another public service interface.

Renderer Data Flow Rule

Renderer features should keep the data layer, logic layer, and UI layer distinct.

Rules:

  • feature services are the logic boundary for assembling commands, adapters, request flow, and side effects
  • feature stores are the data boundary for UI-facing state
  • reducers or equivalent pure transition helpers should own deterministic state changes when store mutations become non-trivial
  • selectors should own derived render data, filtering, grouping, and presentation-ready projections
  • UI should render from state-library snapshots (currently valtio store snapshots) and call service commands in response to user events

Do not push orchestration into React components just because the workflow starts from a click, route, or subscription. If the flow needs to combine preload APIs, daemon data, local state, derived flags, or cross-step error handling, it belongs behind the owning service and store contract.

Renderer Store Rule

Renderer service stores are for UI responsiveness, not daemon-owned durable state.

Rules:

  • the feature service owns the store and all mutations
  • UI reads service.store through a reactive snapshot hook
  • UI invokes service commands for writes and side effects
  • public service interfaces should expose readonly store types
  • concrete mutable stores should stay under services/internal/**

This keeps rendering responsive while preserving the service as the feature's logic and state authority.

Workspace Agent Activity Source Of Truth

Workspace agent activity is a renderer feature-service concern, not an AgentGUI or MessageCenter local store concern.

Rules:

  • register one WorkspaceAgentActivityService in the workspace window container
  • pass that service into AgentGUI, MessageCenter, and other workspace workbench contributions that need agent activity
  • keep controller/cache ownership inside WorkspaceAgentActivityService for activity engine and reconcile state; the Agent Session Replay feature owns its recording binding, recorder state, and Engine observer bridge
  • keep daemon session-directory query projection and external-import refresh workflows in focused internal operation collaborators; the public service remains the facade and the only owner of engine/reconcile coordination
  • use the shared business-event stream only as a live update signal
  • reconcile session and message state through the normal tuttid HTTP client APIs, including message afterVersion reads after reconnect

Do not reintroduce a per-session SSE client, a workspace-scoped SSE client, or a second renderer-side agent activity controller when a consumer can use WorkspaceAgentActivityService.

Renderer React Boundary Rule

Treat fewer useEffect calls as a core architecture constraint, not just a style preference.

React components should stay close to DOM concerns: render snapshots, handle DOM events, and subscribe to external state through the feature's UI hook. Feature-level data flow belongs in services, stores, reducers, and selectors.

React effects are escape hatches for synchronizing with real external systems such as browser APIs, timers, subscriptions, and imperative third-party widgets. They should not become the default place for feature orchestration, derived state, command sequencing, or cross-feature coordination.

When a component needs an effect to keep local state in sync with other state, prefer moving that flow into the owning service, reducer, selector, or a small subscription hook. UI event handlers should call service commands; they should not directly mutate stores or coordinate business workflows.

Polling snapshots are events, not component lifecycle identities. A renderer feature that reacts to a request plus a polling service must keep the request session and its idempotency state in a window-scoped service/controller. Do not put an object derived from the latest polling snapshot into an effect dependency chain that attaches a workflow, resets session state, or starts an external command. Host-handle binding may use a lifecycle effect, but polling and render churn must be unable to replay the command.

Renderer Render Stability Rule

Treat render stability as a feature-boundary concern, not as a late micro-optimization pass.

The renderer is not a traditional React app where components are expected to absorb most orchestration and synchronization work. tutti prefers logic and view separation: services own commands and side effects, stores own UI-facing state, selectors own derived render data, and React stays focused on subscriptions, DOM events, and presentation.

Rules:

  • in the desktop renderer, treat the renderer build's React Compiler pass as the default memoization baseline; do not cargo-cult React.memo, useMemo, or useCallback into every component
  • reach for handwritten memoization only when it expresses a real cache boundary, stabilizes a known hot prop surface, or profiling shows the compiler and current structure are not enough
  • split UI by ownership and update frequency; do not let one large container mix unrelated high-churn state such as text input, polling state, panel visibility, and broad workspace shells
  • keep expensive projection work out of raw render paths; filtering, grouping, sorting, and presentation-ready view-model shaping should live in selectors or in narrowly-scoped memoized projections
  • subscribe as narrowly as the rendered surface needs; broad store subscriptions inside large container components defeat the value of fine-grained state updates
  • keep hot props stable across render boundaries when they feed large subtrees or repeated list rows; large object literals, action bags, and repeated inline handler creation should not be the default shape at those boundaries
  • prefer explicit event handlers, selector inputs, reducer transitions, and service commands over effect-driven local-state synchronization
  • use startTransition or useDeferredValue only for clearly non-urgent UI updates or known expensive derived renders when a feature intentionally trades immediacy for smoother interaction

Practical review questions:

  • is this component doing orchestration, projection, and rendering at the same time
  • would a small state change here force unrelated UI regions to re-render
  • should this derived data move into a selector or service-owned projection instead of being recomputed during every render
  • is this list or subtree receiving unstable object or function props that could be avoided by better boundaries
  • is a useEffect compensating for state placement that should be fixed structurally instead

Avoid these renderer anti-patterns:

  • giant top-level components that accumulate unrelated state domains and then fan that churn into large child trees
  • expensive projection functions executed directly inside render without a selector boundary or a narrowly-scoped memoized projection
  • large inline labels, actions, or similar object literals passed through hot component boundaries
  • broad store subscriptions in large container components that only need a smaller derived slice
  • list rows that recreate multiple inline handlers or helper objects on every parent render
  • useCallback dependencies that point at unstable object properties instead of a stable command boundary
  • defining helper components or heavy render helpers inside another component when they can be extracted or hoisted

Prefer fixing structure before adding defensive memoization. In this repository, a better service boundary, selector, store subscription, or component split is usually the right first move.

Renderer DI Rule

Renderer DI is used for feature composition and replaceable capabilities.

Rules:

  • use @tutti-os/infra/di for service tokens and window-scoped containers
  • window shells create the container and register the features they compose
  • feature services may depend on other public service interfaces or on feature-local adapters
  • prefer explicit registration in renderer/src/app/windows/*/create*Container.ts
  • do not use DI as a global service locator from arbitrary UI components
  • do not import another feature's services/internal/**; use its public interface or feature index.ts

Feature-to-feature dependencies should be wired by the window composition layer unless a direct dependency on another feature's public service interface is intentionally part of the feature contract.

Renderer DI should distinguish feature services from host adapters:

  • use @I<Feature>Service tokens for public feature services that other renderer features may depend on
  • use @I<Capability>Service tokens for shared renderer capabilities that are host-agnostic and reusable across feature services, such as INotificationService
  • pass external capabilities as explicit adapter or registration parameters when they are not renderer feature services, including tuttid clients, event-stream clients, preload host APIs, platform APIs, runtime APIs, browser APIs, and terminal command runners
  • keep host-specific adaptation at the window composition or feature registration boundary, then inject public service dependencies through the DI container
  • do not create thin DI wrapper services only to rename a preload or daemon client; add a service token only when there is a real renderer-owned contract or shared capability

Notification-specific rule:

  • feature services that need user-visible feedback should depend on INotificationService instead of receiving ad hoc toast callbacks from the window shell
  • UI-only feedback may stay in UI code when it is purely local presentation behavior, but repeated or cross-step notification logic should move behind the owning service
  • actionable notifications should get a dedicated contract when needed instead of overloading the basic notification service with workflow-specific button semantics

The mechanical guard for private feature implementation imports is:

pnpm check:renderer-boundaries

The staged-file variant is:

pnpm check:renderer-boundaries:staged

Workspace Workbench is stricter than the generic renderer feature rule because it is a product host adapter for shared Workbench packages. Its UI must use public service or controller seams instead of importing workspace-workbench/services/internal/**. See Workbench.

Renderer Preload Access Rule

Treat window.tutti as a renderer composition-root input.

Rules:

  • window container files under renderer/src/app/windows/**/create*Container.ts may read window.tutti to pass the typed preload API into feature registrations
  • feature services should receive preload capabilities through explicit adapters created by their registration function
  • feature UI, feature services, renderer libraries, and ordinary app files must not read window.tutti directly
  • renderer/src/global.d.ts may declare Window.tutti for type support

The mechanical guard is part of:

pnpm check:renderer-boundaries

src/shared

shared is intentionally narrow.

Only place code here when it is:

  • stable
  • clearly cross-cutting
  • not strongly owned by main, preload, or renderer

Do not turn shared into a generic bucket for convenience extraction.

Call Path Rule

Desktop feature calls should choose one of three paths by responsibility:

renderer -> managed localhost tuttid
renderer -> preload -> IPC -> main native capability -> OS
renderer -> preload -> IPC -> main host-assisted flow -> tuttid and OS

This rule keeps the layers understandable:

  • renderer stays focused on UI and business-facing backend calls
  • preload stays focused on typed host capability exposure and runtime bootstrap metadata
  • main stays focused on Electron, daemon supervision, local process integration, and narrow host-assisted flows

Ordinary business queries, mutations, and streams should use the typed renderer-side tuttid client. Host capabilities such as file pickers, shell open, updater, preferences, and window lifecycle should go through preload and main. Flows that need both native host authority and daemon authority should stay explicit and few.

Transport Boundary Rule

apps/desktop treats transport as a main concern.

Rules:

  • renderer must not derive daemon endpoints or lifecycle policy on its own
  • preload may expose the minimum backend config renderer needs to talk to the managed local backend
  • renderer window/platform composition may construct the typed backend client from that preload-provided config before injecting it into feature services
  • only main/transport owns managed localhost endpoint discovery, token issuance, and desktop daemon fetch behavior

This lets transport evolve without forcing feature rewrites.

IPC Rules

IPC modules should stay thin.

They may:

  • register handlers
  • receive renderer calls
  • forward requests into transport or native helpers

They should not:

  • accumulate business orchestration layers by default
  • become a second application service tier

If a deeper abstraction is needed later, add it only when complexity proves the need.

Multi-Window Rule

Windows are shells, not separate products.

Current implementation:

  • dashboard and workspace are separate Electron windows
  • both resolve through the business preload and renderer bundle
  • minimum-version admission is a separate Electron capability and renderer boundary

Preferred growth path:

  • window-specific shells under renderer app/
  • reusable feature domains under renderer features/
  • separate preload entries only when window capability surfaces differ

That means:

  • renderer/src/app/windows/dashboard and renderer/src/app/windows/workspace remain shell entrypoints
  • future reusable product modules should grow under renderer/src/features/*
  • do not split dashboard or workspace into separate renderer bundles unless capability boundaries truly diverge

Do not copy feature code per window unless the behavior truly diverges. Do not split into separate preload entries or renderer bundles just because multiple windows exist; split only when capability boundaries become materially different.

Current Window Model

The current user-facing shell model is:

  • restore a recent workspace when possible
  • otherwise open the dashboard launcher
  • open the selected workspace in a workspace window
  • close the dashboard after launching a workspace

See Desktop Windows for the current window behavior and lifecycle model.

Review Questions

When reviewing desktop changes, ask:

  1. Does this logic belong to Electron, or does it belong to tuttid?
  2. Is transport knowledge leaking upward into preload or renderer?
  3. Is main acting as a thin bridge, or is it starting to accumulate business logic?
  4. Is a new shared helper truly cross-cutting, or should it stay local to its layer?
  5. Would this change make transport or window growth harder later?
  6. Is React being used for rendering, DOM events, and subscriptions, or is useEffect becoming hidden feature orchestration?