{"owner":"streamlabs","repo":"desktop","hasSkills":true,"totalSkillsCount":2,"totalTokensCount":7214,"categories":["claude-rule","project-spec"],"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md","ARCHITECTURE.md"],"skills":{"CLAUDE.md":"# CLAUDE.md\n\nGuidance for AI agents working in this repository. Keep this file lean — it loads\ninto every session. Deep architecture lives in `ARCHITECTURE.md` (read it when a\nchange touches the window/service/state/IPC model).\n\nThis is **Streamlabs Desktop** (`slobs-client`): an Electron live‑streaming app\nbuilt on OBS. Large, old, and mid‑migration on two fronts (Vue → React for UI,\nVuex → Realm/React for state), with several non‑obvious invariants. Read the\nrelevant service before editing it; prefer targeted reads over broad assumptions.\n\n## Commands\n\nPackage manager is **Yarn Berry (3.1.1)** — never use `npm`.\n\n| Task                | Command                 | Notes                                                                                                                         |\n| ------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| Lint + format       | `yarn eslint`           | Prettier runs _through_ ESLint (`eslint-plugin-prettier`). `eslint --fix` formats.                                            |\n| Typecheck           | `yarn typecheck`        | **Fast self-verify.** `tsc --noEmit` for the app + React. Use this to check changes instead of a full build or the e2e suite. |\n| Iterative dev build | `yarn watch`            | Webpack watch; use this while developing.                                                                                     |\n| One‑shot dev build  | `yarn compile`          | Slow: clears `bundles/media` and rebuilds everything. Don't run casually.                                                     |\n| Run the app         | `yarn start`            | Launches Electron against the last build.                                                                                     |\n| Single test file    | `yarn test:file <path>` | Compiles tests, runs one file.                                                                                                |\n| Full test suite     | `yarn test`             | **Heavy/slow e2e** — see Testing below. Don't run unless asked.                                                               |\n\nFor a fast type check without a full build, run **`yarn typecheck`** — it runs\n`tsc --noEmit` for the non‑React app (`tsconfig.json`) and for `app/components-react`\n(its stricter config, `strictNullChecks: true`). This is the quickest way to verify\nyour changes; reserve `yarn compile` and the e2e suite for when you actually need\nthem. (The build still typechecks via `ts-loader`; tests compile via `tsc -p test`.)\n\n## Code style\n\n- TypeScript, formatted by Prettier via ESLint. **Don't memorize the rules** —\n  let `yarn eslint` (`--fix`) apply them, and match the surrounding file.\n- `strictNullChecks` is **intentionally OFF** globally. Do **not** enable it\n  repo‑wide. A subset of files opts in via `strict-null-check-files/` +\n  `SLOBS_STRICT_NULLS`; only add to that set deliberately.\n\n## Architecture in one screen\n\nMulti‑window Electron app. Every window runs the _same_ JS bundle but plays a\ndifferent role:\n\n- **worker** — invisible, persistent renderer that runs the **entire services\n  layer**. All service methods actually execute here.\n- **main** — the primary UI window.\n- **child** — kept warm in the background for things like Source Properties.\n- plus transient **one‑off** windows (projectors, pop‑outs), apps, webviews.\n\nThe UI windows (main, child, one‑off) **don't run services** — they call them\nremotely. A call from any non‑worker window is sent to the **Electron main\nprocess** (`main.js`), which forwards it to the **worker** window, then routes the\nresult back to the originating window. Note: the _main process_ (`main.js`, Node)\nis the router — **not** the _main window_, which is just another UI client.\n\n**Services** (`app/services/`) are strict singletons holding all domain logic.\nNormal application code reaches a service through the `@Inject()` decorator. They're\nregistered in `app/app-services.ts`.\n\n## State — know which mechanism to use\n\nState management is **mid‑migration**. For new code, choose by scope:\n\n- **UI‑only state** → React state (`useState` / hooks), local to the component.\n- **Service state that must sync across windows/processes** → **Realm**\n  (`RealmObject` / `RealmService` in `app/services/realm.ts`), which replicates\n  across all processes. React reads it via `app/components-react/hooks/realm.ts`.\n\nMuch existing service state still lives in **Vuex** via `StatefulService<TState>`\n(read through `this.state`, mutated only via `@mutation()` methods). You'll\nmaintain it where it already exists, but **don't reach for Vuex for new state.**\n\nSee `ARCHITECTURE.md` for the full model and the _why_ behind the sharp edges.\n\n## Hard rules (these are easy to get wrong)\n\n1. **Vuex mutations are pure.** When editing an existing `StatefulService`, a\n   `@mutation()` method may touch only `this.state` and its own arguments — no side\n   effects, no calling other services, no async. In dev a Proxy enforces this and\n   throws (`app/services/core/stateful-service.ts:49`).\n2. **Calling a service from a UI window — pick the right form:**\n   - `Service.actions.method()` — **default**. Async, fire‑and‑forget, returns\n     `void`.\n   - `Service.actions.return.method()` — async **with** a return value (resolves\n     when the worker finishes). Use sparingly.\n   - `Service.method()` (omit `.actions`) — **synchronous**; it blocks the calling\n     UI process and logs a console warning. Avoid except where absolutely\n     necessary — it degrades the user experience.\n   - Reads: use `views` (Vuex) or read Realm objects directly.\n3. **Register new services** in `app/app-services.ts` — it's hand‑maintained, not\n   generated. A service that isn't registered won't resolve.\n4. **New UI is React.** Functional components + hooks only, in\n   `app/components-react/` (`.tsx`). Vue (`app/components/`, `.vue`) is legacy and\n   frozen — only touch it to migrate a component to React.\n5. **Don't edit generated/build output:** `bundles/`, `*.g.less`, `updater/build/`,\n   `dist/`, `test-dist/`, `docs/dist/`.\n6. **Honor in‑code warnings.** Respect `DO NOT CALL` / `@warning` markers — e.g.\n   `app/services/sources/sources.ts` `updatePropertiesManagerSettingsInStore`,\n   and the deprecated method in `app/services/video.ts`.\n\n## Conventions\n\n- Filenames map to classes: `foo-bar.ts` → `class FooBarService`.\n- Common decorators: `@Inject()` (DI), `@mutation()` (Vuex mutation),\n  `@InitAfter('OtherService')` (ordered init), `@InheritMutations()`.\n- Service lifecycle hooks: `init()` (once per app) → `mounted()` (once per window)\n  → `afterInit()`.\n- Cross‑service / cross‑window events use **RxJS** `Subject`s.\n\n## Alert type `apiKey` field naming\n\nWhen adding a new alert type to `alerts-config.ts`, the `apiKey` field must match\nthe **backend field prefix**, not the internal alert type name. For example,\nan alert previously had the alert type `twitter_subscription` in the repository\nbut because the backend returns settings with keys like `twitter_sub_image_href`,\nnot `twitter_subscription_image_href`, it requires `apiKey: 'twitter_sub'`,\nnot `twitter_subscription_*`. The variation field parser in `AlertBoxModule.setData()`\nuses `${apiKey}_` prefix matching (see `app/components-react/widgets/useAlertBox.tsx`\nline 207). Mismatching this causes variation settings to fail initialization silently,\nleading to UI freezes. Also map internal alert types to API endpoints in\n`WidgetsService.playAlert()` (`app/services/widgets/widgets.ts`) if the backend uses\na different name there.\n\n## Testing\n\nTests are **integration/e2e via WebdriverIO** — they launch the real Electron app\nand drive it, run **serially**, and are slow. They are not fast unit tests. Run a\nsingle file with `yarn test:file <path>` when iterating; avoid the full `yarn test`\nsuite (and `yarn package`) unless explicitly asked.\n\n## Where things live\n\n| Path                                      | What                                                           |\n| ----------------------------------------- | -------------------------------------------------------------- |\n| `main.js`                                 | Electron main process: windows, IPC routing, updater, logging. |\n| `app/app.ts`                              | Renderer bootstrap (services, i18n, Sentry).                   |\n| `app/app-services.ts`                     | Central service registry.                                      |\n| `app/services/`                           | All services (domain logic).                                   |\n| `app/services/core/`                      | Service base, `StatefulService`, DI, mutations.                |\n| `app/services/realm.ts`                   | Realm‑backed cross‑process state.                              |\n| `app/services-manager.ts`                 | Service instantiation + IPC proxying.                          |\n| `app/services/api/internal-api-client.ts` | Client side of cross‑window service calls.                     |\n| `app/store/`                              | Vuex store + cross‑window mutation sync (legacy).              |\n| `app/components-react/`                   | React UI (current).                                            |\n| `app/components/`                         | Vue UI (legacy).                                               |\n| `test/`                                   | e2e/stress/screen/performance tests + helpers.                 |\n\n## Useful env vars (dev)\n\n`SLOBS_REPORT_TO_SENTRY`, `SLOBS_PRODUCTION_DEBUG` (open dev tools on start),\n`SLOBS_CACHE_DIR`, `SLOBS_FORCE_AUTO_UPDATE`, `SLOBS_STRICT_NULLS`. See README for\nthe full list.\n","ARCHITECTURE.md":"# Architecture\n\n> **Scope.** This document describes the **core platform** of Streamlabs Desktop —\n> the process/window model, the services layer, cross‑window communication, and\n> state management. It deliberately does *not* cover feature domains (streaming,\n> scenes/sources, platforms, widgets, etc.); those may get their own docs later.\n>\n> **Conventions.** Non‑obvious claims name the source file (and the relevant\n> function or symbol) rather than a line number — grep the symbol to find the code,\n> and the reference stays valid as the code moves. Sharp edges and non‑obvious\n> invariants are collected in [Known wrinkles](#6-known-wrinkles).\n>\n> **Keeping this current.** Treat it like the matklad model: document the things\n> that change *slowly* (the boundaries and invariants) and point at code for the\n> rest. When a change alters one of those boundaries, update this file in the same\n> PR.\n\n---\n\n## 1. The big picture\n\nStreamlabs Desktop is an Electron app. Electron gives us two kinds of process:\n\n- one **main process** (Node.js) — this is `main.js`, the entry point named in\n  `package.json` `\"main\"`. It owns windows, native lifecycle, the updater,\n  logging, and **routes all inter‑window IPC**. It is *not* a UI.\n- many **renderer processes** — Chromium windows, each running the same compiled\n  bundle but parameterised by a `windowId` URL query (`main.js`, the\n  `loadURL(...?windowId=…)` calls).\n\nThe renderers are not peers of equal responsibility. Exactly one — the **worker**\nwindow — runs the entire services layer and hosts the native OBS backend. Every\nother window is a UI client that *calls* services remotely.\n\n```\n   ┌─────────────────────────┐          ┌──────────────────────────────┐\n   │ UI windows (renderers)  │          │  worker window (renderer)    │\n   │ main / child / one-off  │          │  • runs ALL services         │\n   │ • Vue + React UI        │          │  • hosts obs-studio-node     │\n   │ • InternalApiClient     │          │  • executes calls; emits     │\n   │   turns calls into IPC   │          │    results + events          │\n   └────────────┬────────────┘          └───────────────┬──────────────┘\n                │                                        │\n                │   renderers never message each other directly —\n                │   every request / response / event hops through main.js\n                │                                        │\n   ┌────────────▼────────────────────────────────────────▼─────────────┐\n   │              Electron main process — main.js  (Node)               │\n   │   The \"router\": a process in its own right (Electron's main        │\n   │   process), but NOT a window. Forwards each service-request from a  │\n   │   UI window to the worker, routes the response back, and relays     │\n   │   vuex-mutation / services-message between windows.                 │\n   └────────────────────────────────────────────────────────────────────┘\n```\n\n**The single most misunderstood point:** the *main process* (`main.js`) is the\nrouter — **not** the *main window*. The main window is just another UI client,\nexactly like the child window. Service calls from any UI window go through the\nmain process to the **worker** window; the main window is never in that path.\n(`main.js`, `sendRequest` and the `services-request` handlers; the worker is the\nonly window that runs services — `app/services-manager.ts` `init`, and the worker\nbranch of the `DOMContentLoaded` handler in `app/app.ts`.)\n\n---\n\n## 2. Windows\n\nAll windows load the same bundle; behaviour is selected by `windowId` (`app/app.ts`,\nthe root component's `render` switch on `windowId`).\n\n| Window | Created | Role |\n| --- | --- | --- |\n| **worker** | `main.js` (`workerWindow` creation) | Invisible (`show: false`), persistent. Runs the entire services layer and hosts OBS (`app/app.ts`, the `isWorkerWindow()` branch). |\n| **main** | `main.js` (`mainWindow` creation) | The primary application UI. A service *client*, not a host. |\n| **child** | created/kept warm by `WindowsService` | Always running, hidden until needed (e.g. Source Properties) because spinning up a window is slow (`README.md`, Development). |\n| **one‑off** | on demand | Transient windows: projectors, pop‑outs, etc. Also clients. |\n\nPlus other JS runtimes for Apps, embedded webviews, and the like (`README.md`,\nDevelopment).\n\n**Security posture worth knowing:** worker and main windows run with\n`nodeIntegration: true` and `contextIsolation: false` (`main.js`, the\n`BrowserWindow` `webPreferences`). This is why renderer code can `require` Node\nmodules and use `@electron/remote` directly. Treat anything loaded into these\nwindows as fully privileged.\n\n**Shutdown ordering** is deliberate: closing the main window triggers a\n`shutdown` message to the worker with a 10‑second grace period (`main.js`,\n`mainWindow.on('close')`); the worker window refuses to close first so test\nteardown and `App.stop()` behave (`main.js`, `workerWindow.on('close')`).\n\n---\n\n## 3. The services layer\n\nDomain logic lives in **services** under `app/services/`. A service is a strict\nsingleton; the base class is `app/services/core/service.ts`.\n\n### 3.1 Singletons\n\n`Service` enforces single‑instance construction with a symbol guard — you cannot\n`new` a service yourself (`service.ts`, `createInstance` and the `singletonEnforcer`\nguard). Access is always through the static `instance` getter (`service.ts`).\n\n### 3.2 Registration and lookup\n\nEvery service is hand‑registered in `app/app-services.ts`. `ServicesManager`\n(`app/services-manager.ts`) holds that registry (the `services` map) and resolves\nnames to instances/helpers (`getResource`). A service that isn't registered cannot\nbe resolved.\n\n### 3.3 Dependency injection\n\nNormal application code reaches a service through the `@Inject()` decorator, which\ndefines a lazy getter resolving the service by property name (or an explicit name)\nat access time (`app/services/core/injector.ts`, `Inject`).\n\n`getResource<T>(name)` (`injector.ts`) and `ServicesManager.getResource`\n(`services-manager.ts`) also exist, but these are **plumbing** — for infrastructure\n(the IPC layer, serialization) and for poking at services from a dev console.\nApplication code should use `@Inject()`, not `getResource`.\n\n### 3.4 Lifecycle\n\nCreated via the factory `createInstance` (`service.ts`), in this order:\n\n1. `init()` — once per application lifetime.\n2. `mounted()` — once per window.\n3. `afterInit()` — once per app, after observers are listening.\n\n(All three are defined on `Service` in `service.ts`.) Services can observe another\nservice's initialization with `@InitAfter('Other')`; `ServicesManager.initObservers`\nwires this up off the `serviceAfterInit` subject in the worker.\n\n### 3.5 The worker/non‑worker split (key mechanism)\n\n`ServicesManager.init` (`services-manager.ts`) branches on whether it is running in\nthe worker:\n\n- **Non‑worker windows:** install an IPC proxy over *every* service via\n  `Service.setupProxy(...)`, and replace the init function with a no‑op via\n  `Service.setupInitFunction(...)`. So in a UI window, \"calling a service\" actually\n  produces an IPC request, and services do **not** run their `init()` locally.\n- **Worker window:** no proxy; services run for real, and the `InitAfter`\n  observer wiring is enabled.\n\nThis single branch is what makes the same service class behave as a *local object*\nin the worker and a *remote stub* everywhere else.\n\n---\n\n## 4. Cross‑window communication (the service RPC)\n\nThis is the heart of the platform. A method call in a UI window has to execute in\nthe worker and return a serializable result. The path has three parts: a client\nproxy, the main‑process router, and a worker‑side executor. The wire format is\nJSON‑RPC (`app/services/api/jsonrpc`).\n\n### 4.1 Client side — `InternalApiClient`\n\n`app/services/api/internal-api-client.ts`. Instantiated in **every non‑worker (UI)\nwindow — main, child, and one‑off alike** — by the proxy setup in\n`ServicesManager.init` (`services-manager.ts`). The main window is *not* special\nhere: it goes through the same `InternalApiClient` as any other UI window.\n\n`applyIpcProxy` wraps a service in a `Proxy` (`internal-api-client.ts`). When you\ncall a method it builds a JSON‑RPC request and ships it over IPC. There are three\ncall shapes, selected by how you access the method (all handled in\n`getRequestHandler`):\n\n| You write | Transport | Returns | When |\n| --- | --- | --- | --- |\n| `Service.actions.foo()` | `ipcRenderer.send('services-request-async')` with `noReturn:true` | `void` | **Default.** Fire‑and‑forget. |\n| `Service.actions.return.foo()` | same async channel, `fetchMutations:true`, `noReturn:false` | `Promise<result>` (resolved later via the `services-response-async` handler in `listenWorkerWindowMessages`) | Need a return value. Use sparingly. |\n| `Service.foo()` | `ipcRenderer.sendSync('services-request')` | result **synchronously** | Avoid — blocks the UI process. Logs a warning in dev. |\n\nThe `actions`/`return` accessors are matched literally in the `applyIpcProxy`\nproxy. Methods marked `__executeInCurrentWindow` (via the `@ExecuteInCurrentWindow()`\ndecorator) bypass the proxy and run locally — this is how, e.g., Realm connections\nare made per‑process (see §5.3).\n\nArguments and results that contain **service helpers** are serialized by reference\n(`{_type:'HELPER', resourceId}`) and re‑wrapped on the other side (in\n`getRequestHandler` and `handleResult`). Helpers themselves can't be proxied —\nattempting to call a helper method through the client throws (in `applyIpcProxy`).\n\n### 4.2 Router — the main process\n\nA UI window's `ipcRenderer` send goes to `ipcMain` in `main.js`, never directly to\nanother renderer. `main.js` forwards every request to the worker and remembers how\nto reply:\n\n- The `services-request` and `services-request-async` handlers both call\n  `sendRequest`, which does `workerWindow.webContents.send('services-request', …)`\n  and stores the originating `event` keyed by request id (`main.js`).\n- When the worker replies `services-response`, the `services-response` handler\n  routes it back: sync requests resolve via `event.returnValue`, async via\n  `event.reply('services-response-async', …)` (`main.js`).\n\nThere's a standing intent to eventually make renderers talk to the worker without\nround‑tripping the main process (see the comment near `getWorkerWindowId` in\n`main.js`), but today everything is proxied through it.\n\n### 4.3 Worker side — `IpcServerService` + `RpcApi`\n\nThe worker registers a single handler, `ipcRenderer.on('services-request', …)`, in\n`IpcServerService.listen` (`app/services/api/ipc-server/ipc-server.ts`), which\nexecutes the request via `InternalApiService.executeServiceRequest` and sends a\n`services-response` unless the request was `noReturn`.\n\n`executeServiceRequest` lives in the shared base `app/services/api/rpc-api.ts`. Its\nchain `executeServiceRequest` → `handleServiceRequest` → `serializePayload` looks up\nthe resource, invokes the method, and **serializes the payload into something\ntransferable**:\n\n- a **plain value** → returned as‑is.\n- an **RxJS `Observable`** → subscribed once; a `{_type:'SUBSCRIPTION', emitter:\n  'STREAM'}` token is returned, and subsequent emissions are pushed as\n  `services-message` events (`serializePayload`).\n- a **`Promise`** → a `{_type:'SUBSCRIPTION', emitter:'PROMISE'}` token is returned\n  immediately; when it settles, `sendPromiseMessage` emits a `services-message`\n  event carrying the resolution/rejection.\n- a **`Service` / helper / `RealmObject`** → serialized by reference (in\n  `serializePayload`).\n\nEvents (stream emissions, promise settlements) flow worker → main process →\nall non‑worker windows via `services-message` (`ipc-server.ts` `sendEvent`;\n`main.js` the `services-message` handler; `internal-api-client.ts`\n`listenWorkerWindowMessages`).\n\n### 4.4 End‑to‑end: an async action with a return value\n\n1. UI: `SomeService.actions.return.doThing(arg)`.\n2. `InternalApiClient` sends `services-request-async` with `fetchMutations:true`\n   (`getRequestHandler`).\n3. `main.js` forwards to the worker, remembers the reply target (the\n   `services-request-async` handler).\n4. Worker `IpcServerService` runs `RpcApi.executeServiceRequest`; the method\n   returns a `Promise`, so the worker first replies with a `PROMISE` subscription\n   token plus any buffered mutations (`handleServiceRequest` / `serializePayload`).\n5. When the worker‑side promise settles, a `services-message` PROMISE event is\n   broadcast; the client matches it by id and resolves the caller's promise\n   (`listenWorkerWindowMessages`).\n\n---\n\n## 5. State management\n\nState is **mid‑migration between two systems**. Pick by scope:\n\n- **UI‑only state** → React local state (`useState`/hooks).\n- **Service state that must sync across windows/processes, or must persist to disk\n  across application runs** → **Realm** (§5.3). Realm covers both: it has a\n  persistent (on‑disk) database and an ephemeral (in‑memory) one.\n- **Legacy** → **Vuex** via `StatefulService` (§5.2). Still pervasive; maintained\n  where it exists, not chosen for new state.\n\n### 5.1 Why state is hard here\n\nBecause services execute in the worker but UI renders in other windows, the UI\nneeds a *local, reactive* copy of state — it can't synchronously reach across\nprocesses on every render. The two systems solve this differently: Vuex\n**replicates** state by broadcasting mutations over IPC; Realm **shares** state at\nthe database layer, with each process holding its own connection.\n\n### 5.2 Vuex / `StatefulService` (legacy)\n\n`app/services/core/stateful-service.ts` + `app/store/index.ts`.\n\n- A `StatefulService<TState>` gets an auto‑generated Vuex module; read state via\n  `this.state`, which is just the service's slice of the store (`StatefulService.state`\n  in `stateful-service.ts`). Modules are assembled in `createStore`\n  (`store/index.ts`).\n- State is mutated **only** through `@mutation()` methods (`mutation` /\n  `registerMutation` in `stateful-service.ts`). **Mutations must be pure** — touch\n  only `this.state` and their arguments, no side effects, no async. In dev a `Proxy`\n  inside `registerMutation` enforces this and throws on any other access. The\n  reason: a mutation has to be replayable in any window from its serialized\n  payload, so it can't depend on worker‑only context.\n- **Replication:** the store‑sync plugin (`store/index.ts`) subscribes to local\n  commits and, for any non‑`__vuexSyncIgnore` mutation, both feeds it to the\n  worker's mutation buffer and broadcasts it to other windows. The broadcast is\n  queued and flushed on a timer to coalesce re‑renders\n  (`sendMutationToRendererWindows` / `flushMutations`). The main process relays\n  `vuex-mutation` to every *other* registered window (`main.js`, the\n  `vuex-mutation` handler).\n- **New window bootstrap:** a freshly opened window registers (the `vuex-register`\n  send in `store/index.ts`), the main process asks the worker to dump current state\n  (`main.js`, the `vuex-register` handler), and the window applies it via\n  `BULK_LOAD_STATE` (the `vuex-loadState` handler in `store/index.ts`). **Ordering\n  invariant:** a renderer window ignores incoming mutations until after that bulk\n  load (`storeCanReceiveMutations` in `store/index.ts`), so early mutations are\n  intentionally dropped to avoid applying deltas to a state that hasn't loaded yet.\n- **Consistency on synchronous reads:** when a UI window makes a *synchronous*\n  service call, the worker buffers any mutations produced during the call and\n  attaches them to the response (`handleServiceRequest` with\n  `startBufferingMutations`/`stopBufferingMutations` in `rpc-api.ts`), and the\n  client commits them immediately before returning (`getRequestHandler` in\n  `internal-api-client.ts`). This keeps the caller's local store consistent with the\n  value it just received without waiting for the async broadcast.\n\n### 5.3 Realm (current direction)\n\n`app/services/realm.ts`, with a React hook at\n`app/components-react/hooks/realm.ts`.\n\n- Two databases: a **persistent** on‑disk realm (`persistent.realm` under\n  `userData`) and an **ephemeral** in‑memory realm (`RealmService`,\n  `persistentConfig` / `ephemeralConfig` in `realm.ts`). A `RealmObject` subclass\n  declares which it belongs to at registration (`registerObject`). Choose the\n  **persistent** DB for state that must survive across application runs (it's\n  written to disk); the **ephemeral** DB for cross‑process state that needn't\n  persist.\n- **Each process opens its own connections** — `connect()` and `getDb()` are\n  marked `@ExecuteInCurrentWindow()` so they run locally rather than being proxied\n  to the worker (`realm.ts`); the worker triggers the initial `RealmService…connect()`\n  during bootstrap (`app/app.ts`). Because every process points at the same\n  realm(s), Realm propagates changes across all connections — that's the\n  cross‑process sync, achieved without our IPC mutation broadcast.\n- **Across the service RPC**, a `RealmObject` is passed by reference\n  (`{_type:'REALM_OBJECT', resourceId, realmType}`) and reconstructed on the other\n  side via `RealmService.registeredClasses[type].fromId(...)` (`serializePayload` in\n  `rpc-api.ts`; `handleResult` in `internal-api-client.ts`). The receiving window\n  then reads through its *own* local connection.\n\n> **Note:** the ephemeral realm still propagates across processes despite being\n> in‑memory — a file‑backed `inMemory` realm is shared by path, so every process's\n> connection observes the same data.\n\n### 5.4 Events (RxJS)\n\nCross‑service and cross‑window notifications use RxJS `Subject`s. Within the worker\nthey're ordinary subscriptions; when a subscription crosses the RPC boundary it\nbecomes a `STREAM` subscription delivered over `services-message` (§4.3).\n\n---\n\n## 6. Known wrinkles\n\nThese are real, load‑bearing quirks an agent (or human) will trip over.\n\n- **`window['servicesManager']` global (production plumbing — leave it alone).**\n  Set in `createStore` (`store/index.ts`, \"This is bad and I should feel bad\") to\n  break an import cycle. It *is* load‑bearing: read by `ViewHandler.getServiceViews`\n  (`stateful-service.ts`, which backs cross‑service `views` access in ~15+ services)\n  and by `service-helper.ts`. Those existing uses stay. **Do not add new ones**, and\n  never reach for it from application‑level services code.\n- **`window.sm` — dev console only.** A debug handle on the services manager, set\n  only in dev mode or under `SLOBS_PRODUCTION_DEBUG` (`services-manager.ts`, `init`).\n  It's for poking at services from the console; never use it in real code or\n  plumbing.\n- **Sync IPC blocks the UI.** `Service.foo()` (no `.actions`) uses `sendSync` and\n  freezes the calling renderer until the worker replies (`main.js`, `sendRequest`;\n  `internal-api-client.ts`, `getRequestHandler`). Only acceptable where truly\n  necessary.\n- **Helpers can't be proxied.** Calling a helper method through the IPC client\n  throws by design (`internal-api-client.ts`, `applyIpcProxy`).\n- **`strictNullChecks` is off globally** with an opt‑in subset\n  (`strict-null-check-files/`, `SLOBS_STRICT_NULLS`). Don't flip it repo‑wide.\n- **Privileged renderers.** `nodeIntegration: true` / `contextIsolation: false`\n  (`main.js`, the `BrowserWindow` `webPreferences`).\n\n---\n\n## 7. Key files\n\n| File | Responsibility |\n| --- | --- |\n| `main.js` | Electron main process: window creation, IPC routing, mutation relay, updater, logging, shutdown. |\n| `app/app.ts` | Renderer bootstrap; worker‑only OBS init; Realm connect; Vue root. |\n| `app/services/core/service.ts` | Singleton base, lifecycle, `actions` proxy. |\n| `app/services/core/injector.ts` | `@Inject()` and `getResource`. |\n| `app/services/core/stateful-service.ts` | Vuex‑backed services, `@mutation()`, mutation‑purity proxy, `ViewHandler`. |\n| `app/app-services.ts` | The service registry. |\n| `app/services-manager.ts` | Instance management; worker/non‑worker proxy split; `InitAfter` wiring. |\n| `app/store/index.ts` | Vuex store, mutation replication, bulk‑load bootstrap. |\n| `app/services/api/internal-api-client.ts` | Client side of the service RPC (UI windows). |\n| `app/services/api/ipc-server/ipc-server.ts` | Worker side: receives requests, returns responses/events. |\n| `app/services/api/rpc-api.ts` | Request execution + payload serialization (Promises, Observables, helpers, Realm). |\n| `app/services/realm.ts` | Realm databases, per‑process connections, cross‑process state. |\n\n---\n\n*Maintenance: this document covers the slow‑moving core‑platform boundaries and\ninvariants. When a change moves one of them, update this file (and its file +\nsymbol references) in the same PR.*\n"},"files":{"CLAUDE.md":"# CLAUDE.md\n\nGuidance for AI agents working in this repository. Keep this file lean — it loads\ninto every session. Deep architecture lives in `ARCHITECTURE.md` (read it when a\nchange touches the window/service/state/IPC model).\n\nThis is **Streamlabs Desktop** (`slobs-client`): an Electron live‑streaming app\nbuilt on OBS. Large, old, and mid‑migration on two fronts (Vue → React for UI,\nVuex → Realm/React for state), with several non‑obvious invariants. Read the\nrelevant service before editing it; prefer targeted reads over broad assumptions.\n\n## Commands\n\nPackage manager is **Yarn Berry (3.1.1)** — never use `npm`.\n\n| Task                | Command                 | Notes                                                                                                                         |\n| ------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| Lint + format       | `yarn eslint`           | Prettier runs _through_ ESLint (`eslint-plugin-prettier`). `eslint --fix` formats.                                            |\n| Typecheck           | `yarn typecheck`        | **Fast self-verify.** `tsc --noEmit` for the app + React. Use this to check changes instead of a full build or the e2e suite. |\n| Iterative dev build | `yarn watch`            | Webpack watch; use this while developing.                                                                                     |\n| One‑shot dev build  | `yarn compile`          | Slow: clears `bundles/media` and rebuilds everything. Don't run casually.                                                     |\n| Run the app         | `yarn start`            | Launches Electron against the last build.                                                                                     |\n| Single test file    | `yarn test:file <path>` | Compiles tests, runs one file.                                                                                                |\n| Full test suite     | `yarn test`             | **Heavy/slow e2e** — see Testing below. Don't run unless asked.                                                               |\n\nFor a fast type check without a full build, run **`yarn typecheck`** — it runs\n`tsc --noEmit` for the non‑React app (`tsconfig.json`) and for `app/components-react`\n(its stricter config, `strictNullChecks: true`). This is the quickest way to verify\nyour changes; reserve `yarn compile` and the e2e suite for when you actually need\nthem. (The build still typechecks via `ts-loader`; tests compile via `tsc -p test`.)\n\n## Code style\n\n- TypeScript, formatted by Prettier via ESLint. **Don't memorize the rules** —\n  let `yarn eslint` (`--fix`) apply them, and match the surrounding file.\n- `strictNullChecks` is **intentionally OFF** globally. Do **not** enable it\n  repo‑wide. A subset of files opts in via `strict-null-check-files/` +\n  `SLOBS_STRICT_NULLS`; only add to that set deliberately.\n\n## Architecture in one screen\n\nMulti‑window Electron app. Every window runs the _same_ JS bundle but plays a\ndifferent role:\n\n- **worker** — invisible, persistent renderer that runs the **entire services\n  layer**. All service methods actually execute here.\n- **main** — the primary UI window.\n- **child** — kept warm in the background for things like Source Properties.\n- plus transient **one‑off** windows (projectors, pop‑outs), apps, webviews.\n\nThe UI windows (main, child, one‑off) **don't run services** — they call them\nremotely. A call from any non‑worker window is sent to the **Electron main\nprocess** (`main.js`), which forwards it to the **worker** window, then routes the\nresult back to the originating window. Note: the _main process_ (`main.js`, Node)\nis the router — **not** the _main window_, which is just another UI client.\n\n**Services** (`app/services/`) are strict singletons holding all domain logic.\nNormal application code reaches a service through the `@Inject()` decorator. They're\nregistered in `app/app-services.ts`.\n\n## State — know which mechanism to use\n\nState management is **mid‑migration**. For new code, choose by scope:\n\n- **UI‑only state** → React state (`useState` / hooks), local to the component.\n- **Service state that must sync across windows/processes** → **Realm**\n  (`RealmObject` / `RealmService` in `app/services/realm.ts`), which replicates\n  across all processes. React reads it via `app/components-react/hooks/realm.ts`.\n\nMuch existing service state still lives in **Vuex** via `StatefulService<TState>`\n(read through `this.state`, mutated only via `@mutation()` methods). You'll\nmaintain it where it already exists, but **don't reach for Vuex for new state.**\n\nSee `ARCHITECTURE.md` for the full model and the _why_ behind the sharp edges.\n\n## Hard rules (these are easy to get wrong)\n\n1. **Vuex mutations are pure.** When editing an existing `StatefulService`, a\n   `@mutation()` method may touch only `this.state` and its own arguments — no side\n   effects, no calling other services, no async. In dev a Proxy enforces this and\n   throws (`app/services/core/stateful-service.ts:49`).\n2. **Calling a service from a UI window — pick the right form:**\n   - `Service.actions.method()` — **default**. Async, fire‑and‑forget, returns\n     `void`.\n   - `Service.actions.return.method()` — async **with** a return value (resolves\n     when the worker finishes). Use sparingly.\n   - `Service.method()` (omit `.actions`) — **synchronous**; it blocks the calling\n     UI process and logs a console warning. Avoid except where absolutely\n     necessary — it degrades the user experience.\n   - Reads: use `views` (Vuex) or read Realm objects directly.\n3. **Register new services** in `app/app-services.ts` — it's hand‑maintained, not\n   generated. A service that isn't registered won't resolve.\n4. **New UI is React.** Functional components + hooks only, in\n   `app/components-react/` (`.tsx`). Vue (`app/components/`, `.vue`) is legacy and\n   frozen — only touch it to migrate a component to React.\n5. **Don't edit generated/build output:** `bundles/`, `*.g.less`, `updater/build/`,\n   `dist/`, `test-dist/`, `docs/dist/`.\n6. **Honor in‑code warnings.** Respect `DO NOT CALL` / `@warning` markers — e.g.\n   `app/services/sources/sources.ts` `updatePropertiesManagerSettingsInStore`,\n   and the deprecated method in `app/services/video.ts`.\n\n## Conventions\n\n- Filenames map to classes: `foo-bar.ts` → `class FooBarService`.\n- Common decorators: `@Inject()` (DI), `@mutation()` (Vuex mutation),\n  `@InitAfter('OtherService')` (ordered init), `@InheritMutations()`.\n- Service lifecycle hooks: `init()` (once per app) → `mounted()` (once per window)\n  → `afterInit()`.\n- Cross‑service / cross‑window events use **RxJS** `Subject`s.\n\n## Alert type `apiKey` field naming\n\nWhen adding a new alert type to `alerts-config.ts`, the `apiKey` field must match\nthe **backend field prefix**, not the internal alert type name. For example,\nan alert previously had the alert type `twitter_subscription` in the repository\nbut because the backend returns settings with keys like `twitter_sub_image_href`,\nnot `twitter_subscription_image_href`, it requires `apiKey: 'twitter_sub'`,\nnot `twitter_subscription_*`. The variation field parser in `AlertBoxModule.setData()`\nuses `${apiKey}_` prefix matching (see `app/components-react/widgets/useAlertBox.tsx`\nline 207). Mismatching this causes variation settings to fail initialization silently,\nleading to UI freezes. Also map internal alert types to API endpoints in\n`WidgetsService.playAlert()` (`app/services/widgets/widgets.ts`) if the backend uses\na different name there.\n\n## Testing\n\nTests are **integration/e2e via WebdriverIO** — they launch the real Electron app\nand drive it, run **serially**, and are slow. They are not fast unit tests. Run a\nsingle file with `yarn test:file <path>` when iterating; avoid the full `yarn test`\nsuite (and `yarn package`) unless explicitly asked.\n\n## Where things live\n\n| Path                                      | What                                                           |\n| ----------------------------------------- | -------------------------------------------------------------- |\n| `main.js`                                 | Electron main process: windows, IPC routing, updater, logging. |\n| `app/app.ts`                              | Renderer bootstrap (services, i18n, Sentry).                   |\n| `app/app-services.ts`                     | Central service registry.                                      |\n| `app/services/`                           | All services (domain logic).                                   |\n| `app/services/core/`                      | Service base, `StatefulService`, DI, mutations.                |\n| `app/services/realm.ts`                   | Realm‑backed cross‑process state.                              |\n| `app/services-manager.ts`                 | Service instantiation + IPC proxying.                          |\n| `app/services/api/internal-api-client.ts` | Client side of cross‑window service calls.                     |\n| `app/store/`                              | Vuex store + cross‑window mutation sync (legacy).              |\n| `app/components-react/`                   | React UI (current).                                            |\n| `app/components/`                         | Vue UI (legacy).                                               |\n| `test/`                                   | e2e/stress/screen/performance tests + helpers.                 |\n\n## Useful env vars (dev)\n\n`SLOBS_REPORT_TO_SENTRY`, `SLOBS_PRODUCTION_DEBUG` (open dev tools on start),\n`SLOBS_CACHE_DIR`, `SLOBS_FORCE_AUTO_UPDATE`, `SLOBS_STRICT_NULLS`. See README for\nthe full list.\n","ARCHITECTURE.md":"# Architecture\n\n> **Scope.** This document describes the **core platform** of Streamlabs Desktop —\n> the process/window model, the services layer, cross‑window communication, and\n> state management. It deliberately does *not* cover feature domains (streaming,\n> scenes/sources, platforms, widgets, etc.); those may get their own docs later.\n>\n> **Conventions.** Non‑obvious claims name the source file (and the relevant\n> function or symbol) rather than a line number — grep the symbol to find the code,\n> and the reference stays valid as the code moves. Sharp edges and non‑obvious\n> invariants are collected in [Known wrinkles](#6-known-wrinkles).\n>\n> **Keeping this current.** Treat it like the matklad model: document the things\n> that change *slowly* (the boundaries and invariants) and point at code for the\n> rest. When a change alters one of those boundaries, update this file in the same\n> PR.\n\n---\n\n## 1. The big picture\n\nStreamlabs Desktop is an Electron app. Electron gives us two kinds of process:\n\n- one **main process** (Node.js) — this is `main.js`, the entry point named in\n  `package.json` `\"main\"`. It owns windows, native lifecycle, the updater,\n  logging, and **routes all inter‑window IPC**. It is *not* a UI.\n- many **renderer processes** — Chromium windows, each running the same compiled\n  bundle but parameterised by a `windowId` URL query (`main.js`, the\n  `loadURL(...?windowId=…)` calls).\n\nThe renderers are not peers of equal responsibility. Exactly one — the **worker**\nwindow — runs the entire services layer and hosts the native OBS backend. Every\nother window is a UI client that *calls* services remotely.\n\n```\n   ┌─────────────────────────┐          ┌──────────────────────────────┐\n   │ UI windows (renderers)  │          │  worker window (renderer)    │\n   │ main / child / one-off  │          │  • runs ALL services         │\n   │ • Vue + React UI        │          │  • hosts obs-studio-node     │\n   │ • InternalApiClient     │          │  • executes calls; emits     │\n   │   turns calls into IPC   │          │    results + events          │\n   └────────────┬────────────┘          └───────────────┬──────────────┘\n                │                                        │\n                │   renderers never message each other directly —\n                │   every request / response / event hops through main.js\n                │                                        │\n   ┌────────────▼────────────────────────────────────────▼─────────────┐\n   │              Electron main process — main.js  (Node)               │\n   │   The \"router\": a process in its own right (Electron's main        │\n   │   process), but NOT a window. Forwards each service-request from a  │\n   │   UI window to the worker, routes the response back, and relays     │\n   │   vuex-mutation / services-message between windows.                 │\n   └────────────────────────────────────────────────────────────────────┘\n```\n\n**The single most misunderstood point:** the *main process* (`main.js`) is the\nrouter — **not** the *main window*. The main window is just another UI client,\nexactly like the child window. Service calls from any UI window go through the\nmain process to the **worker** window; the main window is never in that path.\n(`main.js`, `sendRequest` and the `services-request` handlers; the worker is the\nonly window that runs services — `app/services-manager.ts` `init`, and the worker\nbranch of the `DOMContentLoaded` handler in `app/app.ts`.)\n\n---\n\n## 2. Windows\n\nAll windows load the same bundle; behaviour is selected by `windowId` (`app/app.ts`,\nthe root component's `render` switch on `windowId`).\n\n| Window | Created | Role |\n| --- | --- | --- |\n| **worker** | `main.js` (`workerWindow` creation) | Invisible (`show: false`), persistent. Runs the entire services layer and hosts OBS (`app/app.ts`, the `isWorkerWindow()` branch). |\n| **main** | `main.js` (`mainWindow` creation) | The primary application UI. A service *client*, not a host. |\n| **child** | created/kept warm by `WindowsService` | Always running, hidden until needed (e.g. Source Properties) because spinning up a window is slow (`README.md`, Development). |\n| **one‑off** | on demand | Transient windows: projectors, pop‑outs, etc. Also clients. |\n\nPlus other JS runtimes for Apps, embedded webviews, and the like (`README.md`,\nDevelopment).\n\n**Security posture worth knowing:** worker and main windows run with\n`nodeIntegration: true` and `contextIsolation: false` (`main.js`, the\n`BrowserWindow` `webPreferences`). This is why renderer code can `require` Node\nmodules and use `@electron/remote` directly. Treat anything loaded into these\nwindows as fully privileged.\n\n**Shutdown ordering** is deliberate: closing the main window triggers a\n`shutdown` message to the worker with a 10‑second grace period (`main.js`,\n`mainWindow.on('close')`); the worker window refuses to close first so test\nteardown and `App.stop()` behave (`main.js`, `workerWindow.on('close')`).\n\n---\n\n## 3. The services layer\n\nDomain logic lives in **services** under `app/services/`. A service is a strict\nsingleton; the base class is `app/services/core/service.ts`.\n\n### 3.1 Singletons\n\n`Service` enforces single‑instance construction with a symbol guard — you cannot\n`new` a service yourself (`service.ts`, `createInstance` and the `singletonEnforcer`\nguard). Access is always through the static `instance` getter (`service.ts`).\n\n### 3.2 Registration and lookup\n\nEvery service is hand‑registered in `app/app-services.ts`. `ServicesManager`\n(`app/services-manager.ts`) holds that registry (the `services` map) and resolves\nnames to instances/helpers (`getResource`). A service that isn't registered cannot\nbe resolved.\n\n### 3.3 Dependency injection\n\nNormal application code reaches a service through the `@Inject()` decorator, which\ndefines a lazy getter resolving the service by property name (or an explicit name)\nat access time (`app/services/core/injector.ts`, `Inject`).\n\n`getResource<T>(name)` (`injector.ts`) and `ServicesManager.getResource`\n(`services-manager.ts`) also exist, but these are **plumbing** — for infrastructure\n(the IPC layer, serialization) and for poking at services from a dev console.\nApplication code should use `@Inject()`, not `getResource`.\n\n### 3.4 Lifecycle\n\nCreated via the factory `createInstance` (`service.ts`), in this order:\n\n1. `init()` — once per application lifetime.\n2. `mounted()` — once per window.\n3. `afterInit()` — once per app, after observers are listening.\n\n(All three are defined on `Service` in `service.ts`.) Services can observe another\nservice's initialization with `@InitAfter('Other')`; `ServicesManager.initObservers`\nwires this up off the `serviceAfterInit` subject in the worker.\n\n### 3.5 The worker/non‑worker split (key mechanism)\n\n`ServicesManager.init` (`services-manager.ts`) branches on whether it is running in\nthe worker:\n\n- **Non‑worker windows:** install an IPC proxy over *every* service via\n  `Service.setupProxy(...)`, and replace the init function with a no‑op via\n  `Service.setupInitFunction(...)`. So in a UI window, \"calling a service\" actually\n  produces an IPC request, and services do **not** run their `init()` locally.\n- **Worker window:** no proxy; services run for real, and the `InitAfter`\n  observer wiring is enabled.\n\nThis single branch is what makes the same service class behave as a *local object*\nin the worker and a *remote stub* everywhere else.\n\n---\n\n## 4. Cross‑window communication (the service RPC)\n\nThis is the heart of the platform. A method call in a UI window has to execute in\nthe worker and return a serializable result. The path has three parts: a client\nproxy, the main‑process router, and a worker‑side executor. The wire format is\nJSON‑RPC (`app/services/api/jsonrpc`).\n\n### 4.1 Client side — `InternalApiClient`\n\n`app/services/api/internal-api-client.ts`. Instantiated in **every non‑worker (UI)\nwindow — main, child, and one‑off alike** — by the proxy setup in\n`ServicesManager.init` (`services-manager.ts`). The main window is *not* special\nhere: it goes through the same `InternalApiClient` as any other UI window.\n\n`applyIpcProxy` wraps a service in a `Proxy` (`internal-api-client.ts`). When you\ncall a method it builds a JSON‑RPC request and ships it over IPC. There are three\ncall shapes, selected by how you access the method (all handled in\n`getRequestHandler`):\n\n| You write | Transport | Returns | When |\n| --- | --- | --- | --- |\n| `Service.actions.foo()` | `ipcRenderer.send('services-request-async')` with `noReturn:true` | `void` | **Default.** Fire‑and‑forget. |\n| `Service.actions.return.foo()` | same async channel, `fetchMutations:true`, `noReturn:false` | `Promise<result>` (resolved later via the `services-response-async` handler in `listenWorkerWindowMessages`) | Need a return value. Use sparingly. |\n| `Service.foo()` | `ipcRenderer.sendSync('services-request')` | result **synchronously** | Avoid — blocks the UI process. Logs a warning in dev. |\n\nThe `actions`/`return` accessors are matched literally in the `applyIpcProxy`\nproxy. Methods marked `__executeInCurrentWindow` (via the `@ExecuteInCurrentWindow()`\ndecorator) bypass the proxy and run locally — this is how, e.g., Realm connections\nare made per‑process (see §5.3).\n\nArguments and results that contain **service helpers** are serialized by reference\n(`{_type:'HELPER', resourceId}`) and re‑wrapped on the other side (in\n`getRequestHandler` and `handleResult`). Helpers themselves can't be proxied —\nattempting to call a helper method through the client throws (in `applyIpcProxy`).\n\n### 4.2 Router — the main process\n\nA UI window's `ipcRenderer` send goes to `ipcMain` in `main.js`, never directly to\nanother renderer. `main.js` forwards every request to the worker and remembers how\nto reply:\n\n- The `services-request` and `services-request-async` handlers both call\n  `sendRequest`, which does `workerWindow.webContents.send('services-request', …)`\n  and stores the originating `event` keyed by request id (`main.js`).\n- When the worker replies `services-response`, the `services-response` handler\n  routes it back: sync requests resolve via `event.returnValue`, async via\n  `event.reply('services-response-async', …)` (`main.js`).\n\nThere's a standing intent to eventually make renderers talk to the worker without\nround‑tripping the main process (see the comment near `getWorkerWindowId` in\n`main.js`), but today everything is proxied through it.\n\n### 4.3 Worker side — `IpcServerService` + `RpcApi`\n\nThe worker registers a single handler, `ipcRenderer.on('services-request', …)`, in\n`IpcServerService.listen` (`app/services/api/ipc-server/ipc-server.ts`), which\nexecutes the request via `InternalApiService.executeServiceRequest` and sends a\n`services-response` unless the request was `noReturn`.\n\n`executeServiceRequest` lives in the shared base `app/services/api/rpc-api.ts`. Its\nchain `executeServiceRequest` → `handleServiceRequest` → `serializePayload` looks up\nthe resource, invokes the method, and **serializes the payload into something\ntransferable**:\n\n- a **plain value** → returned as‑is.\n- an **RxJS `Observable`** → subscribed once; a `{_type:'SUBSCRIPTION', emitter:\n  'STREAM'}` token is returned, and subsequent emissions are pushed as\n  `services-message` events (`serializePayload`).\n- a **`Promise`** → a `{_type:'SUBSCRIPTION', emitter:'PROMISE'}` token is returned\n  immediately; when it settles, `sendPromiseMessage` emits a `services-message`\n  event carrying the resolution/rejection.\n- a **`Service` / helper / `RealmObject`** → serialized by reference (in\n  `serializePayload`).\n\nEvents (stream emissions, promise settlements) flow worker → main process →\nall non‑worker windows via `services-message` (`ipc-server.ts` `sendEvent`;\n`main.js` the `services-message` handler; `internal-api-client.ts`\n`listenWorkerWindowMessages`).\n\n### 4.4 End‑to‑end: an async action with a return value\n\n1. UI: `SomeService.actions.return.doThing(arg)`.\n2. `InternalApiClient` sends `services-request-async` with `fetchMutations:true`\n   (`getRequestHandler`).\n3. `main.js` forwards to the worker, remembers the reply target (the\n   `services-request-async` handler).\n4. Worker `IpcServerService` runs `RpcApi.executeServiceRequest`; the method\n   returns a `Promise`, so the worker first replies with a `PROMISE` subscription\n   token plus any buffered mutations (`handleServiceRequest` / `serializePayload`).\n5. When the worker‑side promise settles, a `services-message` PROMISE event is\n   broadcast; the client matches it by id and resolves the caller's promise\n   (`listenWorkerWindowMessages`).\n\n---\n\n## 5. State management\n\nState is **mid‑migration between two systems**. Pick by scope:\n\n- **UI‑only state** → React local state (`useState`/hooks).\n- **Service state that must sync across windows/processes, or must persist to disk\n  across application runs** → **Realm** (§5.3). Realm covers both: it has a\n  persistent (on‑disk) database and an ephemeral (in‑memory) one.\n- **Legacy** → **Vuex** via `StatefulService` (§5.2). Still pervasive; maintained\n  where it exists, not chosen for new state.\n\n### 5.1 Why state is hard here\n\nBecause services execute in the worker but UI renders in other windows, the UI\nneeds a *local, reactive* copy of state — it can't synchronously reach across\nprocesses on every render. The two systems solve this differently: Vuex\n**replicates** state by broadcasting mutations over IPC; Realm **shares** state at\nthe database layer, with each process holding its own connection.\n\n### 5.2 Vuex / `StatefulService` (legacy)\n\n`app/services/core/stateful-service.ts` + `app/store/index.ts`.\n\n- A `StatefulService<TState>` gets an auto‑generated Vuex module; read state via\n  `this.state`, which is just the service's slice of the store (`StatefulService.state`\n  in `stateful-service.ts`). Modules are assembled in `createStore`\n  (`store/index.ts`).\n- State is mutated **only** through `@mutation()` methods (`mutation` /\n  `registerMutation` in `stateful-service.ts`). **Mutations must be pure** — touch\n  only `this.state` and their arguments, no side effects, no async. In dev a `Proxy`\n  inside `registerMutation` enforces this and throws on any other access. The\n  reason: a mutation has to be replayable in any window from its serialized\n  payload, so it can't depend on worker‑only context.\n- **Replication:** the store‑sync plugin (`store/index.ts`) subscribes to local\n  commits and, for any non‑`__vuexSyncIgnore` mutation, both feeds it to the\n  worker's mutation buffer and broadcasts it to other windows. The broadcast is\n  queued and flushed on a timer to coalesce re‑renders\n  (`sendMutationToRendererWindows` / `flushMutations`). The main process relays\n  `vuex-mutation` to every *other* registered window (`main.js`, the\n  `vuex-mutation` handler).\n- **New window bootstrap:** a freshly opened window registers (the `vuex-register`\n  send in `store/index.ts`), the main process asks the worker to dump current state\n  (`main.js`, the `vuex-register` handler), and the window applies it via\n  `BULK_LOAD_STATE` (the `vuex-loadState` handler in `store/index.ts`). **Ordering\n  invariant:** a renderer window ignores incoming mutations until after that bulk\n  load (`storeCanReceiveMutations` in `store/index.ts`), so early mutations are\n  intentionally dropped to avoid applying deltas to a state that hasn't loaded yet.\n- **Consistency on synchronous reads:** when a UI window makes a *synchronous*\n  service call, the worker buffers any mutations produced during the call and\n  attaches them to the response (`handleServiceRequest` with\n  `startBufferingMutations`/`stopBufferingMutations` in `rpc-api.ts`), and the\n  client commits them immediately before returning (`getRequestHandler` in\n  `internal-api-client.ts`). This keeps the caller's local store consistent with the\n  value it just received without waiting for the async broadcast.\n\n### 5.3 Realm (current direction)\n\n`app/services/realm.ts`, with a React hook at\n`app/components-react/hooks/realm.ts`.\n\n- Two databases: a **persistent** on‑disk realm (`persistent.realm` under\n  `userData`) and an **ephemeral** in‑memory realm (`RealmService`,\n  `persistentConfig` / `ephemeralConfig` in `realm.ts`). A `RealmObject` subclass\n  declares which it belongs to at registration (`registerObject`). Choose the\n  **persistent** DB for state that must survive across application runs (it's\n  written to disk); the **ephemeral** DB for cross‑process state that needn't\n  persist.\n- **Each process opens its own connections** — `connect()` and `getDb()` are\n  marked `@ExecuteInCurrentWindow()` so they run locally rather than being proxied\n  to the worker (`realm.ts`); the worker triggers the initial `RealmService…connect()`\n  during bootstrap (`app/app.ts`). Because every process points at the same\n  realm(s), Realm propagates changes across all connections — that's the\n  cross‑process sync, achieved without our IPC mutation broadcast.\n- **Across the service RPC**, a `RealmObject` is passed by reference\n  (`{_type:'REALM_OBJECT', resourceId, realmType}`) and reconstructed on the other\n  side via `RealmService.registeredClasses[type].fromId(...)` (`serializePayload` in\n  `rpc-api.ts`; `handleResult` in `internal-api-client.ts`). The receiving window\n  then reads through its *own* local connection.\n\n> **Note:** the ephemeral realm still propagates across processes despite being\n> in‑memory — a file‑backed `inMemory` realm is shared by path, so every process's\n> connection observes the same data.\n\n### 5.4 Events (RxJS)\n\nCross‑service and cross‑window notifications use RxJS `Subject`s. Within the worker\nthey're ordinary subscriptions; when a subscription crosses the RPC boundary it\nbecomes a `STREAM` subscription delivered over `services-message` (§4.3).\n\n---\n\n## 6. Known wrinkles\n\nThese are real, load‑bearing quirks an agent (or human) will trip over.\n\n- **`window['servicesManager']` global (production plumbing — leave it alone).**\n  Set in `createStore` (`store/index.ts`, \"This is bad and I should feel bad\") to\n  break an import cycle. It *is* load‑bearing: read by `ViewHandler.getServiceViews`\n  (`stateful-service.ts`, which backs cross‑service `views` access in ~15+ services)\n  and by `service-helper.ts`. Those existing uses stay. **Do not add new ones**, and\n  never reach for it from application‑level services code.\n- **`window.sm` — dev console only.** A debug handle on the services manager, set\n  only in dev mode or under `SLOBS_PRODUCTION_DEBUG` (`services-manager.ts`, `init`).\n  It's for poking at services from the console; never use it in real code or\n  plumbing.\n- **Sync IPC blocks the UI.** `Service.foo()` (no `.actions`) uses `sendSync` and\n  freezes the calling renderer until the worker replies (`main.js`, `sendRequest`;\n  `internal-api-client.ts`, `getRequestHandler`). Only acceptable where truly\n  necessary.\n- **Helpers can't be proxied.** Calling a helper method through the IPC client\n  throws by design (`internal-api-client.ts`, `applyIpcProxy`).\n- **`strictNullChecks` is off globally** with an opt‑in subset\n  (`strict-null-check-files/`, `SLOBS_STRICT_NULLS`). Don't flip it repo‑wide.\n- **Privileged renderers.** `nodeIntegration: true` / `contextIsolation: false`\n  (`main.js`, the `BrowserWindow` `webPreferences`).\n\n---\n\n## 7. Key files\n\n| File | Responsibility |\n| --- | --- |\n| `main.js` | Electron main process: window creation, IPC routing, mutation relay, updater, logging, shutdown. |\n| `app/app.ts` | Renderer bootstrap; worker‑only OBS init; Realm connect; Vue root. |\n| `app/services/core/service.ts` | Singleton base, lifecycle, `actions` proxy. |\n| `app/services/core/injector.ts` | `@Inject()` and `getResource`. |\n| `app/services/core/stateful-service.ts` | Vuex‑backed services, `@mutation()`, mutation‑purity proxy, `ViewHandler`. |\n| `app/app-services.ts` | The service registry. |\n| `app/services-manager.ts` | Instance management; worker/non‑worker proxy split; `InitAfter` wiring. |\n| `app/store/index.ts` | Vuex store, mutation replication, bulk‑load bootstrap. |\n| `app/services/api/internal-api-client.ts` | Client side of the service RPC (UI windows). |\n| `app/services/api/ipc-server/ipc-server.ts` | Worker side: receives requests, returns responses/events. |\n| `app/services/api/rpc-api.ts` | Request execution + payload serialization (Promises, Observables, helpers, Realm). |\n| `app/services/realm.ts` | Realm databases, per‑process connections, cross‑process state. |\n\n---\n\n*Maintenance: this document covers the slow‑moving core‑platform boundaries and\ninvariants. When a change moves one of them, update this file (and its file +\nsymbol references) in the same PR.*\n"},"items":[{"name":"ARCHITECTURE.md","path":"ARCHITECTURE.md","rawUrl":"https://raw.githubusercontent.com/streamlabs/desktop/HEAD/ARCHITECTURE.md","title":"System Architecture & Module Boundaries","category":"project-spec","format":"markdown","content":"# Architecture\n\n> **Scope.** This document describes the **core platform** of Streamlabs Desktop —\n> the process/window model, the services layer, cross‑window communication, and\n> state management. It deliberately does *not* cover feature domains (streaming,\n> scenes/sources, platforms, widgets, etc.); those may get their own docs later.\n>\n> **Conventions.** Non‑obvious claims name the source file (and the relevant\n> function or symbol) rather than a line number — grep the symbol to find the code,\n> and the reference stays valid as the code moves. Sharp edges and non‑obvious\n> invariants are collected in [Known wrinkles](#6-known-wrinkles).\n>\n> **Keeping this current.** Treat it like the matklad model: document the things\n> that change *slowly* (the boundaries and invariants) and point at code for the\n> rest. When a change alters one of those boundaries, update this file in the same\n> PR.\n\n---\n\n## 1. The big picture\n\nStreamlabs Desktop is an Electron app. Electron gives us two kinds of process:\n\n- one **main process** (Node.js) — this is `main.js`, the entry point named in\n  `package.json` `\"main\"`. It owns windows, native lifecycle, the updater,\n  logging, and **routes all inter‑window IPC**. It is *not* a UI.\n- many **renderer processes** — Chromium windows, each running the same compiled\n  bundle but parameterised by a `windowId` URL query (`main.js`, the\n  `loadURL(...?windowId=…)` calls).\n\nThe renderers are not peers of equal responsibility. Exactly one — the **worker**\nwindow — runs the entire services layer and hosts the native OBS backend. Every\nother window is a UI client that *calls* services remotely.\n\n```\n   ┌─────────────────────────┐          ┌──────────────────────────────┐\n   │ UI windows (renderers)  │          │  worker window (renderer)    │\n   │ main / child / one-off  │          │  • runs ALL services         │\n   │ • Vue + React UI        │          │  • hosts obs-studio-node     │\n   │ • InternalApiClient     │          │  • executes calls; emits     │\n   │   turns calls into IPC   │          │    results + events          │\n   └────────────┬────────────┘          └───────────────┬──────────────┘\n                │                                        │\n                │   renderers never message each other directly —\n                │   every request / response / event hops through main.js\n                │                                        │\n   ┌────────────▼────────────────────────────────────────▼─────────────┐\n   │              Electron main process — main.js  (Node)               │\n   │   The \"router\": a process in its own right (Electron's main        │\n   │   process), but NOT a window. Forwards each service-request from a  │\n   │   UI window to the worker, routes the response back, and relays     │\n   │   vuex-mutation / services-message between windows.                 │\n   └────────────────────────────────────────────────────────────────────┘\n```\n\n**The single most misunderstood point:** the *main process* (`main.js`) is the\nrouter — **not** the *main window*. The main window is just another UI client,\nexactly like the child window. Service calls from any UI window go through the\nmain process to the **worker** window; the main window is never in that path.\n(`main.js`, `sendRequest` and the `services-request` handlers; the worker is the\nonly window that runs services — `app/services-manager.ts` `init`, and the worker\nbranch of the `DOMContentLoaded` handler in `app/app.ts`.)\n\n---\n\n## 2. Windows\n\nAll windows load the same bundle; behaviour is selected by `windowId` (`app/app.ts`,\nthe root component's `render` switch on `windowId`).\n\n| Window | Created | Role |\n| --- | --- | --- |\n| **worker** | `main.js` (`workerWindow` creation) | Invisible (`show: false`), persistent. Runs the entire services layer and hosts OBS (`app/app.ts`, the `isWorkerWindow()` branch). |\n| **main** | `main.js` (`mainWindow` creation) | The primary application UI. A service *client*, not a host. |\n| **child** | created/kept warm by `WindowsService` | Always running, hidden until needed (e.g. Source Properties) because spinning up a window is slow (`README.md`, Development). |\n| **one‑off** | on demand | Transient windows: projectors, pop‑outs, etc. Also clients. |\n\nPlus other JS runtimes for Apps, embedded webviews, and the like (`README.md`,\nDevelopment).\n\n**Security posture worth knowing:** worker and main windows run with\n`nodeIntegration: true` and `contextIsolation: false` (`main.js`, the\n`BrowserWindow` `webPreferences`). This is why renderer code can `require` Node\nmodules and use `@electron/remote` directly. Treat anything loaded into these\nwindows as fully privileged.\n\n**Shutdown ordering** is deliberate: closing the main window triggers a\n`shutdown` message to the worker with a 10‑second grace period (`main.js`,\n`mainWindow.on('close')`); the worker window refuses to close first so test\nteardown and `App.stop()` behave (`main.js`, `workerWindow.on('close')`).\n\n---\n\n## 3. The services layer\n\nDomain logic lives in **services** under `app/services/`. A service is a strict\nsingleton; the base class is `app/services/core/service.ts`.\n\n### 3.1 Singletons\n\n`Service` enforces single‑instance construction with a symbol guard — you cannot\n`new` a service yourself (`service.ts`, `createInstance` and the `singletonEnforcer`\nguard). Access is always through the static `instance` getter (`service.ts`).\n\n### 3.2 Registration and lookup\n\nEvery service is hand‑registered in `app/app-services.ts`. `ServicesManager`\n(`app/services-manager.ts`) holds that registry (the `services` map) and resolves\nnames to instances/helpers (`getResource`). A service that isn't registered cannot\nbe resolved.\n\n### 3.3 Dependency injection\n\nNormal application code reaches a service through the `@Inject()` decorator, which\ndefines a lazy getter resolving the service by property name (or an explicit name)\nat access time (`app/services/core/injector.ts`, `Inject`).\n\n`getResource<T>(name)` (`injector.ts`) and `ServicesManager.getResource`\n(`services-manager.ts`) also exist, but these are **plumbing** — for infrastructure\n(the IPC layer, serialization) and for poking at services from a dev console.\nApplication code should use `@Inject()`, not `getResource`.\n\n### 3.4 Lifecycle\n\nCreated via the factory `createInstance` (`service.ts`), in this order:\n\n1. `init()` — once per application lifetime.\n2. `mounted()` — once per window.\n3. `afterInit()` — once per app, after observers are listening.\n\n(All three are defined on `Service` in `service.ts`.) Services can observe another\nservice's initialization with `@InitAfter('Other')`; `ServicesManager.initObservers`\nwires this up off the `serviceAfterInit` subject in the worker.\n\n### 3.5 The worker/non‑worker split (key mechanism)\n\n`ServicesManager.init` (`services-manager.ts`) branches on whether it is running in\nthe worker:\n\n- **Non‑worker windows:** install an IPC proxy over *every* service via\n  `Service.setupProxy(...)`, and replace the init function with a no‑op via\n  `Service.setupInitFunction(...)`. So in a UI window, \"calling a service\" actually\n  produces an IPC request, and services do **not** run their `init()` locally.\n- **Worker window:** no proxy; services run for real, and the `InitAfter`\n  observer wiring is enabled.\n\nThis single branch is what makes the same service class behave as a *local object*\nin the worker and a *remote stub* everywhere else.\n\n---\n\n## 4. Cross‑window communication (the service RPC)\n\nThis is the heart of the platform. A method call in a UI window has to execute in\nthe worker and return a serializable result. The path has three parts: a client\nproxy, the main‑process router, and a worker‑side executor. The wire format is\nJSON‑RPC (`app/services/api/jsonrpc`).\n\n### 4.1 Client side — `InternalApiClient`\n\n`app/services/api/internal-api-client.ts`. Instantiated in **every non‑worker (UI)\nwindow — main, child, and one‑off alike** — by the proxy setup in\n`ServicesManager.init` (`services-manager.ts`). The main window is *not* special\nhere: it goes through the same `InternalApiClient` as any other UI window.\n\n`applyIpcProxy` wraps a service in a `Proxy` (`internal-api-client.ts`). When you\ncall a method it builds a JSON‑RPC request and ships it over IPC. There are three\ncall shapes, selected by how you access the method (all handled in\n`getRequestHandler`):\n\n| You write | Transport | Returns | When |\n| --- | --- | --- | --- |\n| `Service.actions.foo()` | `ipcRenderer.send('services-request-async')` with `noReturn:true` | `void` | **Default.** Fire‑and‑forget. |\n| `Service.actions.return.foo()` | same async channel, `fetchMutations:true`, `noReturn:false` | `Promise<result>` (resolved later via the `services-response-async` handler in `listenWorkerWindowMessages`) | Need a return value. Use sparingly. |\n| `Service.foo()` | `ipcRenderer.sendSync('services-request')` | result **synchronously** | Avoid — blocks the UI process. Logs a warning in dev. |\n\nThe `actions`/`return` accessors are matched literally in the `applyIpcProxy`\nproxy. Methods marked `__executeInCurrentWindow` (via the `@ExecuteInCurrentWindow()`\ndecorator) bypass the proxy and run locally — this is how, e.g., Realm connections\nare made per‑process (see §5.3).\n\nArguments and results that contain **service helpers** are serialized by reference\n(`{_type:'HELPER', resourceId}`) and re‑wrapped on the other side (in\n`getRequestHandler` and `handleResult`). Helpers themselves can't be proxied —\nattempting to call a helper method through the client throws (in `applyIpcProxy`).\n\n### 4.2 Router — the main process\n\nA UI window's `ipcRenderer` send goes to `ipcMain` in `main.js`, never directly to\nanother renderer. `main.js` forwards every request to the worker and remembers how\nto reply:\n\n- The `services-request` and `services-request-async` handlers both call\n  `sendRequest`, which does `workerWindow.webContents.send('services-request', …)`\n  and stores the originating `event` keyed by request id (`main.js`).\n- When the worker replies `services-response`, the `services-response` handler\n  routes it back: sync requests resolve via `event.returnValue`, async via\n  `event.reply('services-response-async', …)` (`main.js`).\n\nThere's a standing intent to eventually make renderers talk to the worker without\nround‑tripping the main process (see the comment near `getWorkerWindowId` in\n`main.js`), but today everything is proxied through it.\n\n### 4.3 Worker side — `IpcServerService` + `RpcApi`\n\nThe worker registers a single handler, `ipcRenderer.on('services-request', …)`, in\n`IpcServerService.listen` (`app/services/api/ipc-server/ipc-server.ts`), which\nexecutes the request via `InternalApiService.executeServiceRequest` and sends a\n`services-response` unless the request was `noReturn`.\n\n`executeServiceRequest` lives in the shared base `app/services/api/rpc-api.ts`. Its\nchain `executeServiceRequest` → `handleServiceRequest` → `serializePayload` looks up\nthe resource, invokes the method, and **serializes the payload into something\ntransferable**:\n\n- a **plain value** → returned as‑is.\n- an **RxJS `Observable`** → subscribed once; a `{_type:'SUBSCRIPTION', emitter:\n  'STREAM'}` token is returned, and subsequent emissions are pushed as\n  `services-message` events (`serializePayload`).\n- a **`Promise`** → a `{_type:'SUBSCRIPTION', emitter:'PROMISE'}` token is returned\n  immediately; when it settles, `sendPromiseMessage` emits a `services-message`\n  event carrying the resolution/rejection.\n- a **`Service` / helper / `RealmObject`** → serialized by reference (in\n  `serializePayload`).\n\nEvents (stream emissions, promise settlements) flow worker → main process →\nall non‑worker windows via `services-message` (`ipc-server.ts` `sendEvent`;\n`main.js` the `services-message` handler; `internal-api-client.ts`\n`listenWorkerWindowMessages`).\n\n### 4.4 End‑to‑end: an async action with a return value\n\n1. UI: `SomeService.actions.return.doThing(arg)`.\n2. `InternalApiClient` sends `services-request-async` with `fetchMutations:true`\n   (`getRequestHandler`).\n3. `main.js` forwards to the worker, remembers the reply target (the\n   `services-request-async` handler).\n4. Worker `IpcServerService` runs `RpcApi.executeServiceRequest`; the method\n   returns a `Promise`, so the worker first replies with a `PROMISE` subscription\n   token plus any buffered mutations (`handleServiceRequest` / `serializePayload`).\n5. When the worker‑side promise settles, a `services-message` PROMISE event is\n   broadcast; the client matches it by id and resolves the caller's promise\n   (`listenWorkerWindowMessages`).\n\n---\n\n## 5. State management\n\nState is **mid‑migration between two systems**. Pick by scope:\n\n- **UI‑only state** → React local state (`useState`/hooks).\n- **Service state that must sync across windows/processes, or must persist to disk\n  across application runs** → **Realm** (§5.3). Realm covers both: it has a\n  persistent (on‑disk) database and an ephemeral (in‑memory) one.\n- **Legacy** → **Vuex** via `StatefulService` (§5.2). Still pervasive; maintained\n  where it exists, not chosen for new state.\n\n### 5.1 Why state is hard here\n\nBecause services execute in the worker but UI renders in other windows, the UI\nneeds a *local, reactive* copy of state — it can't synchronously reach across\nprocesses on every render. The two systems solve this differently: Vuex\n**replicates** state by broadcasting mutations over IPC; Realm **shares** state at\nthe database layer, with each process holding its own connection.\n\n### 5.2 Vuex / `StatefulService` (legacy)\n\n`app/services/core/stateful-service.ts` + `app/store/index.ts`.\n\n- A `StatefulService<TState>` gets an auto‑generated Vuex module; read state via\n  `this.state`, which is just the service's slice of the store (`StatefulService.state`\n  in `stateful-service.ts`). Modules are assembled in `createStore`\n  (`store/index.ts`).\n- State is mutated **only** through `@mutation()` methods (`mutation` /\n  `registerMutation` in `stateful-service.ts`). **Mutations must be pure** — touch\n  only `this.state` and their arguments, no side effects, no async. In dev a `Proxy`\n  inside `registerMutation` enforces this and throws on any other access. The\n  reason: a mutation has to be replayable in any window from its serialized\n  payload, so it can't depend on worker‑only context.\n- **Replication:** the store‑sync plugin (`store/index.ts`) subscribes to local\n  commits and, for any non‑`__vuexSyncIgnore` mutation, both feeds it to the\n  worker's mutation buffer and broadcasts it to other windows. The broadcast is\n  queued and flushed on a timer to coalesce re‑renders\n  (`sendMutationToRendererWindows` / `flushMutations`). The main process relays\n  `vuex-mutation` to every *other* registered window (`main.js`, the\n  `vuex-mutation` handler).\n- **New window bootstrap:** a freshly opened window registers (the `vuex-register`\n  send in `store/index.ts`), the main process asks the worker to dump current state\n  (`main.js`, the `vuex-register` handler), and the window applies it via\n  `BULK_LOAD_STATE` (the `vuex-loadState` handler in `store/index.ts`). **Ordering\n  invariant:** a renderer window ignores incoming mutations until after that bulk\n  load (`storeCanReceiveMutations` in `store/index.ts`), so early mutations are\n  intentionally dropped to avoid applying deltas to a state that hasn't loaded yet.\n- **Consistency on synchronous reads:** when a UI window makes a *synchronous*\n  service call, the worker buffers any mutations produced during the call and\n  attaches them to the response (`handleServiceRequest` with\n  `startBufferingMutations`/`stopBufferingMutations` in `rpc-api.ts`), and the\n  client commits them immediately before returning (`getRequestHandler` in\n  `internal-api-client.ts`). This keeps the caller's local store consistent with the\n  value it just received without waiting for the async broadcast.\n\n### 5.3 Realm (current direction)\n\n`app/services/realm.ts`, with a React hook at\n`app/components-react/hooks/realm.ts`.\n\n- Two databases: a **persistent** on‑disk realm (`persistent.realm` under\n  `userData`) and an **ephemeral** in‑memory realm (`RealmService`,\n  `persistentConfig` / `ephemeralConfig` in `realm.ts`). A `RealmObject` subclass\n  declares which it belongs to at registration (`registerObject`). Choose the\n  **persistent** DB for state that must survive across application runs (it's\n  written to disk); the **ephemeral** DB for cross‑process state that needn't\n  persist.\n- **Each process opens its own connections** — `connect()` and `getDb()` are\n  marked `@ExecuteInCurrentWindow()` so they run locally rather than being proxied\n  to the worker (`realm.ts`); the worker triggers the initial `RealmService…connect()`\n  during bootstrap (`app/app.ts`). Because every process points at the same\n  realm(s), Realm propagates changes across all connections — that's the\n  cross‑process sync, achieved without our IPC mutation broadcast.\n- **Across the service RPC**, a `RealmObject` is passed by reference\n  (`{_type:'REALM_OBJECT', resourceId, realmType}`) and reconstructed on the other\n  side via `RealmService.registeredClasses[type].fromId(...)` (`serializePayload` in\n  `rpc-api.ts`; `handleResult` in `internal-api-client.ts`). The receiving window\n  then reads through its *own* local connection.\n\n> **Note:** the ephemeral realm still propagates across processes despite being\n> in‑memory — a file‑backed `inMemory` realm is shared by path, so every process's\n> connection observes the same data.\n\n### 5.4 Events (RxJS)\n\nCross‑service and cross‑window notifications use RxJS `Subject`s. Within the worker\nthey're ordinary subscriptions; when a subscription crosses the RPC boundary it\nbecomes a `STREAM` subscription delivered over `services-message` (§4.3).\n\n---\n\n## 6. Known wrinkles\n\nThese are real, load‑bearing quirks an agent (or human) will trip over.\n\n- **`window['servicesManager']` global (production plumbing — leave it alone).**\n  Set in `createStore` (`store/index.ts`, \"This is bad and I should feel bad\") to\n  break an import cycle. It *is* load‑bearing: read by `ViewHandler.getServiceViews`\n  (`stateful-service.ts`, which backs cross‑service `views` access in ~15+ services)\n  and by `service-helper.ts`. Those existing uses stay. **Do not add new ones**, and\n  never reach for it from application‑level services code.\n- **`window.sm` — dev console only.** A debug handle on the services manager, set\n  only in dev mode or under `SLOBS_PRODUCTION_DEBUG` (`services-manager.ts`, `init`).\n  It's for poking at services from the console; never use it in real code or\n  plumbing.\n- **Sync IPC blocks the UI.** `Service.foo()` (no `.actions`) uses `sendSync` and\n  freezes the calling renderer until the worker replies (`main.js`, `sendRequest`;\n  `internal-api-client.ts`, `getRequestHandler`). Only acceptable where truly\n  necessary.\n- **Helpers can't be proxied.** Calling a helper method through the IPC client\n  throws by design (`internal-api-client.ts`, `applyIpcProxy`).\n- **`strictNullChecks` is off globally** with an opt‑in subset\n  (`strict-null-check-files/`, `SLOBS_STRICT_NULLS`). Don't flip it repo‑wide.\n- **Privileged renderers.** `nodeIntegration: true` / `contextIsolation: false`\n  (`main.js`, the `BrowserWindow` `webPreferences`).\n\n---\n\n## 7. Key files\n\n| File | Responsibility |\n| --- | --- |\n| `main.js` | Electron main process: window creation, IPC routing, mutation relay, updater, logging, shutdown. |\n| `app/app.ts` | Renderer bootstrap; worker‑only OBS init; Realm connect; Vue root. |\n| `app/services/core/service.ts` | Singleton base, lifecycle, `actions` proxy. |\n| `app/services/core/injector.ts` | `@Inject()` and `getResource`. |\n| `app/services/core/stateful-service.ts` | Vuex‑backed services, `@mutation()`, mutation‑purity proxy, `ViewHandler`. |\n| `app/app-services.ts` | The service registry. |\n| `app/services-manager.ts` | Instance management; worker/non‑worker proxy split; `InitAfter` wiring. |\n| `app/store/index.ts` | Vuex store, mutation replication, bulk‑load bootstrap. |\n| `app/services/api/internal-api-client.ts` | Client side of the service RPC (UI windows). |\n| `app/services/api/ipc-server/ipc-server.ts` | Worker side: receives requests, returns responses/events. |\n| `app/services/api/rpc-api.ts` | Request execution + payload serialization (Promises, Observables, helpers, Realm). |\n| `app/services/realm.ts` | Realm databases, per‑process connections, cross‑process state. |\n\n---\n\n*Maintenance: this document covers the slow‑moving core‑platform boundaries and\ninvariants. When a change moves one of them, update this file (and its file +\nsymbol references) in the same PR.*\n","isInternal":false,"tokens":5053,"sizeBytes":21481},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/streamlabs/desktop/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nGuidance for AI agents working in this repository. Keep this file lean — it loads\ninto every session. Deep architecture lives in `ARCHITECTURE.md` (read it when a\nchange touches the window/service/state/IPC model).\n\nThis is **Streamlabs Desktop** (`slobs-client`): an Electron live‑streaming app\nbuilt on OBS. Large, old, and mid‑migration on two fronts (Vue → React for UI,\nVuex → Realm/React for state), with several non‑obvious invariants. Read the\nrelevant service before editing it; prefer targeted reads over broad assumptions.\n\n## Commands\n\nPackage manager is **Yarn Berry (3.1.1)** — never use `npm`.\n\n| Task                | Command                 | Notes                                                                                                                         |\n| ------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| Lint + format       | `yarn eslint`           | Prettier runs _through_ ESLint (`eslint-plugin-prettier`). `eslint --fix` formats.                                            |\n| Typecheck           | `yarn typecheck`        | **Fast self-verify.** `tsc --noEmit` for the app + React. Use this to check changes instead of a full build or the e2e suite. |\n| Iterative dev build | `yarn watch`            | Webpack watch; use this while developing.                                                                                     |\n| One‑shot dev build  | `yarn compile`          | Slow: clears `bundles/media` and rebuilds everything. Don't run casually.                                                     |\n| Run the app         | `yarn start`            | Launches Electron against the last build.                                                                                     |\n| Single test file    | `yarn test:file <path>` | Compiles tests, runs one file.                                                                                                |\n| Full test suite     | `yarn test`             | **Heavy/slow e2e** — see Testing below. Don't run unless asked.                                                               |\n\nFor a fast type check without a full build, run **`yarn typecheck`** — it runs\n`tsc --noEmit` for the non‑React app (`tsconfig.json`) and for `app/components-react`\n(its stricter config, `strictNullChecks: true`). This is the quickest way to verify\nyour changes; reserve `yarn compile` and the e2e suite for when you actually need\nthem. (The build still typechecks via `ts-loader`; tests compile via `tsc -p test`.)\n\n## Code style\n\n- TypeScript, formatted by Prettier via ESLint. **Don't memorize the rules** —\n  let `yarn eslint` (`--fix`) apply them, and match the surrounding file.\n- `strictNullChecks` is **intentionally OFF** globally. Do **not** enable it\n  repo‑wide. A subset of files opts in via `strict-null-check-files/` +\n  `SLOBS_STRICT_NULLS`; only add to that set deliberately.\n\n## Architecture in one screen\n\nMulti‑window Electron app. Every window runs the _same_ JS bundle but plays a\ndifferent role:\n\n- **worker** — invisible, persistent renderer that runs the **entire services\n  layer**. All service methods actually execute here.\n- **main** — the primary UI window.\n- **child** — kept warm in the background for things like Source Properties.\n- plus transient **one‑off** windows (projectors, pop‑outs), apps, webviews.\n\nThe UI windows (main, child, one‑off) **don't run services** — they call them\nremotely. A call from any non‑worker window is sent to the **Electron main\nprocess** (`main.js`), which forwards it to the **worker** window, then routes the\nresult back to the originating window. Note: the _main process_ (`main.js`, Node)\nis the router — **not** the _main window_, which is just another UI client.\n\n**Services** (`app/services/`) are strict singletons holding all domain logic.\nNormal application code reaches a service through the `@Inject()` decorator. They're\nregistered in `app/app-services.ts`.\n\n## State — know which mechanism to use\n\nState management is **mid‑migration**. For new code, choose by scope:\n\n- **UI‑only state** → React state (`useState` / hooks), local to the component.\n- **Service state that must sync across windows/processes** → **Realm**\n  (`RealmObject` / `RealmService` in `app/services/realm.ts`), which replicates\n  across all processes. React reads it via `app/components-react/hooks/realm.ts`.\n\nMuch existing service state still lives in **Vuex** via `StatefulService<TState>`\n(read through `this.state`, mutated only via `@mutation()` methods). You'll\nmaintain it where it already exists, but **don't reach for Vuex for new state.**\n\nSee `ARCHITECTURE.md` for the full model and the _why_ behind the sharp edges.\n\n## Hard rules (these are easy to get wrong)\n\n1. **Vuex mutations are pure.** When editing an existing `StatefulService`, a\n   `@mutation()` method may touch only `this.state` and its own arguments — no side\n   effects, no calling other services, no async. In dev a Proxy enforces this and\n   throws (`app/services/core/stateful-service.ts:49`).\n2. **Calling a service from a UI window — pick the right form:**\n   - `Service.actions.method()` — **default**. Async, fire‑and‑forget, returns\n     `void`.\n   - `Service.actions.return.method()` — async **with** a return value (resolves\n     when the worker finishes). Use sparingly.\n   - `Service.method()` (omit `.actions`) — **synchronous**; it blocks the calling\n     UI process and logs a console warning. Avoid except where absolutely\n     necessary — it degrades the user experience.\n   - Reads: use `views` (Vuex) or read Realm objects directly.\n3. **Register new services** in `app/app-services.ts` — it's hand‑maintained, not\n   generated. A service that isn't registered won't resolve.\n4. **New UI is React.** Functional components + hooks only, in\n   `app/components-react/` (`.tsx`). Vue (`app/components/`, `.vue`) is legacy and\n   frozen — only touch it to migrate a component to React.\n5. **Don't edit generated/build output:** `bundles/`, `*.g.less`, `updater/build/`,\n   `dist/`, `test-dist/`, `docs/dist/`.\n6. **Honor in‑code warnings.** Respect `DO NOT CALL` / `@warning` markers — e.g.\n   `app/services/sources/sources.ts` `updatePropertiesManagerSettingsInStore`,\n   and the deprecated method in `app/services/video.ts`.\n\n## Conventions\n\n- Filenames map to classes: `foo-bar.ts` → `class FooBarService`.\n- Common decorators: `@Inject()` (DI), `@mutation()` (Vuex mutation),\n  `@InitAfter('OtherService')` (ordered init), `@InheritMutations()`.\n- Service lifecycle hooks: `init()` (once per app) → `mounted()` (once per window)\n  → `afterInit()`.\n- Cross‑service / cross‑window events use **RxJS** `Subject`s.\n\n## Alert type `apiKey` field naming\n\nWhen adding a new alert type to `alerts-config.ts`, the `apiKey` field must match\nthe **backend field prefix**, not the internal alert type name. For example,\nan alert previously had the alert type `twitter_subscription` in the repository\nbut because the backend returns settings with keys like `twitter_sub_image_href`,\nnot `twitter_subscription_image_href`, it requires `apiKey: 'twitter_sub'`,\nnot `twitter_subscription_*`. The variation field parser in `AlertBoxModule.setData()`\nuses `${apiKey}_` prefix matching (see `app/components-react/widgets/useAlertBox.tsx`\nline 207). Mismatching this causes variation settings to fail initialization silently,\nleading to UI freezes. Also map internal alert types to API endpoints in\n`WidgetsService.playAlert()` (`app/services/widgets/widgets.ts`) if the backend uses\na different name there.\n\n## Testing\n\nTests are **integration/e2e via WebdriverIO** — they launch the real Electron app\nand drive it, run **serially**, and are slow. They are not fast unit tests. Run a\nsingle file with `yarn test:file <path>` when iterating; avoid the full `yarn test`\nsuite (and `yarn package`) unless explicitly asked.\n\n## Where things live\n\n| Path                                      | What                                                           |\n| ----------------------------------------- | -------------------------------------------------------------- |\n| `main.js`                                 | Electron main process: windows, IPC routing, updater, logging. |\n| `app/app.ts`                              | Renderer bootstrap (services, i18n, Sentry).                   |\n| `app/app-services.ts`                     | Central service registry.                                      |\n| `app/services/`                           | All services (domain logic).                                   |\n| `app/services/core/`                      | Service base, `StatefulService`, DI, mutations.                |\n| `app/services/realm.ts`                   | Realm‑backed cross‑process state.                              |\n| `app/services-manager.ts`                 | Service instantiation + IPC proxying.                          |\n| `app/services/api/internal-api-client.ts` | Client side of cross‑window service calls.                     |\n| `app/store/`                              | Vuex store + cross‑window mutation sync (legacy).              |\n| `app/components-react/`                   | React UI (current).                                            |\n| `app/components/`                         | Vue UI (legacy).                                               |\n| `test/`                                   | e2e/stress/screen/performance tests + helpers.                 |\n\n## Useful env vars (dev)\n\n`SLOBS_REPORT_TO_SENTRY`, `SLOBS_PRODUCTION_DEBUG` (open dev tools on start),\n`SLOBS_CACHE_DIR`, `SLOBS_FORCE_AUTO_UPDATE`, `SLOBS_STRICT_NULLS`. See README for\nthe full list.\n","isInternal":false,"tokens":2161,"sizeBytes":9861}],"systemPromptSnippet":"<yakaai_skills repo=\"streamlabs/desktop\">\n<!-- File: ARCHITECTURE.md (Tokens: ~5053 | Category: project-spec) -->\n# Architecture\n\n> **Scope.** This document describes the **core platform** of Streamlabs Desktop —\n> the process/window model, the services layer, cross‑window communication, and\n> state management. It deliberately does *not* cover feature domains (streaming,\n> scenes/sources, platforms, widgets, etc.); those may get their own docs later.\n>\n> **Conventions.** Non‑obvious claims name the source file (and the relevant\n> function or symbol) rather than a line number — grep the symbol to find the code,\n> and the reference stays valid as the code moves. Sharp edges and non‑obvious\n> invariants are collected in [Known wrinkles](#6-known-wrinkles).\n>\n> **Keeping this current.** Treat it like the matklad model: document the things\n> that change *slowly* (the boundaries and invariants) and point at code for the\n> rest. When a change alters one of those boundaries, update this file in the same\n> PR.\n\n---\n\n## 1. The big picture\n\nStreamlabs Desktop is an Electron app. Electron gives us two kinds of process:\n\n- one **main process** (Node.js) — this is `main.js`, the entry point named in\n  `package.json` `\"main\"`. It owns windows, native lifecycle, the updater,\n  logging, and **routes all inter‑window IPC**. It is *not* a UI.\n- many **renderer processes** — Chromium windows, each running the same compiled\n  bundle but parameterised by a `windowId` URL query (`main.js`, the\n  `loadURL(...?windowId=…)` calls).\n\nThe renderers are not peers of equal responsibility. Exactly one — the **worker**\nwindow — runs the entire services layer and hosts the native OBS backend. Every\nother window is a UI client that *calls* services remotely.\n\n```\n   ┌─────────────────────────┐          ┌──────────────────────────────┐\n   │ UI windows (renderers)  │          │  worker window (renderer)    │\n   │ main / child / one-off  │          │  • runs ALL services         │\n   │ • Vue + React UI        │          │  • hosts obs-studio-node     │\n   │ • InternalApiClient     │          │  • executes calls; emits     │\n   │   turns calls into IPC   │          │    results + events          │\n   └────────────┬────────────┘          └───────────────┬──────────────┘\n                │                                        │\n                │   renderers never message each other directly —\n                │   every request / response / event hops through main.js\n                │                                        │\n   ┌────────────▼────────────────────────────────────────▼─────────────┐\n   │              Electron main process — main.js  (Node)               │\n   │   The \"router\": a process in its own right (Electron's main        │\n   │   process), but NOT a window. Forwards each service-request from a  │\n   │   UI window to the worker, routes the response back, and relays     │\n   │   vuex-mutation / services-message between windows.                 │\n   └────────────────────────────────────────────────────────────────────┘\n```\n\n**The single most misunderstood point:** the *main process* (`main.js`) is the\nrouter — **not** the *main window*. The main window is just another UI client,\nexactly like the child window. Service calls from any UI window go through the\nmain process to the **worker** window; the main window is never in that path.\n(`main.js`, `sendRequest` and the `services-request` handlers; the worker is the\nonly window that runs services — `app/services-manager.ts` `init`, and the worker\nbranch of the `DOMContentLoaded` handler in `app/app.ts`.)\n\n---\n\n## 2. Windows\n\nAll windows load the same bundle; behaviour is selected by `windowId` (`app/app.ts`,\nthe root component's `render` switch on `windowId`).\n\n| Window | Created | Role |\n| --- | --- | --- |\n| **worker** | `main.js` (`workerWindow` creation) | Invisible (`show: false`), persistent. Runs the entire services layer and hosts OBS (`app/app.ts`, the `isWorkerWindow()` branch). |\n| **main** | `main.js` (`mainWindow` creation) | The primary application UI. A service *client*, not a host. |\n| **child** | created/kept warm by `WindowsService` | Always running, hidden until needed (e.g. Source Properties) because spinning up a window is slow (`README.md`, Development). |\n| **one‑off** | on demand | Transient windows: projectors, pop‑outs, etc. Also clients. |\n\nPlus other JS runtimes for Apps, embedded webviews, and the like (`README.md`,\nDevelopment).\n\n**Security posture worth knowing:** worker and main windows run with\n`nodeIntegration: true` and `contextIsolation: false` (`main.js`, the\n`BrowserWindow` `webPreferences`). This is why renderer code can `require` Node\nmodules and use `@electron/remote` directly. Treat anything loaded into these\nwindows as fully privileged.\n\n**Shutdown ordering** is deliberate: closing the main window triggers a\n`shutdown` message to the worker with a 10‑second grace period (`main.js`,\n`mainWindow.on('close')`); the worker window refuses to close first so test\nteardown and `App.stop()` behave (`main.js`, `workerWindow.on('close')`).\n\n---\n\n## 3. The services layer\n\nDomain logic lives in **services** under `app/services/`. A service is a strict\nsingleton; the base class is `app/services/core/service.ts`.\n\n### 3.1 Singletons\n\n`Service` enforces single‑instance construction with a symbol guard — you cannot\n`new` a service yourself (`service.ts`, `createInstance` and the `singletonEnforcer`\nguard). Access is always through the static `instance` getter (`service.ts`).\n\n### 3.2 Registration and lookup\n\nEvery service is hand‑registered in `app/app-services.ts`. `ServicesManager`\n(`app/services-manager.ts`) holds that registry (the `services` map) and resolves\nnames to instances/helpers (`getResource`). A service that isn't registered cannot\nbe resolved.\n\n### 3.3 Dependency injection\n\nNormal application code reaches a service through the `@Inject()` decorator, which\ndefines a lazy getter resolving the service by property name (or an explicit name)\nat access time (`app/services/core/injector.ts`, `Inject`).\n\n`getResource<T>(name)` (`injector.ts`) and `ServicesManager.getResource`\n(`services-manager.ts`) also exist, but these are **plumbing** — for infrastructure\n(the IPC layer, serialization) and for poking at services from a dev console.\nApplication code should use `@Inject()`, not `getResource`.\n\n### 3.4 Lifecycle\n\nCreated via the factory `createInstance` (`service.ts`), in this order:\n\n1. `init()` — once per application lifetime.\n2. `mounted()` — once per window.\n3. `afterInit()` — once per app, after observers are listening.\n\n(All three are defined on `Service` in `service.ts`.) Services can observe another\nservice's initialization with `@InitAfter('Other')`; `ServicesManager.initObservers`\nwires this up off the `serviceAfterInit` subject in the worker.\n\n### 3.5 The worker/non‑worker split (key mechanism)\n\n`ServicesManager.init` (`services-manager.ts`) branches on whether it is running in\nthe worker:\n\n- **Non‑worker windows:** install an IPC proxy over *every* service via\n  `Service.setupProxy(...)`, and replace the init function with a no‑op via\n  `Service.setupInitFunction(...)`. So in a UI window, \"calling a service\" actually\n  produces an IPC request, and services do **not** run their `init()` locally.\n- **Worker window:** no proxy; services run for real, and the `InitAfter`\n  observer wiring is enabled.\n\nThis single branch is what makes the same service class behave as a *local object*\nin the worker and a *remote stub* everywhere else.\n\n---\n\n## 4. Cross‑window communication (the service RPC)\n\nThis is the heart of the platform. A method call in a UI window has to execute in\nthe worker and return a serializable result. The path has three parts: a client\nproxy, the main‑process router, and a worker‑side executor. The wire format is\nJSON‑RPC (`app/services/api/jsonrpc`).\n\n### 4.1 Client side — `InternalApiClient`\n\n`app/services/api/internal-api-client.ts`. Instantiated in **every non‑worker (UI)\nwindow — main, child, and one‑off alike** — by the proxy setup in\n`ServicesManager.init` (`services-manager.ts`). The main window is *not* special\nhere: it goes through the same `InternalApiClient` as any other UI window.\n\n`applyIpcProxy` wraps a service in a `Proxy` (`internal-api-client.ts`). When you\ncall a method it builds a JSON‑RPC request and ships it over IPC. There are three\ncall shapes, selected by how you access the method (all handled in\n`getRequestHandler`):\n\n| You write | Transport | Returns | When |\n| --- | --- | --- | --- |\n| `Service.actions.foo()` | `ipcRenderer.send('services-request-async')` with `noReturn:true` | `void` | **Default.** Fire‑and‑forget. |\n| `Service.actions.return.foo()` | same async channel, `fetchMutations:true`, `noReturn:false` | `Promise<result>` (resolved later via the `services-response-async` handler in `listenWorkerWindowMessages`) | Need a return value. Use sparingly. |\n| `Service.foo()` | `ipcRenderer.sendSync('services-request')` | result **synchronously** | Avoid — blocks the UI process. Logs a warning in dev. |\n\nThe `actions`/`return` accessors are matched literally in the `applyIpcProxy`\nproxy. Methods marked `__executeInCurrentWindow` (via the `@ExecuteInCurrentWindow()`\ndecorator) bypass the proxy and run locally — this is how, e.g., Realm connections\nare made per‑process (see §5.3).\n\nArguments and results that contain **service helpers** are serialized by reference\n(`{_type:'HELPER', resourceId}`) and re‑wrapped on the other side (in\n`getRequestHandler` and `handleResult`). Helpers themselves can't be proxied —\nattempting to call a helper method through the client throws (in `applyIpcProxy`).\n\n### 4.2 Router — the main process\n\nA UI window's `ipcRenderer` send goes to `ipcMain` in `main.js`, never directly to\nanother renderer. `main.js` forwards every request to the worker and remembers how\nto reply:\n\n- The `services-request` and `services-request-async` handlers both call\n  `sendRequest`, which does `workerWindow.webContents.send('services-request', …)`\n  and stores the originating `event` keyed by request id (`main.js`).\n- When the worker replies `services-response`, the `services-response` handler\n  routes it back: sync requests resolve via `event.returnValue`, async via\n  `event.reply('services-response-async', …)` (`main.js`).\n\nThere's a standing intent to eventually make renderers talk to the worker without\nround‑tripping the main process (see the comment near `getWorkerWindowId` in\n`main.js`), but today everything is proxied through it.\n\n### 4.3 Worker side — `IpcServerService` + `RpcApi`\n\nThe worker registers a single handler, `ipcRenderer.on('services-request', …)`, in\n`IpcServerService.listen` (`app/services/api/ipc-server/ipc-server.ts`), which\nexecutes the request via `InternalApiService.executeServiceRequest` and sends a\n`services-response` unless the request was `noReturn`.\n\n`executeServiceRequest` lives in the shared base `app/services/api/rpc-api.ts`. Its\nchain `executeServiceRequest` → `handleServiceRequest` → `serializePayload` looks up\nthe resource, invokes the method, and **serializes the payload into something\ntransferable**:\n\n- a **plain value** → returned as‑is.\n- an **RxJS `Observable`** → subscribed once; a `{_type:'SUBSCRIPTION', emitter:\n  'STREAM'}` token is returned, and subsequent emissions are pushed as\n  `services-message` events (`serializePayload`).\n- a **`Promise`** → a `{_type:'SUBSCRIPTION', emitter:'PROMISE'}` token is returned\n  immediately; when it settles, `sendPromiseMessage` emits a `services-message`\n  event carrying the resolution/rejection.\n- a **`Service` / helper / `RealmObject`** → serialized by reference (in\n  `serializePayload`).\n\nEvents (stream emissions, promise settlements) flow worker → main process →\nall non‑worker windows via `services-message` (`ipc-server.ts` `sendEvent`;\n`main.js` the `services-message` handler; `internal-api-client.ts`\n`listenWorkerWindowMessages`).\n\n### 4.4 End‑to‑end: an async action with a return value\n\n1. UI: `SomeService.actions.return.doThing(arg)`.\n2. `InternalApiClient` sends `services-request-async` with `fetchMutations:true`\n   (`getRequestHandler`).\n3. `main.js` forwards to the worker, remembers the reply target (the\n   `services-request-async` handler).\n4. Worker `IpcServerService` runs `RpcApi.executeServiceRequest`; the method\n   returns a `Promise`, so the worker first replies with a `PROMISE` subscription\n   token plus any buffered mutations (`handleServiceRequest` / `serializePayload`).\n5. When the worker‑side promise settles, a `services-message` PROMISE event is\n   broadcast; the client matches it by id and resolves the caller's promise\n   (`listenWorkerWindowMessages`).\n\n---\n\n## 5. State management\n\nState is **mid‑migration between two systems**. Pick by scope:\n\n- **UI‑only state** → React local state (`useState`/hooks).\n- **Service state that must sync across windows/processes, or must persist to disk\n  across application runs** → **Realm** (§5.3). Realm covers both: it has a\n  persistent (on‑disk) database and an ephemeral (in‑memory) one.\n- **Legacy** → **Vuex** via `StatefulService` (§5.2). Still pervasive; maintained\n  where it exists, not chosen for new state.\n\n### 5.1 Why state is hard here\n\nBecause services execute in the worker but UI renders in other windows, the UI\nneeds a *local, reactive* copy of state — it can't synchronously reach across\nprocesses on every render. The two systems solve this differently: Vuex\n**replicates** state by broadcasting mutations over IPC; Realm **shares** state at\nthe database layer, with each process holding its own connection.\n\n### 5.2 Vuex / `StatefulService` (legacy)\n\n`app/services/core/stateful-service.ts` + `app/store/index.ts`.\n\n- A `StatefulService<TState>` gets an auto‑generated Vuex module; read state via\n  `this.state`, which is just the service's slice of the store (`StatefulService.state`\n  in `stateful-service.ts`). Modules are assembled in `createStore`\n  (`store/index.ts`).\n- State is mutated **only** through `@mutation()` methods (`mutation` /\n  `registerMutation` in `stateful-service.ts`). **Mutations must be pure** — touch\n  only `this.state` and their arguments, no side effects, no async. In dev a `Proxy`\n  inside `registerMutation` enforces this and throws on any other access. The\n  reason: a mutation has to be replayable in any window from its serialized\n  payload, so it can't depend on worker‑only context.\n- **Replication:** the store‑sync plugin (`store/index.ts`) subscribes to local\n  commits and, for any non‑`__vuexSyncIgnore` mutation, both feeds it to the\n  worker's mutation buffer and broadcasts it to other windows. The broadcast is\n  queued and flushed on a timer to coalesce re‑renders\n  (`sendMutationToRendererWindows` / `flushMutations`). The main process relays\n  `vuex-mutation` to every *other* registered window (`main.js`, the\n  `vuex-mutation` handler).\n- **New window bootstrap:** a freshly opened window registers (the `vuex-register`\n  send in `store/index.ts`), the main process asks the worker to dump current state\n  (`main.js`, the `vuex-register` handler), and the window applies it via\n  `BULK_LOAD_STATE` (the `vuex-loadState` handler in `store/index.ts`). **Ordering\n  invariant:** a renderer window ignores incoming mutations until after that bulk\n  load (`storeCanReceiveMutations` in `store/index.ts`), so early mutations are\n  intentionally dropped to avoid applying deltas to a state that hasn't loaded yet.\n- **Consistency on synchronous reads:** when a UI window makes a *synchronous*\n  service call, the worker buffers any mutations produced during the call and\n  attaches them to the response (`handleServiceRequest` with\n  `startBufferingMutations`/`stopBufferingMutations` in `rpc-api.ts`), and the\n  client commits them immediately before returning (`getRequestHandler` in\n  `internal-api-client.ts`). This keeps the caller's local store consistent with the\n  value it just received without waiting for the async broadcast.\n\n### 5.3 Realm (current direction)\n\n`app/services/realm.ts`, with a React hook at\n`app/components-react/hooks/realm.ts`.\n\n- Two databases: a **persistent** on‑disk realm (`persistent.realm` under\n  `userData`) and an **ephemeral** in‑memory realm (`RealmService`,\n  `persistentConfig` / `ephemeralConfig` in `realm.ts`). A `RealmObject` subclass\n  declares which it belongs to at registration (`registerObject`). Choose the\n  **persistent** DB for state that must survive across application runs (it's\n  written to disk); the **ephemeral** DB for cross‑process state that needn't\n  persist.\n- **Each process opens its own connections** — `connect()` and `getDb()` are\n  marked `@ExecuteInCurrentWindow()` so they run locally rather than being proxied\n  to the worker (`realm.ts`); the worker triggers the initial `RealmService…connect()`\n  during bootstrap (`app/app.ts`). Because every process points at the same\n  realm(s), Realm propagates changes across all connections — that's the\n  cross‑process sync, achieved without our IPC mutation broadcast.\n- **Across the service RPC**, a `RealmObject` is passed by reference\n  (`{_type:'REALM_OBJECT', resourceId, realmType}`) and reconstructed on the other\n  side via `RealmService.registeredClasses[type].fromId(...)` (`serializePayload` in\n  `rpc-api.ts`; `handleResult` in `internal-api-client.ts`). The receiving window\n  then reads through its *own* local connection.\n\n> **Note:** the ephemeral realm still propagates across processes despite being\n> in‑memory — a file‑backed `inMemory` realm is shared by path, so every process's\n> connection observes the same data.\n\n### 5.4 Events (RxJS)\n\nCross‑service and cross‑window notifications use RxJS `Subject`s. Within the worker\nthey're ordinary subscriptions; when a subscription crosses the RPC boundary it\nbecomes a `STREAM` subscription delivered over `services-message` (§4.3).\n\n---\n\n## 6. Known wrinkles\n\nThese are real, load‑bearing quirks an agent (or human) will trip over.\n\n- **`window['servicesManager']` global (production plumbing — leave it alone).**\n  Set in `createStore` (`store/index.ts`, \"This is bad and I should feel bad\") to\n  break an import cycle. It *is* load‑bearing: read by `ViewHandler.getServiceViews`\n  (`stateful-service.ts`, which backs cross‑service `views` access in ~15+ services)\n  and by `service-helper.ts`. Those existing uses stay. **Do not add new ones**, and\n  never reach for it from application‑level services code.\n- **`window.sm` — dev console only.** A debug handle on the services manager, set\n  only in dev mode or under `SLOBS_PRODUCTION_DEBUG` (`services-manager.ts`, `init`).\n  It's for poking at services from the console; never use it in real code or\n  plumbing.\n- **Sync IPC blocks the UI.** `Service.foo()` (no `.actions`) uses `sendSync` and\n  freezes the calling renderer until the worker replies (`main.js`, `sendRequest`;\n  `internal-api-client.ts`, `getRequestHandler`). Only acceptable where truly\n  necessary.\n- **Helpers can't be proxied.** Calling a helper method through the IPC client\n  throws by design (`internal-api-client.ts`, `applyIpcProxy`).\n- **`strictNullChecks` is off globally** with an opt‑in subset\n  (`strict-null-check-files/`, `SLOBS_STRICT_NULLS`). Don't flip it repo‑wide.\n- **Privileged renderers.** `nodeIntegration: true` / `contextIsolation: false`\n  (`main.js`, the `BrowserWindow` `webPreferences`).\n\n---\n\n## 7. Key files\n\n| File | Responsibility |\n| --- | --- |\n| `main.js` | Electron main process: window creation, IPC routing, mutation relay, updater, logging, shutdown. |\n| `app/app.ts` | Renderer bootstrap; worker‑only OBS init; Realm connect; Vue root. |\n| `app/services/core/service.ts` | Singleton base, lifecycle, `actions` proxy. |\n| `app/services/core/injector.ts` | `@Inject()` and `getResource`. |\n| `app/services/core/stateful-service.ts` | Vuex‑backed services, `@mutation()`, mutation‑purity proxy, `ViewHandler`. |\n| `app/app-services.ts` | The service registry. |\n| `app/services-manager.ts` | Instance management; worker/non‑worker proxy split; `InitAfter` wiring. |\n| `app/store/index.ts` | Vuex store, mutation replication, bulk‑load bootstrap. |\n| `app/services/api/internal-api-client.ts` | Client side of the service RPC (UI windows). |\n| `app/services/api/ipc-server/ipc-server.ts` | Worker side: receives requests, returns responses/events. |\n| `app/services/api/rpc-api.ts` | Request execution + payload serialization (Promises, Observables, helpers, Realm). |\n| `app/services/realm.ts` | Realm databases, per‑process connections, cross‑process state. |\n\n---\n\n*Maintenance: this document covers the slow‑moving core‑platform boundaries and\ninvariants. When a change moves one of them, update this file (and its file +\nsymbol references) in the same PR.*\n\n<!-- File: CLAUDE.md (Tokens: ~2161 | Category: claude-rule) -->\n# CLAUDE.md\n\nGuidance for AI agents working in this repository. Keep this file lean — it loads\ninto every session. Deep architecture lives in `ARCHITECTURE.md` (read it when a\nchange touches the window/service/state/IPC model).\n\nThis is **Streamlabs Desktop** (`slobs-client`): an Electron live‑streaming app\nbuilt on OBS. Large, old, and mid‑migration on two fronts (Vue → React for UI,\nVuex → Realm/React for state), with several non‑obvious invariants. Read the\nrelevant service before editing it; prefer targeted reads over broad assumptions.\n\n## Commands\n\nPackage manager is **Yarn Berry (3.1.1)** — never use `npm`.\n\n| Task                | Command                 | Notes                                                                                                                         |\n| ------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| Lint + format       | `yarn eslint`           | Prettier runs _through_ ESLint (`eslint-plugin-prettier`). `eslint --fix` formats.                                            |\n| Typecheck           | `yarn typecheck`        | **Fast self-verify.** `tsc --noEmit` for the app + React. Use this to check changes instead of a full build or the e2e suite. |\n| Iterative dev build | `yarn watch`            | Webpack watch; use this while developing.                                                                                     |\n| One‑shot dev build  | `yarn compile`          | Slow: clears `bundles/media` and rebuilds everything. Don't run casually.                                                     |\n| Run the app         | `yarn start`            | Launches Electron against the last build.                                                                                     |\n| Single test file    | `yarn test:file <path>` | Compiles tests, runs one file.                                                                                                |\n| Full test suite     | `yarn test`             | **Heavy/slow e2e** — see Testing below. Don't run unless asked.                                                               |\n\nFor a fast type check without a full build, run **`yarn typecheck`** — it runs\n`tsc --noEmit` for the non‑React app (`tsconfig.json`) and for `app/components-react`\n(its stricter config, `strictNullChecks: true`). This is the quickest way to verify\nyour changes; reserve `yarn compile` and the e2e suite for when you actually need\nthem. (The build still typechecks via `ts-loader`; tests compile via `tsc -p test`.)\n\n## Code style\n\n- TypeScript, formatted by Prettier via ESLint. **Don't memorize the rules** —\n  let `yarn eslint` (`--fix`) apply them, and match the surrounding file.\n- `strictNullChecks` is **intentionally OFF** globally. Do **not** enable it\n  repo‑wide. A subset of files opts in via `strict-null-check-files/` +\n  `SLOBS_STRICT_NULLS`; only add to that set deliberately.\n\n## Architecture in one screen\n\nMulti‑window Electron app. Every window runs the _same_ JS bundle but plays a\ndifferent role:\n\n- **worker** — invisible, persistent renderer that runs the **entire services\n  layer**. All service methods actually execute here.\n- **main** — the primary UI window.\n- **child** — kept warm in the background for things like Source Properties.\n- plus transient **one‑off** windows (projectors, pop‑outs), apps, webviews.\n\nThe UI windows (main, child, one‑off) **don't run services** — they call them\nremotely. A call from any non‑worker window is sent to the **Electron main\nprocess** (`main.js`), which forwards it to the **worker** window, then routes the\nresult back to the originating window. Note: the _main process_ (`main.js`, Node)\nis the router — **not** the _main window_, which is just another UI client.\n\n**Services** (`app/services/`) are strict singletons holding all domain logic.\nNormal application code reaches a service through the `@Inject()` decorator. They're\nregistered in `app/app-services.ts`.\n\n## State — know which mechanism to use\n\nState management is **mid‑migration**. For new code, choose by scope:\n\n- **UI‑only state** → React state (`useState` / hooks), local to the component.\n- **Service state that must sync across windows/processes** → **Realm**\n  (`RealmObject` / `RealmService` in `app/services/realm.ts`), which replicates\n  across all processes. React reads it via `app/components-react/hooks/realm.ts`.\n\nMuch existing service state still lives in **Vuex** via `StatefulService<TState>`\n(read through `this.state`, mutated only via `@mutation()` methods). You'll\nmaintain it where it already exists, but **don't reach for Vuex for new state.**\n\nSee `ARCHITECTURE.md` for the full model and the _why_ behind the sharp edges.\n\n## Hard rules (these are easy to get wrong)\n\n1. **Vuex mutations are pure.** When editing an existing `StatefulService`, a\n   `@mutation()` method may touch only `this.state` and its own arguments — no side\n   effects, no calling other services, no async. In dev a Proxy enforces this and\n   throws (`app/services/core/stateful-service.ts:49`).\n2. **Calling a service from a UI window — pick the right form:**\n   - `Service.actions.method()` — **default**. Async, fire‑and‑forget, returns\n     `void`.\n   - `Service.actions.return.method()` — async **with** a return value (resolves\n     when the worker finishes). Use sparingly.\n   - `Service.method()` (omit `.actions`) — **synchronous**; it blocks the calling\n     UI process and logs a console warning. Avoid except where absolutely\n     necessary — it degrades the user experience.\n   - Reads: use `views` (Vuex) or read Realm objects directly.\n3. **Register new services** in `app/app-services.ts` — it's hand‑maintained, not\n   generated. A service that isn't registered won't resolve.\n4. **New UI is React.** Functional components + hooks only, in\n   `app/components-react/` (`.tsx`). Vue (`app/components/`, `.vue`) is legacy and\n   frozen — only touch it to migrate a component to React.\n5. **Don't edit generated/build output:** `bundles/`, `*.g.less`, `updater/build/`,\n   `dist/`, `test-dist/`, `docs/dist/`.\n6. **Honor in‑code warnings.** Respect `DO NOT CALL` / `@warning` markers — e.g.\n   `app/services/sources/sources.ts` `updatePropertiesManagerSettingsInStore`,\n   and the deprecated method in `app/services/video.ts`.\n\n## Conventions\n\n- Filenames map to classes: `foo-bar.ts` → `class FooBarService`.\n- Common decorators: `@Inject()` (DI), `@mutation()` (Vuex mutation),\n  `@InitAfter('OtherService')` (ordered init), `@InheritMutations()`.\n- Service lifecycle hooks: `init()` (once per app) → `mounted()` (once per window)\n  → `afterInit()`.\n- Cross‑service / cross‑window events use **RxJS** `Subject`s.\n\n## Alert type `apiKey` field naming\n\nWhen adding a new alert type to `alerts-config.ts`, the `apiKey` field must match\nthe **backend field prefix**, not the internal alert type name. For example,\nan alert previously had the alert type `twitter_subscription` in the repository\nbut because the backend returns settings with keys like `twitter_sub_image_href`,\nnot `twitter_subscription_image_href`, it requires `apiKey: 'twitter_sub'`,\nnot `twitter_subscription_*`. The variation field parser in `AlertBoxModule.setData()`\nuses `${apiKey}_` prefix matching (see `app/components-react/widgets/useAlertBox.tsx`\nline 207). Mismatching this causes variation settings to fail initialization silently,\nleading to UI freezes. Also map internal alert types to API endpoints in\n`WidgetsService.playAlert()` (`app/services/widgets/widgets.ts`) if the backend uses\na different name there.\n\n## Testing\n\nTests are **integration/e2e via WebdriverIO** — they launch the real Electron app\nand drive it, run **serially**, and are slow. They are not fast unit tests. Run a\nsingle file with `yarn test:file <path>` when iterating; avoid the full `yarn test`\nsuite (and `yarn package`) unless explicitly asked.\n\n## Where things live\n\n| Path                                      | What                                                           |\n| ----------------------------------------- | -------------------------------------------------------------- |\n| `main.js`                                 | Electron main process: windows, IPC routing, updater, logging. |\n| `app/app.ts`                              | Renderer bootstrap (services, i18n, Sentry).                   |\n| `app/app-services.ts`                     | Central service registry.                                      |\n| `app/services/`                           | All services (domain logic).                                   |\n| `app/services/core/`                      | Service base, `StatefulService`, DI, mutations.                |\n| `app/services/realm.ts`                   | Realm‑backed cross‑process state.                              |\n| `app/services-manager.ts`                 | Service instantiation + IPC proxying.                          |\n| `app/services/api/internal-api-client.ts` | Client side of cross‑window service calls.                     |\n| `app/store/`                              | Vuex store + cross‑window mutation sync (legacy).              |\n| `app/components-react/`                   | React UI (current).                                            |\n| `app/components/`                         | Vue UI (legacy).                                               |\n| `test/`                                   | e2e/stress/screen/performance tests + helpers.                 |\n\n## Useful env vars (dev)\n\n`SLOBS_REPORT_TO_SENTRY`, `SLOBS_PRODUCTION_DEBUG` (open dev tools on start),\n`SLOBS_CACHE_DIR`, `SLOBS_FORCE_AUTO_UPDATE`, `SLOBS_STRICT_NULLS`. See README for\nthe full list.\n</yakaai_skills>"}