@gryt/core 0.21.0 → 0.23.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.
@@ -0,0 +1,52 @@
1
+ import type { SealedAttachmentKey } from "@gryt/crypto";
2
+ import type { MlsDmContent } from "../mls/content.js";
3
+ import type { DiscordPackageMessage } from "./discordPackage.js";
4
+ export interface ImportFile {
5
+ name: string;
6
+ type: string;
7
+ bytes: Uint8Array;
8
+ }
9
+ /** Where downloads wait until they're uploaded: Cache Storage on the desktop, files on the phone. */
10
+ export interface ImportFileStore {
11
+ has(url: string): Promise<boolean>;
12
+ put(url: string, file: Omit<ImportFile, "name">): Promise<void>;
13
+ take(url: string): Promise<Omit<ImportFile, "name"> | null>;
14
+ drop(url: string): Promise<void>;
15
+ }
16
+ /** What the job needs from the app. Each app supplies its own, and a test supplies fakes. */
17
+ export interface ImportDeps {
18
+ send: (content: MlsDmContent) => Promise<void>;
19
+ /** Null when Discord won't hand it over (expired, gone, refused). */
20
+ download: (url: string) => Promise<Omit<ImportFile, "name"> | null>;
21
+ upload: (file: ImportFile) => Promise<{
22
+ id: string;
23
+ key: SealedAttachmentKey;
24
+ }>;
25
+ store: ImportFileStore;
26
+ sleep: (ms: number) => Promise<void>;
27
+ newId: () => string;
28
+ }
29
+ export interface ImportProgress {
30
+ messages: {
31
+ done: number;
32
+ total: number;
33
+ };
34
+ downloaded: {
35
+ done: number;
36
+ total: number;
37
+ };
38
+ sent: {
39
+ done: number;
40
+ total: number;
41
+ };
42
+ missing: number;
43
+ bytes: number;
44
+ startedAt: number;
45
+ }
46
+ export interface ImportControl {
47
+ paused: () => boolean;
48
+ stopped: () => boolean;
49
+ }
50
+ export declare const importId: (discordId: string) => string;
51
+ /** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
52
+ export declare function runDiscordImport(messages: DiscordPackageMessage[], deps: ImportDeps, control: ImportControl, report: (p: ImportProgress) => void): Promise<ImportProgress>;
@@ -0,0 +1,102 @@
1
+ import { importBatches } from "../mls/content.js";
2
+ const DOWNLOADS_AT_ONCE = 4;
3
+ /** `mls:send` allows about one send every two seconds sustained (RL_SEND in the server). */
4
+ const SEND_GAP_MS = 2_000;
5
+ const FILE_BATCH = 20;
6
+ export const importId = (discordId) => `discord-${discordId}`;
7
+ function nameOf(url) {
8
+ try {
9
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() || "file");
10
+ }
11
+ catch {
12
+ return "file";
13
+ }
14
+ }
15
+ /** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
16
+ export async function runDiscordImport(messages, deps, control, report) {
17
+ const withFiles = messages.filter((m) => m.attachments.length > 0);
18
+ const urls = [...new Set(withFiles.flatMap((m) => m.attachments))];
19
+ const p = {
20
+ messages: { done: 0, total: messages.length },
21
+ downloaded: { done: 0, total: urls.length },
22
+ sent: { done: 0, total: urls.length },
23
+ missing: 0,
24
+ bytes: 0,
25
+ startedAt: Date.now(),
26
+ };
27
+ const tick = () => report({ ...p, messages: { ...p.messages }, downloaded: { ...p.downloaded }, sent: { ...p.sent } });
28
+ tick();
29
+ const hold = async () => {
30
+ while (control.paused() && !control.stopped())
31
+ await deps.sleep(500);
32
+ };
33
+ const sendAll = async (entries) => {
34
+ for (const batch of importBatches(entries, deps.newId)) {
35
+ await hold();
36
+ if (control.stopped())
37
+ return;
38
+ await deps.send(batch);
39
+ p.messages.done += batch.entries.length;
40
+ tick();
41
+ await deps.sleep(SEND_GAP_MS);
42
+ }
43
+ };
44
+ await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
45
+ const missing = new Set();
46
+ let next = 0;
47
+ const worker = async () => {
48
+ while (!control.stopped()) {
49
+ await hold();
50
+ if (next >= urls.length)
51
+ break;
52
+ const url = urls[next++];
53
+ if (!(await deps.store.has(url))) {
54
+ const file = await deps.download(url).catch(() => null);
55
+ if (file) {
56
+ p.bytes += file.bytes.length;
57
+ await deps.store.put(url, file);
58
+ }
59
+ else {
60
+ missing.add(url);
61
+ p.missing = missing.size;
62
+ }
63
+ }
64
+ p.downloaded.done++;
65
+ tick();
66
+ }
67
+ };
68
+ await Promise.all(Array.from({ length: DOWNLOADS_AT_ONCE }, worker));
69
+ const uploaded = new Map();
70
+ let pending = [];
71
+ for (const m of withFiles) {
72
+ if (control.stopped())
73
+ break;
74
+ const attachments = {};
75
+ for (const url of m.attachments) {
76
+ await hold();
77
+ let up = uploaded.get(url);
78
+ const stored = up || missing.has(url) ? null : await deps.store.take(url);
79
+ if (stored) {
80
+ up = await deps.upload({ name: nameOf(url), ...stored });
81
+ uploaded.set(url, up);
82
+ await deps.store.drop(url);
83
+ }
84
+ if (up)
85
+ attachments[up.id] = up.key;
86
+ p.sent.done++;
87
+ tick();
88
+ }
89
+ const lost = m.attachments.length - Object.keys(attachments).length;
90
+ const note = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
91
+ const text = [m.text, note].filter(Boolean).join("\n");
92
+ pending.push({ id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) });
93
+ if (pending.length >= FILE_BATCH) {
94
+ await sendAll(pending);
95
+ pending = [];
96
+ }
97
+ }
98
+ if (pending.length)
99
+ await sendAll(pending);
100
+ tick();
101
+ return p;
102
+ }
@@ -0,0 +1,5 @@
1
+ import type { PackageFiles } from "./discordPackage.js";
2
+ /** Raw DEFLATE, as a zip stores it. `size` is the inflated length the zip's header gives. */
3
+ export declare function inflateRaw(data: Uint8Array, size: number): Uint8Array;
4
+ /** The files in a zip held in memory, read lazily. */
5
+ export declare function openZipBytes(zip: Uint8Array): PackageFiles;
@@ -0,0 +1,187 @@
1
+ /* A zip reader with its own inflate (RFC 1951), for Hermes, which has no DecompressionStream.
2
+ Read bit by bit: slow next to zlib, and fine for a package of JSON. No Zip64, no encryption. */
3
+ class Bits {
4
+ pos = 0;
5
+ bit = 0;
6
+ data;
7
+ constructor(data) {
8
+ this.data = data;
9
+ }
10
+ read(n) {
11
+ let v = 0;
12
+ for (let i = 0; i < n; i++) {
13
+ if (this.pos >= this.data.length)
14
+ throw new Error("The zip ends in the middle of a file.");
15
+ v |= ((this.data[this.pos] >> this.bit) & 1) << i;
16
+ if (++this.bit === 8) {
17
+ this.bit = 0;
18
+ this.pos++;
19
+ }
20
+ }
21
+ return v;
22
+ }
23
+ align() {
24
+ if (this.bit) {
25
+ this.bit = 0;
26
+ this.pos++;
27
+ }
28
+ }
29
+ }
30
+ function tree(lengths) {
31
+ const counts = new Uint16Array(16);
32
+ for (let i = 0; i < lengths.length; i++)
33
+ counts[lengths[i]]++;
34
+ counts[0] = 0;
35
+ const offsets = new Uint16Array(16);
36
+ for (let i = 1; i < 16; i++)
37
+ offsets[i] = offsets[i - 1] + counts[i - 1];
38
+ const symbols = new Uint16Array(lengths.length);
39
+ for (let i = 0; i < lengths.length; i++)
40
+ if (lengths[i])
41
+ symbols[offsets[lengths[i]]++] = i;
42
+ return { counts, symbols };
43
+ }
44
+ function decode(bits, t) {
45
+ let sum = 0;
46
+ let cur = 0;
47
+ let len = 0;
48
+ do {
49
+ cur = 2 * cur + bits.read(1);
50
+ if (++len > 15)
51
+ throw new Error("The zip has a damaged file in it.");
52
+ sum += t.counts[len];
53
+ cur -= t.counts[len];
54
+ } while (cur >= 0);
55
+ return t.symbols[sum + cur];
56
+ }
57
+ const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
58
+ const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
59
+ const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
60
+ const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
61
+ const ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
62
+ function fixedLength(i) {
63
+ if (i < 144)
64
+ return 8;
65
+ if (i < 256)
66
+ return 9;
67
+ return i < 280 ? 7 : 8;
68
+ }
69
+ const FIXED_LIT = tree(Array.from({ length: 288 }, (_, i) => fixedLength(i)));
70
+ const FIXED_DIST = tree(new Array(30).fill(5));
71
+ function dynamicTrees(bits) {
72
+ const hlit = bits.read(5) + 257;
73
+ const hdist = bits.read(5) + 1;
74
+ const hclen = bits.read(4) + 4;
75
+ const codeLengths = new Uint8Array(19);
76
+ for (let i = 0; i < hclen; i++)
77
+ codeLengths[ORDER[i]] = bits.read(3);
78
+ const codes = tree(codeLengths);
79
+ const lengths = new Uint8Array(hlit + hdist);
80
+ for (let n = 0; n < hlit + hdist;) {
81
+ const sym = decode(bits, codes);
82
+ if (sym < 16) {
83
+ lengths[n++] = sym;
84
+ continue;
85
+ }
86
+ let repeat = 0;
87
+ let value = 0;
88
+ if (sym === 16) {
89
+ if (n === 0)
90
+ throw new Error("The zip has a damaged file in it.");
91
+ value = lengths[n - 1];
92
+ repeat = 3 + bits.read(2);
93
+ }
94
+ else if (sym === 17)
95
+ repeat = 3 + bits.read(3);
96
+ else
97
+ repeat = 11 + bits.read(7);
98
+ while (repeat-- > 0)
99
+ lengths[n++] = value;
100
+ }
101
+ return [tree(lengths.subarray(0, hlit)), tree(lengths.subarray(hlit))];
102
+ }
103
+ /** Raw DEFLATE, as a zip stores it. `size` is the inflated length the zip's header gives. */
104
+ export function inflateRaw(data, size) {
105
+ const out = new Uint8Array(size);
106
+ let o = 0;
107
+ const bits = new Bits(data);
108
+ let last = 0;
109
+ while (!last) {
110
+ last = bits.read(1);
111
+ const type = bits.read(2);
112
+ if (type === 0) {
113
+ bits.align();
114
+ const len = data[bits.pos] | (data[bits.pos + 1] << 8);
115
+ bits.pos += 4;
116
+ out.set(data.subarray(bits.pos, bits.pos + len), o);
117
+ bits.pos += len;
118
+ o += len;
119
+ continue;
120
+ }
121
+ if (type === 3)
122
+ throw new Error("The zip has a damaged file in it.");
123
+ const [lit, dist] = type === 1 ? [FIXED_LIT, FIXED_DIST] : dynamicTrees(bits);
124
+ for (;;) {
125
+ const sym = decode(bits, lit);
126
+ if (sym === 256)
127
+ break;
128
+ if (sym < 256) {
129
+ out[o++] = sym;
130
+ continue;
131
+ }
132
+ const li = sym - 257;
133
+ const length = LENGTH_BASE[li] + bits.read(LENGTH_EXTRA[li]);
134
+ const di = decode(bits, dist);
135
+ const back = DIST_BASE[di] + bits.read(DIST_EXTRA[di]);
136
+ if (back > o || o + length > size)
137
+ throw new Error("The zip has a damaged file in it.");
138
+ for (let k = 0; k < length; k++, o++)
139
+ out[o] = out[o - back];
140
+ }
141
+ }
142
+ return o === size ? out : out.subarray(0, o);
143
+ }
144
+ /** The files in a zip held in memory, read lazily. */
145
+ export function openZipBytes(zip) {
146
+ const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
147
+ let eocd = -1;
148
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65_557); i--) {
149
+ if (view.getUint32(i, true) === 0x06054b50) {
150
+ eocd = i;
151
+ break;
152
+ }
153
+ }
154
+ if (eocd < 0)
155
+ throw new Error("That file isn't a zip.");
156
+ const count = view.getUint16(eocd + 10, true);
157
+ let p = view.getUint32(eocd + 16, true);
158
+ if (p === 0xffffffff)
159
+ throw new Error("That zip is too big to read here.");
160
+ const decoder = new TextDecoder();
161
+ const entries = new Map();
162
+ for (let n = 0; n < count && view.getUint32(p, true) === 0x02014b50; n++) {
163
+ const nameLength = view.getUint16(p + 28, true);
164
+ const name = decoder.decode(zip.subarray(p + 46, p + 46 + nameLength));
165
+ if (!name.endsWith("/")) {
166
+ entries.set(name, { method: view.getUint16(p + 10, true), compressed: view.getUint32(p + 20, true), size: view.getUint32(p + 24, true), local: view.getUint32(p + 42, true) });
167
+ }
168
+ p += 46 + nameLength + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
169
+ }
170
+ return {
171
+ names: [...entries.keys()],
172
+ async read(name) {
173
+ const e = entries.get(name);
174
+ if (!e)
175
+ throw new Error(`${name} isn't in the zip.`);
176
+ if (view.getUint32(e.local, true) !== 0x04034b50)
177
+ throw new Error(`${name} is damaged in the zip.`);
178
+ const start = e.local + 30 + view.getUint16(e.local + 26, true) + view.getUint16(e.local + 28, true);
179
+ const data = zip.subarray(start, start + e.compressed);
180
+ if (e.method === 0)
181
+ return decoder.decode(data);
182
+ if (e.method !== 8)
183
+ throw new Error(`${name} is packed in a way this can't read.`);
184
+ return decoder.decode(inflateRaw(data, e.size));
185
+ },
186
+ };
187
+ }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
15
  export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
16
16
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
17
17
  export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
18
+ export { inflateRaw, openZipBytes } from "./import/unzip.js";
19
+ export { type ImportControl, type ImportDeps, type ImportFile, type ImportFileStore, importId, type ImportProgress, runDiscordImport, } from "./import/discordImportJob.js";
18
20
  export type { DmSealingMode, MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupPosition, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsDmDiagnostics, MlsOwnDevice, MlsOwnDeviceAdd, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
19
21
  export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
20
22
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@ export { mlsPinsFromPeerPins } from "./mls/pins.js";
17
17
  export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
18
18
  export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
19
19
  export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
20
+ export { inflateRaw, openZipBytes } from "./import/unzip.js";
21
+ export { importId, runDiscordImport, } from "./import/discordImportJob.js";
20
22
  export { createApproverPairing, } from "./pairing/approver.js";
21
23
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
22
24
  export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {