208 lines
7.4 KiB
Markdown
208 lines
7.4 KiB
Markdown
|
|
# Adding a module
|
||
|
|
|
||
|
|
A walk-through for adding a 7th module — say, `ISafariSync` for syncing
|
||
|
|
Safari bookmarks — following the patterns already in place for the six
|
||
|
|
shipping modules. Every step cites where the equivalent already lives.
|
||
|
|
|
||
|
|
## 0. Decide direction and OS surface
|
||
|
|
|
||
|
|
- **Direction**: read-only (like iPhoto), 2-way via SendQueueClient (like
|
||
|
|
iCal), or 2-way via AppleScript (like iNotes)?
|
||
|
|
- **OS surface**: which framework? Safari bookmarks live in
|
||
|
|
`~/Library/Safari/Bookmarks.plist`; assume a plist reader for this example.
|
||
|
|
- **Interval**: how often to tick? Cheap reads (EventKit) every 300s; slow
|
||
|
|
AppleScript every 600s; near-realtime queries (chat.db) every 30s.
|
||
|
|
|
||
|
|
Document this in a one-line summary you can paste into the README table.
|
||
|
|
|
||
|
|
## 1. SwiftPM target
|
||
|
|
|
||
|
|
Add a target block in `Package.swift` modelled on
|
||
|
|
`Package.swift:80-87` (`IReminderSync` is the closest analogue):
|
||
|
|
|
||
|
|
```swift
|
||
|
|
.target(
|
||
|
|
name: "ISafariSync",
|
||
|
|
dependencies: ["MacSyncShared"],
|
||
|
|
path: "@packages/isafari/Sources/ISafariSync"
|
||
|
|
// linkerSettings only if you need a framework like EventKit
|
||
|
|
),
|
||
|
|
.testTarget(
|
||
|
|
name: "ISafariSyncTests",
|
||
|
|
dependencies: ["ISafariSync"],
|
||
|
|
path: "@packages/isafari/Tests/ISafariSyncTests"
|
||
|
|
),
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `"ISafariSync"` to the `MacSyncApp` executable dependencies block
|
||
|
|
(`Package.swift:97-110`).
|
||
|
|
|
||
|
|
## 2. Module source files
|
||
|
|
|
||
|
|
Create four files under `@packages/isafari/Sources/ISafariSync/`:
|
||
|
|
|
||
|
|
| File | Role | Example to copy |
|
||
|
|
|-------------------|-----------------------------------------------------|------------------------------------------------------------------------------|
|
||
|
|
| `Reader.swift` | Talks to the OS (plist parse, EventKit, AppleScript)| `@packages/inotes/Sources/INoteSync/Reader.swift` |
|
||
|
|
| `APIClient.swift` | Typed HTTPS calls over `BaseAPIClient` | `@packages/inotes/Sources/INoteSync/APIClient.swift` (esp. lines 60-130) |
|
||
|
|
| `SyncManager.swift`| Subclass of `BaseSyncManager<Stats, Error>` | `@packages/inotes/Sources/INoteSync/SyncManager.swift` (full file) |
|
||
|
|
| `Sender.swift` | Only if bidirectional | `@packages/ical/Sources/ICalSync/Sender.swift` |
|
||
|
|
|
||
|
|
Skeleton for `SyncManager.swift`:
|
||
|
|
|
||
|
|
```swift
|
||
|
|
import MacSyncShared
|
||
|
|
|
||
|
|
public struct ISafariSyncStats: Equatable, Sendable {
|
||
|
|
public var bookmarkCount: Int = 0
|
||
|
|
public init() {}
|
||
|
|
}
|
||
|
|
|
||
|
|
public enum ISafariSyncError: Equatable, Sendable {
|
||
|
|
case none
|
||
|
|
case bookmarksUnavailable
|
||
|
|
case connectionFailed(String)
|
||
|
|
}
|
||
|
|
|
||
|
|
@MainActor
|
||
|
|
public final class SyncManager: BaseSyncManager<ISafariSyncStats, ISafariSyncError> {
|
||
|
|
public static let shared = SyncManager()
|
||
|
|
private init() {
|
||
|
|
super.init(
|
||
|
|
initialStats: ISafariSyncStats(),
|
||
|
|
noError: .none,
|
||
|
|
persistenceKey: "isafari", // becomes the UserDefaults watermark namespace
|
||
|
|
timerInterval: 600
|
||
|
|
)
|
||
|
|
}
|
||
|
|
public override func performSync() async {
|
||
|
|
// Reader.fetch -> APIClient.sync(payloads) -> stats update
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
If bidirectional, also override `didStartSync()` / `willStopSync()` and own a
|
||
|
|
`lazy var sendQueueClient: SendQueueClient<SafariSendTransport>`. The two
|
||
|
|
existing patterns:
|
||
|
|
|
||
|
|
- EventKit: `@packages/ical/Sources/ICalSync/SyncManager.swift:54-73`
|
||
|
|
- AppleScript: `@packages/inotes/Sources/INoteSync/SyncManager.swift:53-72`
|
||
|
|
|
||
|
|
## 3. Server entities, features, surfaces
|
||
|
|
|
||
|
|
The repo follows a strict three-layer split:
|
||
|
|
|
||
|
|
- `src/server/src/entities/<entity>/` — schema migrations, types, repo
|
||
|
|
- `src/server/src/features/<module>/` — service-level functions (`syncBookmarks`, `queryBookmarks`)
|
||
|
|
- `src/server/src/surfaces/{client,my,admin}/<module>.ts` — HTTP routes
|
||
|
|
|
||
|
|
Files to create for read-only iSafari (mirror `inotes/`):
|
||
|
|
|
||
|
|
```
|
||
|
|
src/server/src/entities/bookmark/{schema.ts,types.ts,repo.ts,index.ts}
|
||
|
|
src/server/src/features/isafari/{index.ts,syncService.ts}
|
||
|
|
src/server/src/surfaces/client/isafari.ts
|
||
|
|
src/server/src/surfaces/my/bookmarks.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
If bidirectional, also create:
|
||
|
|
|
||
|
|
```
|
||
|
|
src/server/src/entities/bookmarkSendItem/{schema.ts,types.ts,index.ts}
|
||
|
|
src/server/src/features/isafari/sendService.ts
|
||
|
|
src/server/src/surfaces/admin/bookmark-send-queue.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
`bookmarkSendItem/schema.ts` reuses the table-builder
|
||
|
|
(`src/server/src/entities/noteSendItem/schema.ts:6-8` for the pattern):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { sendQueueTableSql } from '@/shared/sendQueue/SendQueueRepo';
|
||
|
|
|
||
|
|
export const bookmarkSendItemMigrations = [
|
||
|
|
{ id: '2026-05-13_bookmark_send_queue_initial',
|
||
|
|
sql: sendQueueTableSql('icloud.bookmark_send_queue') },
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
`features/isafari/sendService.ts` (mirroring
|
||
|
|
`src/server/src/features/inotes/sendService.ts:11-19`):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const BOOKMARK_SEND_ACTIONS = ['create_bookmark', 'update_bookmark', 'delete_bookmark'] as const;
|
||
|
|
const payloadSchema = z.object({ /* ... */ });
|
||
|
|
const repo = createSendQueueRepo({
|
||
|
|
tableName: 'icloud.bookmark_send_queue',
|
||
|
|
payloadSchema,
|
||
|
|
allowedActions: BOOKMARK_SEND_ACTIONS,
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## 4. Wire the routes
|
||
|
|
|
||
|
|
Three index files need a one-line edit each (the rest of the surface tree is
|
||
|
|
discovered automatically):
|
||
|
|
|
||
|
|
- `src/server/src/surfaces/client/index.ts:11-19` — add
|
||
|
|
`.route('/isafari', isafariClientRouter)`
|
||
|
|
- `src/server/src/surfaces/my/index.ts:11-19` — add
|
||
|
|
`.route('/bookmarks', bookmarksMyRouter)`
|
||
|
|
- `src/server/src/surfaces/admin/index.ts:10-17` — add
|
||
|
|
`.route('/bookmark-send-queue', bookmarkSendQueueAdminRouter)` if bidirectional
|
||
|
|
|
||
|
|
Migrations also need registration in `src/server/src/app/server.ts:36-52`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
...bookmarkMigrations,
|
||
|
|
...bookmarkSendItemMigrations, // if bidirectional
|
||
|
|
```
|
||
|
|
|
||
|
|
## 5. Web tab
|
||
|
|
|
||
|
|
Create `web/src/api/bookmarks.ts` and `web/src/tabs/Bookmarks/index.tsx`,
|
||
|
|
modelled on `web/src/api/notes.ts` and `web/src/tabs/Notes/index.tsx`. Then
|
||
|
|
add a route in `web/src/App.tsx:55-65`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
const BookmarksTab = lazy(() =>
|
||
|
|
import('./tabs/Bookmarks').then((m) => ({ default: m.BookmarksTab })),
|
||
|
|
);
|
||
|
|
// inside <Routes>:
|
||
|
|
<Route path="/bookmarks" element={<BookmarksTab />} />
|
||
|
|
```
|
||
|
|
|
||
|
|
And a nav entry inside `web/src/layout/AppShell` (not pasted here — copy the
|
||
|
|
Notes entry).
|
||
|
|
|
||
|
|
## 6. App manifest & docs
|
||
|
|
|
||
|
|
- `app.manifest.yaml:6-12` — append `- isafari` to the `modules:` list.
|
||
|
|
- `CLAUDE.md:7-29` — extend the structure block.
|
||
|
|
- Add `docs/modules/isafari.md` modelled on
|
||
|
|
`docs/modules/inotes.md`.
|
||
|
|
|
||
|
|
## 7. Tests
|
||
|
|
|
||
|
|
Mirror an existing module's `Tests/<Module>SyncTests/` directory. Hermetic
|
||
|
|
test coverage is limited to what does not need the OS — see
|
||
|
|
[modules/inotes.md](./modules/inotes.md#tests) for the breakdown.
|
||
|
|
|
||
|
|
For the server, follow `src/server/src/shared/sendQueue/__tests__/SendQueueRepo.test.ts`
|
||
|
|
(plan reference: `~/.claude/plans/magical-tumbling-peach.md:66`) for
|
||
|
|
queue round-trip tests against a test database.
|
||
|
|
|
||
|
|
## Checklist
|
||
|
|
|
||
|
|
- [ ] `Package.swift` target + executable dep
|
||
|
|
- [ ] `Reader.swift`, `APIClient.swift`, `SyncManager.swift` (+ `Sender.swift`)
|
||
|
|
- [ ] Server `entities/<x>/` migration + types + repo
|
||
|
|
- [ ] Server `features/<x>/` service
|
||
|
|
- [ ] Server `surfaces/client/<x>.ts`, `surfaces/my/<x>.ts` (+ admin if bidirectional)
|
||
|
|
- [ ] Migrations registered in `app/server.ts`
|
||
|
|
- [ ] Routes registered in three `surfaces/*/index.ts`
|
||
|
|
- [ ] Web `api/<x>.ts`, `tabs/<X>/index.tsx`, App.tsx route, AppShell nav entry
|
||
|
|
- [ ] `app.manifest.yaml` module list
|
||
|
|
- [ ] `docs/modules/<x>.md`
|
||
|
|
- [ ] Tests: `@packages/<x>/Tests/` + `src/server/src/features/<x>/__tests__/`
|
||
|
|
- [ ] `swift test` green, `bun test` green, `bun run typecheck` green
|