@procedures/ios-backup 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/backup.ts ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * The reader of an iPhone backup in the MobileSync format, without a native dependency.
3
+ *
4
+ * A backup folder holds `Manifest.db` and the files of the phone. The name of each file is its
5
+ * `fileID`, in a subfolder with the first two characters of that name. `Manifest.db` gives the
6
+ * `fileID` of each domain and relative path. The module reads SQLite with `node:sqlite`.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { copyFile, mkdtemp, readdir, rm, stat } from "node:fs/promises";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { DatabaseSync } from "node:sqlite";
14
+
15
+ /** The start of the Apple epoch (`2001-01-01T00:00:00Z`), in milliseconds after the Unix epoch. */
16
+ export const APPLE_EPOCH_MS = 978_307_200_000;
17
+
18
+ /** The domain of the messages database in `Manifest.db`. */
19
+ export const SMS_DOMAIN = "HomeDomain";
20
+
21
+ /** The relative path of the messages database in `Manifest.db`. */
22
+ export const SMS_RELATIVE_PATH = "Library/SMS/sms.db";
23
+
24
+ /** An Apple time above this value is in nanoseconds. A time below it is in seconds. */
25
+ const NANOSECOND_THRESHOLD = 1_000_000_000_000;
26
+
27
+ /** The kinds of `IosBackupError`. */
28
+ export type IosBackupErrorCode = "no-backup" | "encrypted" | "no-file" | "missing-file";
29
+
30
+ /**
31
+ * The error of a backup that the module cannot read. The `code` tells the cause:
32
+ * `no-backup` (no backup folder), `encrypted` (`Manifest.db` is not a readable database),
33
+ * `no-file` (`Manifest.db` does not list the file) or `missing-file` (the folder does not hold the file).
34
+ */
35
+ export class IosBackupError extends Error {
36
+ /** The cause of the error. */
37
+ readonly code: IosBackupErrorCode;
38
+
39
+ constructor(code: IosBackupErrorCode, message: string, options?: ErrorOptions) {
40
+ super(message, options);
41
+ this.name = "IosBackupError";
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ /** A backup folder. */
47
+ export interface BackupFolder {
48
+ /** The path of the backup folder. */
49
+ readonly path: string;
50
+ /** The time of the last change to its `Manifest.db`, as an ISO 8601 string. */
51
+ readonly modifiedAt: string;
52
+ }
53
+
54
+ /** A file of a backup. */
55
+ export interface BackupFile {
56
+ /** The ID of the file in `Manifest.db`. */
57
+ readonly fileId: string;
58
+ /** The path of the file in the backup folder. */
59
+ readonly path: string;
60
+ }
61
+
62
+ /** A message of the messages database. The names are the column names of `sms.db`. */
63
+ export interface BackupMessage {
64
+ /** The unique ID of the message. */
65
+ readonly guid: string;
66
+ /** The time of the message, as an ISO 8601 string in UTC, to the second. */
67
+ readonly date: string;
68
+ /** The text of the message. */
69
+ readonly text: string;
70
+ /** True for a message that the owner of the phone sent. */
71
+ readonly is_from_me: boolean;
72
+ /** The phone number or the email address of the other person, when the database has one. */
73
+ readonly handle?: string | undefined;
74
+ /** The service of the message, for example `iMessage` or `SMS`. */
75
+ readonly service?: string | undefined;
76
+ /** The identifier of the chat: a phone number, an email address or a group ID. */
77
+ readonly chat_id?: string | undefined;
78
+ /** The name of the chat, when the chat has a name. */
79
+ readonly chat_name?: string | undefined;
80
+ /** True when the text comes from the `attributedBody` column, because the `text` column is empty. */
81
+ readonly from_attributed_body?: boolean | undefined;
82
+ }
83
+
84
+ /** The filter of `readMessages` and `readSmsDatabase`. */
85
+ export interface MessageQuery {
86
+ /** Only the messages at this time or after it (ISO 8601). */
87
+ readonly since?: string | undefined;
88
+ /** Only the messages before this time (ISO 8601). */
89
+ readonly until?: string | undefined;
90
+ /** Only the newest messages, at most this number. The result stays in time order. */
91
+ readonly limit?: number | undefined;
92
+ }
93
+
94
+ /**
95
+ * This function gives the default backup folders: the Apple Devices app and iTunes on Windows,
96
+ * and Finder on macOS. A folder that does not exist gives no backups.
97
+ */
98
+ export function defaultBackupRoots(home: string = homedir()): string[] {
99
+ return [
100
+ join(home, "Apple", "MobileSync", "Backup"),
101
+ join(home, "AppData", "Roaming", "Apple Computer", "MobileSync", "Backup"),
102
+ join(home, "Library", "Application Support", "MobileSync", "Backup"),
103
+ ];
104
+ }
105
+
106
+ /**
107
+ * This function lists the backup folders in the roots, the newest first. A backup folder is a
108
+ * subfolder with a `Manifest.db`. The time of `Manifest.db` gives the order.
109
+ */
110
+ export async function listBackups(roots: readonly string[] = defaultBackupRoots()): Promise<BackupFolder[]> {
111
+ const found: Array<BackupFolder & { readonly mtime: number }> = [];
112
+ for (const root of roots) {
113
+ let entries;
114
+ try {
115
+ entries = await readdir(root, { withFileTypes: true });
116
+ } catch {
117
+ continue;
118
+ }
119
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
120
+ for (const entry of entries) {
121
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
122
+ const path = join(root, entry.name);
123
+ const info = await stat(join(path, "Manifest.db")).catch(() => undefined);
124
+ if (info === undefined || !info.isFile()) continue;
125
+ found.push({ path, modifiedAt: info.mtime.toISOString(), mtime: info.mtimeMs });
126
+ }
127
+ }
128
+ // The sort is stable: of two folders with the same time, the first found stays first
129
+ found.sort((a, b) => b.mtime - a.mtime);
130
+ return found.map(({ path, modifiedAt }) => ({ path, modifiedAt }));
131
+ }
132
+
133
+ /** This function gives the newest backup folder in the roots, or `undefined` when there is none. */
134
+ export async function findLatestBackup(roots?: readonly string[]): Promise<string | undefined> {
135
+ return (await listBackups(roots))[0]?.path;
136
+ }
137
+
138
+ /**
139
+ * This function changes an Apple time to an ISO 8601 string in UTC, to the second. The value is
140
+ * in nanoseconds after 2001-01-01 (iOS 11 and later), or in seconds (older databases).
141
+ */
142
+ export function appleDateToIso(raw: number | bigint): string {
143
+ let ms: number;
144
+ if (typeof raw === "bigint") {
145
+ ms = raw > BigInt(NANOSECOND_THRESHOLD) ? Number(raw / 1_000_000n) : Number(raw) * 1000;
146
+ } else {
147
+ ms = raw > NANOSECOND_THRESHOLD ? raw / 1e6 : raw * 1000;
148
+ }
149
+ return new Date(ms + APPLE_EPOCH_MS).toISOString().replace(/\.\d{3}Z$/, "Z");
150
+ }
151
+
152
+ /** This function changes an ISO 8601 time to an Apple time in nanoseconds. */
153
+ export function isoToAppleDate(iso: string): bigint {
154
+ const ms = Date.parse(iso);
155
+ if (Number.isNaN(ms)) throw new RangeError(`Not a time: ${JSON.stringify(iso)}`);
156
+ return BigInt(ms - APPLE_EPOCH_MS) * 1_000_000n;
157
+ }
158
+
159
+ /** The class names of the string object in a typedstream. */
160
+ const STRING_CLASSES = ["NSString", "NSMutableString"];
161
+ /** The byte after the text of a string object in a typedstream. */
162
+ const END_OF_OBJECT = 0x86;
163
+
164
+ /**
165
+ * This function gets the plain text from the `attributedBody` column of a message: an
166
+ * `NSAttributedString` in the typedstream format. It gives `undefined` when the value has no text.
167
+ */
168
+ export function extractTypedstreamText(body: Uint8Array): string | undefined {
169
+ const bytes = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
170
+ let marker = -1;
171
+ for (const name of STRING_CLASSES) {
172
+ const at = bytes.indexOf(name, 0, "latin1");
173
+ if (at !== -1 && (marker === -1 || at < marker)) marker = at;
174
+ }
175
+ if (marker === -1) return undefined;
176
+ const plus = bytes.indexOf(0x2b, marker); // "+": the start of the bytes of the string
177
+ if (plus === -1 || plus + 1 >= bytes.length) return undefined;
178
+ let start = plus + 1;
179
+ const first = bytes[start] as number;
180
+ let length: number | undefined;
181
+ // A typedstream integer: one byte below 0x80, or 0x81 and 2 bytes, or 0x82 and 4 bytes (little-endian)
182
+ if (first === 0x81) {
183
+ length = start + 3 <= bytes.length ? bytes.readUInt16LE(start + 1) : undefined;
184
+ start += 3;
185
+ } else if (first === 0x82) {
186
+ length = start + 5 <= bytes.length ? bytes.readUInt32LE(start + 1) : undefined;
187
+ start += 5;
188
+ } else {
189
+ length = first < 0x80 ? first : undefined;
190
+ start += 1;
191
+ }
192
+ if (start > bytes.length) return undefined;
193
+ let end: number;
194
+ if (length !== undefined && start + length <= bytes.length) {
195
+ end = start + length;
196
+ } else {
197
+ // No length that agrees with the value: the text stops at the end of the object
198
+ const stop = bytes.indexOf(END_OF_OBJECT, start);
199
+ end = stop === -1 ? bytes.length : stop;
200
+ }
201
+ const text = bytes.subarray(start, end).toString("utf8").trim();
202
+ return text.length > 0 ? text : undefined;
203
+ }
204
+
205
+ type Sqlite = typeof import("node:sqlite");
206
+
207
+ /**
208
+ * This function loads `node:sqlite` at the first read. Thus an import of the package does not
209
+ * show the experimental warning of `node:sqlite`, and does not fail on an older Node.
210
+ */
211
+ async function loadSqlite(): Promise<Sqlite> {
212
+ try {
213
+ return await import("node:sqlite");
214
+ } catch (error) {
215
+ throw new Error("@procedures/ios-backup needs node:sqlite: use Node 22.13 or later", { cause: error });
216
+ }
217
+ }
218
+
219
+ /** This function gives a new temporary folder to `use`, and deletes the folder after it. */
220
+ async function withScratch<T>(use: (dir: string) => Promise<T>): Promise<T> {
221
+ const dir = await mkdtemp(join(tmpdir(), "ios-backup-"));
222
+ try {
223
+ return await use(dir);
224
+ } finally {
225
+ // On Windows, a file can stay locked for some milliseconds after it closes
226
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * This function finds a file of a backup by its domain and its relative path in `Manifest.db`.
232
+ * It gives `undefined` when `Manifest.db` does not list the file. It reads a copy of
233
+ * `Manifest.db` in a temporary folder, and changes nothing in the backup.
234
+ */
235
+ export async function findBackupFile(backup: string, domain: string, relativePath: string): Promise<BackupFile | undefined> {
236
+ const manifest = join(backup, "Manifest.db");
237
+ if (!existsSync(manifest)) throw new IosBackupError("no-backup", `The folder has no Manifest.db: ${backup}`);
238
+ const { DatabaseSync } = await loadSqlite();
239
+ const fileId = await withScratch(async (scratch) => {
240
+ const copy = join(scratch, "Manifest.db");
241
+ await copyFile(manifest, copy);
242
+ let db: DatabaseSync | undefined;
243
+ try {
244
+ db = new DatabaseSync(copy, { readOnly: true });
245
+ const row = db.prepare("SELECT fileID FROM Files WHERE domain = ? AND relativePath = ?").get(domain, relativePath);
246
+ const id = row?.["fileID"];
247
+ return typeof id === "string" ? id : undefined;
248
+ } catch (error) {
249
+ throw new IosBackupError(
250
+ "encrypted",
251
+ "Cannot read Manifest.db. An encrypted backup gives this error: make a backup without encryption.",
252
+ { cause: error },
253
+ );
254
+ } finally {
255
+ db?.close();
256
+ }
257
+ });
258
+ if (fileId === undefined) return undefined;
259
+ for (const path of [join(backup, fileId.slice(0, 2), fileId), join(backup, fileId)]) {
260
+ if (existsSync(path)) return { fileId, path };
261
+ }
262
+ throw new IosBackupError("missing-file", `Manifest.db lists ${domain}:${relativePath}, but the backup has no file ${fileId}`);
263
+ }
264
+
265
+ /** The time of a message in nanoseconds, for the two units of `message.date`. */
266
+ const MESSAGE_NS = `(CASE WHEN m.date > ${NANOSECOND_THRESHOLD} THEN m.date ELSE m.date * 1000000000 END)`;
267
+
268
+ interface MessageRow {
269
+ readonly guid: string;
270
+ readonly text: string | null;
271
+ readonly body: Uint8Array | null;
272
+ readonly date: bigint;
273
+ readonly is_from_me: bigint | null;
274
+ readonly service: string | null;
275
+ readonly handle: string | null;
276
+ readonly chat_id: string | null;
277
+ readonly chat_name: string | null;
278
+ }
279
+
280
+ /** This function gives the value when it is a string that is not empty. */
281
+ const present = (value: string | null): string | undefined => (value === null || value.length === 0 ? undefined : value);
282
+
283
+ /**
284
+ * This function reads the messages of a messages database (`sms.db` of a backup, or `chat.db` of
285
+ * macOS), in time order. It opens the file read only. A message without text is not in the result.
286
+ */
287
+ export async function readSmsDatabase(file: string, query: MessageQuery = {}): Promise<BackupMessage[]> {
288
+ const where = ["m.guid IS NOT NULL", "((m.text IS NOT NULL AND m.text != '') OR m.attributedBody IS NOT NULL)"];
289
+ const parameters: Array<bigint | number> = [];
290
+ if (query.since !== undefined) {
291
+ where.push(`${MESSAGE_NS} >= ?`);
292
+ parameters.push(isoToAppleDate(query.since));
293
+ }
294
+ if (query.until !== undefined) {
295
+ where.push(`${MESSAGE_NS} < ?`);
296
+ parameters.push(isoToAppleDate(query.until));
297
+ }
298
+ const limited = query.limit !== undefined;
299
+ if (limited) parameters.push(Math.max(0, Math.floor(query.limit as number)));
300
+ const sql = `SELECT m.guid AS guid, m.text AS text, m.attributedBody AS body, m.date AS date,
301
+ m.is_from_me AS is_from_me, m.service AS service, h.id AS handle,
302
+ c.chat_identifier AS chat_id, c.display_name AS chat_name
303
+ FROM message m
304
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
305
+ LEFT JOIN chat c ON c.ROWID = (
306
+ SELECT cmj.chat_id FROM chat_message_join cmj WHERE cmj.message_id = m.ROWID ORDER BY cmj.chat_id LIMIT 1
307
+ )
308
+ WHERE ${where.join(" AND ")}
309
+ ORDER BY ${MESSAGE_NS} ${limited ? "DESC" : "ASC"}, m.ROWID ${limited ? "DESC" : "ASC"}
310
+ ${limited ? "LIMIT ?" : ""}`;
311
+
312
+ const { DatabaseSync } = await loadSqlite();
313
+ const db = new DatabaseSync(file, { readOnly: true });
314
+ let rows: MessageRow[];
315
+ try {
316
+ const statement = db.prepare(sql);
317
+ statement.setReadBigInts(true);
318
+ rows = statement.all(...parameters) as unknown as MessageRow[];
319
+ } finally {
320
+ db.close();
321
+ }
322
+ if (limited) rows.reverse();
323
+
324
+ const messages: BackupMessage[] = [];
325
+ for (const row of rows) {
326
+ let text = present(row.text);
327
+ let fromBody = false;
328
+ if (text === undefined && row.body !== null) {
329
+ text = extractTypedstreamText(row.body);
330
+ fromBody = text !== undefined;
331
+ }
332
+ if (text === undefined) continue;
333
+ const handle = present(row.handle);
334
+ const service = present(row.service);
335
+ const chatId = present(row.chat_id);
336
+ const chatName = present(row.chat_name);
337
+ messages.push({
338
+ guid: row.guid,
339
+ date: appleDateToIso(row.date),
340
+ text,
341
+ is_from_me: Number(row.is_from_me ?? 0) === 1,
342
+ ...(handle === undefined ? {} : { handle }),
343
+ ...(service === undefined ? {} : { service }),
344
+ ...(chatId === undefined ? {} : { chat_id: chatId }),
345
+ ...(chatName === undefined ? {} : { chat_name: chatName }),
346
+ ...(fromBody ? { from_attributed_body: true } : {}),
347
+ });
348
+ }
349
+ return messages;
350
+ }
351
+
352
+ /**
353
+ * This function reads the messages of a backup folder, in time order. It copies `sms.db` to a
354
+ * temporary folder, reads the copy and deletes it. It changes nothing in the backup.
355
+ */
356
+ export async function readMessages(backup: string, query: MessageQuery = {}): Promise<BackupMessage[]> {
357
+ const file = await findBackupFile(backup, SMS_DOMAIN, SMS_RELATIVE_PATH);
358
+ if (file === undefined) throw new IosBackupError("no-file", `The backup has no messages database (${SMS_RELATIVE_PATH})`);
359
+ return withScratch(async (scratch) => {
360
+ const copy = join(scratch, "sms.db");
361
+ await copyFile(file.path, copy);
362
+ return readSmsDatabase(copy, query);
363
+ });
364
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
+ export * from "./module.js";
3
+ export * from "./procedures.js";
4
+ export * from "./backup.js";
package/src/module.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The procedures of @procedures/ios-backup as data. Importing this file registers nothing.
3
+ */
4
+
5
+ import { defineModule, registerModules, PROCEDURE_REGISTRY, type ProcedureModule, type ProcedureRegistry } from "@procedures/client";
6
+ import { iosBackupProcedures } from "./procedures.js";
7
+
8
+ export const iosBackupModule: ProcedureModule = defineModule("@procedures/ios-backup", iosBackupProcedures);
9
+
10
+ export default iosBackupModule;
11
+
12
+ /**
13
+ * This function registers the procedures of @procedures/ios-backup in a registry. The default is the
14
+ * global registry.
15
+ */
16
+ export function registerIosBackupProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
17
+ registerModules(registry, [iosBackupModule]);
18
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The procedures of `@procedures/ios-backup`: `iosBackup.find`, `iosBackup.file` and
3
+ * `iosBackup.messages`. Each one reads files and changes nothing.
4
+ */
5
+
6
+ import { createProcedure, zodAdapter, type AnyProcedure } from "@procedures/client";
7
+ import { z } from "zod";
8
+ import {
9
+ IosBackupError,
10
+ defaultBackupRoots,
11
+ findBackupFile,
12
+ findLatestBackup,
13
+ listBackups,
14
+ readMessages,
15
+ type BackupFolder,
16
+ type BackupMessage,
17
+ } from "./backup.js";
18
+
19
+ /** The procedures read files and change nothing. A copy in a temporary folder is deleted after the read. */
20
+ const READS_FILES = { reads: ["fs"], writes: [] };
21
+
22
+ const rootsField = z
23
+ .array(z.string().min(1))
24
+ .optional()
25
+ .describe("The folders that hold the backup folders (default: the MobileSync folders of Windows and macOS)");
26
+ const backupField = z.string().min(1).optional().describe("The backup folder (default: the newest backup in the roots)");
27
+ const timeField = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { message: "Use an ISO 8601 time" });
28
+
29
+ const BackupFolderSchema = z.object({ path: z.string(), modifiedAt: z.string() });
30
+
31
+ /** This function gives the backup folder of the input, or the newest backup in the roots. */
32
+ async function resolveBackup(input: { backup?: string | undefined; roots?: string[] | undefined }): Promise<string> {
33
+ if (input.backup !== undefined) return input.backup;
34
+ const roots = input.roots ?? defaultBackupRoots();
35
+ const latest = await findLatestBackup(roots);
36
+ if (latest === undefined) {
37
+ throw new IosBackupError("no-backup", `No iPhone backup in ${roots.join(", ")}. Make a backup without encryption first.`);
38
+ }
39
+ return latest;
40
+ }
41
+
42
+ // iosBackup.find
43
+
44
+ const FindInputSchema = z.object({ roots: rootsField });
45
+ type FindInput = z.infer<typeof FindInputSchema>;
46
+
47
+ interface FindOutput {
48
+ /** The newest backup folder, when there is one. */
49
+ latest?: BackupFolder | undefined;
50
+ /** All the backup folders, the newest first. */
51
+ backups: BackupFolder[];
52
+ /** The folders that the call examined. */
53
+ roots: string[];
54
+ }
55
+ const FindOutputSchema = z.object({
56
+ latest: BackupFolderSchema.optional(),
57
+ backups: z.array(BackupFolderSchema),
58
+ roots: z.array(z.string()),
59
+ });
60
+
61
+ export const iosBackupFindProcedure: AnyProcedure = createProcedure()
62
+ .path(["iosBackup", "find"])
63
+ .input(zodAdapter<FindInput>(FindInputSchema))
64
+ .output(zodAdapter<FindOutput>(FindOutputSchema))
65
+ .meta({
66
+ description: "Find the iPhone backups (MobileSync) on this computer, the newest first",
67
+ args: [],
68
+ output: "json",
69
+ effects: READS_FILES,
70
+ })
71
+ .handler(async (input: FindInput): Promise<FindOutput> => {
72
+ const roots = input.roots ?? defaultBackupRoots();
73
+ const backups = await listBackups(roots);
74
+ const latest = backups[0];
75
+ return { ...(latest === undefined ? {} : { latest }), backups, roots };
76
+ })
77
+ .build() as AnyProcedure;
78
+
79
+ // iosBackup.file
80
+
81
+ const FileInputSchema = z.object({
82
+ domain: z.string().min(1).describe("The domain of the file in Manifest.db, for example HomeDomain"),
83
+ relativePath: z.string().min(1).describe("The relative path of the file in its domain, for example Library/SMS/sms.db"),
84
+ backup: backupField,
85
+ roots: rootsField,
86
+ });
87
+ type FileInput = z.infer<typeof FileInputSchema>;
88
+
89
+ interface FileOutput {
90
+ /** The backup folder. */
91
+ backup: string;
92
+ /** True when Manifest.db lists the file. */
93
+ found: boolean;
94
+ /** The ID of the file in Manifest.db. */
95
+ fileId?: string | undefined;
96
+ /** The path of the file in the backup folder. */
97
+ path?: string | undefined;
98
+ }
99
+ const FileOutputSchema = z.object({
100
+ backup: z.string(),
101
+ found: z.boolean(),
102
+ fileId: z.string().optional(),
103
+ path: z.string().optional(),
104
+ });
105
+
106
+ export const iosBackupFileProcedure: AnyProcedure = createProcedure()
107
+ .path(["iosBackup", "file"])
108
+ .input(zodAdapter<FileInput>(FileInputSchema))
109
+ .output(zodAdapter<FileOutput>(FileOutputSchema))
110
+ .meta({
111
+ description: "Find a file of an iPhone backup by its domain and relative path in Manifest.db",
112
+ args: ["domain", "relativePath"],
113
+ output: "json",
114
+ effects: READS_FILES,
115
+ })
116
+ .handler(async (input: FileInput): Promise<FileOutput> => {
117
+ const backup = await resolveBackup(input);
118
+ const file = await findBackupFile(backup, input.domain, input.relativePath);
119
+ return file === undefined ? { backup, found: false } : { backup, found: true, ...file };
120
+ })
121
+ .build() as AnyProcedure;
122
+
123
+ // iosBackup.messages
124
+
125
+ const MessagesInputSchema = z.object({
126
+ backup: backupField,
127
+ roots: rootsField,
128
+ since: timeField.optional().describe("Only the messages at this time or after it (ISO 8601)"),
129
+ until: timeField.optional().describe("Only the messages before this time (ISO 8601)"),
130
+ limit: z.number().int().positive().optional().describe("Only the newest messages, at most this number"),
131
+ });
132
+ type MessagesInput = z.infer<typeof MessagesInputSchema>;
133
+
134
+ interface MessagesOutput {
135
+ /** The backup folder. */
136
+ backup: string;
137
+ /** The messages, in time order. */
138
+ messages: BackupMessage[];
139
+ }
140
+ const MessageSchema = z.object({
141
+ guid: z.string(),
142
+ date: z.string(),
143
+ text: z.string(),
144
+ is_from_me: z.boolean(),
145
+ handle: z.string().optional(),
146
+ service: z.string().optional(),
147
+ chat_id: z.string().optional(),
148
+ chat_name: z.string().optional(),
149
+ from_attributed_body: z.boolean().optional(),
150
+ });
151
+ const MessagesOutputSchema = z.object({ backup: z.string(), messages: z.array(MessageSchema) });
152
+
153
+ export const iosBackupMessagesProcedure: AnyProcedure = createProcedure()
154
+ .path(["iosBackup", "messages"])
155
+ .input(zodAdapter<MessagesInput>(MessagesInputSchema))
156
+ .output(zodAdapter<MessagesOutput>(MessagesOutputSchema))
157
+ .meta({
158
+ description: "Read the messages (iMessage and SMS) of an iPhone backup, in time order",
159
+ args: [],
160
+ output: "json",
161
+ effects: READS_FILES,
162
+ })
163
+ .handler(async (input: MessagesInput): Promise<MessagesOutput> => {
164
+ const backup = await resolveBackup(input);
165
+ const messages = await readMessages(backup, { since: input.since, until: input.until, limit: input.limit });
166
+ return { backup, messages };
167
+ })
168
+ .build() as AnyProcedure;
169
+
170
+ export const iosBackupProcedures: AnyProcedure[] = [iosBackupFindProcedure, iosBackupFileProcedure, iosBackupMessagesProcedure];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Importing this file registers the procedures of @procedures/ios-backup in the global registry:
3
+ * `import "@procedures/ios-backup/register"`. The package root registers nothing.
4
+ */
5
+
6
+ import { registerIosBackupProcedures } from "./module.js";
7
+
8
+ export * from "./module.js";
9
+
10
+ registerIosBackupProcedures();