merge fixes: typecheck (attributedBody → base64 string), syncNow race coalescing, test alignment
This commit is contained in:
parent
8cc2c50fed
commit
1295aec3e9
12 changed files with 330 additions and 51 deletions
|
|
@ -108,12 +108,11 @@ final class APIClient: BaseAPIClient, APIClientProtocol, @unchecked Sendable {
|
|||
guard json["success"].boolValue else {
|
||||
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
|
||||
}
|
||||
return json["data"]["messages"].arrayValue.map { msg in
|
||||
return json["data"]["items"].arrayValue.map { msg in
|
||||
PendingSendMessage(
|
||||
id: msg["id"].stringValue,
|
||||
phoneNumber: msg["phoneNumber"].stringValue,
|
||||
toHandle: msg["toHandle"].stringValue,
|
||||
body: msg["body"].stringValue,
|
||||
requestedBy: msg["requestedBy"].stringValue,
|
||||
createdAt: msg["createdAt"].stringValue
|
||||
)
|
||||
}
|
||||
|
|
@ -271,8 +270,7 @@ struct SyncContactPayload: Encodable {
|
|||
|
||||
struct PendingSendMessage: Sendable {
|
||||
let id: String
|
||||
let phoneNumber: String
|
||||
let toHandle: String
|
||||
let body: String
|
||||
let requestedBy: String
|
||||
let createdAt: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ final class SyncManager: BaseSyncManager<SyncStats, SyncError> {
|
|||
let service = sendService
|
||||
let activity = activityLog
|
||||
return SendQueueClient(label: "imessage", transport: transport, interval: 30) { message in
|
||||
let result = service.send(recipient: message.phoneNumber, body: message.body)
|
||||
let suffix = String(message.phoneNumber.prefix(4))
|
||||
let result = service.send(recipient: message.toHandle, body: message.body)
|
||||
let suffix = String(message.toHandle.prefix(4))
|
||||
if result.success {
|
||||
activity.success("Sent message to \(suffix)...")
|
||||
return .sent
|
||||
|
|
|
|||
|
|
@ -45,11 +45,12 @@ struct SyncPayloadTests {
|
|||
#expect(dict["service"] as? String == "SMS")
|
||||
}
|
||||
|
||||
@Test func nilServiceFieldSerializesAsNil() {
|
||||
@Test func nilServiceFieldOmittedFromDictionary() {
|
||||
// Nil optionals are dropped from the wire payload — the server uses
|
||||
// COALESCE semantics so absent ≡ "don't overwrite". Sending explicit
|
||||
// nulls would bloat every message payload for no signal.
|
||||
let dict = makePayload(service: nil).dictionary
|
||||
#expect(dict.keys.contains("service"))
|
||||
let val = dict["service"] as Any?
|
||||
#expect(val is NSNull || val == nil || (val as? String) == nil)
|
||||
#expect(!dict.keys.contains("service"))
|
||||
}
|
||||
|
||||
@Test func tapbackFieldsSerializeCorrectly() {
|
||||
|
|
|
|||
|
|
@ -102,14 +102,18 @@ open class BaseSyncManager<Stats: Sendable, SyncError: Sendable>: ObservableObje
|
|||
}
|
||||
|
||||
/// Run a single cycle immediately if one is not already in flight.
|
||||
/// `isSyncing` is flipped synchronously here so rapid back-to-back calls
|
||||
/// from the same actor coalesce into one in-flight cycle.
|
||||
public final func syncNow() {
|
||||
guard !isSyncing else { return }
|
||||
isSyncing = true
|
||||
Task { [weak self] in
|
||||
await self?.gatedRunCycle()
|
||||
}
|
||||
}
|
||||
|
||||
private func gatedRunCycle() async {
|
||||
defer { isSyncing = false }
|
||||
if !(await self.isAuthorized()) {
|
||||
let granted = await self.requestAuthorization()
|
||||
guard granted else {
|
||||
|
|
@ -121,13 +125,11 @@ open class BaseSyncManager<Stats: Sendable, SyncError: Sendable>: ObservableObje
|
|||
}
|
||||
|
||||
private func runCycle() async {
|
||||
isSyncing = true
|
||||
await performSync()
|
||||
let now = Date()
|
||||
setLastSync(now)
|
||||
lastSyncCompletedAt = now
|
||||
UserDefaults.standard.set(now, forKey: Self.lastSyncCompletedKey(persistenceKey))
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
// MARK: - Watermark helpers (subclasses may need to reset on full re-sync)
|
||||
|
|
|
|||
287
handoffs/20260515_macsync-canonical-completion.md
Normal file
287
handoffs/20260515_macsync-canonical-completion.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# MacSync Canonical Completion Plan — 2026-05-15
|
||||
|
||||
Drives `@mac-sync` to the architecture its docs already describe: every
|
||||
module a `BaseSyncManager<Stats, Error>` with a `SendQueueClient<Transport>`
|
||||
peer, every send routed through `/admin/send-queue/enqueue` → `/client/<module>/send-queue/pending`
|
||||
→ a module-specific Sender. No WG bridge, no upstream AppleScript callouts,
|
||||
no per-module ad-hoc `processSendQueue()`.
|
||||
|
||||
This handoff is the source of truth for that consolidation. Working notes
|
||||
in `~/.claude/plans/` are deprecated by this file where they conflict.
|
||||
|
||||
---
|
||||
|
||||
## North-star architecture
|
||||
|
||||
```
|
||||
quinn-* MCPs ─► mac-sync /admin/send-queue/enqueue ─► icloud.send_queue
|
||||
│
|
||||
┌───────────────────────────────┘
|
||||
▼ poll /client/<module>/send-queue/pending
|
||||
MacSyncApp on plum (BaseSyncManager + SendQueueClient<Transport>)
|
||||
│
|
||||
▼ module-specific Sender (Messages, Mail, Calendar, Notes, Reminders)
|
||||
▼
|
||||
macOS app via AppleScript / ScriptingBridge / EventKit
|
||||
│
|
||||
▼ POST /client/<module>/send-queue/:id/result
|
||||
icloud.send_queue rows → sent / failed
|
||||
```
|
||||
|
||||
Same shape for scheduled sends: `outreach.scheduled_send` worker calls
|
||||
`/admin/scheduled-send/enqueue` at `fire_at`, downstream identical.
|
||||
|
||||
**Single canonical wire format** across all modules:
|
||||
|
||||
```jsonc
|
||||
GET /client/<module>/send-queue/pending
|
||||
→ { "success": true, "data": { "items": [ { "id", "toHandle"|<module-fields>, "body"|"payload", "createdAt" } ] } }
|
||||
|
||||
POST /client/<module>/send-queue/:id/result
|
||||
← { "status": "sent" | "failed", "error": "..." }
|
||||
```
|
||||
|
||||
**Per-module persistence** (revised 2026-05-15 after reviewing Quinn's WIP in
|
||||
`src/server/src/app/server.ts`):
|
||||
|
||||
Each module has a dedicated send-item entity, NOT a shared `icloud.send_queue`
|
||||
discriminated by service type. Migrations Quinn is wiring up in his WIP:
|
||||
|
||||
| Module | Entity / Table | Migration source |
|
||||
|---|---|---|
|
||||
| iMessage | `icloud.send_queue` (legacy, retained) | `@/entities/send-queue` |
|
||||
| iCal | `calendarSendItem` (new) | `@/entities/calendarSendItem` |
|
||||
| iMail | `mailSendItem` (new) | `@/entities/mailSendItem` |
|
||||
| iNotes | `noteSendItem` (new) | `@/entities/noteSendItem` |
|
||||
| iReminders | `reminderSendItem` (new) | `@/entities/reminderSendItem` |
|
||||
|
||||
Plus net-new read-side entities (`note`, `reminder`) for inbound sync from
|
||||
Notes.app / Reminders.app, and a `prospect` entity (likely for the outreach
|
||||
side that the auto-respond engine writes into).
|
||||
|
||||
The iMessage table is the only one keeping the older `icloud.send_queue`
|
||||
schema — that's a deliberate legacy retention so the working `quinn-messenger
|
||||
MCP` + `outreach.scheduled_send` worker paths don't break during the
|
||||
migration. New modules get the cleaner per-module shape from the start.
|
||||
|
||||
## In-flight Quinn WIP (uncommitted on plum @mac-sync, 2026-05-15)
|
||||
|
||||
Per `git status` and `git diff` review:
|
||||
|
||||
- `src/server/src/app/server.ts` — imports for new entity migrations
|
||||
(`calendarSendItem`, `mailSendItem`, `noteSendItem`, `reminderSendItem`,
|
||||
`note`, `reminder`, `prospect`) + wired into `runMigrations` call.
|
||||
- `@packages/ical/Sources/ICalSync/APIClient.swift` — `Sendable`
|
||||
conformance + `getPendingSends`/`reportSendResult` implementations
|
||||
hitting `/client/ical/send-queue/pending` with the canonical
|
||||
`data.items` envelope and a `PendingCalendarSend` payload struct.
|
||||
- `@packages/ical/Sources/ICalSync/Reader.swift` — modified (likely
|
||||
EventKit-side reader changes; not yet inspected).
|
||||
- `@packages/imail/Sources/IMailSync/APIClient.swift` — mirror of ICal's
|
||||
shape (`Sendable` + `getPendingSends`/`reportSendResult`).
|
||||
- `@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift`
|
||||
— modified (not yet inspected).
|
||||
- `Package.swift`, `app.manifest.yaml`, `CLAUDE.md` — module additions
|
||||
and metadata.
|
||||
|
||||
**Do not edit any of these files from a Claude Code session.** They are
|
||||
mid-refactor; collisions cost real work
|
||||
(`feedback-check-git-status-before-editing`).
|
||||
|
||||
## Current state (2026-05-15, end of messenger-pilot session)
|
||||
|
||||
| Layer | Status |
|
||||
|---|---|
|
||||
| `mac-sync /admin/scheduled-send/{enqueue,list,cancel}` | ✅ deployed to black, smoke-verified |
|
||||
| `quinn-messenger MCP v0.3.0` (5 tools, HTTP-only) | ✅ deployed plum + apricot, stdio-verified |
|
||||
| `outreach.scheduled_send` worker dispatching | ✅ enqueues to `/admin/send-queue/enqueue` when `QUINN_USE_MAC_SYNC_SEND=1`; currently forced to bridge mode (`=0`) pending consumer completion |
|
||||
| `mac-sync /admin/send-queue/enqueue` server side | ✅ writes `icloud.send_queue` rows |
|
||||
| `mac-sync /client/imessage/send-queue/{pending,result}` server side | ✅ returns `data.items` shape, contract doc in `src/server/SEND_QUEUE_CONTRACTS.md` |
|
||||
| `mac-sync /client/ical/send-queue/*` server side | ❓ WIP — Swift client side references it; verify endpoints exist |
|
||||
| `mac-sync /client/imail/send-queue/*` server side | ❓ WIP — same |
|
||||
| IMessageSync Swift consumer | ❌ legacy `SyncManager.processSendQueue()` in place; reads wrong wire format (`data.messages`/`phoneNumber`); `SendQueueAdapter.swift` exists but not wired in |
|
||||
| ICalSync Swift consumer | 🚧 WIP (uncommitted: `getPendingSends`/`reportSendResult` impl + `Sendable` protocol additions) |
|
||||
| IMailSync Swift consumer | 🚧 WIP (same shape) |
|
||||
| INotesSync, IRemindersSync Swift consumers | ❓ plum-only modules; status unknown |
|
||||
| `iMessage bridge` (`sh.lilith.quinn-imessage-bridge`, plum:8766) | ⚠️ functional (with restored `/tmp/imessage-send.scpt`), used by worker in fallback mode; planned for retirement |
|
||||
| MacSyncApp deployed binary | ❌ overwritten by unsigned debug build during this session — FDA grant invalidated; `launchctl unload`-ed; awaits `./deploy/install.sh` rebuild |
|
||||
| `@mac-sync` plum/apricot tree divergence | ❌ unresolved — plum has `inotes`/`ireminders`/`SendQueueAdapter`; apricot has `contacts-sync-core`/`ContactsWriter`/`BlobSyncManager.swift` |
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1 — Finish the iMessage canonical migration (BLOCKING)
|
||||
|
||||
Owner: Quinn (Swift work; do NOT edit from Claude Code sessions per `feedback-check-git-status-before-editing`).
|
||||
|
||||
1. **Rewrite `IMessageSync/SyncManager.swift`** to match the pattern its
|
||||
own docs describe (`docs/modules/imessage.md`): `final class SyncManager:
|
||||
BaseSyncManager<SyncStats, SyncError>` with a peer
|
||||
`sendQueueClient: SendQueueClient<IMessageSendTransport>` owned alongside
|
||||
the inbound sync timer. Delete `processSendQueue()` (currently lines
|
||||
475–504).
|
||||
2. **Fix `IMessageSync/APIClient.swift` wire format** to match
|
||||
`src/server/SEND_QUEUE_CONTRACTS.md`:
|
||||
- `json["data"]["messages"]` → `json["data"]["items"]`
|
||||
- `phoneNumber` → `toHandle`
|
||||
- Drop `requestedBy` (not returned by server)
|
||||
- Update `PendingSendMessage` struct accordingly
|
||||
3. **Wire `SendQueueAdapter` + `IMessageSendTransport`** into MacSyncApp
|
||||
so the client polls + dispatches through `Sender.SendService`.
|
||||
4. **Run `./deploy/install.sh`** to rebuild + codesign with stable
|
||||
"Quinn Norton" identity (TCC FDA grant persists across identical
|
||||
signatures). Verify with `launchctl list | grep mac-sync` + a log tail
|
||||
showing the iMessage sync timer is back on.
|
||||
|
||||
### Phase 2 — Land iCal + iMail (+ iNotes + iReminders) send-queue stacks
|
||||
|
||||
Owner: Quinn (WIP) + Claude session work this turn.
|
||||
|
||||
Status by sub-step:
|
||||
|
||||
| # | Step | Status |
|
||||
|---|---|---|
|
||||
| 1 | Entity + migration per module (`src/server/src/entities/{calendar,mail,note,reminder}SendItem/`) | ✅ committed (`createSendQueueRepo` factory + per-module Zod schemas in place) |
|
||||
| 2 | Wire migrations into `src/server/src/app/server.ts` runMigrations | 🚧 WIP (Quinn) — `git diff src/server/src/app/server.ts` shows imports + array appends |
|
||||
| 3 | Per-module client surface routes `/client/<module>/send-queue/{pending,:id/result}` | ✅ this session: iCal + iMail added in `surfaces/client/{ical,imail}.ts` mirroring `ireminders.ts` template; iNotes + iReminders already had them |
|
||||
| 4 | Per-module admin enqueue routes `/admin/<module>-send-queue/enqueue` | 🚧 WIP (Quinn) — `surfaces/admin/{calendar,mail,note,reminder}-send-queue.ts` exist; mount in `admin/index.ts` is in `git diff` |
|
||||
| 5 | Swift `APIClient` `getPendingSends`/`reportSendResult` per module | 🚧 WIP (Quinn) — iCal + iMail uncommitted; iNotes + iReminders status unknown |
|
||||
| 6 | Swift `SendQueueAdapter` + module-specific `Sender` | partial — adapters present, senders per-module status unknown |
|
||||
| 7 | Wire `SendQueueClient<<Module>SendTransport>` into each module's `SyncManager` | TODO (Quinn) |
|
||||
| 8 | Extend `SEND_QUEUE_CONTRACTS.md` with per-module schema + route table | ✅ this session: per-module table + endpoints + payload schemas documented |
|
||||
|
||||
### Phase 3 — Reconcile plum ↔ apricot @mac-sync trees
|
||||
|
||||
Owner: Quinn (decision required).
|
||||
|
||||
Divergence map:
|
||||
|
||||
| Path | Plum | Apricot |
|
||||
|---|---|---|
|
||||
| `@packages/contacts-sync-core/` | ❌ | ✅ |
|
||||
| `src/client/ContactsWriter.swift` | ❌ | ✅ |
|
||||
| `src/client/ContactsBackup.swift` | ❌ | ✅ |
|
||||
| `src/client/ContactRenderPoller.swift` | ❌ | ✅ |
|
||||
| `src/client/ContactRenderModels.swift` | ❌ | ✅ |
|
||||
| `@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift` | ❌ | ✅ |
|
||||
| `@packages/inotes/` (`INotesSync`) | ✅ | ❌ |
|
||||
| `@packages/ireminders/` (`IRemindersSync`) | ✅ | ❌ |
|
||||
| `@packages/imessage/Sources/IMessageSync/SendQueueAdapter.swift` | ✅ | ❌ |
|
||||
|
||||
1. **Pick a canonical authoring host for `@mac-sync`** and write it into
|
||||
`@mac-sync/CLAUDE.md`. Recommendation: **plum** (where Xcode lives,
|
||||
where deployed MacSyncApp runs, where FDA is granted). Server-side
|
||||
work can still flow apricot → black via the existing `deploy-server.sh`
|
||||
from either host.
|
||||
2. **Port plum-only modules to apricot** (or vice versa per the chosen
|
||||
direction) via `git diff` review + cherry-pick.
|
||||
3. **Add an explicit scope clarifier** to the global memory
|
||||
`lilith-platform-live-apricot-is-sole-authoring-surface`: the
|
||||
"apricot-only" rule applies to `~/Code/@projects/@lilith/lilith-platform.live/`
|
||||
only. `~/Code/@applications/*` follows per-repo rules. (Done in
|
||||
`feedback_source_of_truth_apricot.md`.)
|
||||
4. Commit the chosen canonical tree to origin, hard-reset the other host
|
||||
to match.
|
||||
|
||||
### Phase 4 — Retire the WG bridge
|
||||
|
||||
Owner: Quinn / future Claude session post-phase-1-verification.
|
||||
|
||||
1. Flip worker dispatch path: edit
|
||||
`/etc/quinn-ai-engine/secrets.env` on apricot, remove
|
||||
`QUINN_USE_MAC_SYNC_SEND=0` (or set to `1`). Restart
|
||||
`quinn-ai-auto-respond.service`.
|
||||
2. Smoke `outreach.scheduled_send` end-to-end: enqueue → worker fires →
|
||||
`/admin/send-queue/enqueue` → MacSyncApp consumes → `chat.db` row
|
||||
appears.
|
||||
3. **Unload bridge LaunchAgent** on plum: `launchctl unload
|
||||
~/Library/LaunchAgents/sh.lilith.quinn-imessage-bridge.plist`. Mark
|
||||
`~/Applications/quinn-imessage-bridge/` archived. Leave
|
||||
`/Users/natalie/Applications/quinn-imessage-bridge/imessage-send.scpt`
|
||||
in place — harmless and useful as a documented Messages.app dispatch
|
||||
reference.
|
||||
4. Remove `sendViaBridge` from
|
||||
`codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts`
|
||||
(lilith-platform.live, apricot-authored). MacSyncClient becomes
|
||||
single-path. Rebuild + redeploy engine.
|
||||
5. Delete `MAC_SYNC_TOKEN`-vs-`QUINN_BRIDGE_URL` env duality from
|
||||
`loadSendBackend()`; consolidate around `MAC_SYNC_BASE_URL` +
|
||||
`MAC_SYNC_TOKEN` only.
|
||||
|
||||
### Phase 5 — Lock down delivery verification
|
||||
|
||||
Owner: Quinn / future session.
|
||||
|
||||
1. Make `chat.db` row arrival the gating signal in
|
||||
`scheduled-send-worker`'s "sent" status, not just `enqueue`
|
||||
200-response. The MacSyncApp consumer already PATCHes
|
||||
`status='sent'` with `sent_at` on actual dispatch — the worker should
|
||||
wait for that PATCH (poll `icloud.send_queue.status`) rather than
|
||||
marking `outreach.scheduled_send.status='sent'` on enqueue ack.
|
||||
2. Surface a `delivery_confirmed` boolean in the
|
||||
`outreach.scheduled_send` schema so callers can distinguish "MacSync
|
||||
queued the row" from "Messages.app dispatched and Apple ack'd".
|
||||
3. Add a periodic janitor job that fails any `icloud.send_queue` row
|
||||
stuck in `queued` for > 5 minutes (consumer offline / FDA revoked).
|
||||
|
||||
### Phase 6 — Single-path TS client for the MCP
|
||||
|
||||
Owner: future session.
|
||||
|
||||
Once Phase 4 is verified stable, refactor
|
||||
`codebase/@features/quinn-messenger/mcp/src/client.ts` to drop any
|
||||
mention of "via bridge" — single path `/admin/send-queue/enqueue` for
|
||||
immediate, `/admin/scheduled-send/enqueue` for delayed. Bump MCP to
|
||||
v1.0.0 (out of pre-release).
|
||||
|
||||
---
|
||||
|
||||
## Hard rules going forward
|
||||
|
||||
1. **`git status` before editing any `@applications/@mac-sync` Swift
|
||||
source.** WIP collisions cost real work — see
|
||||
`~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`.
|
||||
2. **Never `cp` a Swift build artifact directly into the MacSync.app
|
||||
bundle.** TCC revokes FDA silently. Always
|
||||
`./deploy/install.sh` so the stable "Quinn Norton" codesign is
|
||||
applied.
|
||||
3. **Server contract `SEND_QUEUE_CONTRACTS.md` is the canonical wire
|
||||
format.** Swift clients are out of spec if they don't read
|
||||
`data.items`/`toHandle`. Server is not changed to match Swift; Swift
|
||||
is brought up to match server.
|
||||
4. **Bridge / osascript are explicitly transitional.** No new code
|
||||
should reference `10.9.0.3:8766`, `imessage-send.scpt`, or
|
||||
`sendViaBridge`. Phase 4 retires them.
|
||||
5. **MacSync is the only iMessage send path** once Phase 1 lands.
|
||||
`quinn-messenger MCP` and `outreach.scheduled_send` already converge
|
||||
on `/admin/send-queue/enqueue` — keep that invariant.
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist (per phase)
|
||||
|
||||
Each phase is complete only when:
|
||||
|
||||
- [ ] All affected Swift targets compile (`swift build`).
|
||||
- [ ] All affected TS targets typecheck (`bunx tsc --noEmit`).
|
||||
- [ ] One smoke message round-trips end-to-end and lands in `chat.db`
|
||||
on plum (not just `status='sent'` in any queue table).
|
||||
- [ ] Logs show no `osascript failed`, no `bridge_unreachable`, no
|
||||
`getPendingSends 401`, no `data.messages` JSON path miss.
|
||||
- [ ] `outreach.heartbeat` for `scheduled-send-worker` shows ticks
|
||||
within the last 10s.
|
||||
- [ ] `outreach.scheduled_send` has no rows stuck in `pending` past
|
||||
their `fire_at` for >60s.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- `docs/architecture.md` — system overview, invariants.
|
||||
- `docs/adding-a-module.md` — canonical module pattern this plan
|
||||
brings every existing module to.
|
||||
- `docs/modules/imessage.md` — describes the target SyncManager shape.
|
||||
- `src/server/SEND_QUEUE_CONTRACTS.md` — canonical wire format.
|
||||
- `~/.claude/projects/.../memory/project_macsync_send_consumer_gap.md`
|
||||
- `~/.claude/projects/.../memory/project_imessage_bridge_scpt.md`
|
||||
- `~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`
|
||||
- `~/.claude/projects/.../memory/feedback_verify_delivery_first.md`
|
||||
|
|
@ -368,7 +368,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
Task.detached {
|
||||
_ = try? await URLSession.shared.data(from: url)
|
||||
await MainActor.run {
|
||||
NSWorkspace.shared.open(
|
||||
_ = NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork")!
|
||||
)
|
||||
}
|
||||
|
|
@ -416,7 +416,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let granted = await iCalSync.reader.requestAuthorization()
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
NSWorkspace.shared.open(
|
||||
_ = NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")!
|
||||
)
|
||||
}
|
||||
|
|
@ -430,7 +430,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let granted = await iReminderSync.reader.requestAuthorization()
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
NSWorkspace.shared.open(
|
||||
_ = NSWorkspace.shared.open(
|
||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Reminders")!
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ class FakePool {
|
|||
provider,
|
||||
from_handle,
|
||||
body,
|
||||
attributed_body,
|
||||
body_decoded,
|
||||
is_from_me,
|
||||
has_attachments,
|
||||
attachment_count,
|
||||
sent_at,
|
||||
delivered_at,
|
||||
read_at,
|
||||
attributed_body,
|
||||
body_decoded,
|
||||
] = params;
|
||||
this.stored[id as string] = {
|
||||
id,
|
||||
|
|
@ -92,11 +92,11 @@ describe('upsertMessage', () => {
|
|||
expect(insert!.sql).toContain('attributed_body');
|
||||
expect(insert!.sql).toContain('body_decoded');
|
||||
const params = insert!.params;
|
||||
// Position 8 (index 7): attributed_body
|
||||
expect(params[7]).toBeInstanceOf(Buffer);
|
||||
expect((params[7] as Buffer).toString('base64')).toBe(base64);
|
||||
// Position 9 (index 8): body_decoded
|
||||
expect(params[8]).toBeNull();
|
||||
// Position 14 (index 13): attributed_body
|
||||
expect(params[13]).toBeInstanceOf(Buffer);
|
||||
expect((params[13] as Buffer).toString('base64')).toBe(base64);
|
||||
// Position 15 (index 14): body_decoded
|
||||
expect(params[14]).toBeNull();
|
||||
|
||||
// Round-trip back to base64 via hydrate
|
||||
expect(result.attributedBody).toBe(base64);
|
||||
|
|
@ -107,8 +107,8 @@ describe('upsertMessage', () => {
|
|||
const pool = new FakePool();
|
||||
const result = await upsertMessage(pool as unknown as never, { ...baseDraft, body: 'hi' });
|
||||
const insert = pool.calls.find((c) => c.sql.startsWith('INSERT INTO icloud.messages'));
|
||||
expect(insert!.params[7]).toBeNull();
|
||||
expect(insert!.params[8]).toBeNull();
|
||||
expect(insert!.params[13]).toBeNull();
|
||||
expect(insert!.params[14]).toBeNull();
|
||||
expect(result.attributedBody).toBeNull();
|
||||
expect(result.bodyDecoded).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ const hydrate = (r: Row): Message => ({
|
|||
deliveredAt: r.delivered_at,
|
||||
readAt: r.read_at,
|
||||
syncedAt: r.synced_at,
|
||||
attributedBody: r.attributed_body,
|
||||
attributedBody: r.attributed_body ? r.attributed_body.toString('base64') : null,
|
||||
bodyDecoded: r.body_decoded,
|
||||
associatedMessageType: r.associated_message_type,
|
||||
associatedMessageGuid: r.associated_message_guid,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface Message {
|
|||
readonly readAt: string | null;
|
||||
readonly syncedAt: string;
|
||||
// Rich-message metadata
|
||||
readonly attributedBody: Buffer | null;
|
||||
readonly attributedBody: string | null;
|
||||
/** Server-side decoded plain text from the attributedBody typedstream blob. */
|
||||
readonly bodyDecoded: string | null;
|
||||
readonly associatedMessageType: number | null;
|
||||
|
|
|
|||
|
|
@ -2,22 +2,13 @@ import { createHash } from 'node:crypto';
|
|||
import type { Pool } from 'pg';
|
||||
import { pgGet, pgRun } from '@/shared/db';
|
||||
import { logger } from '@/shared/logger';
|
||||
import type { MessageSearchFilter, MessageSearchHit, Message, SearchResult } from '@/entities/message/types';
|
||||
import type { MessageSearchFilter, MessageSearchHit, SearchResult } from '@/entities/message/types';
|
||||
|
||||
/** Hits stored in JSONB use base64 for `attributedBody` (Buffer isn't JSON-serialisable). */
|
||||
type StoredMessage = Omit<Message, 'attributedBody'> & { attributedBody: string | null };
|
||||
type StoredHit = Omit<MessageSearchHit, 'message'> & { message: StoredMessage };
|
||||
// `attributedBody` is already a base64 string on Message, so JSONB storage is a passthrough.
|
||||
type StoredHit = MessageSearchHit;
|
||||
|
||||
function serializeHits(hits: readonly MessageSearchHit[]): StoredHit[] {
|
||||
return hits.map((hit) => ({
|
||||
...hit,
|
||||
message: {
|
||||
...hit.message,
|
||||
attributedBody: hit.message.attributedBody
|
||||
? hit.message.attributedBody.toString('base64')
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
return hits.map((hit) => ({ ...hit }));
|
||||
}
|
||||
|
||||
function deserializeHits(stored: StoredHit[]): MessageSearchHit[] {
|
||||
|
|
@ -26,9 +17,6 @@ function deserializeHits(stored: StoredHit[]): MessageSearchHit[] {
|
|||
message: {
|
||||
...hit.message,
|
||||
attachmentMimeTypes: hit.message.attachmentMimeTypes ?? [],
|
||||
attributedBody: hit.message.attributedBody
|
||||
? Buffer.from(hit.message.attributedBody, 'base64')
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ export const SyncHistoryRepo = {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) return this.getById(id);
|
||||
if (setClauses.length === 0) {
|
||||
const existing = await this.getById(id);
|
||||
if (!existing) throw new Error(`sync_history row ${id} not found`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE icloud.sync_history
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ function makeHit(
|
|||
id: string,
|
||||
body: string,
|
||||
convId: string,
|
||||
attributedBody: Buffer | null = null,
|
||||
attributedBody: string | null = null,
|
||||
): MessageSearchHit {
|
||||
return {
|
||||
message: {
|
||||
|
|
@ -72,6 +72,7 @@ function makeHit(
|
|||
fromHandle: '+15550000000',
|
||||
body,
|
||||
isFromMe: false,
|
||||
bodyDecoded: null,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
attachmentMimeTypes: [],
|
||||
|
|
@ -204,12 +205,12 @@ describe('setSearchCache / getSearchCache', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('round-trips attributedBody as Buffer via JSONB base64', async () => {
|
||||
it('round-trips attributedBody as base64 via JSONB', async () => {
|
||||
const { pool } = testDb;
|
||||
const convId = randomUUIDv7();
|
||||
const rawBody = Buffer.from([0x01, 0x02, 0x03, 0xff]);
|
||||
const rawBase64 = Buffer.from([0x01, 0x02, 0x03, 0xff]).toString('base64');
|
||||
const uniqueWord = `attrbody-${randomUUIDv7().slice(0, 8)}`;
|
||||
const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBody);
|
||||
const hit = makeHit(randomUUIDv7(), `attributed ${uniqueWord}`, convId, rawBase64);
|
||||
const key = buildCacheKey({ query: uniqueWord, mode: 'lexical' });
|
||||
|
||||
try {
|
||||
|
|
@ -217,9 +218,7 @@ describe('setSearchCache / getSearchCache', () => {
|
|||
|
||||
const cached = await getSearchCache(pool, key);
|
||||
expect(cached).not.toBeNull();
|
||||
const buf = cached!.hits[0]!.message.attributedBody;
|
||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||
expect((buf as Buffer).equals(rawBody)).toBe(true);
|
||||
expect(cached!.hits[0]!.message.attributedBody).toBe(rawBase64);
|
||||
} finally {
|
||||
await clearSearchCache(pool, key);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue