macsync/docs/adding-a-module.md

7.4 KiB

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):

.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:

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):

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):

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:

...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:

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 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