Skip to content

Commit b336a19

Browse files
committed
Merge branch 'main' into feature/deep-link-and-oauth-refactor
2 parents 6b38df8 + 6d4ebe3 commit b336a19

File tree

94 files changed

+3557
-2653
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

94 files changed

+3557
-2653
lines changed

ARCHITECTURE.md

Lines changed: 235 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,56 @@ export const MAIN_TOKENS = Object.freeze({
6767
});
6868
```
6969

70-
### Using a Service
70+
### Injecting Dependencies
71+
72+
Services should declare dependencies via constructor injection:
73+
74+
```typescript
75+
import { inject, injectable } from "inversify";
76+
import { MAIN_TOKENS } from "../di/tokens";
77+
78+
@injectable()
79+
export class MyService {
80+
constructor(
81+
@inject(MAIN_TOKENS.OtherService)
82+
private readonly otherService: OtherService,
83+
) {}
84+
85+
doSomething() {
86+
return this.otherService.getData();
87+
}
88+
}
89+
```
90+
91+
### Using Services in tRPC Routers
92+
93+
tRPC routers resolve services from the container:
7194

7295
```typescript
73-
import { get } from "@main/di"; // or @renderer/di
96+
import { container } from "../../di/container";
97+
import { MAIN_TOKENS } from "../../di/tokens";
98+
99+
const getService = () => container.get<MyService>(MAIN_TOKENS.MyService);
100+
101+
export const myRouter = router({
102+
getData: publicProcedure.query(() => getService().getData()),
103+
});
104+
```
74105

75-
const myService = get<MyService>(TOKENS.MyService);
76-
myService.doSomething();
106+
### Testing with Mocks
107+
108+
Constructor injection makes testing straightforward:
109+
110+
```typescript
111+
// Direct instantiation with mock
112+
const mockOtherService = { getData: vi.fn().mockReturnValue("test") };
113+
const service = new MyService(mockOtherService as OtherService);
114+
115+
// Or rebind in container for integration tests
116+
container.snapshot();
117+
container.rebind(MAIN_TOKENS.OtherService).toConstantValue(mockOtherService);
118+
// ... run tests ...
119+
container.restore();
77120
```
78121

79122
## IPC via tRPC
@@ -84,27 +127,26 @@ We use [tRPC](https://trpc.io/) with [trpc-electron](https://github.com/jsonnull
84127

85128
```typescript
86129
// src/main/trpc/routers/my-router.ts
87-
import { z } from "zod";
130+
import { container } from "../../di/container";
131+
import { MAIN_TOKENS } from "../../di/tokens";
132+
import {
133+
getDataInput,
134+
getDataOutput,
135+
updateDataInput,
136+
} from "../../services/my-service/schemas";
88137
import { router, publicProcedure } from "../trpc";
89-
import { get } from "@main/di";
90-
import { MAIN_TOKENS } from "@main/di/tokens";
138+
139+
const getService = () => container.get<MyService>(MAIN_TOKENS.MyService);
91140

92141
export const myRouter = router({
93-
// Query - for read operations
94142
getData: publicProcedure
95-
.input(z.object({ id: z.string() }))
96-
.query(async ({ input }) => {
97-
const service = get<MyService>(MAIN_TOKENS.MyService);
98-
return service.getData(input.id);
99-
}),
143+
.input(getDataInput)
144+
.output(getDataOutput)
145+
.query(({ input }) => getService().getData(input.id)),
100146

101-
// Mutation - for write operations
102147
updateData: publicProcedure
103-
.input(z.object({ id: z.string(), value: z.string() }))
104-
.mutation(async ({ input }) => {
105-
const service = get<MyService>(MAIN_TOKENS.MyService);
106-
return service.updateData(input.id, input.value);
107-
}),
148+
.input(updateDataInput)
149+
.mutation(({ input }) => getService().updateData(input.id, input.value)),
108150
});
109151
```
110152

@@ -199,12 +241,64 @@ Main services should be:
199241
src/main/services/
200242
├── my-service/
201243
│ ├── service.ts # The injectable service class
202-
│ └── types.ts # Types and interfaces
244+
│ ├── schemas.ts # Zod schemas for tRPC input/output
245+
│ └── types.ts # Internal types (not exposed via tRPC)
203246
204247
src/renderer/services/
205248
├── my-service.ts # Renderer-side service
206249
```
207250

251+
### Zod Schemas
252+
253+
All tRPC inputs and outputs use Zod schemas as the single source of truth. Types are inferred from schemas.
254+
255+
```typescript
256+
// src/main/services/my-service/schemas.ts
257+
import { z } from "zod";
258+
259+
export const getDataInput = z.object({
260+
id: z.string(),
261+
});
262+
263+
export const getDataOutput = z.object({
264+
id: z.string(),
265+
name: z.string(),
266+
createdAt: z.string(),
267+
});
268+
269+
export type GetDataInput = z.infer<typeof getDataInput>;
270+
export type GetDataOutput = z.infer<typeof getDataOutput>;
271+
```
272+
273+
```typescript
274+
// src/main/trpc/routers/my-router.ts
275+
import { getDataInput, getDataOutput } from "../../services/my-service/schemas";
276+
277+
export const myRouter = router({
278+
getData: publicProcedure
279+
.input(getDataInput)
280+
.output(getDataOutput)
281+
.query(({ input }) => getService().getData(input.id)),
282+
});
283+
```
284+
285+
```typescript
286+
// src/main/services/my-service/service.ts
287+
import type { GetDataInput, GetDataOutput } from "./schemas";
288+
289+
@injectable()
290+
export class MyService {
291+
async getData(id: string): Promise<GetDataOutput> {
292+
// ...
293+
}
294+
}
295+
```
296+
297+
This pattern provides:
298+
- Runtime validation of inputs and outputs
299+
- Single source of truth for types
300+
- Explicit API contracts between main and renderer
301+
208302
## Adding a New Feature
209303

210304
1. **Create the service** in `src/main/services/`
@@ -214,6 +308,127 @@ src/renderer/services/
214308
5. **Add router** to `src/main/trpc/router.ts`
215309
6. **Use in renderer** via `trpcReact` hooks
216310

311+
## Events (tRPC Subscriptions)
312+
313+
For pushing real-time updates from main to renderer, use tRPC subscriptions with typed event emitters.
314+
315+
### 1. Define Events in schemas.ts
316+
317+
Use a const object for event names and an interface for payloads:
318+
319+
```typescript
320+
// src/main/services/my-service/schemas.ts
321+
export const MyServiceEvent = {
322+
ItemCreated: "item-created",
323+
ItemDeleted: "item-deleted",
324+
} as const;
325+
326+
export interface MyServiceEvents {
327+
[MyServiceEvent.ItemCreated]: { id: string; name: string };
328+
[MyServiceEvent.ItemDeleted]: { id: string };
329+
}
330+
```
331+
332+
### 2. Extend TypedEventEmitter in Service
333+
334+
```typescript
335+
// src/main/services/my-service/service.ts
336+
import { TypedEventEmitter } from "../../lib/typed-event-emitter";
337+
import { MyServiceEvent, type MyServiceEvents } from "./schemas";
338+
339+
@injectable()
340+
export class MyService extends TypedEventEmitter<MyServiceEvents> {
341+
async createItem(name: string) {
342+
const item = { id: "123", name };
343+
// TypeScript enforces correct event name and payload shape
344+
this.emit(MyServiceEvent.ItemCreated, item);
345+
return item;
346+
}
347+
}
348+
```
349+
350+
### 3. Create Subscriptions in Router
351+
352+
Use a helper to reduce boilerplate. For global events (broadcast to all subscribers):
353+
354+
```typescript
355+
// src/main/trpc/routers/my-router.ts
356+
import { on } from "node:events";
357+
import { MyServiceEvent, type MyServiceEvents } from "../../services/my-service/schemas";
358+
359+
function subscribe<K extends keyof MyServiceEvents>(event: K) {
360+
return publicProcedure.subscription(async function* (opts) {
361+
const service = getService();
362+
for await (const [payload] of on(service, event, { signal: opts.signal })) {
363+
yield payload as MyServiceEvents[K];
364+
}
365+
});
366+
}
367+
368+
export const myRouter = router({
369+
// ... queries and mutations
370+
onItemCreated: subscribe(MyServiceEvent.ItemCreated),
371+
onItemDeleted: subscribe(MyServiceEvent.ItemDeleted),
372+
});
373+
```
374+
375+
For per-instance events (e.g., shell sessions), filter by an identifier:
376+
377+
```typescript
378+
// Events include an identifier to filter on
379+
export interface ShellEvents {
380+
[ShellEvent.Data]: { sessionId: string; data: string };
381+
[ShellEvent.Exit]: { sessionId: string; exitCode: number };
382+
}
383+
384+
// Router filters events to the specific session
385+
function subscribeToSession<K extends keyof ShellEvents>(event: K) {
386+
return publicProcedure
387+
.input(sessionIdInput)
388+
.subscription(async function* (opts) {
389+
const service = getService();
390+
const targetSessionId = opts.input.sessionId;
391+
392+
for await (const [payload] of on(service, event, { signal: opts.signal })) {
393+
const data = payload as ShellEvents[K];
394+
if (data.sessionId === targetSessionId) {
395+
yield data;
396+
}
397+
}
398+
});
399+
}
400+
401+
export const shellRouter = router({
402+
onData: subscribeToSession(ShellEvent.Data),
403+
onExit: subscribeToSession(ShellEvent.Exit),
404+
});
405+
```
406+
407+
### 4. Subscribe in Renderer
408+
409+
```typescript
410+
// React component - global events
411+
trpcReact.my.onItemCreated.useSubscription(undefined, {
412+
enabled: true,
413+
onData: (item) => {
414+
// item is typed as { id: string; name: string }
415+
console.log("Created:", item);
416+
},
417+
});
418+
419+
// React component - per-session events
420+
trpcReact.shell.onData.useSubscription(
421+
{ sessionId },
422+
{
423+
enabled: !!sessionId,
424+
onData: (event) => {
425+
// event is typed as { sessionId: string; data: string }
426+
terminal.write(event.data);
427+
},
428+
},
429+
);
430+
```
431+
217432
## Code Style
218433

219434
See [CLAUDE.md](./CLAUDE.md) for linting, formatting, and import conventions.
Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
import "reflect-metadata";
22
import { Container } from "inversify";
3+
import { ContextMenuService } from "../services/context-menu/service.js";
34
import { DeepLinkService } from "../services/deep-link/service.js";
5+
import { DockBadgeService } from "../services/dock-badge/service.js";
6+
import { ExternalAppsService } from "../services/external-apps/service.js";
7+
import { FileWatcherService } from "../services/file-watcher/service.js";
8+
import { FoldersService } from "../services/folders/service.js";
9+
import { FsService } from "../services/fs/service.js";
410
import { GitService } from "../services/git/service.js";
511
import { OAuthService } from "../services/oauth/service.js";
12+
import { ShellService } from "../services/shell/service.js";
13+
import { UpdatesService } from "../services/updates/service.js";
614
import { MAIN_TOKENS } from "./tokens.js";
715

8-
/**
9-
* Main process dependency injection container
10-
*/
1116
export const container = new Container({
1217
defaultScope: "Singleton",
1318
});
1419

15-
// Bind services
16-
container.bind<GitService>(MAIN_TOKENS.GitService).to(GitService);
17-
container
18-
.bind<DeepLinkService>(MAIN_TOKENS.DeepLinkService)
19-
.to(DeepLinkService);
20-
container.bind<OAuthService>(MAIN_TOKENS.OAuthService).to(OAuthService);
21-
22-
export function get<T>(token: symbol): T {
23-
return container.get<T>(token);
24-
}
20+
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
21+
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
22+
container.bind(MAIN_TOKENS.DockBadgeService).to(DockBadgeService);
23+
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
24+
container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService);
25+
container.bind(MAIN_TOKENS.FoldersService).to(FoldersService);
26+
container.bind(MAIN_TOKENS.FsService).to(FsService);
27+
container.bind(MAIN_TOKENS.GitService).to(GitService);
28+
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
29+
container.bind(MAIN_TOKENS.ShellService).to(ShellService);
30+
container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService);

apps/array/src/main/di/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { container, get } from "./container.js";
1+
export { container } from "./container.js";
22
export { MAIN_TOKENS } from "./tokens.js";

apps/array/src/main/di/tokens.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
*/
77
export const MAIN_TOKENS = Object.freeze({
88
// Services
9+
ContextMenuService: Symbol.for("Main.ContextMenuService"),
10+
DockBadgeService: Symbol.for("Main.DockBadgeService"),
11+
ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
12+
FileWatcherService: Symbol.for("Main.FileWatcherService"),
13+
FoldersService: Symbol.for("Main.FoldersService"),
14+
FsService: Symbol.for("Main.FsService"),
915
GitService: Symbol.for("Main.GitService"),
1016
DeepLinkService: Symbol.for("Main.DeepLinkService"),
1117
OAuthService: Symbol.for("Main.OAuthService"),
18+
ShellService: Symbol.for("Main.ShellService"),
19+
UpdatesService: Symbol.for("Main.UpdatesService"),
1220
});

0 commit comments

Comments
 (0)