@thezelijah/majik-message 1.0.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/LICENSE +67 -0
- package/README.md +265 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +34 -0
- package/dist/core/contacts/majik-contact-directory.js +165 -0
- package/dist/core/contacts/majik-contact.d.ts +53 -0
- package/dist/core/contacts/majik-contact.js +135 -0
- package/dist/core/crypto/constants.d.ts +7 -0
- package/dist/core/crypto/constants.js +6 -0
- package/dist/core/crypto/crypto-provider.d.ts +20 -0
- package/dist/core/crypto/crypto-provider.js +70 -0
- package/dist/core/crypto/encryption-engine.d.ts +59 -0
- package/dist/core/crypto/encryption-engine.js +257 -0
- package/dist/core/crypto/keystore.d.ts +126 -0
- package/dist/core/crypto/keystore.js +575 -0
- package/dist/core/messages/envelope-cache.d.ts +51 -0
- package/dist/core/messages/envelope-cache.js +375 -0
- package/dist/core/messages/message-envelope.d.ts +36 -0
- package/dist/core/messages/message-envelope.js +161 -0
- package/dist/core/scanner/scanner-engine.d.ts +27 -0
- package/dist/core/scanner/scanner-engine.js +120 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/types.js +1 -0
- package/dist/core/utils/APITranscoder.d.ts +114 -0
- package/dist/core/utils/APITranscoder.js +305 -0
- package/dist/core/utils/idb-majik-system.d.ts +15 -0
- package/dist/core/utils/idb-majik-system.js +37 -0
- package/dist/core/utils/majik-file-utils.d.ts +16 -0
- package/dist/core/utils/majik-file-utils.js +153 -0
- package/dist/core/utils/utilities.d.ts +22 -0
- package/dist/core/utils/utilities.js +80 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/majik-message.d.ts +202 -0
- package/dist/majik-message.js +940 -0
- package/package.json +97 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
// MajikMessage.ts
|
|
2
|
+
import { MajikContact, } from "./core/contacts/majik-contact";
|
|
3
|
+
import { KEY_ALGO } from "./core/crypto/constants";
|
|
4
|
+
import { ScannerEngine } from "./core/scanner/scanner-engine";
|
|
5
|
+
import { MessageEnvelope } from "./core/messages/message-envelope";
|
|
6
|
+
import { EnvelopeCache, } from "./core/messages/envelope-cache";
|
|
7
|
+
import { EncryptionEngine } from "./core/crypto/encryption-engine";
|
|
8
|
+
import { KeyStore } from "./core/crypto/keystore";
|
|
9
|
+
import { MajikContactDirectory, } from "./core/contacts/majik-contact-directory";
|
|
10
|
+
import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUtf8, utf8ToBase64, } from "./core/utils/utilities";
|
|
11
|
+
import { autoSaveMajikFileData, loadSavedMajikFileData, } from "./core/utils/majik-file-utils";
|
|
12
|
+
import { randomBytes } from "@stablelib/random";
|
|
13
|
+
import { idbLoadBlob, idbSaveBlob } from "./core/utils/idb-majik-system";
|
|
14
|
+
export class MajikMessage {
|
|
15
|
+
// Optional PIN protection (hashed). If set, UI should prompt for PIN to unlock.
|
|
16
|
+
pinHash = null;
|
|
17
|
+
id;
|
|
18
|
+
contactDirectory;
|
|
19
|
+
envelopeCache;
|
|
20
|
+
scanner;
|
|
21
|
+
listeners = new Map();
|
|
22
|
+
ownAccounts = new Map();
|
|
23
|
+
ownAccountsOrder = []; // keeps the order of IDs, first is active
|
|
24
|
+
autosaveTimer = null;
|
|
25
|
+
autosaveIntervalMs = 15000; // periodic backup interval
|
|
26
|
+
autosaveDebounceMs = 500; // debounce for rapid changes
|
|
27
|
+
constructor(config, id) {
|
|
28
|
+
this.id = id || arrayToBase64(randomBytes(32));
|
|
29
|
+
this.contactDirectory =
|
|
30
|
+
config.contactDirectory || new MajikContactDirectory();
|
|
31
|
+
this.envelopeCache = config.envelopeCache || new EnvelopeCache();
|
|
32
|
+
// Initialize scanner
|
|
33
|
+
this.scanner = new ScannerEngine({
|
|
34
|
+
contactDirectory: this.contactDirectory,
|
|
35
|
+
onEnvelopeFound: (env) => this.handleEnvelope(env),
|
|
36
|
+
onUntrusted: (raw) => this.emit("untrusted", raw),
|
|
37
|
+
onError: (err, ctx) => this.emit("error", err, ctx),
|
|
38
|
+
});
|
|
39
|
+
// Prepare listeners map
|
|
40
|
+
["message", "envelope", "untrusted", "error"].forEach((e) => this.listeners.set(e, []));
|
|
41
|
+
// Attach autosave handlers so state is persisted automatically
|
|
42
|
+
this.attachAutosaveHandlers();
|
|
43
|
+
}
|
|
44
|
+
/* ================================
|
|
45
|
+
* Account Management
|
|
46
|
+
* ================================ */
|
|
47
|
+
/**
|
|
48
|
+
* Create a new account (generates identity via KeyStore) and add it as an own account.
|
|
49
|
+
* Returns the created identity id and a backup blob (base64) that the user should store.
|
|
50
|
+
*/
|
|
51
|
+
async createAccount(passphrase, label) {
|
|
52
|
+
const identity = await KeyStore.createIdentity(passphrase);
|
|
53
|
+
// Import public key into a MajikContact
|
|
54
|
+
const contact = new MajikContact({
|
|
55
|
+
id: identity.id,
|
|
56
|
+
publicKey: identity.publicKey,
|
|
57
|
+
fingerprint: identity.fingerprint,
|
|
58
|
+
meta: { label: label || "" },
|
|
59
|
+
});
|
|
60
|
+
this.addOwnAccount(contact);
|
|
61
|
+
const backup = await KeyStore.exportIdentityBackup(identity.id);
|
|
62
|
+
return { id: identity.id, fingerprint: identity.fingerprint, backup };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Import an account from a backup blob (created with `exportIdentityBackup`) and unlock it.
|
|
66
|
+
*/
|
|
67
|
+
async importAccountFromBackup(backupBase64, passphrase, label) {
|
|
68
|
+
await KeyStore.importIdentityBackup(backupBase64);
|
|
69
|
+
// Unlock the imported identity
|
|
70
|
+
const decoded = JSON.parse(base64ToUtf8(backupBase64));
|
|
71
|
+
const id = decoded.id;
|
|
72
|
+
const identity = await KeyStore.unlockIdentity(id, passphrase);
|
|
73
|
+
const contact = new MajikContact({
|
|
74
|
+
id: identity.id,
|
|
75
|
+
publicKey: identity.publicKey,
|
|
76
|
+
fingerprint: identity.fingerprint,
|
|
77
|
+
meta: { label: label || "" },
|
|
78
|
+
});
|
|
79
|
+
if (!!this.getOwnAccountById(identity.id)) {
|
|
80
|
+
throw new Error("Account with the same ID already exists");
|
|
81
|
+
}
|
|
82
|
+
this.addOwnAccount(contact);
|
|
83
|
+
return { id: identity.id, fingerprint: identity.fingerprint };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Generate a BIP39 mnemonic for backup (12 words by default).
|
|
87
|
+
*/
|
|
88
|
+
generateMnemonic() {
|
|
89
|
+
return KeyStore.generateMnemonic();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Export a mnemonic-encrypted backup for an unlocked identity.
|
|
93
|
+
*/
|
|
94
|
+
async exportAccountMnemonicBackup(id, mnemonic) {
|
|
95
|
+
return KeyStore.exportIdentityMnemonicBackup(id, mnemonic);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Import an account from a mnemonic-encrypted backup blob and store it using `passphrase`.
|
|
99
|
+
*/
|
|
100
|
+
async importAccountFromMnemonicBackup(backupBase64, mnemonic, passphrase, label) {
|
|
101
|
+
const identity = await KeyStore.importIdentityFromMnemonicBackup(backupBase64, mnemonic, passphrase);
|
|
102
|
+
const contact = new MajikContact({
|
|
103
|
+
id: identity.id,
|
|
104
|
+
publicKey: identity.publicKey,
|
|
105
|
+
fingerprint: identity.fingerprint,
|
|
106
|
+
meta: { label: label || "" },
|
|
107
|
+
});
|
|
108
|
+
if (!!this.getOwnAccountById(identity.id)) {
|
|
109
|
+
throw new Error("Account with the same ID already exists");
|
|
110
|
+
}
|
|
111
|
+
this.addOwnAccount(contact);
|
|
112
|
+
return { id: identity.id, fingerprint: identity.fingerprint };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Create a new account deterministically from `mnemonic` and store it encrypted with `passphrase`.
|
|
116
|
+
* Returns the created identity id (which equals fingerprint) and fingerprint.
|
|
117
|
+
*/
|
|
118
|
+
async createAccountFromMnemonic(mnemonic, passphrase, label) {
|
|
119
|
+
const identity = await KeyStore.createIdentityFromMnemonic(mnemonic, passphrase);
|
|
120
|
+
const contact = new MajikContact({
|
|
121
|
+
id: identity.id,
|
|
122
|
+
publicKey: identity.publicKey,
|
|
123
|
+
fingerprint: identity.fingerprint,
|
|
124
|
+
meta: { label: label || "" },
|
|
125
|
+
});
|
|
126
|
+
const backup = await KeyStore.exportIdentityMnemonicBackup(identity.id, mnemonic);
|
|
127
|
+
this.addOwnAccount(contact);
|
|
128
|
+
return {
|
|
129
|
+
id: identity.id,
|
|
130
|
+
fingerprint: identity.fingerprint,
|
|
131
|
+
backup: backup,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
addOwnAccount(account) {
|
|
135
|
+
if (!this.ownAccounts.has(account.id)) {
|
|
136
|
+
this.ownAccounts.set(account.id, account);
|
|
137
|
+
this.ownAccountsOrder.push(account.id);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (!this.contactDirectory.hasContact(account.id)) {
|
|
141
|
+
this.contactDirectory.addContact(account);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
// ignore if contact can't be added
|
|
146
|
+
}
|
|
147
|
+
this.scheduleAutosave();
|
|
148
|
+
}
|
|
149
|
+
listOwnAccounts() {
|
|
150
|
+
const userAccounts = this.ownAccountsOrder
|
|
151
|
+
.map((id) => this.ownAccounts.get(id))
|
|
152
|
+
.filter((c) => !!c);
|
|
153
|
+
return userAccounts;
|
|
154
|
+
}
|
|
155
|
+
getOwnAccountById(id) {
|
|
156
|
+
return this.ownAccounts.get(id);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Set an active account (moves it to index 0)
|
|
160
|
+
*/
|
|
161
|
+
setActiveAccount(id) {
|
|
162
|
+
if (!this.ownAccounts.has(id))
|
|
163
|
+
return false;
|
|
164
|
+
// Remove ID from current position
|
|
165
|
+
const index = this.ownAccountsOrder.indexOf(id);
|
|
166
|
+
if (index > -1)
|
|
167
|
+
this.ownAccountsOrder.splice(index, 1);
|
|
168
|
+
// Add to the front
|
|
169
|
+
this.ownAccountsOrder.unshift(id);
|
|
170
|
+
this.scheduleAutosave();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
getActiveAccount() {
|
|
174
|
+
if (this.ownAccountsOrder.length === 0)
|
|
175
|
+
return null;
|
|
176
|
+
return this.ownAccounts.get(this.ownAccountsOrder[0]) || null;
|
|
177
|
+
}
|
|
178
|
+
isAccountActive(id) {
|
|
179
|
+
if (!this.ownAccounts.has(id))
|
|
180
|
+
return false;
|
|
181
|
+
if (this.ownAccountsOrder.length === 0)
|
|
182
|
+
return false;
|
|
183
|
+
return this.ownAccountsOrder[0] === id;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Remove an own account from the in-memory registry.
|
|
187
|
+
*/
|
|
188
|
+
removeOwnAccount(id) {
|
|
189
|
+
if (!this.ownAccounts.has(id))
|
|
190
|
+
return false;
|
|
191
|
+
this.ownAccounts.delete(id);
|
|
192
|
+
const idx = this.ownAccountsOrder.indexOf(id);
|
|
193
|
+
if (idx > -1)
|
|
194
|
+
this.ownAccountsOrder.splice(idx, 1);
|
|
195
|
+
this.removeContact(id);
|
|
196
|
+
// remove cached envelopes addressed to this identity
|
|
197
|
+
this.envelopeCache.deleteByFingerprint(id).catch((error) => {
|
|
198
|
+
console.warn("Account not found in cache: ", error);
|
|
199
|
+
});
|
|
200
|
+
this.scheduleAutosave();
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Retrieve a contact from the directory by ID.
|
|
205
|
+
* Validates that the input is a non-empty string.
|
|
206
|
+
* Returns the MajikContact instance or null if not found.
|
|
207
|
+
*/
|
|
208
|
+
getContactByID(id) {
|
|
209
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
210
|
+
throw new Error("Invalid contact ID: must be a non-empty string");
|
|
211
|
+
}
|
|
212
|
+
if (!this.contactDirectory.hasContact(id)) {
|
|
213
|
+
return null; // Not found
|
|
214
|
+
}
|
|
215
|
+
return this.contactDirectory.getContact(id) ?? null;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Returns a JSON string representation of a contact
|
|
219
|
+
* suitable for sharing.
|
|
220
|
+
*/
|
|
221
|
+
async exportContactAsJSON(contactId) {
|
|
222
|
+
const contact = this.contactDirectory.getContact(contactId);
|
|
223
|
+
if (!contact)
|
|
224
|
+
return null;
|
|
225
|
+
// Support raw-key wrappers produced by the Stablelib provider
|
|
226
|
+
let publicKeyBase64;
|
|
227
|
+
const anyPub = contact.publicKey;
|
|
228
|
+
if (anyPub && anyPub.raw instanceof Uint8Array) {
|
|
229
|
+
publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
|
|
233
|
+
publicKeyBase64 = arrayBufferToBase64(raw);
|
|
234
|
+
}
|
|
235
|
+
const payload = {
|
|
236
|
+
id: contact.id,
|
|
237
|
+
label: contact.meta?.label || "",
|
|
238
|
+
publicKey: publicKeyBase64,
|
|
239
|
+
fingerprint: contact.fingerprint,
|
|
240
|
+
};
|
|
241
|
+
return JSON.stringify(payload, null, 2); // pretty-print for easier copy-paste
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Returns a compact base64 string for sharing a contact.
|
|
245
|
+
* Encodes JSON payload into base64.
|
|
246
|
+
*/
|
|
247
|
+
async exportContactAsString(contactId) {
|
|
248
|
+
const json = await this.exportContactAsJSON(contactId);
|
|
249
|
+
if (!json)
|
|
250
|
+
return null;
|
|
251
|
+
return utf8ToBase64(json);
|
|
252
|
+
}
|
|
253
|
+
/* ================================
|
|
254
|
+
* Contact Management
|
|
255
|
+
* ================================ */
|
|
256
|
+
async importContactFromJSON(jsonStr) {
|
|
257
|
+
try {
|
|
258
|
+
const data = JSON.parse(jsonStr);
|
|
259
|
+
if (!data.id || !data.publicKey || !data.fingerprint)
|
|
260
|
+
return {
|
|
261
|
+
success: false,
|
|
262
|
+
message: "Invalid contact JSON",
|
|
263
|
+
};
|
|
264
|
+
// If publicKey is a base64 string, import it
|
|
265
|
+
let publicKeyPromise;
|
|
266
|
+
if (typeof data.publicKey === "string") {
|
|
267
|
+
try {
|
|
268
|
+
const rawBuffer = base64ToArrayBuffer(data.publicKey);
|
|
269
|
+
try {
|
|
270
|
+
publicKeyPromise = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
// Fallback: create a raw-key wrapper when the browser does not support the namedCurve
|
|
274
|
+
publicKeyPromise = { raw: new Uint8Array(rawBuffer) };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
console.error("Failed to parse publicKey base64", e);
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
message: "Failed to parse publicKey base64",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// assume already a CryptoKey
|
|
287
|
+
publicKeyPromise = await Promise.resolve(data.publicKey);
|
|
288
|
+
}
|
|
289
|
+
const contact = new MajikContact({
|
|
290
|
+
id: data.id,
|
|
291
|
+
publicKey: publicKeyPromise,
|
|
292
|
+
fingerprint: data.fingerprint,
|
|
293
|
+
meta: { label: data.label },
|
|
294
|
+
});
|
|
295
|
+
this.addContact(contact);
|
|
296
|
+
return {
|
|
297
|
+
success: true,
|
|
298
|
+
message: "Contact imported successfully",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
console.error("Failed to import contact from JSON:", err);
|
|
303
|
+
return {
|
|
304
|
+
success: false,
|
|
305
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* List cached envelopes stored in the local cache (most recent first).
|
|
311
|
+
* Returns objects: { id, envelope, timestamp, source }
|
|
312
|
+
*/
|
|
313
|
+
async listCachedEnvelopes(offset = 0, limit = 50) {
|
|
314
|
+
return await this.envelopeCache.listRecent(offset, limit);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Clear cached envelopes stored in the local cache.
|
|
318
|
+
*/
|
|
319
|
+
async clearCachedEnvelopes() {
|
|
320
|
+
const response = await this.envelopeCache.clear();
|
|
321
|
+
if (!response?.success) {
|
|
322
|
+
throw new Error(response.message);
|
|
323
|
+
}
|
|
324
|
+
this.scheduleAutosave();
|
|
325
|
+
return response.success;
|
|
326
|
+
}
|
|
327
|
+
async hasOwnIdentity(fingerprint) {
|
|
328
|
+
return await KeyStore.hasIdentity(fingerprint);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Attempt to decrypt a given envelope and return the plaintext string.
|
|
332
|
+
* Will prompt to unlock identity if necessary.
|
|
333
|
+
*/
|
|
334
|
+
async decryptEnvelope(envelope, bypassIdentity = false) {
|
|
335
|
+
const fingerprint = envelope.extractFingerprint();
|
|
336
|
+
const authorizedAccount = this.listContacts(true).find((a) => a.fingerprint === fingerprint);
|
|
337
|
+
if (!authorizedAccount) {
|
|
338
|
+
throw new Error("No matching own account to decrypt this envelope");
|
|
339
|
+
}
|
|
340
|
+
let privateKey;
|
|
341
|
+
if (bypassIdentity) {
|
|
342
|
+
const activeAccount = this.getActiveAccount();
|
|
343
|
+
if (!activeAccount) {
|
|
344
|
+
throw new Error("No active account available to bypass identity check");
|
|
345
|
+
}
|
|
346
|
+
privateKey = await KeyStore.getPrivateKey(activeAccount.id);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
privateKey = await this.ensureIdentityUnlocked(authorizedAccount.id);
|
|
350
|
+
}
|
|
351
|
+
if (!privateKey) {
|
|
352
|
+
throw new Error("No private key found for this fingerprint.");
|
|
353
|
+
}
|
|
354
|
+
let decrypted;
|
|
355
|
+
if (envelope.isGroup()) {
|
|
356
|
+
const recipientKey = envelope.getRecipientKey(fingerprint);
|
|
357
|
+
if (!recipientKey) {
|
|
358
|
+
throw new Error("No recipient key found for this fingerprint");
|
|
359
|
+
}
|
|
360
|
+
decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, fingerprint);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
|
|
364
|
+
}
|
|
365
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
366
|
+
? window.location.hostname
|
|
367
|
+
: "extension");
|
|
368
|
+
return decrypted;
|
|
369
|
+
}
|
|
370
|
+
async importContactFromString(base64Str) {
|
|
371
|
+
const jsonStr = base64ToUtf8(base64Str);
|
|
372
|
+
const isImportSuccess = await this.importContactFromJSON(jsonStr);
|
|
373
|
+
if (!isImportSuccess.success)
|
|
374
|
+
throw new Error(isImportSuccess.message);
|
|
375
|
+
}
|
|
376
|
+
addContact(contact) {
|
|
377
|
+
this.contactDirectory.addContact(contact);
|
|
378
|
+
this.scheduleAutosave();
|
|
379
|
+
}
|
|
380
|
+
removeContact(id) {
|
|
381
|
+
const removalStatus = this.contactDirectory.removeContact(id);
|
|
382
|
+
if (!removalStatus.success) {
|
|
383
|
+
throw new Error(removalStatus.message);
|
|
384
|
+
}
|
|
385
|
+
this.scheduleAutosave();
|
|
386
|
+
}
|
|
387
|
+
updateContactMeta(id, meta) {
|
|
388
|
+
this.contactDirectory.updateContactMeta(id, meta);
|
|
389
|
+
this.scheduleAutosave();
|
|
390
|
+
}
|
|
391
|
+
blockContact(id) {
|
|
392
|
+
this.contactDirectory.blockContact(id);
|
|
393
|
+
this.scheduleAutosave();
|
|
394
|
+
}
|
|
395
|
+
unblockContact(id) {
|
|
396
|
+
this.contactDirectory.unblockContact(id);
|
|
397
|
+
this.scheduleAutosave();
|
|
398
|
+
}
|
|
399
|
+
listContacts(all = true) {
|
|
400
|
+
const contacts = this.contactDirectory.listContacts(true);
|
|
401
|
+
if (all) {
|
|
402
|
+
return contacts;
|
|
403
|
+
}
|
|
404
|
+
const userAccounts = this.listOwnAccounts();
|
|
405
|
+
const userAccountIds = new Set(userAccounts.map((a) => a.id));
|
|
406
|
+
return contacts.filter((contact) => !userAccountIds.has(contact.id));
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Update the passphrase for an identity.
|
|
410
|
+
* - `id` defaults to the current active account if not provided.
|
|
411
|
+
* - Throws if no active account exists or passphrase is invalid.
|
|
412
|
+
*/
|
|
413
|
+
async updatePassphrase(currentPassphrase, newPassphrase, id) {
|
|
414
|
+
// Determine target account
|
|
415
|
+
const targetAccount = id
|
|
416
|
+
? this.getOwnAccountById(id)
|
|
417
|
+
: this.getActiveAccount();
|
|
418
|
+
if (!targetAccount) {
|
|
419
|
+
throw new Error("No target account specified and no active account available");
|
|
420
|
+
}
|
|
421
|
+
// Delegate to KeyStore
|
|
422
|
+
await KeyStore.updatePassphrase(targetAccount.id, currentPassphrase, newPassphrase);
|
|
423
|
+
// Optionally emit an event or autosave
|
|
424
|
+
this.scheduleAutosave();
|
|
425
|
+
}
|
|
426
|
+
/* ================================
|
|
427
|
+
* Encryption / Decryption
|
|
428
|
+
* ================================ */
|
|
429
|
+
/**
|
|
430
|
+
* Encrypts a plaintext message for a single recipient (solo message)
|
|
431
|
+
* Returns a MessageEnvelope instance and caches it automatically.
|
|
432
|
+
*/
|
|
433
|
+
async encryptSoloMessage(toId, plaintext, cache = true) {
|
|
434
|
+
const contact = this.contactDirectory.getContact(toId);
|
|
435
|
+
if (!contact)
|
|
436
|
+
throw new Error(`No contact with id "${toId}"`);
|
|
437
|
+
const payload = await EncryptionEngine.encryptSoloMessage(plaintext, contact.publicKey);
|
|
438
|
+
const payloadJSON = JSON.stringify(payload);
|
|
439
|
+
const encoder = new TextEncoder();
|
|
440
|
+
const payloadBytes = encoder.encode(payloadJSON);
|
|
441
|
+
// Envelope: [version byte][fingerprint][payload]
|
|
442
|
+
const versionByte = new Uint8Array([1]);
|
|
443
|
+
const fingerprintBytes = new Uint8Array(base64ToArrayBuffer(contact.fingerprint));
|
|
444
|
+
const blob = new Uint8Array(versionByte.length + fingerprintBytes.length + payloadBytes.length);
|
|
445
|
+
blob.set(versionByte, 0);
|
|
446
|
+
blob.set(fingerprintBytes, versionByte.length);
|
|
447
|
+
blob.set(payloadBytes, versionByte.length + fingerprintBytes.length);
|
|
448
|
+
const envelope = new MessageEnvelope(blob.buffer);
|
|
449
|
+
if (!!cache) {
|
|
450
|
+
// Cache envelope
|
|
451
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
452
|
+
? window.location.hostname
|
|
453
|
+
: "extension");
|
|
454
|
+
}
|
|
455
|
+
this.scheduleAutosave();
|
|
456
|
+
this.emit("envelope", envelope);
|
|
457
|
+
return envelope;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Encrypts a plaintext message for a group of recipients.
|
|
461
|
+
* Returns a unified group MessageEnvelope instance.
|
|
462
|
+
*/
|
|
463
|
+
async encryptGroupMessage(recipientIds, plaintext, cache = true) {
|
|
464
|
+
if (!recipientIds.length) {
|
|
465
|
+
throw new Error("No recipients provided");
|
|
466
|
+
}
|
|
467
|
+
// Resolve recipients and their keys
|
|
468
|
+
const recipients = recipientIds.map((id) => {
|
|
469
|
+
const contact = this.contactDirectory.getContact(id);
|
|
470
|
+
if (!contact)
|
|
471
|
+
throw new Error(`No contact with id "${id}"`);
|
|
472
|
+
return {
|
|
473
|
+
id: contact.id,
|
|
474
|
+
publicKey: contact.publicKey,
|
|
475
|
+
fingerprint: contact.fingerprint,
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
// 🔐 Encrypt once for all recipients
|
|
479
|
+
const payload = await EncryptionEngine.encryptGroupMessage(plaintext, recipients);
|
|
480
|
+
// Serialize payload
|
|
481
|
+
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
482
|
+
// Envelope structure: [version byte][sender fingerprint][payload bytes]
|
|
483
|
+
const versionByte = new Uint8Array([2]);
|
|
484
|
+
// Use sender fingerprint for group envelope
|
|
485
|
+
const activeAccount = this.getActiveAccount();
|
|
486
|
+
if (!activeAccount)
|
|
487
|
+
throw new Error("No active account to send from");
|
|
488
|
+
const fingerprintBytes = new Uint8Array(base64ToArrayBuffer(activeAccount.fingerprint));
|
|
489
|
+
// Combine all parts into a single Uint8Array
|
|
490
|
+
const blob = new Uint8Array(versionByte.length + fingerprintBytes.length + payloadBytes.length);
|
|
491
|
+
blob.set(versionByte, 0);
|
|
492
|
+
blob.set(fingerprintBytes, versionByte.length);
|
|
493
|
+
blob.set(payloadBytes, versionByte.length + fingerprintBytes.length);
|
|
494
|
+
// Wrap as MessageEnvelope
|
|
495
|
+
const envelope = new MessageEnvelope(blob.buffer);
|
|
496
|
+
if (!!cache) {
|
|
497
|
+
// Cache envelope
|
|
498
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
499
|
+
? window.location.hostname
|
|
500
|
+
: "extension");
|
|
501
|
+
}
|
|
502
|
+
this.scheduleAutosave();
|
|
503
|
+
this.emit("envelope", envelope);
|
|
504
|
+
return envelope;
|
|
505
|
+
}
|
|
506
|
+
async sendMessage(recipients, plaintext) {
|
|
507
|
+
if (recipients.length === 0 || !recipients) {
|
|
508
|
+
throw new Error("No recipients provided. At least one recipient is required.");
|
|
509
|
+
}
|
|
510
|
+
if (recipients.length === 1) {
|
|
511
|
+
return await this.encryptSoloMessage(recipients[0], plaintext);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
return await this.encryptGroupMessage(recipients, plaintext);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/* ================================
|
|
518
|
+
* High-Level DOM Wrapper
|
|
519
|
+
* ================================ */
|
|
520
|
+
/**
|
|
521
|
+
* Encrypts currently selected text in the browser DOM for given recipients.
|
|
522
|
+
* If `recipients` is empty, defaults to the first own account.
|
|
523
|
+
* Returns the fully serialized base64 envelope string for the scanner.
|
|
524
|
+
*/
|
|
525
|
+
async encryptSelectedTextForScanner(recipients = []) {
|
|
526
|
+
// Delegate to textarea-agnostic implementation
|
|
527
|
+
const plaintext = window.getSelection()?.toString().trim() ?? "";
|
|
528
|
+
return await this.encryptTextForScanner(plaintext, recipients);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Encrypt a provided plaintext string and return a serialized MajikMessage envelope string.
|
|
532
|
+
* Supports single or multiple recipients. Safe to call from background contexts.
|
|
533
|
+
*/
|
|
534
|
+
async encryptTextForScanner(plaintext, recipients = [], cache = true) {
|
|
535
|
+
if (!plaintext?.trim()) {
|
|
536
|
+
console.warn("No text provided to encrypt.");
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
// Determine recipients: default to first own account if none provided
|
|
541
|
+
if (recipients.length === 0) {
|
|
542
|
+
const firstOwn = this.listOwnAccounts()[0];
|
|
543
|
+
if (!firstOwn)
|
|
544
|
+
throw new Error("No own account available for encryption.");
|
|
545
|
+
recipients = [firstOwn.id];
|
|
546
|
+
}
|
|
547
|
+
let envelope;
|
|
548
|
+
if (recipients.length === 1) {
|
|
549
|
+
// Single recipient → solo message
|
|
550
|
+
envelope = await this.encryptSoloMessage(recipients[0], plaintext, cache);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
// Multiple recipients → unified group message
|
|
554
|
+
envelope = await this.encryptGroupMessage(recipients, plaintext, cache);
|
|
555
|
+
}
|
|
556
|
+
// Convert envelope to base64 for scanner
|
|
557
|
+
const envelopeBase64 = arrayBufferToBase64(envelope.raw);
|
|
558
|
+
return `${MessageEnvelope.PREFIX}:${envelopeBase64}`;
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
this.emit("error", err, { context: "encryptTextForScanner" });
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Encrypt text for a given target (label or ID).
|
|
567
|
+
* - If target is self or not found, just encrypt normally.
|
|
568
|
+
* - If target is another contact, always make a group message including self + target.
|
|
569
|
+
*/
|
|
570
|
+
async encryptForTarget(target, // can be label or id
|
|
571
|
+
plaintext) {
|
|
572
|
+
const activeAccount = this.getActiveAccount();
|
|
573
|
+
if (!activeAccount)
|
|
574
|
+
throw new Error("No active account available");
|
|
575
|
+
// Try to find contact by ID first
|
|
576
|
+
let contact = this.listContacts(false).find((c) => c.id === target);
|
|
577
|
+
// If not found by ID, try by label
|
|
578
|
+
if (!contact) {
|
|
579
|
+
contact = this.listContacts(false).find((c) => c.meta?.label === target);
|
|
580
|
+
}
|
|
581
|
+
// If still not found or it's self → solo encryption
|
|
582
|
+
if (!contact || contact.id === activeAccount.id) {
|
|
583
|
+
return this.encryptTextForScanner(plaintext, [activeAccount.id]);
|
|
584
|
+
}
|
|
585
|
+
// Otherwise → group with self + target contact
|
|
586
|
+
return this.encryptTextForScanner(plaintext, [
|
|
587
|
+
activeAccount.id,
|
|
588
|
+
contact.id,
|
|
589
|
+
]);
|
|
590
|
+
}
|
|
591
|
+
/* ================================
|
|
592
|
+
* DOM Scanning
|
|
593
|
+
* ================================ */
|
|
594
|
+
scanDOM(rootNode) {
|
|
595
|
+
this.scanner.scanDOM(rootNode);
|
|
596
|
+
}
|
|
597
|
+
startDOMObserver(rootNode) {
|
|
598
|
+
this.scanner.startDOMObserver(rootNode);
|
|
599
|
+
}
|
|
600
|
+
stopDOMObserver() {
|
|
601
|
+
this.scanner.stopDOMObserver();
|
|
602
|
+
}
|
|
603
|
+
/* ================================
|
|
604
|
+
* Event Handling
|
|
605
|
+
* ================================ */
|
|
606
|
+
on(event, callback) {
|
|
607
|
+
this.listeners.get(event)?.push(callback);
|
|
608
|
+
}
|
|
609
|
+
emit(event, ...args) {
|
|
610
|
+
this.listeners.get(event)?.forEach((cb) => cb(...args));
|
|
611
|
+
}
|
|
612
|
+
/* ================================
|
|
613
|
+
* Envelope Handling
|
|
614
|
+
* ================================ */
|
|
615
|
+
async handleEnvelope(envelope) {
|
|
616
|
+
// Skip if already cached
|
|
617
|
+
const cached = await this.envelopeCache.get(envelope);
|
|
618
|
+
if (cached)
|
|
619
|
+
return;
|
|
620
|
+
const fingerprint = envelope.extractFingerprint();
|
|
621
|
+
// contact is the directory entry (useful for metadata); ownAccount
|
|
622
|
+
// must match fingerprint for this device to be able to decrypt.
|
|
623
|
+
const contact = this.contactDirectory.getContactByFingerprint(fingerprint);
|
|
624
|
+
const ownAccount = this.listOwnAccounts().find((a) => a.fingerprint === fingerprint);
|
|
625
|
+
// If this envelope isn't addressed to one of our own accounts, mark as untrusted
|
|
626
|
+
if (!ownAccount) {
|
|
627
|
+
this.emit("untrusted", envelope);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
// Ensure identity unlocked; try interactive prompt if needed
|
|
632
|
+
const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
|
|
633
|
+
let decrypted;
|
|
634
|
+
if (envelope.isGroup()) {
|
|
635
|
+
const recipientKey = envelope.getRecipientKey(fingerprint);
|
|
636
|
+
if (!recipientKey) {
|
|
637
|
+
throw new Error("No recipient key found for this fingerprint");
|
|
638
|
+
}
|
|
639
|
+
decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, fingerprint);
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
|
|
643
|
+
}
|
|
644
|
+
// Cache envelope (record source as current hostname when available)
|
|
645
|
+
await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
|
|
646
|
+
? window.location.hostname
|
|
647
|
+
: "extension");
|
|
648
|
+
this.scheduleAutosave();
|
|
649
|
+
this.emit("message", decrypted, envelope, contact);
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
this.emit("error", err, { envelope });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Ensure an identity is unlocked. If locked, prompt the user for a passphrase.
|
|
657
|
+
* `promptFn` can be provided to show a custom UI: either synchronous returning string
|
|
658
|
+
* or async Promise<string>. If omitted, falls back to `window.prompt`.
|
|
659
|
+
*/
|
|
660
|
+
async ensureIdentityUnlocked(id, promptFn) {
|
|
661
|
+
try {
|
|
662
|
+
return await KeyStore.getPrivateKey(id);
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
// If KeyStore indicates unlocking is required, prompt the user
|
|
666
|
+
const needsUnlock = err instanceof Error &&
|
|
667
|
+
/must be unlocked|unlockIdentity/.test(err.message);
|
|
668
|
+
if (!needsUnlock)
|
|
669
|
+
throw err;
|
|
670
|
+
// Ask for passphrase
|
|
671
|
+
let passphrase = null;
|
|
672
|
+
if (promptFn) {
|
|
673
|
+
const res = promptFn(id);
|
|
674
|
+
passphrase = typeof res === "string" ? res : await res;
|
|
675
|
+
}
|
|
676
|
+
else if (KeyStore.onUnlockRequested) {
|
|
677
|
+
const res = KeyStore.onUnlockRequested(id);
|
|
678
|
+
passphrase = typeof res === "string" ? res : await res;
|
|
679
|
+
}
|
|
680
|
+
else if (typeof window !== "undefined" && window.prompt) {
|
|
681
|
+
passphrase = window.prompt("Enter passphrase to unlock identity:", "");
|
|
682
|
+
}
|
|
683
|
+
if (!passphrase)
|
|
684
|
+
throw new Error("Unlock cancelled");
|
|
685
|
+
// Attempt to unlock
|
|
686
|
+
await KeyStore.unlockIdentity(id, passphrase);
|
|
687
|
+
return await KeyStore.getPrivateKey(id);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async isPassphraseValid(passphrase, id) {
|
|
691
|
+
const target = id ? this.getOwnAccountById(id) : this.getActiveAccount();
|
|
692
|
+
if (!target)
|
|
693
|
+
return false;
|
|
694
|
+
return KeyStore.isPassphraseValid(target.id, passphrase);
|
|
695
|
+
}
|
|
696
|
+
async toJSON() {
|
|
697
|
+
const finalJSON = {
|
|
698
|
+
id: this.id,
|
|
699
|
+
contacts: await this.contactDirectory.toJSON(),
|
|
700
|
+
envelopeCache: this.envelopeCache.toJSON(),
|
|
701
|
+
};
|
|
702
|
+
// Serialize own accounts (preserve order)
|
|
703
|
+
try {
|
|
704
|
+
const ownAccountsArr = [];
|
|
705
|
+
for (const id of this.ownAccountsOrder) {
|
|
706
|
+
const acct = this.ownAccounts.get(id);
|
|
707
|
+
if (!acct)
|
|
708
|
+
continue;
|
|
709
|
+
ownAccountsArr.push(await acct.toJSON());
|
|
710
|
+
}
|
|
711
|
+
finalJSON.ownAccounts = {
|
|
712
|
+
accounts: ownAccountsArr,
|
|
713
|
+
order: [...this.ownAccountsOrder],
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
// ignore serialization errors for own accounts
|
|
718
|
+
console.warn("Failed to serialize ownAccounts:", e);
|
|
719
|
+
}
|
|
720
|
+
// include optional PIN hash
|
|
721
|
+
finalJSON.pinHash = this.pinHash || null;
|
|
722
|
+
return finalJSON;
|
|
723
|
+
}
|
|
724
|
+
static async fromJSON(json) {
|
|
725
|
+
const newDirectory = new MajikContactDirectory();
|
|
726
|
+
const parsedContacts = await newDirectory.fromJSON(json.contacts);
|
|
727
|
+
const parsedEnvelopeCache = EnvelopeCache.fromJSON(json.envelopeCache);
|
|
728
|
+
const parsedInstance = new MajikMessage({
|
|
729
|
+
contactDirectory: parsedContacts,
|
|
730
|
+
envelopeCache: parsedEnvelopeCache,
|
|
731
|
+
keyStore: KeyStore,
|
|
732
|
+
}, json.id);
|
|
733
|
+
// Restore ownAccounts if present
|
|
734
|
+
try {
|
|
735
|
+
if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
|
|
736
|
+
for (const acct of json.ownAccounts.accounts) {
|
|
737
|
+
try {
|
|
738
|
+
const raw = base64ToArrayBuffer(acct.publicKeyBase64);
|
|
739
|
+
const publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
|
|
740
|
+
const contact = MajikContact.create(acct.id, publicKey, acct.fingerprint, acct.meta);
|
|
741
|
+
parsedInstance.ownAccounts.set(contact.id, contact);
|
|
742
|
+
}
|
|
743
|
+
catch (e) {
|
|
744
|
+
// SubtleCrypto may not support importing X25519 keys in some environments.
|
|
745
|
+
// This is non-fatal: we fall back to raw-key wrappers elsewhere.
|
|
746
|
+
console.info("Fallback restoring own account (using raw-key wrapper)", acct.id, e);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (Array.isArray(json.ownAccounts.order)) {
|
|
750
|
+
parsedInstance.ownAccountsOrder = [...json.ownAccounts.order];
|
|
751
|
+
}
|
|
752
|
+
// Fallback: if accounts array was empty but order exists, try to populate
|
|
753
|
+
// ownAccounts from the restored contactDirectory entries
|
|
754
|
+
try {
|
|
755
|
+
if (Array.isArray(json.ownAccounts.order) &&
|
|
756
|
+
parsedInstance.ownAccounts.size === 0) {
|
|
757
|
+
for (const id of json.ownAccounts.order) {
|
|
758
|
+
try {
|
|
759
|
+
const c = parsedInstance.contactDirectory.getContact(id);
|
|
760
|
+
if (c)
|
|
761
|
+
parsedInstance.ownAccounts.set(id, c);
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
// ignore missing contacts
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
// ignore
|
|
771
|
+
}
|
|
772
|
+
// Also add own accounts into contact directory for discoverability
|
|
773
|
+
try {
|
|
774
|
+
parsedInstance.ownAccountsOrder.forEach((id) => {
|
|
775
|
+
const c = parsedInstance.ownAccounts.get(id);
|
|
776
|
+
if (c && !parsedInstance.contactDirectory.hasContact(c.id)) {
|
|
777
|
+
parsedInstance.contactDirectory.addContact(c);
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
// ignore
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
catch (e) {
|
|
787
|
+
console.warn("Error restoring ownAccounts:", e);
|
|
788
|
+
}
|
|
789
|
+
// restore pin hash if present
|
|
790
|
+
try {
|
|
791
|
+
const anyJson = json;
|
|
792
|
+
if (anyJson.pinHash)
|
|
793
|
+
parsedInstance.pinHash = anyJson.pinHash;
|
|
794
|
+
}
|
|
795
|
+
catch (e) {
|
|
796
|
+
// ignore
|
|
797
|
+
}
|
|
798
|
+
return parsedInstance;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Set a PIN (stores hash). Passphrase is any string; we store SHA-256(base64) of it.
|
|
802
|
+
*/
|
|
803
|
+
async setPIN(pin) {
|
|
804
|
+
if (!pin)
|
|
805
|
+
throw new Error("PIN must be a non-empty string");
|
|
806
|
+
const hash = await MajikMessage.hashPIN(pin);
|
|
807
|
+
this.pinHash = hash;
|
|
808
|
+
this.scheduleAutosave();
|
|
809
|
+
}
|
|
810
|
+
async clearPIN() {
|
|
811
|
+
this.pinHash = null;
|
|
812
|
+
this.scheduleAutosave();
|
|
813
|
+
}
|
|
814
|
+
async isValidPIN(pin) {
|
|
815
|
+
if (!this.pinHash)
|
|
816
|
+
return true; // no PIN set => always valid
|
|
817
|
+
const hash = await MajikMessage.hashPIN(pin);
|
|
818
|
+
return hash === this.pinHash;
|
|
819
|
+
}
|
|
820
|
+
getPinHash() {
|
|
821
|
+
return this.pinHash || null;
|
|
822
|
+
}
|
|
823
|
+
static async hashPIN(pin) {
|
|
824
|
+
const data = new TextEncoder().encode(pin);
|
|
825
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
826
|
+
// base64 encode
|
|
827
|
+
const b64 = arrayBufferToBase64(digest);
|
|
828
|
+
return b64;
|
|
829
|
+
}
|
|
830
|
+
/* ================================
|
|
831
|
+
* Persistence
|
|
832
|
+
* ================================ */
|
|
833
|
+
autosaveIntervalId = null;
|
|
834
|
+
attachAutosaveHandlers() {
|
|
835
|
+
if (typeof window !== "undefined") {
|
|
836
|
+
// Save before unload (best-effort)
|
|
837
|
+
try {
|
|
838
|
+
window.addEventListener("beforeunload", () => {
|
|
839
|
+
void this.saveState();
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
// ignore
|
|
844
|
+
}
|
|
845
|
+
// Start periodic backups
|
|
846
|
+
this.startAutosave();
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
startAutosave() {
|
|
850
|
+
if (this.autosaveIntervalId)
|
|
851
|
+
return;
|
|
852
|
+
if (typeof window === "undefined")
|
|
853
|
+
return;
|
|
854
|
+
this.autosaveIntervalId = window.setInterval(() => {
|
|
855
|
+
void this.saveState();
|
|
856
|
+
}, this.autosaveIntervalMs);
|
|
857
|
+
}
|
|
858
|
+
stopAutosave() {
|
|
859
|
+
if (!this.autosaveIntervalId)
|
|
860
|
+
return;
|
|
861
|
+
if (typeof window !== "undefined") {
|
|
862
|
+
window.clearInterval(this.autosaveIntervalId);
|
|
863
|
+
}
|
|
864
|
+
this.autosaveIntervalId = null;
|
|
865
|
+
}
|
|
866
|
+
scheduleAutosave() {
|
|
867
|
+
try {
|
|
868
|
+
if (this.autosaveTimer) {
|
|
869
|
+
if (typeof window !== "undefined")
|
|
870
|
+
window.clearTimeout(this.autosaveTimer);
|
|
871
|
+
this.autosaveTimer = null;
|
|
872
|
+
}
|
|
873
|
+
if (typeof window !== "undefined") {
|
|
874
|
+
this.autosaveTimer = window.setTimeout(() => {
|
|
875
|
+
void this.saveState();
|
|
876
|
+
this.autosaveTimer = null;
|
|
877
|
+
}, this.autosaveDebounceMs);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
catch (e) {
|
|
881
|
+
// ignore scheduling errors
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** Save current state into IndexedDB (autosave). */
|
|
885
|
+
async saveState() {
|
|
886
|
+
try {
|
|
887
|
+
const jsonDocument = await this.toJSON();
|
|
888
|
+
const autosaveBlob = autoSaveMajikFileData(jsonDocument);
|
|
889
|
+
await idbSaveBlob("majik-message-state", autosaveBlob);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
console.error("Failed to save MajikMessage state:", err);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/** Load state from IndexedDB and apply to this instance. */
|
|
896
|
+
async loadState() {
|
|
897
|
+
try {
|
|
898
|
+
const autosaveData = await idbLoadBlob("majik-message-state");
|
|
899
|
+
if (!autosaveData?.data)
|
|
900
|
+
return;
|
|
901
|
+
const blobFile = autosaveData.data;
|
|
902
|
+
const loadedData = await loadSavedMajikFileData(blobFile);
|
|
903
|
+
const parsedJSON = loadedData.j;
|
|
904
|
+
// Use fromJSON to ensure ownAccounts and other fields are restored consistently
|
|
905
|
+
const restored = await MajikMessage.fromJSON(parsedJSON);
|
|
906
|
+
this.id = restored.id;
|
|
907
|
+
this.contactDirectory = restored.contactDirectory;
|
|
908
|
+
this.envelopeCache = restored.envelopeCache;
|
|
909
|
+
this.ownAccounts = restored.ownAccounts;
|
|
910
|
+
this.ownAccountsOrder = [...restored.ownAccountsOrder];
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
console.error("Failed to load MajikMessage state:", err);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Try to load an existing state from IDB; if none exists, create a fresh instance and save it.
|
|
918
|
+
*/
|
|
919
|
+
static async loadOrCreate(config) {
|
|
920
|
+
try {
|
|
921
|
+
const saved = await idbLoadBlob("majik-message-state");
|
|
922
|
+
if (saved?.data) {
|
|
923
|
+
const loaded = await loadSavedMajikFileData(saved.data);
|
|
924
|
+
const parsedJSON = loaded.j;
|
|
925
|
+
const instance = await MajikMessage.fromJSON(parsedJSON);
|
|
926
|
+
console.log("Account Loaded Successfully");
|
|
927
|
+
instance.attachAutosaveHandlers();
|
|
928
|
+
return instance;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
console.warn("Error trying to load saved MajikMessage state:", err);
|
|
933
|
+
}
|
|
934
|
+
// No saved state; create new and persist initial state
|
|
935
|
+
const created = new MajikMessage(config);
|
|
936
|
+
await created.saveState();
|
|
937
|
+
created.attachAutosaveHandlers();
|
|
938
|
+
return created;
|
|
939
|
+
}
|
|
940
|
+
}
|