diggylab 0.1.30
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/README.md +104 -0
- package/cli.js +2781 -0
- package/package.json +37 -0
package/cli.js
ADDED
|
@@ -0,0 +1,2781 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/cli.ts
|
|
4
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
5
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
6
|
+
import { hostname, homedir } from "node:os";
|
|
7
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
// ../../packages/facebook-scanner/dist/parser.js
|
|
11
|
+
var paths = {
|
|
12
|
+
text: [["message", "text"], ["comet_sections", "content", "story", "message", "text"], ["text"]],
|
|
13
|
+
url: [["url"], ["permalink_url"], ["wwwURL"], ["comet_sections", "content", "story", "url"]],
|
|
14
|
+
author: [["actors", 0, "name"], ["actor", "name"], ["author", "name"]],
|
|
15
|
+
avatar: [
|
|
16
|
+
["actors", 0, "profile_picture", "uri"],
|
|
17
|
+
["actor", "profile_picture", "uri"],
|
|
18
|
+
["author", "profile_picture", "uri"],
|
|
19
|
+
["comet_sections", "content", "story", "actors", 0, "profile_picture", "uri"]
|
|
20
|
+
],
|
|
21
|
+
time: [["creation_time"], ["created_time"], ["publish_time"]],
|
|
22
|
+
group: [["to", "name"], ["group", "name"], ["target", "name"]],
|
|
23
|
+
groupId: [["to", "id"], ["group", "id"], ["target", "id"]]
|
|
24
|
+
};
|
|
25
|
+
var textFormatPaths = [
|
|
26
|
+
["text_format_metadata"],
|
|
27
|
+
["comet_sections", "message", "story", "text_format_metadata"],
|
|
28
|
+
[
|
|
29
|
+
"comet_sections",
|
|
30
|
+
"content",
|
|
31
|
+
"story",
|
|
32
|
+
"comet_sections",
|
|
33
|
+
"message",
|
|
34
|
+
"story",
|
|
35
|
+
"text_format_metadata"
|
|
36
|
+
]
|
|
37
|
+
];
|
|
38
|
+
var facebookHosts = /* @__PURE__ */ new Set([
|
|
39
|
+
"facebook.com",
|
|
40
|
+
"fb.com",
|
|
41
|
+
"m.facebook.com",
|
|
42
|
+
"mbasic.facebook.com",
|
|
43
|
+
"mobile.facebook.com",
|
|
44
|
+
"web.facebook.com",
|
|
45
|
+
"www.facebook.com",
|
|
46
|
+
"www.fb.com"
|
|
47
|
+
]);
|
|
48
|
+
var stableIdPattern = /^\d+$/;
|
|
49
|
+
function isData(value) {
|
|
50
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
function get(root, path) {
|
|
53
|
+
return path.reduce((value, key) => {
|
|
54
|
+
if (Array.isArray(value) && typeof key === "number")
|
|
55
|
+
return value[key];
|
|
56
|
+
return isData(value) && typeof key === "string" ? value[key] : void 0;
|
|
57
|
+
}, root);
|
|
58
|
+
}
|
|
59
|
+
function firstString(root, candidates, trim = true) {
|
|
60
|
+
for (const path of candidates) {
|
|
61
|
+
const value = get(root, path);
|
|
62
|
+
if ((typeof value === "string" || typeof value === "number") && String(value).trim()) {
|
|
63
|
+
return trim ? String(value).trim() : String(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
function firstData(root, candidates) {
|
|
69
|
+
for (const path of candidates) {
|
|
70
|
+
const value = get(root, path);
|
|
71
|
+
if (isData(value))
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
function requestedGroupIdentifier(value) {
|
|
77
|
+
if (!value || /\s/.test(value))
|
|
78
|
+
return void 0;
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(value);
|
|
81
|
+
const match = /^\/groups\/([a-z0-9._-]+)\/?$/i.exec(url.pathname);
|
|
82
|
+
if (!["http:", "https:"].includes(url.protocol) || !facebookHosts.has(url.hostname.toLowerCase()) || url.username || url.password || url.port || !match) {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
return match[1].toLowerCase();
|
|
86
|
+
} catch {
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function normalizePostUrl(value, requestedGroups, verifiedGroupId) {
|
|
91
|
+
if (!value || /\s/.test(value))
|
|
92
|
+
return void 0;
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(value, "https://www.facebook.com");
|
|
95
|
+
if (!["http:", "https:"].includes(url.protocol) || !facebookHosts.has(url.hostname.toLowerCase()) || url.username || url.password || url.port) {
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
const post = /^\/groups\/([a-z0-9._-]+)\/posts\/(\d+)\/?$/i.exec(url.pathname);
|
|
99
|
+
if (post) {
|
|
100
|
+
const group = post[1].toLowerCase();
|
|
101
|
+
if (requestedGroups && !requestedGroups.has(group) && (!verifiedGroupId || !requestedGroups.has(verifiedGroupId.toLowerCase())) || url.searchParams.has("comment_id") || url.searchParams.has("reply_comment_id")) {
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
return `https://www.facebook.com/groups/${group}/posts/${post[2]}/`;
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname.toLowerCase() !== "/permalink.php")
|
|
107
|
+
return void 0;
|
|
108
|
+
const storyIds = url.searchParams.getAll("story_fbid");
|
|
109
|
+
const groupIds = url.searchParams.getAll("id");
|
|
110
|
+
if (storyIds.length !== 1 || groupIds.length !== 1 || !stableIdPattern.test(storyIds[0]) || !stableIdPattern.test(groupIds[0]) || requestedGroups && !requestedGroups.has(groupIds[0]) || url.searchParams.has("comment_id") || url.searchParams.has("reply_comment_id")) {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
return `https://www.facebook.com/permalink.php?story_fbid=${storyIds[0]}&id=${groupIds[0]}`;
|
|
114
|
+
} catch {
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function toIso(value) {
|
|
119
|
+
if (!value)
|
|
120
|
+
return void 0;
|
|
121
|
+
const numeric = Number(value);
|
|
122
|
+
const date = Number.isFinite(numeric) ? new Date(numeric * 1e3) : new Date(value);
|
|
123
|
+
return Number.isNaN(date.valueOf()) ? void 0 : date.toISOString();
|
|
124
|
+
}
|
|
125
|
+
function getAttachmentImages(node) {
|
|
126
|
+
const urls = /* @__PURE__ */ new Set();
|
|
127
|
+
collectAttachmentImageUrls(node, urls, 0);
|
|
128
|
+
return dedupeImageUrls([...urls]);
|
|
129
|
+
}
|
|
130
|
+
function dedupeImageUrls(urls) {
|
|
131
|
+
const best = /* @__PURE__ */ new Map();
|
|
132
|
+
const order = [];
|
|
133
|
+
for (const url of urls) {
|
|
134
|
+
let pathname = url;
|
|
135
|
+
try {
|
|
136
|
+
pathname = new URL(url).pathname;
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
const quality = imageQuality(url);
|
|
140
|
+
const current = best.get(pathname);
|
|
141
|
+
if (!current) {
|
|
142
|
+
order.push(pathname);
|
|
143
|
+
best.set(pathname, { url, quality });
|
|
144
|
+
} else if (quality > current.quality) {
|
|
145
|
+
best.set(pathname, { url, quality });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return order.map((pathname) => best.get(pathname).url).slice(0, 6);
|
|
149
|
+
}
|
|
150
|
+
function imageQuality(url) {
|
|
151
|
+
const match = /[?&]stp=[^&]*?_p(\d+)x(\d+)/.exec(url);
|
|
152
|
+
return match ? Math.max(Number(match[1]), Number(match[2])) : Number.MAX_SAFE_INTEGER;
|
|
153
|
+
}
|
|
154
|
+
function collectFallbackImageUrls(value, urls, depth) {
|
|
155
|
+
if (!value || depth > 12 || urls.size >= 12)
|
|
156
|
+
return;
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
for (const item of value)
|
|
159
|
+
collectFallbackImageUrls(item, urls, depth + 1);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!isData(value))
|
|
163
|
+
return;
|
|
164
|
+
if ("comment_id" in value || "parent_comment" in value)
|
|
165
|
+
return;
|
|
166
|
+
for (const [key, child] of Object.entries(value)) {
|
|
167
|
+
if (key === "profile_picture" || key === "text_format_metadata" || key === "actors" || key === "actor" || key === "actor_photo") {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (typeof child === "string") {
|
|
171
|
+
if (/^https?:\/\//i.test(child) && /(scontent\.|fbcdn\.net)/i.test(child) && /\.(jpe?g|png|webp|avif)(\?|#|$)/i.test(child) && !isProfilePictureUrl(child)) {
|
|
172
|
+
urls.add(child);
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
collectFallbackImageUrls(child, urls, depth + 1);
|
|
177
|
+
if (urls.size >= 12)
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function getTextFormat(node) {
|
|
182
|
+
const metadata = firstData(node, textFormatPaths);
|
|
183
|
+
if (!metadata)
|
|
184
|
+
return void 0;
|
|
185
|
+
const textFormat = {
|
|
186
|
+
backgroundColor: firstString(metadata, [["background_color"], ["background", "color"]]),
|
|
187
|
+
backgroundImageUrl: firstString(metadata, [
|
|
188
|
+
["background_image", "uri"],
|
|
189
|
+
["background", "image", "uri"],
|
|
190
|
+
["portrait_background_image", "uri"],
|
|
191
|
+
["background", "portrait_image", "uri"]
|
|
192
|
+
], false),
|
|
193
|
+
color: firstString(metadata, [["color"]]),
|
|
194
|
+
fontStyle: firstString(metadata, [["font_style"]]),
|
|
195
|
+
fontWeight: firstString(metadata, [["font_weight"]])
|
|
196
|
+
};
|
|
197
|
+
return Object.values(textFormat).some(Boolean) ? textFormat : void 0;
|
|
198
|
+
}
|
|
199
|
+
function collectImageUrls(value, urls, depth) {
|
|
200
|
+
if (!value || depth > 12 || urls.size >= 12)
|
|
201
|
+
return;
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
if (/^https?:\/\//i.test(value) && /(scontent\.|fbcdn\.net|\.(avif|gif|jpe?g|png|webp)(\?|#|$))/i.test(value) && !isProfilePictureUrl(value)) {
|
|
204
|
+
urls.add(value);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (typeof value !== "object")
|
|
209
|
+
return;
|
|
210
|
+
for (const child of Array.isArray(value) ? value : Object.values(value)) {
|
|
211
|
+
collectImageUrls(child, urls, depth + 1);
|
|
212
|
+
if (urls.size >= 12)
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function isProfilePictureUrl(url) {
|
|
217
|
+
return /\/v\/t1\.30497-1\//i.test(url) || /[?&]stp=cp0_/i.test(url);
|
|
218
|
+
}
|
|
219
|
+
function collectAttachmentImageUrls(value, urls, depth) {
|
|
220
|
+
if (!value || depth > 10 || urls.size >= 12)
|
|
221
|
+
return;
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
for (const item of value)
|
|
224
|
+
collectAttachmentImageUrls(item, urls, depth + 1);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!isData(value))
|
|
228
|
+
return;
|
|
229
|
+
if (Array.isArray(value.attachments) || isData(value.attachments)) {
|
|
230
|
+
collectImageUrls(value.attachments, urls, 0);
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(value.all_attachments) || isData(value.all_attachments)) {
|
|
233
|
+
collectImageUrls(value.all_attachments, urls, 0);
|
|
234
|
+
}
|
|
235
|
+
for (const child of Object.values(value)) {
|
|
236
|
+
collectAttachmentImageUrls(child, urls, depth + 1);
|
|
237
|
+
if (urls.size >= 12)
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function toItem(node, requestedGroup) {
|
|
242
|
+
const text = firstString(node, paths.text);
|
|
243
|
+
const typename = typeof node.__typename === "string" ? node.__typename : void 0;
|
|
244
|
+
if (!text || typename && !/(post|story)$/i.test(typename) || "comment_id" in node || "parent_comment" in node) {
|
|
245
|
+
return void 0;
|
|
246
|
+
}
|
|
247
|
+
const candidateUrl = firstString(node, paths.url, false);
|
|
248
|
+
const url = candidateUrl ? normalizePostUrl(candidateUrl, /* @__PURE__ */ new Set([requestedGroup]), firstString(node, paths.groupId)) : void 0;
|
|
249
|
+
if (!url)
|
|
250
|
+
return void 0;
|
|
251
|
+
const authorAvatarUrl = firstString(node, paths.avatar, false);
|
|
252
|
+
let attachmentImages = getAttachmentImages(node);
|
|
253
|
+
if (!attachmentImages.length) {
|
|
254
|
+
const fallbackUrls = /* @__PURE__ */ new Set();
|
|
255
|
+
collectFallbackImageUrls(node, fallbackUrls, 0);
|
|
256
|
+
if (fallbackUrls.size)
|
|
257
|
+
attachmentImages = dedupeImageUrls([...fallbackUrls]);
|
|
258
|
+
}
|
|
259
|
+
const textFormat = getTextFormat(node);
|
|
260
|
+
return {
|
|
261
|
+
text,
|
|
262
|
+
url,
|
|
263
|
+
authorName: firstString(node, paths.author),
|
|
264
|
+
...authorAvatarUrl ? { authorAvatarUrl } : {},
|
|
265
|
+
...attachmentImages.length ? { attachmentImages } : {},
|
|
266
|
+
...textFormat ? { textFormat } : {},
|
|
267
|
+
publishedAt: toIso(firstString(node, paths.time)),
|
|
268
|
+
groupName: firstString(node, paths.group)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function visit(value, requestedGroup, items) {
|
|
272
|
+
if (Array.isArray(value)) {
|
|
273
|
+
for (const item2 of value)
|
|
274
|
+
visit(item2, requestedGroup, items);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!isData(value))
|
|
278
|
+
return;
|
|
279
|
+
for (const child of Object.values(value))
|
|
280
|
+
visit(child, requestedGroup, items);
|
|
281
|
+
const item = toItem(value, requestedGroup);
|
|
282
|
+
if (item)
|
|
283
|
+
items.push(item);
|
|
284
|
+
}
|
|
285
|
+
function mergeFacebookScanItems(items, limit) {
|
|
286
|
+
const unique = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const item of items) {
|
|
288
|
+
const url = normalizePostUrl(item.url);
|
|
289
|
+
if (!url || !item.text.trim())
|
|
290
|
+
continue;
|
|
291
|
+
const existing = unique.get(url);
|
|
292
|
+
if (!existing && unique.size >= limit)
|
|
293
|
+
continue;
|
|
294
|
+
if (!existing) {
|
|
295
|
+
unique.set(url, { ...item, text: item.text.trim(), url });
|
|
296
|
+
} else {
|
|
297
|
+
if (!existing.authorAvatarUrl && item.authorAvatarUrl) {
|
|
298
|
+
existing.authorAvatarUrl = item.authorAvatarUrl;
|
|
299
|
+
}
|
|
300
|
+
const attachmentImages = dedupeImageUrls([
|
|
301
|
+
...existing.attachmentImages ?? [],
|
|
302
|
+
...item.attachmentImages ?? []
|
|
303
|
+
]);
|
|
304
|
+
if (attachmentImages.length)
|
|
305
|
+
existing.attachmentImages = attachmentImages;
|
|
306
|
+
if (item.textFormat) {
|
|
307
|
+
existing.textFormat = { ...item.textFormat, ...existing.textFormat };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return [...unique.values()];
|
|
312
|
+
}
|
|
313
|
+
function parseFacebookGraphqlBody(body, groupUrl) {
|
|
314
|
+
const items = [];
|
|
315
|
+
const requestedGroup = requestedGroupIdentifier(groupUrl);
|
|
316
|
+
if (!requestedGroup)
|
|
317
|
+
return items;
|
|
318
|
+
for (const line of body.split(/\r?\n/)) {
|
|
319
|
+
const json = line.trim().replace(/^for\s*\(;;\);/, "");
|
|
320
|
+
if (!json.startsWith("{") && !json.startsWith("["))
|
|
321
|
+
continue;
|
|
322
|
+
try {
|
|
323
|
+
visit(JSON.parse(json), requestedGroup, items);
|
|
324
|
+
} catch {
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return mergeFacebookScanItems(items, Number.POSITIVE_INFINITY);
|
|
328
|
+
}
|
|
329
|
+
async function extractFacebookDomItems(page, groupUrl) {
|
|
330
|
+
const requestedGroup = requestedGroupIdentifier(groupUrl);
|
|
331
|
+
if (!requestedGroup)
|
|
332
|
+
return [];
|
|
333
|
+
const requestedGroups = /* @__PURE__ */ new Set([requestedGroup]);
|
|
334
|
+
const groupNavigationUrls = await page.locator('[role="tab"] a[href*="/groups/"], a[role="tab"][href*="/groups/"]').evaluateAll((anchors) => anchors.map((anchor) => anchor.href));
|
|
335
|
+
for (const navigationUrl of groupNavigationUrls) {
|
|
336
|
+
const alias = requestedGroupIdentifier(navigationUrl);
|
|
337
|
+
if (alias)
|
|
338
|
+
requestedGroups.add(alias);
|
|
339
|
+
}
|
|
340
|
+
const raw = await page.locator('[role="article"]').evaluateAll((articles) => articles.map((article) => {
|
|
341
|
+
if (!article.checkVisibility() || article.parentElement?.closest('[role="article"]')) {
|
|
342
|
+
return void 0;
|
|
343
|
+
}
|
|
344
|
+
const belongsToArticle = (element) => element.closest('[role="article"]') === article;
|
|
345
|
+
const anchors = Array.from(article.querySelectorAll("a[href]")).filter((anchor) => belongsToArticle(anchor) && anchor.checkVisibility());
|
|
346
|
+
const urls = anchors.map((anchor) => anchor.getAttribute("href") ?? "").filter((href) => /\/posts\/|permalink\.php/.test(href));
|
|
347
|
+
const message = Array.from(article.querySelectorAll('[data-ad-preview="message"]')).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
348
|
+
const author = Array.from(article.querySelectorAll("h2 a, h3 a, strong a")).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
349
|
+
const time = Array.from(article.querySelectorAll("time[datetime], abbr[data-utime]")).find((element) => belongsToArticle(element) && element.checkVisibility());
|
|
350
|
+
const attachmentImages = Array.from(article.querySelectorAll("img")).filter((image) => belongsToArticle(image) && image.naturalWidth >= 200).map((image) => image.currentSrc || image.src).filter((src) => /^https?:\/\//i.test(src) && /(scontent\.|fbcdn\.net)/i.test(src));
|
|
351
|
+
return {
|
|
352
|
+
text: message?.innerText ?? "",
|
|
353
|
+
urls,
|
|
354
|
+
authorName: author?.innerText,
|
|
355
|
+
publishedAt: time?.getAttribute("datetime") ?? time?.getAttribute("data-utime") ?? void 0,
|
|
356
|
+
...attachmentImages.length ? { attachmentImages } : {}
|
|
357
|
+
};
|
|
358
|
+
}));
|
|
359
|
+
return mergeFacebookScanItems(raw.flatMap((item) => {
|
|
360
|
+
const url = item?.urls.map((candidate) => normalizePostUrl(candidate, requestedGroups)).find((candidate) => candidate !== void 0);
|
|
361
|
+
return item && item.text.trim() && url ? [{
|
|
362
|
+
text: item.text,
|
|
363
|
+
url,
|
|
364
|
+
authorName: item.authorName,
|
|
365
|
+
publishedAt: item.publishedAt,
|
|
366
|
+
...item.attachmentImages?.length ? { attachmentImages: dedupeImageUrls(item.attachmentImages) } : {}
|
|
367
|
+
}] : [];
|
|
368
|
+
}), Number.POSITIVE_INFINITY);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ../../packages/facebook-scanner/dist/session.js
|
|
372
|
+
async function detectFacebookSession(page, context) {
|
|
373
|
+
let url;
|
|
374
|
+
try {
|
|
375
|
+
url = new URL(page.url());
|
|
376
|
+
} catch {
|
|
377
|
+
return "LOGIN_REQUIRED";
|
|
378
|
+
}
|
|
379
|
+
const host = url.hostname.toLowerCase();
|
|
380
|
+
if (host !== "facebook.com" && !host.endsWith(".facebook.com"))
|
|
381
|
+
return "LOGIN_REQUIRED";
|
|
382
|
+
if (/^\/checkpoint(?:\/|$)/i.test(url.pathname))
|
|
383
|
+
return "CHECKPOINT_REQUIRED";
|
|
384
|
+
if (/^\/login(?:\/|$)/i.test(url.pathname))
|
|
385
|
+
return "LOGIN_REQUIRED";
|
|
386
|
+
if (await page.locator('input[name="email"]').count())
|
|
387
|
+
return "LOGIN_REQUIRED";
|
|
388
|
+
const cookies = await context.cookies();
|
|
389
|
+
const hasUser = cookies.some((cookie) => cookie.name === "c_user" && cookie.value.trim().length > 0 && (cookie.domain === "facebook.com" || cookie.domain.endsWith(".facebook.com")));
|
|
390
|
+
return hasUser ? "READY" : "LOGIN_REQUIRED";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ../../packages/facebook-scanner/dist/scanner.js
|
|
394
|
+
var FacebookScannerError = class extends Error {
|
|
395
|
+
code;
|
|
396
|
+
constructor(code) {
|
|
397
|
+
super(code);
|
|
398
|
+
this.code = code;
|
|
399
|
+
this.name = "FacebookScannerError";
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
var FacebookScanner = class {
|
|
403
|
+
browserType;
|
|
404
|
+
extractDomItems;
|
|
405
|
+
parseGraphqlBody;
|
|
406
|
+
graphqlResponseTimeoutMs;
|
|
407
|
+
running = false;
|
|
408
|
+
constructor(options) {
|
|
409
|
+
this.browserType = options.browserType;
|
|
410
|
+
this.extractDomItems = options.extractDomItems ?? extractFacebookDomItems;
|
|
411
|
+
this.parseGraphqlBody = options.parseGraphqlBody ?? parseFacebookGraphqlBody;
|
|
412
|
+
this.graphqlResponseTimeoutMs = options.graphqlResponseTimeoutMs ?? 1e4;
|
|
413
|
+
}
|
|
414
|
+
async scanMany(options) {
|
|
415
|
+
if (this.running)
|
|
416
|
+
throw new FacebookScannerError("SCANNER_BUSY");
|
|
417
|
+
this.running = true;
|
|
418
|
+
let browser = null;
|
|
419
|
+
try {
|
|
420
|
+
browser = await this.browserType.launch({ headless: true });
|
|
421
|
+
const results = [];
|
|
422
|
+
for (const [index, groupUrl] of options.groupUrls.entries()) {
|
|
423
|
+
assertNotAborted(options.signal);
|
|
424
|
+
const items = await this.scanGroup(browser, options, groupUrl, index);
|
|
425
|
+
results.push(items);
|
|
426
|
+
}
|
|
427
|
+
return results;
|
|
428
|
+
} finally {
|
|
429
|
+
await browser?.close();
|
|
430
|
+
this.running = false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async scanGroup(browser, options, groupUrl, groupIndex) {
|
|
434
|
+
const context = await browser.newContext({ storageState: options.storageState });
|
|
435
|
+
let page = null;
|
|
436
|
+
const graphqlItems = [];
|
|
437
|
+
const responseTasks = [];
|
|
438
|
+
const responseHandler = (response) => {
|
|
439
|
+
if (!response.url().includes("/api/graphql/"))
|
|
440
|
+
return;
|
|
441
|
+
responseTasks.push(withTimeout(response.text(), this.graphqlResponseTimeoutMs).then((body) => graphqlItems.push(...this.parseGraphqlBody(body, groupUrl))).then(() => void 0).catch(() => void 0));
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
page = await context.newPage();
|
|
445
|
+
page.on("response", responseHandler);
|
|
446
|
+
await page.route("**/*", (route) => ["font", "image", "media"].includes(route.request().resourceType()) ? route.abort() : route.continue());
|
|
447
|
+
const target = new URL(groupUrl);
|
|
448
|
+
target.searchParams.set("sorting_setting", "CHRONOLOGICAL");
|
|
449
|
+
try {
|
|
450
|
+
await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
451
|
+
} catch {
|
|
452
|
+
throw new FacebookScannerError("NAVIGATION_FAILED");
|
|
453
|
+
}
|
|
454
|
+
const condition = await detectFacebookSession(page, context);
|
|
455
|
+
if (condition !== "READY")
|
|
456
|
+
throw new FacebookScannerError(condition);
|
|
457
|
+
await options.onProgress?.({ groupIndex, groupCount: options.groupUrls.length, phase: "SCANNING", itemCount: 0 });
|
|
458
|
+
let domItems = [];
|
|
459
|
+
let items = [];
|
|
460
|
+
let idleRounds = 0;
|
|
461
|
+
while (items.length < options.limit && idleRounds < 3) {
|
|
462
|
+
assertNotAborted(options.signal);
|
|
463
|
+
const previousCount = items.length;
|
|
464
|
+
await page.waitForTimeout(1500);
|
|
465
|
+
await drain(responseTasks, this.graphqlResponseTimeoutMs);
|
|
466
|
+
domItems = await this.extractDomItems(page, groupUrl);
|
|
467
|
+
items = mergeFacebookScanItems([...graphqlItems, ...domItems], options.limit);
|
|
468
|
+
if (items.length !== previousCount) {
|
|
469
|
+
await options.onProgress?.({
|
|
470
|
+
groupIndex,
|
|
471
|
+
groupCount: options.groupUrls.length,
|
|
472
|
+
phase: "SCANNING",
|
|
473
|
+
itemCount: items.length
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
idleRounds = items.length === previousCount ? idleRounds + 1 : 0;
|
|
477
|
+
if (items.length < options.limit)
|
|
478
|
+
await page.mouse.wheel(0, 2e3);
|
|
479
|
+
}
|
|
480
|
+
assertNotAborted(options.signal);
|
|
481
|
+
await drain(responseTasks, this.graphqlResponseTimeoutMs);
|
|
482
|
+
items = mergeFacebookScanItems([...graphqlItems, ...domItems], options.limit);
|
|
483
|
+
if (!items.length)
|
|
484
|
+
throw new FacebookScannerError("NO_POSTS");
|
|
485
|
+
await options.onProgress?.({ groupIndex, groupCount: options.groupUrls.length, phase: "COMPLETE", itemCount: items.length });
|
|
486
|
+
return items;
|
|
487
|
+
} finally {
|
|
488
|
+
page?.off("response", responseHandler);
|
|
489
|
+
await page?.unroute("**/*").catch(() => void 0);
|
|
490
|
+
await Promise.allSettled(responseTasks);
|
|
491
|
+
await page?.close();
|
|
492
|
+
await context.close();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
function assertNotAborted(signal) {
|
|
497
|
+
if (signal?.aborted)
|
|
498
|
+
throw new FacebookScannerError("SCAN_CANCELED");
|
|
499
|
+
}
|
|
500
|
+
async function drain(tasks, timeoutMs) {
|
|
501
|
+
const pending = tasks.splice(0);
|
|
502
|
+
if (pending.length)
|
|
503
|
+
await withTimeout(Promise.allSettled(pending), timeoutMs).catch(() => void 0);
|
|
504
|
+
}
|
|
505
|
+
function withTimeout(promise, timeoutMs) {
|
|
506
|
+
let timer;
|
|
507
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
508
|
+
timer = setTimeout(() => reject(new Error("Facebook response timeout.")), timeoutMs);
|
|
509
|
+
});
|
|
510
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/cli/cli.ts
|
|
514
|
+
import { chromium } from "playwright";
|
|
515
|
+
import WebSocket from "ws";
|
|
516
|
+
|
|
517
|
+
// src/main/activity-log.ts
|
|
518
|
+
import { gzip } from "node:zlib";
|
|
519
|
+
import { promisify } from "node:util";
|
|
520
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
521
|
+
import { basename, join } from "node:path";
|
|
522
|
+
var gzipAsync = promisify(gzip);
|
|
523
|
+
var ActivityLog = class {
|
|
524
|
+
options;
|
|
525
|
+
listeners = /* @__PURE__ */ new Set();
|
|
526
|
+
pending = Promise.resolve();
|
|
527
|
+
constructor(options) {
|
|
528
|
+
this.options = {
|
|
529
|
+
now: Date.now,
|
|
530
|
+
randomUuid: () => crypto.randomUUID(),
|
|
531
|
+
maxFileBytes: 2 * 1024 * 1024,
|
|
532
|
+
maxDirectoryBytes: 10 * 1024 * 1024,
|
|
533
|
+
...options
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
append(input) {
|
|
537
|
+
const entry2 = {
|
|
538
|
+
...input,
|
|
539
|
+
id: this.options.randomUuid(),
|
|
540
|
+
timestamp: new Date(this.options.now()).toISOString(),
|
|
541
|
+
code: sanitize(input.code).toUpperCase().replace(/[^A-Z0-9_]/g, "_").slice(0, 80) || "EVENT",
|
|
542
|
+
message: sanitize(input.message).slice(0, 500)
|
|
543
|
+
};
|
|
544
|
+
this.pending = this.pending.catch(() => void 0).then(async () => {
|
|
545
|
+
await this.persist(entry2);
|
|
546
|
+
for (const listener of this.listeners) listener(entry2);
|
|
547
|
+
});
|
|
548
|
+
return this.pending;
|
|
549
|
+
}
|
|
550
|
+
async list(limit = 500) {
|
|
551
|
+
await this.pending;
|
|
552
|
+
const names = (await this.names()).sort().slice(-7);
|
|
553
|
+
const entries = [];
|
|
554
|
+
for (const name of names) {
|
|
555
|
+
const lines = await readFile(join(this.options.directory, name), "utf8").catch(() => "");
|
|
556
|
+
for (const line of lines.split("\n")) {
|
|
557
|
+
if (!line) continue;
|
|
558
|
+
try {
|
|
559
|
+
const entry2 = JSON.parse(line);
|
|
560
|
+
if (isEntry(entry2)) entries.push(entry2);
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return entries.slice(-Math.max(1, Math.min(limit, 1e3)));
|
|
566
|
+
}
|
|
567
|
+
subscribe(listener) {
|
|
568
|
+
this.listeners.add(listener);
|
|
569
|
+
return () => this.listeners.delete(listener);
|
|
570
|
+
}
|
|
571
|
+
clear() {
|
|
572
|
+
this.pending = this.pending.catch(() => void 0).then(async () => {
|
|
573
|
+
await Promise.all((await this.names()).map((name) => rm(join(this.options.directory, name), { force: true })));
|
|
574
|
+
});
|
|
575
|
+
return this.pending;
|
|
576
|
+
}
|
|
577
|
+
async exportDay(day, destination) {
|
|
578
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || basename(destination) === "") {
|
|
579
|
+
throw new TypeError("Invalid activity export.");
|
|
580
|
+
}
|
|
581
|
+
await this.pending;
|
|
582
|
+
const source = await readFile(join(this.options.directory, `${day}.jsonl`));
|
|
583
|
+
await writeFile(destination, await gzipAsync(source), { mode: 384 });
|
|
584
|
+
}
|
|
585
|
+
async persist(entry2) {
|
|
586
|
+
await mkdir(this.options.directory, { recursive: true });
|
|
587
|
+
const day = entry2.timestamp.slice(0, 10);
|
|
588
|
+
const path = join(this.options.directory, `${day}.jsonl`);
|
|
589
|
+
const existing = await readFile(path, "utf8").catch(() => "");
|
|
590
|
+
const line = `${JSON.stringify(entry2)}
|
|
591
|
+
`;
|
|
592
|
+
let value = `${existing}${line}`;
|
|
593
|
+
while (Buffer.byteLength(value) > this.options.maxFileBytes && value.includes("\n")) {
|
|
594
|
+
value = value.slice(value.indexOf("\n") + 1);
|
|
595
|
+
}
|
|
596
|
+
await writeFile(`${path}.tmp`, value, { encoding: "utf8", mode: 384 });
|
|
597
|
+
await rename(`${path}.tmp`, path);
|
|
598
|
+
await this.prune(day);
|
|
599
|
+
}
|
|
600
|
+
async prune(currentDay) {
|
|
601
|
+
const minimumDay = /* @__PURE__ */ new Date(`${currentDay}T00:00:00.000Z`);
|
|
602
|
+
minimumDay.setUTCDate(minimumDay.getUTCDate() - 6);
|
|
603
|
+
const minimum = minimumDay.toISOString().slice(0, 10);
|
|
604
|
+
let names = (await this.names()).sort();
|
|
605
|
+
await Promise.all(names.filter((name) => name.slice(0, 10) < minimum).map((name) => rm(join(this.options.directory, name), { force: true })));
|
|
606
|
+
names = (await this.names()).sort();
|
|
607
|
+
const sizes = await Promise.all(names.map(async (name) => ({
|
|
608
|
+
name,
|
|
609
|
+
size: await stat(join(this.options.directory, name)).then((value) => value.size).catch(() => 0)
|
|
610
|
+
})));
|
|
611
|
+
let total = sizes.reduce((sum, item) => sum + item.size, 0);
|
|
612
|
+
while (total > this.options.maxDirectoryBytes && sizes.length > 1) {
|
|
613
|
+
const oldest = sizes.shift();
|
|
614
|
+
if (!oldest) break;
|
|
615
|
+
await rm(join(this.options.directory, oldest.name), { force: true });
|
|
616
|
+
total -= oldest.size;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
names() {
|
|
620
|
+
return readdir(this.options.directory).then((names) => names.filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(name))).catch(() => []);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
function sanitize(value) {
|
|
624
|
+
return value.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]").replace(/\b(authorization|cookie|set-cookie|token|access[ _-]?token|refresh[ _-]?token|api[ _-]?key|private[ _-]?key|signature|pairing[ _-]?code|storage[ _-]?state)\b\s*[:=]\s*[^\s;]+/gi, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/gi, "https://[REDACTED]@").replace(/\b[A-F0-9]{32}\b/gi, "[REDACTED]").replace(/[\r\n\t]+/g, " ").trim();
|
|
625
|
+
}
|
|
626
|
+
function isEntry(value) {
|
|
627
|
+
return typeof value?.id === "string" && typeof value.timestamp === "string" && ["INFO", "WARN", "ERROR"].includes(value.level) && ["CONNECTION", "PAIRING", "FACEBOOK", "SCAN", "UPLOAD", "SYSTEM"].includes(value.category) && typeof value.code === "string" && typeof value.message === "string";
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/main/browser-runtime.ts
|
|
631
|
+
function createBrowserLauncher(browserType, executablePath) {
|
|
632
|
+
if (!executablePath) return browserType;
|
|
633
|
+
return {
|
|
634
|
+
launch: (options) => browserType.launch({ ...options, executablePath })
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/main/json-store.ts
|
|
639
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
640
|
+
import { dirname } from "node:path";
|
|
641
|
+
var defaultIo = { mkdir: mkdir2, readFile: readFile2, rename: rename2, writeFile: writeFile2 };
|
|
642
|
+
var JsonFileStore = class {
|
|
643
|
+
constructor(path, defaultValue, io = defaultIo) {
|
|
644
|
+
this.path = path;
|
|
645
|
+
this.defaultValue = defaultValue;
|
|
646
|
+
this.io = io;
|
|
647
|
+
}
|
|
648
|
+
path;
|
|
649
|
+
defaultValue;
|
|
650
|
+
io;
|
|
651
|
+
async read() {
|
|
652
|
+
try {
|
|
653
|
+
return JSON.parse(await this.io.readFile(this.path, "utf8"));
|
|
654
|
+
} catch {
|
|
655
|
+
return this.defaultValue();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async write(value) {
|
|
659
|
+
const temporaryPath = `${this.path}.tmp`;
|
|
660
|
+
await this.io.mkdir(dirname(this.path), { recursive: true });
|
|
661
|
+
await this.io.writeFile(temporaryPath, JSON.stringify(value), { encoding: "utf8", mode: 384 });
|
|
662
|
+
await this.io.rename(temporaryPath, this.path);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// src/main/credential-store.ts
|
|
667
|
+
var CredentialStore = class {
|
|
668
|
+
constructor(path, crypto2, io) {
|
|
669
|
+
this.crypto = crypto2;
|
|
670
|
+
this.store = new JsonFileStore(path, () => ({}), io);
|
|
671
|
+
}
|
|
672
|
+
crypto;
|
|
673
|
+
store;
|
|
674
|
+
ensureEncryptionAvailable() {
|
|
675
|
+
if (!this.crypto.isEncryptionAvailable()) {
|
|
676
|
+
throw new Error("OS credential encryption is unavailable.");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async set(key, value) {
|
|
680
|
+
this.ensureEncryptionAvailable();
|
|
681
|
+
const credentials = await this.store.read();
|
|
682
|
+
credentials[key] = this.crypto.encryptString(value).toString("base64");
|
|
683
|
+
await this.store.write(credentials);
|
|
684
|
+
}
|
|
685
|
+
async get(key) {
|
|
686
|
+
const encrypted = (await this.store.read())[key];
|
|
687
|
+
return encrypted ? this.crypto.decryptString(Buffer.from(encrypted, "base64")) : null;
|
|
688
|
+
}
|
|
689
|
+
async delete(key) {
|
|
690
|
+
const credentials = await this.store.read();
|
|
691
|
+
delete credentials[key];
|
|
692
|
+
await this.store.write(credentials);
|
|
693
|
+
}
|
|
694
|
+
clear() {
|
|
695
|
+
return this.store.write({});
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
// src/main/device-credential-manager.ts
|
|
700
|
+
var DeviceCredentialManager = class {
|
|
701
|
+
store;
|
|
702
|
+
http;
|
|
703
|
+
createIdentity;
|
|
704
|
+
stored = null;
|
|
705
|
+
access = null;
|
|
706
|
+
refreshPromise = null;
|
|
707
|
+
constructor(store, http, createIdentity) {
|
|
708
|
+
this.store = store;
|
|
709
|
+
this.http = http;
|
|
710
|
+
this.createIdentity = createIdentity;
|
|
711
|
+
}
|
|
712
|
+
async load() {
|
|
713
|
+
this.stored = await this.store.load();
|
|
714
|
+
return this.stored;
|
|
715
|
+
}
|
|
716
|
+
async pair(input) {
|
|
717
|
+
this.store.ensureEncryptionAvailable();
|
|
718
|
+
const identity = this.createIdentity();
|
|
719
|
+
const result = await this.http.pair({ ...input, publicKey: identity.publicKey });
|
|
720
|
+
return this.persistResult(result, identity.privateKey, identity.publicKey);
|
|
721
|
+
}
|
|
722
|
+
refresh() {
|
|
723
|
+
if (this.refreshPromise) return this.refreshPromise;
|
|
724
|
+
this.refreshPromise = this.refreshOnce().finally(() => {
|
|
725
|
+
this.refreshPromise = null;
|
|
726
|
+
});
|
|
727
|
+
return this.refreshPromise;
|
|
728
|
+
}
|
|
729
|
+
getStoredSession() {
|
|
730
|
+
return this.stored;
|
|
731
|
+
}
|
|
732
|
+
getAccessCredential() {
|
|
733
|
+
return this.access;
|
|
734
|
+
}
|
|
735
|
+
async clear() {
|
|
736
|
+
this.stored = null;
|
|
737
|
+
this.access = null;
|
|
738
|
+
await this.store.clear();
|
|
739
|
+
}
|
|
740
|
+
async refreshOnce() {
|
|
741
|
+
if (!this.stored) throw new Error("Desktop Agent is not paired.");
|
|
742
|
+
let result;
|
|
743
|
+
try {
|
|
744
|
+
result = await this.http.refresh(this.stored.refreshToken);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (hasErrorCode(error, "CREDENTIAL_REVOKED")) await this.clear();
|
|
747
|
+
throw error;
|
|
748
|
+
}
|
|
749
|
+
return this.persistResult(result, this.stored.privateKey, this.stored.publicKey);
|
|
750
|
+
}
|
|
751
|
+
async persistResult(result, privateKey, publicKey) {
|
|
752
|
+
const session = {
|
|
753
|
+
privateKey,
|
|
754
|
+
publicKey,
|
|
755
|
+
refreshToken: result.refreshToken,
|
|
756
|
+
refreshTokenExpiresAt: result.refreshTokenExpiresAt,
|
|
757
|
+
device: result.device
|
|
758
|
+
};
|
|
759
|
+
await this.store.save(session);
|
|
760
|
+
const access = {
|
|
761
|
+
accessToken: result.accessToken,
|
|
762
|
+
accessTokenExpiresAt: result.accessTokenExpiresAt,
|
|
763
|
+
session
|
|
764
|
+
};
|
|
765
|
+
this.stored = session;
|
|
766
|
+
this.access = access;
|
|
767
|
+
return access;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
function hasErrorCode(error, code) {
|
|
771
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/main/device-name.ts
|
|
775
|
+
function sanitizeDeviceName(value) {
|
|
776
|
+
return [...value].filter((character) => {
|
|
777
|
+
const code = character.codePointAt(0) ?? 0;
|
|
778
|
+
return code >= 32 && code !== 127;
|
|
779
|
+
}).join("").replace(/\s+/g, " ").trim().slice(0, 80) || "Desktop Agent";
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/main/desktop-agent-http.ts
|
|
783
|
+
var RESPONSE_LIMIT = 64 * 1024;
|
|
784
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
785
|
+
var DesktopAgentClientError = class extends Error {
|
|
786
|
+
code;
|
|
787
|
+
constructor(code) {
|
|
788
|
+
super(code);
|
|
789
|
+
this.code = code;
|
|
790
|
+
this.name = "DesktopAgentClientError";
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
var DesktopAgentHttpClient = class {
|
|
794
|
+
origin;
|
|
795
|
+
fetcher;
|
|
796
|
+
constructor(origin, fetcher = fetch) {
|
|
797
|
+
this.origin = origin;
|
|
798
|
+
this.fetcher = fetcher;
|
|
799
|
+
}
|
|
800
|
+
pair(input) {
|
|
801
|
+
return this.requestCredential("/desktop-agent-auth/pair", {
|
|
802
|
+
...input,
|
|
803
|
+
code: input.code.trim().toUpperCase(),
|
|
804
|
+
name: input.name.trim()
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
refresh(refreshToken) {
|
|
808
|
+
return this.requestCredential("/desktop-agent-auth/refresh", { refreshToken });
|
|
809
|
+
}
|
|
810
|
+
async me(accessToken) {
|
|
811
|
+
const value = await this.request("/desktop-agent-auth/me", {
|
|
812
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
813
|
+
method: "GET"
|
|
814
|
+
});
|
|
815
|
+
if (!isDeviceSummary(value)) throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
816
|
+
return value;
|
|
817
|
+
}
|
|
818
|
+
async requestCredential(path, body) {
|
|
819
|
+
const value = await this.request(path, {
|
|
820
|
+
body: JSON.stringify(body),
|
|
821
|
+
headers: { "content-type": "application/json" },
|
|
822
|
+
method: "POST"
|
|
823
|
+
});
|
|
824
|
+
if (!isCredentialResult(value)) {
|
|
825
|
+
throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
826
|
+
}
|
|
827
|
+
return value;
|
|
828
|
+
}
|
|
829
|
+
async request(path, init) {
|
|
830
|
+
const controller = new AbortController();
|
|
831
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
832
|
+
timeout.unref?.();
|
|
833
|
+
let response;
|
|
834
|
+
try {
|
|
835
|
+
response = await this.fetcher(`${this.origin}${path}`, {
|
|
836
|
+
...init,
|
|
837
|
+
cache: "no-store",
|
|
838
|
+
signal: controller.signal
|
|
839
|
+
});
|
|
840
|
+
} catch {
|
|
841
|
+
throw new DesktopAgentClientError("NETWORK_ERROR");
|
|
842
|
+
} finally {
|
|
843
|
+
clearTimeout(timeout);
|
|
844
|
+
}
|
|
845
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim();
|
|
846
|
+
if (contentType !== "application/json") {
|
|
847
|
+
throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
848
|
+
}
|
|
849
|
+
const value = await readBoundedJson(response);
|
|
850
|
+
if (!response.ok) throw mapServerError(response.status, value);
|
|
851
|
+
return value;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
async function readBoundedJson(response) {
|
|
855
|
+
const length = Number(response.headers.get("content-length"));
|
|
856
|
+
if (Number.isFinite(length) && length > RESPONSE_LIMIT) {
|
|
857
|
+
throw new DesktopAgentClientError("DESKTOP_RESPONSE_TOO_LARGE");
|
|
858
|
+
}
|
|
859
|
+
if (!response.body) throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
860
|
+
const reader = response.body.getReader();
|
|
861
|
+
const chunks = [];
|
|
862
|
+
let size = 0;
|
|
863
|
+
for (; ; ) {
|
|
864
|
+
const { done, value } = await reader.read();
|
|
865
|
+
if (done) break;
|
|
866
|
+
size += value.byteLength;
|
|
867
|
+
if (size > RESPONSE_LIMIT) {
|
|
868
|
+
await reader.cancel();
|
|
869
|
+
throw new DesktopAgentClientError("DESKTOP_RESPONSE_TOO_LARGE");
|
|
870
|
+
}
|
|
871
|
+
chunks.push(value);
|
|
872
|
+
}
|
|
873
|
+
const body = Buffer.concat(chunks, size).toString("utf8");
|
|
874
|
+
try {
|
|
875
|
+
return JSON.parse(body);
|
|
876
|
+
} catch {
|
|
877
|
+
throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function mapServerError(status, value) {
|
|
881
|
+
const code = value && typeof value === "object" && "code" in value ? value.code : void 0;
|
|
882
|
+
if (code === "DESKTOP_AGENT_PAIRING_CODE_INVALID") {
|
|
883
|
+
return new DesktopAgentClientError("PAIRING_CODE_INVALID");
|
|
884
|
+
}
|
|
885
|
+
if (code === "DESKTOP_AGENT_PLAN_RESTRICTED") {
|
|
886
|
+
return new DesktopAgentClientError("PLAN_RESTRICTED");
|
|
887
|
+
}
|
|
888
|
+
if (code === "DESKTOP_AGENT_TOKEN_INVALID" || code === "DESKTOP_AGENT_DEVICE_REVOKED") {
|
|
889
|
+
return new DesktopAgentClientError("CREDENTIAL_REVOKED");
|
|
890
|
+
}
|
|
891
|
+
return new DesktopAgentClientError(status >= 500 ? "SERVER_UNAVAILABLE" : "DESKTOP_RESPONSE_INVALID");
|
|
892
|
+
}
|
|
893
|
+
function isCredentialResult(value) {
|
|
894
|
+
if (!value || typeof value !== "object") return false;
|
|
895
|
+
const result = value;
|
|
896
|
+
return typeof result.accessToken === "string" && typeof result.accessTokenExpiresAt === "string" && typeof result.refreshToken === "string" && typeof result.refreshTokenExpiresAt === "string" && isDeviceSummary(result.device);
|
|
897
|
+
}
|
|
898
|
+
function isDeviceSummary(value) {
|
|
899
|
+
if (!value || typeof value !== "object") return false;
|
|
900
|
+
const device = value;
|
|
901
|
+
return typeof device.id === "string" && typeof device.name === "string" && (device.platform === "WINDOWS" || device.platform === "MACOS" || device.platform === "LINUX") && typeof device.agentVersion === "string" && typeof device.facebookReady === "boolean" && (device.accessMode === "FULL" || device.accessMode === "PLAN_RESTRICTED");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/main/desktop-agent-session.ts
|
|
905
|
+
import { randomUUID } from "node:crypto";
|
|
906
|
+
|
|
907
|
+
// ../../packages/shared/src/index.ts
|
|
908
|
+
var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
|
|
909
|
+
|
|
910
|
+
// src/main/desktop-agent-session.ts
|
|
911
|
+
var MESSAGE_FRESHNESS_MS = 5 * 60 * 1e3;
|
|
912
|
+
var MAX_REPLAY_IDS = 512;
|
|
913
|
+
var DesktopAgentSession = class {
|
|
914
|
+
options;
|
|
915
|
+
snapshot = emptySnapshot("UNPAIRED");
|
|
916
|
+
listeners = /* @__PURE__ */ new Set();
|
|
917
|
+
jobListeners = /* @__PURE__ */ new Set();
|
|
918
|
+
replayIds = /* @__PURE__ */ new Set();
|
|
919
|
+
socket = null;
|
|
920
|
+
heartbeatTimer = null;
|
|
921
|
+
accessRefreshTimer = null;
|
|
922
|
+
reconnectTimer = null;
|
|
923
|
+
generation = 0;
|
|
924
|
+
reconnectAttempt = 0;
|
|
925
|
+
handshake = "CHALLENGE";
|
|
926
|
+
reportedState = "IDLE";
|
|
927
|
+
reportedJobId = null;
|
|
928
|
+
constructor(options) {
|
|
929
|
+
this.options = options;
|
|
930
|
+
}
|
|
931
|
+
getSnapshot() {
|
|
932
|
+
return this.snapshot;
|
|
933
|
+
}
|
|
934
|
+
subscribe(listener) {
|
|
935
|
+
this.listeners.add(listener);
|
|
936
|
+
return () => this.listeners.delete(listener);
|
|
937
|
+
}
|
|
938
|
+
subscribeJobs(listener) {
|
|
939
|
+
this.jobListeners.add(listener);
|
|
940
|
+
return () => this.jobListeners.delete(listener);
|
|
941
|
+
}
|
|
942
|
+
setReportedState(state) {
|
|
943
|
+
this.reportedState = state;
|
|
944
|
+
}
|
|
945
|
+
restoreJob(jobId) {
|
|
946
|
+
this.reportedJobId = jobId;
|
|
947
|
+
this.update({ ...this.snapshot, currentJobId: jobId });
|
|
948
|
+
}
|
|
949
|
+
claimJob(jobId) {
|
|
950
|
+
return this.sendAuthenticated("SCAN_JOB_CLAIM", { jobId });
|
|
951
|
+
}
|
|
952
|
+
heartbeatJob(jobId, attemptToken) {
|
|
953
|
+
return this.sendAuthenticated("SCAN_JOB_HEARTBEAT", { jobId, attemptToken });
|
|
954
|
+
}
|
|
955
|
+
progressJob(jobId, attemptToken, message) {
|
|
956
|
+
return this.sendAuthenticated("SCAN_JOB_PROGRESS", { jobId, attemptToken, message });
|
|
957
|
+
}
|
|
958
|
+
finishJob(jobId) {
|
|
959
|
+
if (this.reportedJobId !== jobId && this.snapshot.currentJobId !== jobId) return false;
|
|
960
|
+
this.reportedJobId = null;
|
|
961
|
+
this.update({ ...this.snapshot, currentJobId: null, pendingJobId: null });
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
clearJob() {
|
|
965
|
+
this.reportedJobId = null;
|
|
966
|
+
this.reportedState = "IDLE";
|
|
967
|
+
this.update({ ...this.snapshot, currentJobId: null, pendingJobId: null });
|
|
968
|
+
}
|
|
969
|
+
async start(publishConnecting = true) {
|
|
970
|
+
const generation = ++this.generation;
|
|
971
|
+
const stored = await this.options.credentials.load();
|
|
972
|
+
if (generation !== this.generation) return;
|
|
973
|
+
if (!stored) {
|
|
974
|
+
this.update(emptySnapshot("UNPAIRED"));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (publishConnecting) this.update(snapshotForSession("CONNECTING", stored));
|
|
978
|
+
try {
|
|
979
|
+
const access = await this.options.credentials.refresh();
|
|
980
|
+
if (generation === this.generation) {
|
|
981
|
+
this.connect(access, generation);
|
|
982
|
+
this.scheduleAccessRefresh(access, generation);
|
|
983
|
+
}
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (generation !== this.generation) return;
|
|
986
|
+
if (hasErrorCode2(error, "CREDENTIAL_REVOKED")) {
|
|
987
|
+
this.update(snapshotForSession("REVOKED", null, "CREDENTIAL_REVOKED"));
|
|
988
|
+
} else {
|
|
989
|
+
this.scheduleReconnect(stored, generation, "NETWORK_ERROR");
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async pair(input) {
|
|
994
|
+
const generation = ++this.generation;
|
|
995
|
+
this.closeActive();
|
|
996
|
+
this.update(emptySnapshot("PAIRING"));
|
|
997
|
+
try {
|
|
998
|
+
const access = await this.options.credentials.pair(input);
|
|
999
|
+
if (generation !== this.generation) return;
|
|
1000
|
+
this.update(snapshotForSession("CONNECTING", access.session));
|
|
1001
|
+
this.connect(access, generation);
|
|
1002
|
+
this.scheduleAccessRefresh(access, generation);
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
if (generation !== this.generation) return;
|
|
1005
|
+
this.update(snapshotForSession("UNPAIRED", null, readErrorCode(error)));
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
retry() {
|
|
1010
|
+
this.cancelTimers();
|
|
1011
|
+
return this.start();
|
|
1012
|
+
}
|
|
1013
|
+
async unlink() {
|
|
1014
|
+
++this.generation;
|
|
1015
|
+
this.closeActive();
|
|
1016
|
+
await this.options.credentials.clear();
|
|
1017
|
+
this.reportedJobId = null;
|
|
1018
|
+
this.reportedState = "IDLE";
|
|
1019
|
+
this.update(emptySnapshot("UNPAIRED"));
|
|
1020
|
+
}
|
|
1021
|
+
stop() {
|
|
1022
|
+
++this.generation;
|
|
1023
|
+
this.closeActive();
|
|
1024
|
+
}
|
|
1025
|
+
connect(access, generation) {
|
|
1026
|
+
if (generation !== this.generation) return;
|
|
1027
|
+
this.replayIds.clear();
|
|
1028
|
+
this.handshake = "CHALLENGE";
|
|
1029
|
+
const socket = this.options.createSocket(this.options.websocketUrl, access.accessToken);
|
|
1030
|
+
this.socket = socket;
|
|
1031
|
+
socket.on("message", (data) => this.handleMessage(socket, data, access, generation));
|
|
1032
|
+
socket.on("error", () => this.handleDisconnect(socket, access.session, generation));
|
|
1033
|
+
socket.on("close", () => this.handleDisconnect(socket, access.session, generation));
|
|
1034
|
+
}
|
|
1035
|
+
handleMessage(socket, data, access, generation) {
|
|
1036
|
+
if (generation !== this.generation || socket !== this.socket) return;
|
|
1037
|
+
const envelope = parseServerEnvelope(data);
|
|
1038
|
+
if (!envelope || envelope.deviceId !== access.session.device.id || envelope.protocolVersion !== DESKTOP_AGENT_PROTOCOL_VERSION || Math.abs(Date.now() - Date.parse(envelope.sentAt)) > MESSAGE_FRESHNESS_MS || this.replayIds.has(envelope.messageId)) {
|
|
1039
|
+
this.protocolFailure(socket, access.session, generation);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
this.rememberMessage(envelope.messageId);
|
|
1043
|
+
if (envelope.type === "CREDENTIAL_REVOKED") {
|
|
1044
|
+
void this.revoke(socket, generation);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (envelope.type === "UPDATE_REQUIRED") {
|
|
1048
|
+
++this.generation;
|
|
1049
|
+
this.socket = null;
|
|
1050
|
+
this.cancelTimers();
|
|
1051
|
+
socket.close();
|
|
1052
|
+
this.update({
|
|
1053
|
+
...snapshotForSession("UPDATE_REQUIRED", access.session),
|
|
1054
|
+
minimumAgentVersion: envelope.payload.minimumAgentVersion
|
|
1055
|
+
});
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (this.handshake === "CHALLENGE") {
|
|
1059
|
+
if (envelope.type !== "CHALLENGE") {
|
|
1060
|
+
this.protocolFailure(socket, access.session, generation);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
this.send(socket, access.session.device.id, "HELLO", {
|
|
1064
|
+
challenge: envelope.payload.challenge,
|
|
1065
|
+
signature: this.options.signChallenge(
|
|
1066
|
+
access.session.privateKey,
|
|
1067
|
+
access.session.device.id,
|
|
1068
|
+
envelope.payload.challenge
|
|
1069
|
+
),
|
|
1070
|
+
agentVersion: this.options.agentVersion,
|
|
1071
|
+
platform: this.options.platform,
|
|
1072
|
+
state: this.reportedState,
|
|
1073
|
+
facebookReady: this.options.getFacebookReady(),
|
|
1074
|
+
currentJobId: this.reportedJobId
|
|
1075
|
+
});
|
|
1076
|
+
this.handshake = "WELCOME";
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (this.handshake === "WELCOME") {
|
|
1080
|
+
if (envelope.type !== "WELCOME") {
|
|
1081
|
+
this.protocolFailure(socket, access.session, generation);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
this.handshake = "AUTHENTICATED";
|
|
1085
|
+
}
|
|
1086
|
+
this.handleAuthenticatedEnvelope(socket, envelope, access.session, generation);
|
|
1087
|
+
}
|
|
1088
|
+
handleAuthenticatedEnvelope(socket, envelope, stored, generation) {
|
|
1089
|
+
if (envelope.type === "WELCOME") {
|
|
1090
|
+
const state = envelope.payload.accessMode === "FULL" ? "ONLINE" : "RESTRICTED";
|
|
1091
|
+
this.reconnectAttempt = 0;
|
|
1092
|
+
this.update({
|
|
1093
|
+
...snapshotForSession(state, stored),
|
|
1094
|
+
accessMode: envelope.payload.accessMode
|
|
1095
|
+
});
|
|
1096
|
+
this.scheduleHeartbeat(socket, stored.device.id, envelope.payload.heartbeatIntervalMs, generation);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (envelope.type === "ACCESS_MODE") {
|
|
1100
|
+
this.update({
|
|
1101
|
+
...this.snapshot,
|
|
1102
|
+
accessMode: envelope.payload.accessMode,
|
|
1103
|
+
state: envelope.payload.accessMode === "FULL" ? "ONLINE" : "RESTRICTED"
|
|
1104
|
+
});
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (envelope.type === "SCAN_JOB_AVAILABLE") {
|
|
1108
|
+
this.update({ ...this.snapshot, pendingJobId: envelope.payload.jobId });
|
|
1109
|
+
this.emitJob({ type: "AVAILABLE", jobId: envelope.payload.jobId });
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (envelope.type === "SCAN_JOB_STOP") {
|
|
1113
|
+
if (this.reportedJobId === envelope.payload.jobId) this.reportedJobId = null;
|
|
1114
|
+
this.update({ ...this.snapshot, currentJobId: null, pendingJobId: null });
|
|
1115
|
+
this.emitJob({ type: "STOP", ...envelope.payload });
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (envelope.type !== "SCAN_JOB_START") {
|
|
1119
|
+
this.protocolFailure(socket, stored, generation);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
this.update({ ...this.snapshot, currentJobId: envelope.payload.jobId, pendingJobId: null });
|
|
1123
|
+
this.reportedJobId = envelope.payload.jobId;
|
|
1124
|
+
this.emitJob({ type: "START", ...envelope.payload });
|
|
1125
|
+
}
|
|
1126
|
+
scheduleHeartbeat(socket, deviceId, interval, generation) {
|
|
1127
|
+
this.clearHeartbeat();
|
|
1128
|
+
this.heartbeatTimer = this.options.setTimer(() => {
|
|
1129
|
+
if (generation !== this.generation || socket !== this.socket) return;
|
|
1130
|
+
this.send(socket, deviceId, "HEARTBEAT", {
|
|
1131
|
+
state: this.reportedState,
|
|
1132
|
+
facebookReady: this.options.getFacebookReady(),
|
|
1133
|
+
currentJobId: this.reportedJobId
|
|
1134
|
+
});
|
|
1135
|
+
this.scheduleHeartbeat(socket, deviceId, interval, generation);
|
|
1136
|
+
}, interval);
|
|
1137
|
+
}
|
|
1138
|
+
scheduleAccessRefresh(access, generation) {
|
|
1139
|
+
this.clearAccessRefresh();
|
|
1140
|
+
const expiresAt = Date.parse(access.accessTokenExpiresAt);
|
|
1141
|
+
const delay = Math.max(1e3, expiresAt - Date.now() - 6e4);
|
|
1142
|
+
this.accessRefreshTimer = this.options.setTimer(() => {
|
|
1143
|
+
if (generation !== this.generation) return;
|
|
1144
|
+
++this.generation;
|
|
1145
|
+
this.closeActive();
|
|
1146
|
+
void this.start(false);
|
|
1147
|
+
}, delay);
|
|
1148
|
+
}
|
|
1149
|
+
handleDisconnect(socket, stored, generation) {
|
|
1150
|
+
if (generation !== this.generation || socket !== this.socket) return;
|
|
1151
|
+
this.socket = null;
|
|
1152
|
+
this.clearHeartbeat();
|
|
1153
|
+
this.clearAccessRefresh();
|
|
1154
|
+
this.scheduleReconnect(stored, generation, "NETWORK_ERROR");
|
|
1155
|
+
}
|
|
1156
|
+
protocolFailure(socket, stored, generation) {
|
|
1157
|
+
if (socket === this.socket) this.socket = null;
|
|
1158
|
+
socket.close();
|
|
1159
|
+
this.clearHeartbeat();
|
|
1160
|
+
this.clearAccessRefresh();
|
|
1161
|
+
this.scheduleReconnect(stored, generation, "PROTOCOL_VIOLATION");
|
|
1162
|
+
}
|
|
1163
|
+
scheduleReconnect(stored, generation, errorCode) {
|
|
1164
|
+
if (generation !== this.generation || this.reconnectTimer) return;
|
|
1165
|
+
const delay = this.options.reconnectDelay(this.reconnectAttempt++, this.options.random);
|
|
1166
|
+
this.update({
|
|
1167
|
+
...snapshotForSession("RECONNECTING", stored, errorCode),
|
|
1168
|
+
nextRetryAt: new Date(Date.now() + delay).toISOString()
|
|
1169
|
+
});
|
|
1170
|
+
this.reconnectTimer = this.options.setTimer(() => {
|
|
1171
|
+
this.reconnectTimer = null;
|
|
1172
|
+
if (generation === this.generation) void this.start();
|
|
1173
|
+
}, delay);
|
|
1174
|
+
}
|
|
1175
|
+
async revoke(socket, generation) {
|
|
1176
|
+
if (generation !== this.generation) return;
|
|
1177
|
+
++this.generation;
|
|
1178
|
+
this.socket = null;
|
|
1179
|
+
socket.close();
|
|
1180
|
+
this.cancelTimers();
|
|
1181
|
+
await this.options.credentials.clear();
|
|
1182
|
+
this.reportedJobId = null;
|
|
1183
|
+
this.reportedState = "IDLE";
|
|
1184
|
+
this.update(snapshotForSession("REVOKED", null, "CREDENTIAL_REVOKED"));
|
|
1185
|
+
}
|
|
1186
|
+
send(socket, deviceId, type, payload) {
|
|
1187
|
+
socket.send(JSON.stringify({
|
|
1188
|
+
protocolVersion: DESKTOP_AGENT_PROTOCOL_VERSION,
|
|
1189
|
+
deviceId,
|
|
1190
|
+
messageId: randomUUID(),
|
|
1191
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1192
|
+
type,
|
|
1193
|
+
payload
|
|
1194
|
+
}));
|
|
1195
|
+
}
|
|
1196
|
+
sendAuthenticated(type, payload) {
|
|
1197
|
+
const stored = this.options.credentials.getStoredSession();
|
|
1198
|
+
if (!stored || !this.socket || this.handshake !== "AUTHENTICATED" || this.socket.readyState !== 1) {
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
this.send(this.socket, stored.device.id, type, payload);
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
emitJob(event) {
|
|
1205
|
+
for (const listener of this.jobListeners) {
|
|
1206
|
+
Promise.resolve(listener(event)).catch(() => void 0);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
rememberMessage(messageId) {
|
|
1210
|
+
this.replayIds.add(messageId);
|
|
1211
|
+
if (this.replayIds.size > MAX_REPLAY_IDS) {
|
|
1212
|
+
this.replayIds.delete(this.replayIds.values().next().value);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
closeActive() {
|
|
1216
|
+
this.cancelTimers();
|
|
1217
|
+
const socket = this.socket;
|
|
1218
|
+
this.socket = null;
|
|
1219
|
+
socket?.close();
|
|
1220
|
+
}
|
|
1221
|
+
cancelTimers() {
|
|
1222
|
+
this.clearHeartbeat();
|
|
1223
|
+
this.clearAccessRefresh();
|
|
1224
|
+
if (this.reconnectTimer) this.options.clearTimer(this.reconnectTimer);
|
|
1225
|
+
this.reconnectTimer = null;
|
|
1226
|
+
}
|
|
1227
|
+
clearHeartbeat() {
|
|
1228
|
+
if (this.heartbeatTimer) this.options.clearTimer(this.heartbeatTimer);
|
|
1229
|
+
this.heartbeatTimer = null;
|
|
1230
|
+
}
|
|
1231
|
+
clearAccessRefresh() {
|
|
1232
|
+
if (this.accessRefreshTimer) this.options.clearTimer(this.accessRefreshTimer);
|
|
1233
|
+
this.accessRefreshTimer = null;
|
|
1234
|
+
}
|
|
1235
|
+
update(snapshot) {
|
|
1236
|
+
this.snapshot = snapshot;
|
|
1237
|
+
for (const listener of this.listeners) listener(snapshot);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
function parseServerEnvelope(data) {
|
|
1241
|
+
try {
|
|
1242
|
+
const value = JSON.parse(data.toString("utf8"));
|
|
1243
|
+
if (!value || typeof value !== "object") return null;
|
|
1244
|
+
const envelope = value;
|
|
1245
|
+
return typeof envelope.deviceId === "string" && typeof envelope.messageId === "string" && typeof envelope.sentAt === "string" && typeof envelope.type === "string" && Boolean(envelope.payload && typeof envelope.payload === "object") ? value : null;
|
|
1246
|
+
} catch {
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function emptySnapshot(state) {
|
|
1251
|
+
return {
|
|
1252
|
+
state,
|
|
1253
|
+
deviceName: null,
|
|
1254
|
+
accessMode: null,
|
|
1255
|
+
facebookReady: false,
|
|
1256
|
+
currentJobId: null,
|
|
1257
|
+
pendingJobId: null,
|
|
1258
|
+
nextRetryAt: null,
|
|
1259
|
+
minimumAgentVersion: null,
|
|
1260
|
+
errorCode: null
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function snapshotForSession(state, stored, errorCode = null) {
|
|
1264
|
+
return {
|
|
1265
|
+
...emptySnapshot(state),
|
|
1266
|
+
deviceName: stored?.device.name ?? null,
|
|
1267
|
+
accessMode: stored?.device.accessMode ?? null,
|
|
1268
|
+
facebookReady: stored?.device.facebookReady ?? false,
|
|
1269
|
+
errorCode
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function hasErrorCode2(error, code) {
|
|
1273
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
1274
|
+
}
|
|
1275
|
+
function readErrorCode(error) {
|
|
1276
|
+
return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN_ERROR";
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/main/desktop-scan-http.ts
|
|
1280
|
+
import { createHash, createPrivateKey, randomUUID as randomUUID2, sign } from "node:crypto";
|
|
1281
|
+
var RESPONSE_LIMIT2 = 64 * 1024;
|
|
1282
|
+
var REQUEST_TIMEOUT_MS2 = 9e4;
|
|
1283
|
+
var DesktopScanHttpError = class extends Error {
|
|
1284
|
+
code;
|
|
1285
|
+
recoverable;
|
|
1286
|
+
constructor(code, recoverable = false) {
|
|
1287
|
+
super(code);
|
|
1288
|
+
this.code = code;
|
|
1289
|
+
this.recoverable = recoverable;
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
var DesktopScanHttp = class {
|
|
1293
|
+
origin;
|
|
1294
|
+
getCredential;
|
|
1295
|
+
fetcher;
|
|
1296
|
+
constructor(options) {
|
|
1297
|
+
this.origin = options.origin;
|
|
1298
|
+
this.getCredential = options.getCredential;
|
|
1299
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
1300
|
+
}
|
|
1301
|
+
uploadBatch(jobId, payload, signal) {
|
|
1302
|
+
payload = normalizeBatch(payload);
|
|
1303
|
+
return this.signedPost(`/leads/desktop-agent/jobs/${jobId}/batches`, payload, signal);
|
|
1304
|
+
}
|
|
1305
|
+
complete(jobId, payload, signal) {
|
|
1306
|
+
if (payload.outcome === "FAILED") {
|
|
1307
|
+
payload = { ...payload, errorMessage: (payload.errorMessage ?? "Desktop scan failed.").slice(0, 500) };
|
|
1308
|
+
}
|
|
1309
|
+
return this.signedPost(`/leads/desktop-agent/jobs/${jobId}/complete`, payload, signal);
|
|
1310
|
+
}
|
|
1311
|
+
cancel(jobId, attemptToken) {
|
|
1312
|
+
return this.signedPost(`/leads/desktop-agent/jobs/${jobId}/cancel`, { attemptToken });
|
|
1313
|
+
}
|
|
1314
|
+
async signedPost(path, businessPayload, signal) {
|
|
1315
|
+
const credential = this.getCredential();
|
|
1316
|
+
if (!credential) throw new DesktopScanHttpError("CREDENTIAL_UNAVAILABLE");
|
|
1317
|
+
const signedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1318
|
+
const requestNonce = randomUUID2();
|
|
1319
|
+
const unsigned = { ...businessPayload, signedAt, requestNonce, signature: void 0 };
|
|
1320
|
+
const digest = createHash("sha256").update(JSON.stringify(unsigned)).digest("hex");
|
|
1321
|
+
const message = Buffer.from(
|
|
1322
|
+
`desktop-agent-http:1:POST:${path}:${signedAt}:${requestNonce}:${digest}`,
|
|
1323
|
+
"utf8"
|
|
1324
|
+
);
|
|
1325
|
+
const key = createPrivateKey({
|
|
1326
|
+
key: Buffer.from(credential.privateKey, "base64"),
|
|
1327
|
+
format: "der",
|
|
1328
|
+
type: "pkcs8"
|
|
1329
|
+
});
|
|
1330
|
+
const body = {
|
|
1331
|
+
...businessPayload,
|
|
1332
|
+
signedAt,
|
|
1333
|
+
requestNonce,
|
|
1334
|
+
signature: sign(null, message, key).toString("base64")
|
|
1335
|
+
};
|
|
1336
|
+
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS2);
|
|
1337
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
1338
|
+
let response;
|
|
1339
|
+
try {
|
|
1340
|
+
response = await this.fetcher(`${this.origin}${path}`, {
|
|
1341
|
+
body: JSON.stringify(body),
|
|
1342
|
+
cache: "no-store",
|
|
1343
|
+
headers: {
|
|
1344
|
+
authorization: `Bearer ${credential.accessToken}`,
|
|
1345
|
+
"content-type": "application/json"
|
|
1346
|
+
},
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
signal: requestSignal
|
|
1349
|
+
});
|
|
1350
|
+
} catch {
|
|
1351
|
+
throw new DesktopScanHttpError("NETWORK_ERROR", true);
|
|
1352
|
+
}
|
|
1353
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0];
|
|
1354
|
+
if (contentType !== "application/json") throw new DesktopScanHttpError("RESPONSE_INVALID");
|
|
1355
|
+
const value = await readBoundedJson2(response);
|
|
1356
|
+
if (!response.ok) {
|
|
1357
|
+
throw new DesktopScanHttpError(
|
|
1358
|
+
response.status >= 500 ? "SERVER_UNAVAILABLE" : response.status === 413 ? "BATCH_TOO_LARGE" : "BATCH_REJECTED",
|
|
1359
|
+
response.status >= 500
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
return value;
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
async function readBoundedJson2(response) {
|
|
1366
|
+
const length = Number(response.headers.get("content-length"));
|
|
1367
|
+
if (Number.isFinite(length) && length > RESPONSE_LIMIT2) {
|
|
1368
|
+
throw new DesktopScanHttpError("RESPONSE_TOO_LARGE");
|
|
1369
|
+
}
|
|
1370
|
+
if (!response.body) throw new DesktopScanHttpError("RESPONSE_INVALID");
|
|
1371
|
+
const reader = response.body.getReader();
|
|
1372
|
+
const chunks = [];
|
|
1373
|
+
let size = 0;
|
|
1374
|
+
for (; ; ) {
|
|
1375
|
+
const { done, value } = await reader.read();
|
|
1376
|
+
if (done) break;
|
|
1377
|
+
size += value.byteLength;
|
|
1378
|
+
if (size > RESPONSE_LIMIT2) {
|
|
1379
|
+
await reader.cancel();
|
|
1380
|
+
throw new DesktopScanHttpError("RESPONSE_TOO_LARGE");
|
|
1381
|
+
}
|
|
1382
|
+
chunks.push(value);
|
|
1383
|
+
}
|
|
1384
|
+
try {
|
|
1385
|
+
return JSON.parse(Buffer.concat(chunks, size).toString("utf8"));
|
|
1386
|
+
} catch {
|
|
1387
|
+
throw new DesktopScanHttpError("RESPONSE_INVALID");
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
function normalizeBatch(payload) {
|
|
1391
|
+
const items = payload.items.map((item) => {
|
|
1392
|
+
const authorAvatarUrl = normalizeMediaUrl(item.authorAvatarUrl);
|
|
1393
|
+
const attachmentImages = item.attachmentImages?.map(normalizeMediaUrl).filter((url) => Boolean(url)).slice(0, 6);
|
|
1394
|
+
const groupName = typeof item.groupName === "string" && item.groupName.trim() ? item.groupName.trim().slice(0, 200) : void 0;
|
|
1395
|
+
const textFormat = normalizeTextFormat(item.textFormat);
|
|
1396
|
+
return {
|
|
1397
|
+
...item,
|
|
1398
|
+
...groupName ? { groupName } : {},
|
|
1399
|
+
sourceUrl: normalizeFacebookPostUrl(item.sourceUrl),
|
|
1400
|
+
authorAvatarUrl,
|
|
1401
|
+
attachmentImages: attachmentImages?.length ? attachmentImages : void 0,
|
|
1402
|
+
textFormat
|
|
1403
|
+
};
|
|
1404
|
+
});
|
|
1405
|
+
if (!Number.isInteger(payload.sequence) || payload.sequence < 0 || payload.groupItemCount !== void 0 && (!Number.isInteger(payload.groupItemCount) || payload.groupItemCount < items.length) || items.length > 100 || !isFacebookGroupUrl(payload.groupUrl) || items.some((item) => !isFacebookPost(item))) {
|
|
1406
|
+
throw new DesktopScanHttpError("BATCH_INVALID");
|
|
1407
|
+
}
|
|
1408
|
+
return { ...payload, items };
|
|
1409
|
+
}
|
|
1410
|
+
function isFacebookGroupUrl(value) {
|
|
1411
|
+
try {
|
|
1412
|
+
const url = new URL(value);
|
|
1413
|
+
return ["facebook.com", "www.facebook.com"].includes(url.hostname.toLowerCase()) && /^\/groups\/[a-z0-9._-]+\/?$/i.test(url.pathname);
|
|
1414
|
+
} catch {
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
function isFacebookPost(item) {
|
|
1419
|
+
if (!item.text.trim() || item.text.length > 5e4 || (item.authorName?.length ?? 0) > 200) return false;
|
|
1420
|
+
if (item.authorAvatarUrl && !isHttpUrl(item.authorAvatarUrl)) return false;
|
|
1421
|
+
if (item.attachmentImages && (item.attachmentImages.length > 6 || item.attachmentImages.some((url) => !isHttpUrl(url)))) return false;
|
|
1422
|
+
if (item.publishedAt && Number.isNaN(Date.parse(item.publishedAt))) return false;
|
|
1423
|
+
try {
|
|
1424
|
+
const url = new URL(item.sourceUrl);
|
|
1425
|
+
return ["facebook.com", "www.facebook.com"].includes(url.hostname.toLowerCase()) && /^\/groups\/[a-z0-9._-]+\/posts\/\d+\/?$/i.test(url.pathname);
|
|
1426
|
+
} catch {
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
function isHttpUrl(value) {
|
|
1431
|
+
try {
|
|
1432
|
+
return ["http:", "https:"].includes(new URL(value).protocol);
|
|
1433
|
+
} catch {
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function normalizeMediaUrl(value) {
|
|
1438
|
+
return value && value.length <= 2e3 && isHttpUrl(value) ? value : void 0;
|
|
1439
|
+
}
|
|
1440
|
+
function normalizeTextFormat(value) {
|
|
1441
|
+
if (!value) return void 0;
|
|
1442
|
+
const textFormat = {
|
|
1443
|
+
backgroundColor: normalizeFormatToken(value.backgroundColor),
|
|
1444
|
+
backgroundImageUrl: normalizeMediaUrl(value.backgroundImageUrl),
|
|
1445
|
+
color: normalizeFormatToken(value.color),
|
|
1446
|
+
fontStyle: normalizeFormatToken(value.fontStyle),
|
|
1447
|
+
fontWeight: normalizeFormatToken(value.fontWeight)
|
|
1448
|
+
};
|
|
1449
|
+
return Object.values(textFormat).some(Boolean) ? textFormat : void 0;
|
|
1450
|
+
}
|
|
1451
|
+
function normalizeFormatToken(value) {
|
|
1452
|
+
const normalized = value?.trim();
|
|
1453
|
+
return normalized && normalized.length <= 50 ? normalized : void 0;
|
|
1454
|
+
}
|
|
1455
|
+
function normalizeFacebookPostUrl(value) {
|
|
1456
|
+
try {
|
|
1457
|
+
const url = new URL(value);
|
|
1458
|
+
if (url.pathname.toLowerCase() !== "/permalink.php") return value;
|
|
1459
|
+
const groupId = url.searchParams.get("id");
|
|
1460
|
+
const postId = url.searchParams.get("story_fbid");
|
|
1461
|
+
return groupId && postId && /^\d+$/.test(groupId) && /^\d+$/.test(postId) ? `https://www.facebook.com/groups/${groupId}/posts/${postId}/` : value;
|
|
1462
|
+
} catch {
|
|
1463
|
+
return value;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// src/main/desktop-scan-runner.ts
|
|
1468
|
+
var DesktopScanRunner = class {
|
|
1469
|
+
options;
|
|
1470
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1471
|
+
snapshot = idleSnapshot();
|
|
1472
|
+
active = null;
|
|
1473
|
+
abortController = null;
|
|
1474
|
+
heartbeatTimer = null;
|
|
1475
|
+
leaseTimer = null;
|
|
1476
|
+
canceled = false;
|
|
1477
|
+
unsubscribe = null;
|
|
1478
|
+
paused = null;
|
|
1479
|
+
recovered = null;
|
|
1480
|
+
activeAttempt = null;
|
|
1481
|
+
recoveryBlocked = false;
|
|
1482
|
+
constructor(options) {
|
|
1483
|
+
this.options = options;
|
|
1484
|
+
}
|
|
1485
|
+
start() {
|
|
1486
|
+
if (this.unsubscribe) return;
|
|
1487
|
+
this.unsubscribe = this.options.session.subscribeJobs((event) => this.handleEvent(event));
|
|
1488
|
+
}
|
|
1489
|
+
async recover() {
|
|
1490
|
+
let records;
|
|
1491
|
+
try {
|
|
1492
|
+
records = await this.options.spool.loadAll();
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
this.recoveryBlocked = true;
|
|
1495
|
+
this.update({ ...idleSnapshot(), state: "SPOOL_FULL", errorCode: readErrorCode2(error) });
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const current = records.find((record) => Date.parse(record.job.leaseExpiresAt) > this.options.now()) ?? null;
|
|
1499
|
+
await Promise.all(records.filter((record) => record !== current).map((record) => this.options.spool.delete(record.job.jobId)));
|
|
1500
|
+
if (!current) return;
|
|
1501
|
+
this.recovered = current;
|
|
1502
|
+
this.options.session.restoreJob(current.job.jobId);
|
|
1503
|
+
this.options.session.setReportedState("SCANNING");
|
|
1504
|
+
this.update({
|
|
1505
|
+
state: "OFFLINE_PENDING",
|
|
1506
|
+
jobId: current.job.jobId,
|
|
1507
|
+
groupIndex: Math.min(current.nextGroupIndex, Math.max(0, current.job.groupUrls.length - 1)),
|
|
1508
|
+
groupCount: current.job.groupUrls.length,
|
|
1509
|
+
acceptedItems: current.acceptedItems,
|
|
1510
|
+
errorCode: "RECONCILIATION_REQUIRED"
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
stop() {
|
|
1514
|
+
this.canceled = true;
|
|
1515
|
+
this.abortController?.abort();
|
|
1516
|
+
this.clearHeartbeat();
|
|
1517
|
+
this.unsubscribe?.();
|
|
1518
|
+
this.unsubscribe = null;
|
|
1519
|
+
}
|
|
1520
|
+
getSnapshot() {
|
|
1521
|
+
return this.snapshot;
|
|
1522
|
+
}
|
|
1523
|
+
isRunning() {
|
|
1524
|
+
return this.active !== null;
|
|
1525
|
+
}
|
|
1526
|
+
subscribe(listener) {
|
|
1527
|
+
this.listeners.add(listener);
|
|
1528
|
+
return () => this.listeners.delete(listener);
|
|
1529
|
+
}
|
|
1530
|
+
async whenIdle() {
|
|
1531
|
+
await this.active;
|
|
1532
|
+
}
|
|
1533
|
+
async reset() {
|
|
1534
|
+
this.stop();
|
|
1535
|
+
await this.whenIdle();
|
|
1536
|
+
this.canceled = false;
|
|
1537
|
+
this.paused = null;
|
|
1538
|
+
this.recovered = null;
|
|
1539
|
+
this.activeAttempt = null;
|
|
1540
|
+
this.recoveryBlocked = false;
|
|
1541
|
+
this.abortController = null;
|
|
1542
|
+
this.options.session.clearJob();
|
|
1543
|
+
this.options.session.setReportedState("IDLE");
|
|
1544
|
+
this.update(idleSnapshot());
|
|
1545
|
+
}
|
|
1546
|
+
async resumeAfterLogin() {
|
|
1547
|
+
if (this.active || !this.paused || !await this.options.facebookState.isReady()) return false;
|
|
1548
|
+
const paused = this.paused;
|
|
1549
|
+
this.paused = null;
|
|
1550
|
+
this.canceled = false;
|
|
1551
|
+
this.active = this.run(paused.job, paused).finally(() => {
|
|
1552
|
+
this.active = null;
|
|
1553
|
+
});
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
async handleEvent(event) {
|
|
1557
|
+
if (event.type === "AVAILABLE") {
|
|
1558
|
+
await this.claimIfReady(event.jobId);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (event.type === "STOP") {
|
|
1562
|
+
if (this.snapshot.jobId === event.jobId) {
|
|
1563
|
+
this.canceled = true;
|
|
1564
|
+
this.paused = null;
|
|
1565
|
+
this.recovered = null;
|
|
1566
|
+
if (this.active) {
|
|
1567
|
+
this.update({ ...this.snapshot, state: "CANCELING" });
|
|
1568
|
+
this.abortController?.abort();
|
|
1569
|
+
await this.active;
|
|
1570
|
+
} else {
|
|
1571
|
+
this.options.session.setReportedState("IDLE");
|
|
1572
|
+
this.update(idleSnapshot());
|
|
1573
|
+
}
|
|
1574
|
+
await this.options.spool.delete(event.jobId);
|
|
1575
|
+
}
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (this.recoveryBlocked) return;
|
|
1579
|
+
if (this.paused?.job.jobId === event.jobId && this.paused.job.attemptToken === event.attemptToken) {
|
|
1580
|
+
this.paused.job = event;
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (this.active) {
|
|
1584
|
+
if (this.activeAttempt?.jobId === event.jobId && this.activeAttempt.attemptToken === event.attemptToken) {
|
|
1585
|
+
if (Date.parse(event.leaseExpiresAt) > Date.parse(this.activeAttempt.leaseExpiresAt)) {
|
|
1586
|
+
this.activeAttempt.leaseExpiresAt = event.leaseExpiresAt;
|
|
1587
|
+
this.scheduleLeaseTimer(this.abortController, event.leaseExpiresAt);
|
|
1588
|
+
}
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
this.canceled = true;
|
|
1592
|
+
this.abortController?.abort();
|
|
1593
|
+
await this.active;
|
|
1594
|
+
if (this.recoveryBlocked) return;
|
|
1595
|
+
}
|
|
1596
|
+
if (event.cancelRequestedAt) {
|
|
1597
|
+
if (this.recovered?.job.jobId === event.jobId) {
|
|
1598
|
+
await this.options.spool.delete(event.jobId);
|
|
1599
|
+
this.recovered = null;
|
|
1600
|
+
}
|
|
1601
|
+
this.options.session.finishJob(event.jobId);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
const recovered = this.recovered?.job.jobId === event.jobId ? this.recovered : null;
|
|
1605
|
+
if (this.recovered && (!recovered || this.recovered.job.attemptToken !== event.attemptToken)) {
|
|
1606
|
+
await this.options.spool.delete(this.recovered.job.jobId);
|
|
1607
|
+
this.recovered = null;
|
|
1608
|
+
}
|
|
1609
|
+
this.canceled = false;
|
|
1610
|
+
this.active = this.run(event, recovered ? {
|
|
1611
|
+
groupIndex: recovered.nextGroupIndex,
|
|
1612
|
+
sequence: recovered.nextSequence,
|
|
1613
|
+
acceptedItems: recovered.acceptedItems,
|
|
1614
|
+
record: recovered
|
|
1615
|
+
} : void 0).finally(() => {
|
|
1616
|
+
this.active = null;
|
|
1617
|
+
});
|
|
1618
|
+
await this.active;
|
|
1619
|
+
}
|
|
1620
|
+
async claimIfReady(jobId) {
|
|
1621
|
+
const session = this.options.session.getSnapshot();
|
|
1622
|
+
if (this.recoveryBlocked || this.active || this.snapshot.jobId || session.currentJobId || session.state !== "ONLINE" || session.accessMode !== "FULL" || this.options.isLoginActive() || !await this.options.facebookState.isReady()) return;
|
|
1623
|
+
this.options.session.claimJob(jobId);
|
|
1624
|
+
}
|
|
1625
|
+
async run(job, resume) {
|
|
1626
|
+
const controller = new AbortController();
|
|
1627
|
+
this.abortController = controller;
|
|
1628
|
+
this.activeAttempt = {
|
|
1629
|
+
jobId: job.jobId,
|
|
1630
|
+
attemptToken: job.attemptToken,
|
|
1631
|
+
leaseExpiresAt: job.leaseExpiresAt
|
|
1632
|
+
};
|
|
1633
|
+
this.options.session.setReportedState("SCANNING");
|
|
1634
|
+
this.update({
|
|
1635
|
+
state: "SCANNING",
|
|
1636
|
+
jobId: job.jobId,
|
|
1637
|
+
groupIndex: Math.min(resume?.groupIndex ?? 0, Math.max(0, job.groupUrls.length - 1)),
|
|
1638
|
+
groupCount: job.groupUrls.length,
|
|
1639
|
+
acceptedItems: resume?.acceptedItems ?? 0,
|
|
1640
|
+
errorCode: null
|
|
1641
|
+
});
|
|
1642
|
+
this.scheduleHeartbeat(job, controller.signal);
|
|
1643
|
+
this.scheduleLeaseTimer(controller, job.leaseExpiresAt);
|
|
1644
|
+
let sequence = resume?.sequence ?? 0;
|
|
1645
|
+
let currentGroupIndex = resume?.groupIndex ?? 0;
|
|
1646
|
+
let successCompletionStarted = false;
|
|
1647
|
+
let record = resume?.record ?? createSpoolRecord(job, this.options.now());
|
|
1648
|
+
let transportOffline = false;
|
|
1649
|
+
try {
|
|
1650
|
+
if (!resume?.record) await this.options.spool.put(record);
|
|
1651
|
+
for (const batch of record.pendingBatches) {
|
|
1652
|
+
if (!await this.uploadWithRetry(job, batch, controller.signal)) {
|
|
1653
|
+
transportOffline = true;
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
assertActive(controller.signal, this.leaseExpiresAt(job), this.options.now());
|
|
1657
|
+
record = { ...record, pendingBatches: record.pendingBatches.filter((item) => item.sequence !== batch.sequence), acceptedItems: record.acceptedItems + batch.items.length, updatedAt: nowIso(this.options.now()) };
|
|
1658
|
+
await this.options.spool.put(record);
|
|
1659
|
+
this.update({ ...this.snapshot, acceptedItems: record.acceptedItems });
|
|
1660
|
+
}
|
|
1661
|
+
if (record.completion) {
|
|
1662
|
+
successCompletionStarted = record.completion.outcome === "SUCCEEDED";
|
|
1663
|
+
if (transportOffline || !await this.completeWithRetry(job, record.completion, controller.signal)) {
|
|
1664
|
+
throw codedError("OFFLINE_PENDING");
|
|
1665
|
+
}
|
|
1666
|
+
await this.options.spool.delete(job.jobId);
|
|
1667
|
+
this.recovered = null;
|
|
1668
|
+
this.options.session.finishJob(job.jobId);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
for (const [groupIndex, groupUrl] of job.groupUrls.entries()) {
|
|
1672
|
+
if (groupIndex < currentGroupIndex) continue;
|
|
1673
|
+
currentGroupIndex = groupIndex;
|
|
1674
|
+
assertActive(controller.signal, this.leaseExpiresAt(job), this.options.now());
|
|
1675
|
+
const storageState = await this.options.facebookState.load();
|
|
1676
|
+
if (!storageState) throw codedError("FACEBOOK_SESSION_MISSING");
|
|
1677
|
+
this.update({ ...this.snapshot, groupIndex });
|
|
1678
|
+
this.options.session.progressJob(
|
|
1679
|
+
job.jobId,
|
|
1680
|
+
job.attemptToken,
|
|
1681
|
+
`Nh\xF3m ${groupIndex + 1}/${job.groupUrls.length}: ${groupUrl}`
|
|
1682
|
+
);
|
|
1683
|
+
this.options.session.progressJob(
|
|
1684
|
+
job.jobId,
|
|
1685
|
+
job.attemptToken,
|
|
1686
|
+
"\u0110ang g\u1ECDi t\xE1c v\u1EE5 thu th\u1EADp b\u1EB1ng Desktop Agent."
|
|
1687
|
+
);
|
|
1688
|
+
const [items] = await this.options.scanner.scanMany({
|
|
1689
|
+
groupUrls: [groupUrl],
|
|
1690
|
+
limit: job.itemsPerGroup,
|
|
1691
|
+
onProgress: ({ itemCount, phase }) => {
|
|
1692
|
+
if (phase !== "SCANNING") return;
|
|
1693
|
+
const processed = Math.min(itemCount, Math.max(0, job.itemsPerGroup - 1));
|
|
1694
|
+
this.options.session.progressJob(
|
|
1695
|
+
job.jobId,
|
|
1696
|
+
job.attemptToken,
|
|
1697
|
+
`[PROGRESS]: Found ${itemCount} new group posts, ${processed}/${job.itemsPerGroup} for URL ${groupUrl}`
|
|
1698
|
+
);
|
|
1699
|
+
},
|
|
1700
|
+
storageState,
|
|
1701
|
+
signal: controller.signal
|
|
1702
|
+
});
|
|
1703
|
+
assertActive(controller.signal, this.leaseExpiresAt(job), this.options.now());
|
|
1704
|
+
const groupBatches = [];
|
|
1705
|
+
for (let offset = 0; offset < items.length; offset += 100) {
|
|
1706
|
+
const batchItems = items.slice(offset, offset + 100).map(toDesktopPost);
|
|
1707
|
+
const batch = {
|
|
1708
|
+
attemptToken: job.attemptToken,
|
|
1709
|
+
sequence,
|
|
1710
|
+
idempotencyKey: this.options.randomUuid(),
|
|
1711
|
+
groupUrl,
|
|
1712
|
+
groupItemCount: items.length,
|
|
1713
|
+
items: batchItems
|
|
1714
|
+
};
|
|
1715
|
+
sequence += 1;
|
|
1716
|
+
groupBatches.push(batch);
|
|
1717
|
+
}
|
|
1718
|
+
record = {
|
|
1719
|
+
...record,
|
|
1720
|
+
nextGroupIndex: groupIndex + 1,
|
|
1721
|
+
nextSequence: sequence,
|
|
1722
|
+
pendingBatches: [...record.pendingBatches, ...groupBatches],
|
|
1723
|
+
updatedAt: nowIso(this.options.now())
|
|
1724
|
+
};
|
|
1725
|
+
await this.options.spool.put(record);
|
|
1726
|
+
for (const batch of groupBatches) {
|
|
1727
|
+
if (transportOffline || !await this.uploadWithRetry(job, batch, controller.signal)) {
|
|
1728
|
+
transportOffline = true;
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
assertActive(controller.signal, this.leaseExpiresAt(job), this.options.now());
|
|
1732
|
+
record = { ...record, pendingBatches: record.pendingBatches.filter((item) => item.sequence !== batch.sequence), acceptedItems: record.acceptedItems + batch.items.length, updatedAt: nowIso(this.options.now()) };
|
|
1733
|
+
await this.options.spool.put(record);
|
|
1734
|
+
this.update({ ...this.snapshot, acceptedItems: record.acceptedItems });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
assertActive(controller.signal, this.leaseExpiresAt(job), this.options.now());
|
|
1738
|
+
successCompletionStarted = true;
|
|
1739
|
+
const completion = {
|
|
1740
|
+
attemptToken: job.attemptToken,
|
|
1741
|
+
outcome: "SUCCEEDED",
|
|
1742
|
+
idempotencyKey: this.options.randomUuid()
|
|
1743
|
+
};
|
|
1744
|
+
record = { ...record, completion, updatedAt: nowIso(this.options.now()) };
|
|
1745
|
+
await this.options.spool.put(record);
|
|
1746
|
+
if (transportOffline || record.pendingBatches.length > 0 || !await this.completeWithRetry(job, completion, controller.signal)) {
|
|
1747
|
+
throw codedError("OFFLINE_PENDING");
|
|
1748
|
+
}
|
|
1749
|
+
await this.options.spool.delete(job.jobId);
|
|
1750
|
+
this.recovered = null;
|
|
1751
|
+
this.options.session.finishJob(job.jobId);
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
const code = readErrorCode2(error);
|
|
1754
|
+
const checkpoint = code === "LOGIN_REQUIRED" || code === "CHECKPOINT_REQUIRED";
|
|
1755
|
+
if (code === "OFFLINE_PENDING") {
|
|
1756
|
+
this.recovered = record;
|
|
1757
|
+
this.update({ ...this.snapshot, state: "OFFLINE_PENDING", errorCode: "RECONCILIATION_REQUIRED" });
|
|
1758
|
+
} else if (code === "SPOOL_FULL") {
|
|
1759
|
+
this.recovered = record;
|
|
1760
|
+
this.update({ ...this.snapshot, state: "SPOOL_FULL", errorCode: "SPOOL_FULL" });
|
|
1761
|
+
} else if (successCompletionStarted) {
|
|
1762
|
+
this.recovered = record;
|
|
1763
|
+
this.update({ ...this.snapshot, state: "OFFLINE_PENDING", errorCode: "RECONCILIATION_REQUIRED" });
|
|
1764
|
+
} else if (checkpoint && !this.canceled && this.options.now() < Date.parse(this.leaseExpiresAt(job))) {
|
|
1765
|
+
this.paused = {
|
|
1766
|
+
job,
|
|
1767
|
+
groupIndex: currentGroupIndex,
|
|
1768
|
+
sequence,
|
|
1769
|
+
acceptedItems: this.snapshot.acceptedItems
|
|
1770
|
+
};
|
|
1771
|
+
this.options.session.setReportedState(code === "LOGIN_REQUIRED" ? "FACEBOOK_LOGIN_REQUIRED" : "CHECKPOINT_REQUIRED");
|
|
1772
|
+
this.update({ ...this.snapshot, state: code, errorCode: code });
|
|
1773
|
+
if (!this.options.isLoginActive()) {
|
|
1774
|
+
await this.options.requestFacebookLogin().catch(() => void 0);
|
|
1775
|
+
}
|
|
1776
|
+
} else if (!this.canceled && code !== "SCAN_CANCELED" && code !== "LEASE_EXPIRED") {
|
|
1777
|
+
const completion = {
|
|
1778
|
+
attemptToken: job.attemptToken,
|
|
1779
|
+
outcome: "FAILED",
|
|
1780
|
+
errorMessage: code,
|
|
1781
|
+
idempotencyKey: this.options.randomUuid()
|
|
1782
|
+
};
|
|
1783
|
+
record = { ...record, completion, updatedAt: nowIso(this.options.now()) };
|
|
1784
|
+
let confirmed = false;
|
|
1785
|
+
let terminalPersistenceFailed = false;
|
|
1786
|
+
try {
|
|
1787
|
+
await this.options.spool.put(record);
|
|
1788
|
+
confirmed = await this.completeWithRetry(job, completion, controller.signal);
|
|
1789
|
+
} catch (completionError) {
|
|
1790
|
+
const completionCode = readErrorCode2(completionError);
|
|
1791
|
+
terminalPersistenceFailed = true;
|
|
1792
|
+
this.recovered = record;
|
|
1793
|
+
this.update({
|
|
1794
|
+
...this.snapshot,
|
|
1795
|
+
state: completionCode === "SPOOL_FULL" ? "SPOOL_FULL" : "OFFLINE_PENDING",
|
|
1796
|
+
errorCode: completionCode === "SPOOL_FULL" ? "SPOOL_FULL" : "RECONCILIATION_REQUIRED"
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
if (confirmed) {
|
|
1800
|
+
await this.options.spool.delete(job.jobId);
|
|
1801
|
+
this.options.session.finishJob(job.jobId);
|
|
1802
|
+
} else if (!terminalPersistenceFailed) {
|
|
1803
|
+
this.recovered = record;
|
|
1804
|
+
this.update({ ...this.snapshot, state: "OFFLINE_PENDING", errorCode: "RECONCILIATION_REQUIRED" });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
} finally {
|
|
1808
|
+
this.clearHeartbeat();
|
|
1809
|
+
this.clearLeaseTimer();
|
|
1810
|
+
this.abortController = null;
|
|
1811
|
+
this.activeAttempt = null;
|
|
1812
|
+
if (!this.snapshot.errorCode && this.snapshot.state !== "LOGIN_REQUIRED" && this.snapshot.state !== "CHECKPOINT_REQUIRED" && this.snapshot.state !== "OFFLINE_PENDING" && this.snapshot.state !== "SPOOL_FULL") {
|
|
1813
|
+
this.options.session.setReportedState("IDLE");
|
|
1814
|
+
this.update(idleSnapshot());
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
async uploadWithRetry(job, batch, signal) {
|
|
1819
|
+
let attempt = 0;
|
|
1820
|
+
for (; ; ) {
|
|
1821
|
+
assertActive(signal, this.leaseExpiresAt(job), this.options.now());
|
|
1822
|
+
try {
|
|
1823
|
+
await this.options.http.uploadBatch(job.jobId, batch, signal);
|
|
1824
|
+
return true;
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
if (!isRecoverable(error)) throw error;
|
|
1827
|
+
if (attempt >= 4) return false;
|
|
1828
|
+
attempt += 1;
|
|
1829
|
+
await this.options.wait(Math.min(8e3, 500 * 2 ** attempt), signal);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
async completeWithRetry(job, payload, signal) {
|
|
1834
|
+
let attempt = 0;
|
|
1835
|
+
for (; ; ) {
|
|
1836
|
+
assertActive(signal, this.leaseExpiresAt(job), this.options.now());
|
|
1837
|
+
try {
|
|
1838
|
+
await this.options.http.complete(job.jobId, payload, signal);
|
|
1839
|
+
return true;
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
if (!isRecoverable(error)) throw error;
|
|
1842
|
+
if (attempt >= 4) return false;
|
|
1843
|
+
attempt += 1;
|
|
1844
|
+
await this.options.wait(Math.min(8e3, 500 * 2 ** attempt), signal);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
scheduleHeartbeat(job, signal) {
|
|
1849
|
+
this.clearHeartbeat();
|
|
1850
|
+
this.heartbeatTimer = this.options.setTimer(() => {
|
|
1851
|
+
if (signal.aborted || this.options.now() >= Date.parse(this.leaseExpiresAt(job))) return;
|
|
1852
|
+
this.options.session.heartbeatJob(job.jobId, job.attemptToken);
|
|
1853
|
+
this.scheduleHeartbeat(job, signal);
|
|
1854
|
+
}, 15e3);
|
|
1855
|
+
}
|
|
1856
|
+
clearHeartbeat() {
|
|
1857
|
+
if (this.heartbeatTimer) this.options.clearTimer(this.heartbeatTimer);
|
|
1858
|
+
this.heartbeatTimer = null;
|
|
1859
|
+
}
|
|
1860
|
+
clearLeaseTimer() {
|
|
1861
|
+
if (this.leaseTimer) this.options.clearTimer(this.leaseTimer);
|
|
1862
|
+
this.leaseTimer = null;
|
|
1863
|
+
}
|
|
1864
|
+
scheduleLeaseTimer(controller, leaseExpiresAt) {
|
|
1865
|
+
this.clearLeaseTimer();
|
|
1866
|
+
this.leaseTimer = this.options.setTimer(
|
|
1867
|
+
() => controller.abort(),
|
|
1868
|
+
Math.max(0, Date.parse(leaseExpiresAt) - this.options.now())
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
leaseExpiresAt(job) {
|
|
1872
|
+
return this.activeAttempt?.jobId === job.jobId && this.activeAttempt.attemptToken === job.attemptToken ? this.activeAttempt.leaseExpiresAt : job.leaseExpiresAt;
|
|
1873
|
+
}
|
|
1874
|
+
update(snapshot) {
|
|
1875
|
+
this.snapshot = snapshot;
|
|
1876
|
+
for (const listener of this.listeners) listener(snapshot);
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
function toDesktopPost(item) {
|
|
1880
|
+
return {
|
|
1881
|
+
sourceUrl: item.url,
|
|
1882
|
+
text: item.text,
|
|
1883
|
+
...item.authorName ? { authorName: item.authorName } : {},
|
|
1884
|
+
...item.authorAvatarUrl ? { authorAvatarUrl: item.authorAvatarUrl } : {},
|
|
1885
|
+
...item.attachmentImages?.length ? { attachmentImages: item.attachmentImages } : {},
|
|
1886
|
+
...item.textFormat ? { textFormat: item.textFormat } : {},
|
|
1887
|
+
...item.publishedAt ? { publishedAt: item.publishedAt } : {},
|
|
1888
|
+
...item.groupName ? { groupName: item.groupName } : {}
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
function idleSnapshot() {
|
|
1892
|
+
return {
|
|
1893
|
+
state: "IDLE",
|
|
1894
|
+
jobId: null,
|
|
1895
|
+
groupIndex: 0,
|
|
1896
|
+
groupCount: 0,
|
|
1897
|
+
acceptedItems: 0,
|
|
1898
|
+
errorCode: null
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
function assertActive(signal, leaseExpiresAt, now) {
|
|
1902
|
+
if (signal.aborted) throw codedError("SCAN_CANCELED");
|
|
1903
|
+
if (now >= Date.parse(leaseExpiresAt)) throw codedError("LEASE_EXPIRED");
|
|
1904
|
+
}
|
|
1905
|
+
function codedError(code) {
|
|
1906
|
+
return Object.assign(new Error(code), { code });
|
|
1907
|
+
}
|
|
1908
|
+
function createSpoolRecord(job, now) {
|
|
1909
|
+
return {
|
|
1910
|
+
version: 1,
|
|
1911
|
+
job: {
|
|
1912
|
+
jobId: job.jobId,
|
|
1913
|
+
attemptToken: job.attemptToken,
|
|
1914
|
+
groupUrls: job.groupUrls,
|
|
1915
|
+
itemsPerGroup: job.itemsPerGroup,
|
|
1916
|
+
leaseExpiresAt: job.leaseExpiresAt,
|
|
1917
|
+
cancelRequestedAt: job.cancelRequestedAt
|
|
1918
|
+
},
|
|
1919
|
+
nextGroupIndex: 0,
|
|
1920
|
+
nextSequence: 0,
|
|
1921
|
+
acceptedItems: 0,
|
|
1922
|
+
pendingBatches: [],
|
|
1923
|
+
completion: null,
|
|
1924
|
+
updatedAt: nowIso(now)
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
function nowIso(now) {
|
|
1928
|
+
return new Date(now).toISOString();
|
|
1929
|
+
}
|
|
1930
|
+
function readErrorCode2(error) {
|
|
1931
|
+
return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code.slice(0, 500) : "SCAN_FAILED";
|
|
1932
|
+
}
|
|
1933
|
+
function isRecoverable(error) {
|
|
1934
|
+
return Boolean(error && typeof error === "object" && "recoverable" in error && error.recoverable === true);
|
|
1935
|
+
}
|
|
1936
|
+
function abortableDelay(milliseconds, signal) {
|
|
1937
|
+
return new Promise((resolve, reject) => {
|
|
1938
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
1939
|
+
const abort = () => {
|
|
1940
|
+
clearTimeout(timer);
|
|
1941
|
+
reject(codedError("SCAN_CANCELED"));
|
|
1942
|
+
};
|
|
1943
|
+
timer.unref();
|
|
1944
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// src/main/device-session-store.ts
|
|
1949
|
+
var SESSION_KEY = "device-session";
|
|
1950
|
+
var DeviceSessionStore = class {
|
|
1951
|
+
constructor(credentials) {
|
|
1952
|
+
this.credentials = credentials;
|
|
1953
|
+
}
|
|
1954
|
+
credentials;
|
|
1955
|
+
ensureEncryptionAvailable() {
|
|
1956
|
+
this.credentials.ensureEncryptionAvailable();
|
|
1957
|
+
}
|
|
1958
|
+
async load() {
|
|
1959
|
+
const value = await this.credentials.get(SESSION_KEY);
|
|
1960
|
+
if (!value) return null;
|
|
1961
|
+
try {
|
|
1962
|
+
const session = JSON.parse(value);
|
|
1963
|
+
return isStoredSession(session) ? session : null;
|
|
1964
|
+
} catch {
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
save(session) {
|
|
1969
|
+
return this.credentials.set(SESSION_KEY, JSON.stringify(session));
|
|
1970
|
+
}
|
|
1971
|
+
clear() {
|
|
1972
|
+
return this.credentials.delete(SESSION_KEY);
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
function isStoredSession(value) {
|
|
1976
|
+
if (!value || typeof value !== "object") return false;
|
|
1977
|
+
const session = value;
|
|
1978
|
+
return typeof session.privateKey === "string" && typeof session.publicKey === "string" && typeof session.refreshToken === "string" && typeof session.refreshTokenExpiresAt === "string" && Boolean(session.device && typeof session.device.id === "string");
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// src/main/encrypted-scan-spool.ts
|
|
1982
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1983
|
+
import { mkdir as mkdir3, readFile as readFile3, readdir as readdir2, rename as rename3, rm as rm2, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
|
|
1984
|
+
import { join as join2 } from "node:path";
|
|
1985
|
+
var ScanSpoolError = class extends Error {
|
|
1986
|
+
code;
|
|
1987
|
+
constructor(code) {
|
|
1988
|
+
super(code);
|
|
1989
|
+
this.code = code;
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
var EncryptedScanSpool = class {
|
|
1993
|
+
directory;
|
|
1994
|
+
crypto;
|
|
1995
|
+
maxBytes;
|
|
1996
|
+
constructor(directory, crypto2, maxBytes = 25 * 1024 * 1024) {
|
|
1997
|
+
this.directory = directory;
|
|
1998
|
+
this.crypto = crypto2;
|
|
1999
|
+
this.maxBytes = maxBytes;
|
|
2000
|
+
}
|
|
2001
|
+
async put(record) {
|
|
2002
|
+
if (!this.crypto.isEncryptionAvailable()) {
|
|
2003
|
+
throw new ScanSpoolError("SPOOL_ENCRYPTION_UNAVAILABLE");
|
|
2004
|
+
}
|
|
2005
|
+
const path = this.pathFor(record.job.jobId);
|
|
2006
|
+
const payload = JSON.stringify(record);
|
|
2007
|
+
const encrypted = this.crypto.encryptString(JSON.stringify({
|
|
2008
|
+
checksum: createHash2("sha256").update(payload).digest("hex"),
|
|
2009
|
+
payload
|
|
2010
|
+
}));
|
|
2011
|
+
const currentSize = await stat2(path).then((value) => value.size).catch(() => 0);
|
|
2012
|
+
const totalSize = await this.size();
|
|
2013
|
+
if (totalSize - currentSize + encrypted.byteLength > this.maxBytes) {
|
|
2014
|
+
throw new ScanSpoolError("SPOOL_FULL");
|
|
2015
|
+
}
|
|
2016
|
+
await mkdir3(this.directory, { recursive: true });
|
|
2017
|
+
await writeFile3(`${path}.tmp`, encrypted, { mode: 384 });
|
|
2018
|
+
await rename3(`${path}.tmp`, path);
|
|
2019
|
+
}
|
|
2020
|
+
async loadAll() {
|
|
2021
|
+
if (!this.crypto.isEncryptionAvailable()) {
|
|
2022
|
+
throw new ScanSpoolError("SPOOL_ENCRYPTION_UNAVAILABLE");
|
|
2023
|
+
}
|
|
2024
|
+
await this.removeTemporaryFiles();
|
|
2025
|
+
const names = await this.names();
|
|
2026
|
+
const records = [];
|
|
2027
|
+
for (const name of names) {
|
|
2028
|
+
const path = join2(this.directory, name);
|
|
2029
|
+
let decrypted;
|
|
2030
|
+
try {
|
|
2031
|
+
decrypted = this.crypto.decryptString(await readFile3(path));
|
|
2032
|
+
} catch {
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
try {
|
|
2036
|
+
const envelope = JSON.parse(decrypted);
|
|
2037
|
+
if (typeof envelope.payload !== "string" || typeof envelope.checksum !== "string" || createHash2("sha256").update(envelope.payload).digest("hex") !== envelope.checksum) {
|
|
2038
|
+
throw new Error("Invalid spool checksum.");
|
|
2039
|
+
}
|
|
2040
|
+
const record = JSON.parse(envelope.payload);
|
|
2041
|
+
if (!isRecord(record)) throw new Error("Invalid spool record.");
|
|
2042
|
+
records.push(record);
|
|
2043
|
+
} catch {
|
|
2044
|
+
await rm2(path, { force: true });
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return records.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
|
|
2048
|
+
}
|
|
2049
|
+
delete(jobId) {
|
|
2050
|
+
return rm2(this.pathFor(jobId), { force: true });
|
|
2051
|
+
}
|
|
2052
|
+
async bytes() {
|
|
2053
|
+
return this.size();
|
|
2054
|
+
}
|
|
2055
|
+
async clear() {
|
|
2056
|
+
await Promise.all((await this.ownedNames()).map((name) => rm2(join2(this.directory, name), { force: true })));
|
|
2057
|
+
}
|
|
2058
|
+
async size() {
|
|
2059
|
+
const sizes = await Promise.all((await this.ownedNames()).map((name) => stat2(join2(this.directory, name)).then((value) => value.size).catch(() => 0)));
|
|
2060
|
+
return sizes.reduce((total, value) => total + value, 0);
|
|
2061
|
+
}
|
|
2062
|
+
async names() {
|
|
2063
|
+
return readdir2(this.directory).then((names) => names.filter((name) => name.endsWith(".spool"))).catch(() => []);
|
|
2064
|
+
}
|
|
2065
|
+
ownedNames() {
|
|
2066
|
+
return readdir2(this.directory).then((names) => names.filter((name) => name.endsWith(".spool") || name.endsWith(".spool.tmp"))).catch(() => []);
|
|
2067
|
+
}
|
|
2068
|
+
async removeTemporaryFiles() {
|
|
2069
|
+
await Promise.all((await this.ownedNames()).filter((name) => name.endsWith(".tmp")).map((name) => rm2(join2(this.directory, name), { force: true })));
|
|
2070
|
+
}
|
|
2071
|
+
pathFor(jobId) {
|
|
2072
|
+
return join2(this.directory, `${createHash2("sha256").update(jobId).digest("hex")}.spool`);
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
function isRecord(value) {
|
|
2076
|
+
return value?.version === 1 && typeof value.job?.jobId === "string" && typeof value.job.attemptToken === "string" && Array.isArray(value.job.groupUrls) && Number.isInteger(value.nextGroupIndex) && Number.isInteger(value.nextSequence) && Number.isInteger(value.acceptedItems) && Array.isArray(value.pendingBatches) && typeof value.updatedAt === "string";
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// src/main/endpoint-policy.ts
|
|
2080
|
+
var DEVELOPMENT_ORIGIN = "http://127.0.0.1:4000";
|
|
2081
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost"]);
|
|
2082
|
+
function resolveAgentEndpoint(configured, packaged) {
|
|
2083
|
+
const value = configured?.trim();
|
|
2084
|
+
if (packaged && !value) {
|
|
2085
|
+
throw new Error("DIGGYLAB_API_URL is required in a packaged Desktop Agent.");
|
|
2086
|
+
}
|
|
2087
|
+
let url;
|
|
2088
|
+
try {
|
|
2089
|
+
url = new URL(value || DEVELOPMENT_ORIGIN);
|
|
2090
|
+
} catch {
|
|
2091
|
+
throw new Error("Invalid Desktop Agent API origin.");
|
|
2092
|
+
}
|
|
2093
|
+
const rootOnly = url.pathname === "/" && !url.search && !url.hash;
|
|
2094
|
+
const noAuthoritySecrets = !url.username && !url.password;
|
|
2095
|
+
const validProduction = packaged && url.protocol === "https:" && !url.port;
|
|
2096
|
+
const validDevelopment = !packaged && url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname.toLowerCase());
|
|
2097
|
+
if (!rootOnly || !noAuthoritySecrets || !validProduction && !validDevelopment) {
|
|
2098
|
+
throw new Error("Invalid Desktop Agent API origin.");
|
|
2099
|
+
}
|
|
2100
|
+
const httpOrigin = url.origin;
|
|
2101
|
+
const websocket = new URL("/desktop-agent/ws", httpOrigin);
|
|
2102
|
+
websocket.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2103
|
+
return { httpOrigin, websocketUrl: websocket.toString() };
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// src/main/facebook-login-service.ts
|
|
2107
|
+
var FacebookLoginService = class {
|
|
2108
|
+
options;
|
|
2109
|
+
snapshot = { state: "NOT_READY", errorCode: null, accountId: null };
|
|
2110
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2111
|
+
browser = null;
|
|
2112
|
+
context = null;
|
|
2113
|
+
page = null;
|
|
2114
|
+
timer = null;
|
|
2115
|
+
readyBeforeLogin = false;
|
|
2116
|
+
accountIdBeforeLogin = null;
|
|
2117
|
+
pending = Promise.resolve();
|
|
2118
|
+
opening = false;
|
|
2119
|
+
navigationHandler = () => {
|
|
2120
|
+
void this.checkAgain().catch(() => void 0);
|
|
2121
|
+
};
|
|
2122
|
+
constructor(options) {
|
|
2123
|
+
this.options = options;
|
|
2124
|
+
}
|
|
2125
|
+
getSnapshot() {
|
|
2126
|
+
return this.snapshot;
|
|
2127
|
+
}
|
|
2128
|
+
subscribe(listener) {
|
|
2129
|
+
this.listeners.add(listener);
|
|
2130
|
+
return () => this.listeners.delete(listener);
|
|
2131
|
+
}
|
|
2132
|
+
async initialize() {
|
|
2133
|
+
const accountId = await this.options.store.getAccountId();
|
|
2134
|
+
this.update({ state: accountId ? "READY" : "NOT_READY", errorCode: null, accountId });
|
|
2135
|
+
}
|
|
2136
|
+
open() {
|
|
2137
|
+
if (this.opening || this.snapshot.state === "LOGIN_OPEN" || this.snapshot.state === "LOGIN_REQUIRED" || this.snapshot.state === "CHECKPOINT_REQUIRED") {
|
|
2138
|
+
return Promise.reject(new Error("LOGIN_ALREADY_OPEN"));
|
|
2139
|
+
}
|
|
2140
|
+
this.opening = true;
|
|
2141
|
+
return this.serialize(() => this.openNow()).finally(() => {
|
|
2142
|
+
this.opening = false;
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
isActive() {
|
|
2146
|
+
return this.opening || this.snapshot.state === "LOGIN_OPEN" || this.snapshot.state === "LOGIN_REQUIRED" || this.snapshot.state === "CHECKPOINT_REQUIRED";
|
|
2147
|
+
}
|
|
2148
|
+
checkAgain() {
|
|
2149
|
+
return this.serialize(() => this.checkAgainNow());
|
|
2150
|
+
}
|
|
2151
|
+
close(errorCode = null) {
|
|
2152
|
+
return this.serialize(() => this.finish({ state: this.readyBeforeLogin ? "READY" : "NOT_READY", errorCode, accountId: this.accountIdBeforeLogin }));
|
|
2153
|
+
}
|
|
2154
|
+
clear() {
|
|
2155
|
+
return this.serialize(async () => {
|
|
2156
|
+
await this.finish({ state: "NOT_READY", errorCode: null, accountId: null });
|
|
2157
|
+
await this.options.store.clear();
|
|
2158
|
+
this.readyBeforeLogin = false;
|
|
2159
|
+
this.accountIdBeforeLogin = null;
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
async openNow() {
|
|
2163
|
+
if (this.browser) throw new Error("LOGIN_ALREADY_OPEN");
|
|
2164
|
+
const previous = await this.options.store.load();
|
|
2165
|
+
this.accountIdBeforeLogin = await this.options.store.getAccountId();
|
|
2166
|
+
this.readyBeforeLogin = Boolean(this.accountIdBeforeLogin);
|
|
2167
|
+
const browser = await this.options.browserType.launch({ headless: false });
|
|
2168
|
+
try {
|
|
2169
|
+
const context = await browser.newContext({ ...previous ? { storageState: previous } : {} });
|
|
2170
|
+
const page = await context.newPage();
|
|
2171
|
+
this.browser = browser;
|
|
2172
|
+
this.context = context;
|
|
2173
|
+
this.page = page;
|
|
2174
|
+
page.on("framenavigated", this.navigationHandler);
|
|
2175
|
+
this.timer = this.options.setTimer(() => {
|
|
2176
|
+
void this.close("LOGIN_TIMEOUT");
|
|
2177
|
+
}, this.options.timeoutMs);
|
|
2178
|
+
this.update({ state: "LOGIN_OPEN", errorCode: null, accountId: this.accountIdBeforeLogin });
|
|
2179
|
+
await page.goto("https://www.facebook.com/", { waitUntil: "domcontentloaded" });
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
await this.finish({
|
|
2182
|
+
state: this.readyBeforeLogin ? "READY" : "NOT_READY",
|
|
2183
|
+
errorCode: null,
|
|
2184
|
+
accountId: this.accountIdBeforeLogin
|
|
2185
|
+
});
|
|
2186
|
+
throw error;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
async checkAgainNow() {
|
|
2190
|
+
if (!this.page || !this.context) throw new Error("LOGIN_NOT_OPEN");
|
|
2191
|
+
const condition = await this.options.detectSession(this.page, this.context);
|
|
2192
|
+
if (condition !== "READY") {
|
|
2193
|
+
this.update({ state: condition, errorCode: null, accountId: this.accountIdBeforeLogin });
|
|
2194
|
+
return false;
|
|
2195
|
+
}
|
|
2196
|
+
const state = await this.context.storageState();
|
|
2197
|
+
const accountId = await this.options.store.save(state);
|
|
2198
|
+
await this.finish({ state: "READY", errorCode: null, accountId });
|
|
2199
|
+
return true;
|
|
2200
|
+
}
|
|
2201
|
+
async finish(snapshot) {
|
|
2202
|
+
if (this.timer) this.options.clearTimer(this.timer);
|
|
2203
|
+
this.timer = null;
|
|
2204
|
+
const page = this.page;
|
|
2205
|
+
const context = this.context;
|
|
2206
|
+
const browser = this.browser;
|
|
2207
|
+
this.page = null;
|
|
2208
|
+
this.context = null;
|
|
2209
|
+
this.browser = null;
|
|
2210
|
+
page?.off("framenavigated", this.navigationHandler);
|
|
2211
|
+
await context?.close();
|
|
2212
|
+
await browser?.close();
|
|
2213
|
+
this.update(snapshot);
|
|
2214
|
+
}
|
|
2215
|
+
update(snapshot) {
|
|
2216
|
+
if (snapshot.state === this.snapshot.state && snapshot.errorCode === this.snapshot.errorCode && snapshot.accountId === this.snapshot.accountId) return;
|
|
2217
|
+
this.snapshot = snapshot;
|
|
2218
|
+
for (const listener of this.listeners) listener(snapshot);
|
|
2219
|
+
}
|
|
2220
|
+
serialize(operation) {
|
|
2221
|
+
const result = this.pending.catch(() => void 0).then(operation);
|
|
2222
|
+
this.pending = result.then(() => void 0, () => void 0);
|
|
2223
|
+
return result;
|
|
2224
|
+
}
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
// src/main/facebook-session-store.ts
|
|
2228
|
+
var FACEBOOK_SESSION_KEY = "facebook-session";
|
|
2229
|
+
var FacebookSessionStore = class {
|
|
2230
|
+
credentials;
|
|
2231
|
+
constructor(credentials) {
|
|
2232
|
+
this.credentials = credentials;
|
|
2233
|
+
}
|
|
2234
|
+
async load() {
|
|
2235
|
+
const value = await this.credentials.get(FACEBOOK_SESSION_KEY);
|
|
2236
|
+
if (!value) return null;
|
|
2237
|
+
try {
|
|
2238
|
+
const parsed = JSON.parse(value);
|
|
2239
|
+
return isFacebookStorageState(parsed) ? parsed : null;
|
|
2240
|
+
} catch {
|
|
2241
|
+
return null;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async save(state) {
|
|
2245
|
+
if (!isFacebookStorageState(state)) {
|
|
2246
|
+
throw new TypeError("FACEBOOK_SESSION_INVALID");
|
|
2247
|
+
}
|
|
2248
|
+
const accountId = getFacebookAccountId(state);
|
|
2249
|
+
if (!accountId) throw new TypeError("FACEBOOK_SESSION_INVALID");
|
|
2250
|
+
await this.credentials.set(FACEBOOK_SESSION_KEY, JSON.stringify(state));
|
|
2251
|
+
return accountId;
|
|
2252
|
+
}
|
|
2253
|
+
clear() {
|
|
2254
|
+
return this.credentials.delete(FACEBOOK_SESSION_KEY);
|
|
2255
|
+
}
|
|
2256
|
+
async isReady() {
|
|
2257
|
+
return Boolean(await this.getAccountId());
|
|
2258
|
+
}
|
|
2259
|
+
async getAccountId() {
|
|
2260
|
+
const state = await this.load();
|
|
2261
|
+
return state ? getFacebookAccountId(state) : null;
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
function isFacebookStorageState(value) {
|
|
2265
|
+
if (!value || typeof value !== "object") return false;
|
|
2266
|
+
const state = value;
|
|
2267
|
+
return Array.isArray(state.cookies) && state.cookies.every((cookie) => Boolean(cookie && typeof cookie === "object" && typeof cookie.name === "string" && typeof cookie.value === "string" && typeof cookie.domain === "string")) && (state.origins === void 0 || Array.isArray(state.origins));
|
|
2268
|
+
}
|
|
2269
|
+
function getFacebookAccountId(state) {
|
|
2270
|
+
const value = state.cookies.find((cookie) => cookie.name === "c_user" && (cookie.domain === "facebook.com" || cookie.domain.endsWith(".facebook.com")))?.value.trim();
|
|
2271
|
+
return value && /^\d{1,32}$/.test(value) ? value : null;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// src/main/reconnect-backoff.ts
|
|
2275
|
+
var BASE_DELAY_MS = 1e3;
|
|
2276
|
+
var MAX_DELAY_MS = 3e4;
|
|
2277
|
+
function reconnectDelay(attempt, random = Math.random) {
|
|
2278
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempt));
|
|
2279
|
+
return Math.floor(Math.min(0.999999, Math.max(0, random())) * ceiling);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// src/main/device-identity.ts
|
|
2283
|
+
import {
|
|
2284
|
+
createPrivateKey as createPrivateKey2,
|
|
2285
|
+
createPublicKey,
|
|
2286
|
+
generateKeyPairSync,
|
|
2287
|
+
sign as sign2
|
|
2288
|
+
} from "node:crypto";
|
|
2289
|
+
function createDeviceIdentity() {
|
|
2290
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
2291
|
+
return {
|
|
2292
|
+
privateKey: privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"),
|
|
2293
|
+
publicKey: publicKey.export({ format: "der", type: "spki" }).toString("base64")
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
function signChallenge(privateKey, deviceId, challenge) {
|
|
2297
|
+
const message = Buffer.from(
|
|
2298
|
+
`desktop-agent-ws:${DESKTOP_AGENT_PROTOCOL_VERSION}:${deviceId}:${challenge}`,
|
|
2299
|
+
"utf8"
|
|
2300
|
+
);
|
|
2301
|
+
return sign2(null, message, readPrivateKey(privateKey)).toString("base64");
|
|
2302
|
+
}
|
|
2303
|
+
function readPrivateKey(privateKey) {
|
|
2304
|
+
return createPrivateKey2({
|
|
2305
|
+
key: Buffer.from(privateKey, "base64"),
|
|
2306
|
+
format: "der",
|
|
2307
|
+
type: "pkcs8"
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// src/cli/args.ts
|
|
2312
|
+
var FLAGS = {
|
|
2313
|
+
"--api": { key: "api", type: "string" },
|
|
2314
|
+
"--bind": { key: "bind", type: "string" },
|
|
2315
|
+
"--code": { key: "code", type: "string" },
|
|
2316
|
+
"--config": { key: "config", type: "string" },
|
|
2317
|
+
"--display": { key: "display", type: "number" },
|
|
2318
|
+
"--name": { key: "name", type: "string" },
|
|
2319
|
+
"--novnc-web": { key: "novncWeb", type: "string" },
|
|
2320
|
+
"--port": { key: "port", type: "number" },
|
|
2321
|
+
"--public-host": { key: "publicHost", type: "string" }
|
|
2322
|
+
};
|
|
2323
|
+
function parseCliArgs(argv) {
|
|
2324
|
+
const options = {};
|
|
2325
|
+
let command;
|
|
2326
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2327
|
+
const token = argv[index];
|
|
2328
|
+
if (token.startsWith("--")) {
|
|
2329
|
+
const [flag, inline] = token.includes("=") ? token.split("=", 2) : [token, void 0];
|
|
2330
|
+
const spec = FLAGS[flag];
|
|
2331
|
+
if (!spec) return { error: `Unknown option "${flag}".`, options };
|
|
2332
|
+
const raw = inline ?? argv[index + 1];
|
|
2333
|
+
if (raw === void 0 || raw.startsWith("--")) return { error: `Option "${flag}" needs a value.`, options };
|
|
2334
|
+
if (inline === void 0) index += 1;
|
|
2335
|
+
if (spec.type === "number") {
|
|
2336
|
+
const value = Number(raw);
|
|
2337
|
+
if (!Number.isInteger(value) || value <= 0) return { error: `Option "${flag}" must be a positive whole number.`, options };
|
|
2338
|
+
options[spec.key] = value;
|
|
2339
|
+
} else {
|
|
2340
|
+
options[spec.key] = raw;
|
|
2341
|
+
}
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
2344
|
+
if (command === void 0) {
|
|
2345
|
+
command = token;
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
return { error: `Unknown argument "${token}".`, options };
|
|
2349
|
+
}
|
|
2350
|
+
return { command, options };
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// src/cli/file-crypto.ts
|
|
2354
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2355
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2356
|
+
import { dirname as dirname2 } from "node:path";
|
|
2357
|
+
var KEY_BYTES = 32;
|
|
2358
|
+
var IV_BYTES = 12;
|
|
2359
|
+
var TAG_BYTES = 16;
|
|
2360
|
+
function createFileCrypto(keyPath) {
|
|
2361
|
+
const loadKey = () => {
|
|
2362
|
+
if (!existsSync(keyPath)) {
|
|
2363
|
+
mkdirSync(dirname2(keyPath), { mode: 448, recursive: true });
|
|
2364
|
+
writeFileSync(keyPath, randomBytes(KEY_BYTES), { mode: 384 });
|
|
2365
|
+
chmodSync(keyPath, 384);
|
|
2366
|
+
}
|
|
2367
|
+
const key = readFileSync(keyPath);
|
|
2368
|
+
if (key.length !== KEY_BYTES) {
|
|
2369
|
+
throw new Error(`Desktop Agent key file must contain ${KEY_BYTES} bytes: ${keyPath}`);
|
|
2370
|
+
}
|
|
2371
|
+
return key;
|
|
2372
|
+
};
|
|
2373
|
+
return {
|
|
2374
|
+
decryptString(value) {
|
|
2375
|
+
if (value.length <= IV_BYTES + TAG_BYTES) throw new Error("Encrypted credential is truncated.");
|
|
2376
|
+
const decipher = createDecipheriv("aes-256-gcm", loadKey(), value.subarray(0, IV_BYTES));
|
|
2377
|
+
decipher.setAuthTag(value.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
|
|
2378
|
+
return Buffer.concat([
|
|
2379
|
+
decipher.update(value.subarray(IV_BYTES + TAG_BYTES)),
|
|
2380
|
+
decipher.final()
|
|
2381
|
+
]).toString("utf8");
|
|
2382
|
+
},
|
|
2383
|
+
encryptString(value) {
|
|
2384
|
+
const iv = randomBytes(IV_BYTES);
|
|
2385
|
+
const cipher = createCipheriv("aes-256-gcm", loadKey(), iv);
|
|
2386
|
+
const sealed = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
2387
|
+
return Buffer.concat([iv, cipher.getAuthTag(), sealed]);
|
|
2388
|
+
},
|
|
2389
|
+
isEncryptionAvailable() {
|
|
2390
|
+
try {
|
|
2391
|
+
loadKey();
|
|
2392
|
+
return true;
|
|
2393
|
+
} catch {
|
|
2394
|
+
return false;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// src/cli/qr.ts
|
|
2401
|
+
import qrcode from "qrcode-terminal";
|
|
2402
|
+
function terminalQr(value) {
|
|
2403
|
+
let output = "";
|
|
2404
|
+
qrcode.generate(value, { small: true }, (rendered) => {
|
|
2405
|
+
output = rendered;
|
|
2406
|
+
});
|
|
2407
|
+
return output;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// src/cli/virtual-display.ts
|
|
2411
|
+
import { spawn } from "node:child_process";
|
|
2412
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2413
|
+
import { mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2414
|
+
import { createConnection } from "node:net";
|
|
2415
|
+
import { networkInterfaces } from "node:os";
|
|
2416
|
+
import { join as join3 } from "node:path";
|
|
2417
|
+
var START_TIMEOUT_MS = 15e3;
|
|
2418
|
+
function waitForPort(host, port, timeoutMs) {
|
|
2419
|
+
const deadline = Date.now() + timeoutMs;
|
|
2420
|
+
return new Promise((resolve, reject) => {
|
|
2421
|
+
const attempt = () => {
|
|
2422
|
+
const socket = createConnection({ host, port });
|
|
2423
|
+
socket.once("connect", () => {
|
|
2424
|
+
socket.destroy();
|
|
2425
|
+
resolve();
|
|
2426
|
+
});
|
|
2427
|
+
socket.once("error", () => {
|
|
2428
|
+
socket.destroy();
|
|
2429
|
+
if (Date.now() >= deadline) {
|
|
2430
|
+
reject(new Error(`Timed out waiting for ${host}:${port}.`));
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
setTimeout(attempt, 250).unref();
|
|
2434
|
+
});
|
|
2435
|
+
};
|
|
2436
|
+
attempt();
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
function localAddress() {
|
|
2440
|
+
for (const addresses of Object.values(networkInterfaces())) {
|
|
2441
|
+
for (const address of addresses ?? []) {
|
|
2442
|
+
if (address.family === "IPv4" && !address.internal) return address.address;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
return "127.0.0.1";
|
|
2446
|
+
}
|
|
2447
|
+
function start(command, args, env, deps) {
|
|
2448
|
+
const child = deps.spawnProcess(command, args, env);
|
|
2449
|
+
const failure = new Promise((_resolve, reject) => {
|
|
2450
|
+
child.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
2451
|
+
child.on("exit", (code) => reject(new Error(`${command} exited with code ${String(code)}.`)));
|
|
2452
|
+
});
|
|
2453
|
+
return { child, failure };
|
|
2454
|
+
}
|
|
2455
|
+
async function startVirtualDisplay(options, overrides = {}) {
|
|
2456
|
+
const deps = { spawnProcess: spawn, waitForPort, ...overrides };
|
|
2457
|
+
const bind = options.bind ?? "0.0.0.0";
|
|
2458
|
+
const displayNumber = options.displayNumber ?? 99;
|
|
2459
|
+
const height = options.height ?? 900;
|
|
2460
|
+
const novncWeb = options.novncWeb ?? "/usr/share/novnc";
|
|
2461
|
+
const port = options.port ?? 6080;
|
|
2462
|
+
const rfbPort = 5900 + displayNumber;
|
|
2463
|
+
const token = randomBytes2(18).toString("base64url");
|
|
2464
|
+
const tokenFile = join3(options.tokenDirectory, "websockify-token");
|
|
2465
|
+
mkdirSync2(options.tokenDirectory, { mode: 448, recursive: true });
|
|
2466
|
+
writeFileSync2(tokenFile, `${token}: 127.0.0.1:${rfbPort}
|
|
2467
|
+
`, { mode: 384 });
|
|
2468
|
+
const xvfb = start("Xvfb", [`:${displayNumber}`, "-screen", `0`, `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps);
|
|
2469
|
+
const vnc = start("x11vnc", ["-display", `:${displayNumber}`, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps);
|
|
2470
|
+
const proxy = start("websockify", ["--web", novncWeb, "--token-plugin", "TokenFile", "--token-source", tokenFile, `${bind}:${port}`], process.env, deps);
|
|
2471
|
+
const stop = async () => {
|
|
2472
|
+
for (const { child } of [proxy, vnc, xvfb]) {
|
|
2473
|
+
child.kill("SIGTERM");
|
|
2474
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2475
|
+
child.kill("SIGKILL");
|
|
2476
|
+
}
|
|
2477
|
+
rmSync(tokenFile, { force: true });
|
|
2478
|
+
};
|
|
2479
|
+
try {
|
|
2480
|
+
await Promise.race([
|
|
2481
|
+
deps.waitForPort(bind === "0.0.0.0" ? "127.0.0.1" : bind, port, options.timeoutMs ?? START_TIMEOUT_MS),
|
|
2482
|
+
proxy.failure,
|
|
2483
|
+
vnc.failure,
|
|
2484
|
+
xvfb.failure
|
|
2485
|
+
]);
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
await stop();
|
|
2488
|
+
throw error;
|
|
2489
|
+
}
|
|
2490
|
+
const publicHost = options.publicHost ?? localAddress();
|
|
2491
|
+
return {
|
|
2492
|
+
stop,
|
|
2493
|
+
url: `http://${publicHost}:${port}/vnc.html?autoconnect=1&token=${encodeURIComponent(token)}`
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// src/cli/cli.ts
|
|
2498
|
+
var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2499
|
+
var CONFIG_DIRECTORY = ".diggylab";
|
|
2500
|
+
var USAGE = `DiggyLab Desktop Agent CLI
|
|
2501
|
+
|
|
2502
|
+
Usage:
|
|
2503
|
+
diggylab pair --code <code> [--name <device name>] [--api <https://api.diggylab.com>]
|
|
2504
|
+
diggylab login [--port 6080] [--bind 0.0.0.0] [--public-host <ip or host>]
|
|
2505
|
+
diggylab run
|
|
2506
|
+
diggylab status
|
|
2507
|
+
diggylab unpair
|
|
2508
|
+
|
|
2509
|
+
Global options:
|
|
2510
|
+
--config <directory> Agent state directory (default ~/.diggylab)
|
|
2511
|
+
--api <origin> API origin (default $DIGGYLAB_API_URL or https://api.diggylab.com)`;
|
|
2512
|
+
function agentVersion() {
|
|
2513
|
+
let directory = fileURLToPath(new URL(".", import.meta.url));
|
|
2514
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
2515
|
+
const manifest = join4(directory, "package.json");
|
|
2516
|
+
if (existsSync2(manifest)) {
|
|
2517
|
+
try {
|
|
2518
|
+
const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
|
|
2519
|
+
if (typeof version === "string" && version) return version;
|
|
2520
|
+
} catch {
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
directory = dirname3(directory);
|
|
2524
|
+
}
|
|
2525
|
+
return "0.0.0";
|
|
2526
|
+
}
|
|
2527
|
+
function resolvePlatform() {
|
|
2528
|
+
if (process.platform === "darwin") return "MACOS";
|
|
2529
|
+
return process.platform === "linux" ? "LINUX" : "WINDOWS";
|
|
2530
|
+
}
|
|
2531
|
+
function resolveConfigDirectory(options) {
|
|
2532
|
+
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() || join4(homedir(), CONFIG_DIRECTORY);
|
|
2533
|
+
}
|
|
2534
|
+
function resolveEndpoint(options) {
|
|
2535
|
+
const configured = (options.api ?? process.env.DIGGYLAB_API_URL)?.trim() || "https://api.diggylab.com";
|
|
2536
|
+
const loopback = /^https?:\/\/(?:127\.0\.0\.1|\[::1\]|localhost)(?::\d+)?(?:\/|$)/i.test(configured);
|
|
2537
|
+
return resolveAgentEndpoint(configured, !loopback);
|
|
2538
|
+
}
|
|
2539
|
+
function createRuntime(options) {
|
|
2540
|
+
const directory = resolveConfigDirectory(options);
|
|
2541
|
+
const endpoint = resolveEndpoint(options);
|
|
2542
|
+
const crypto2 = createFileCrypto(join4(directory, "key"));
|
|
2543
|
+
const credentials = new CredentialStore(join4(directory, "credentials.json"), crypto2);
|
|
2544
|
+
const sessions = new DeviceSessionStore(credentials);
|
|
2545
|
+
const facebook = new FacebookSessionStore(credentials);
|
|
2546
|
+
const spool = new EncryptedScanSpool(join4(directory, "scan-spool"), crypto2);
|
|
2547
|
+
const activity = new ActivityLog({ directory: join4(directory, "activity") });
|
|
2548
|
+
const http = new DesktopAgentHttpClient(endpoint.httpOrigin);
|
|
2549
|
+
const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
|
|
2550
|
+
return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
|
|
2551
|
+
}
|
|
2552
|
+
function waitForFacebookLogin(login, timeoutMs) {
|
|
2553
|
+
return new Promise((resolve, reject) => {
|
|
2554
|
+
const timer = setTimeout(() => {
|
|
2555
|
+
unsubscribe();
|
|
2556
|
+
reject(new Error("Timed out waiting for the Facebook login."));
|
|
2557
|
+
}, timeoutMs);
|
|
2558
|
+
timer.unref();
|
|
2559
|
+
const unsubscribe = login.subscribe((snapshot) => {
|
|
2560
|
+
if (snapshot.state === "READY") {
|
|
2561
|
+
clearTimeout(timer);
|
|
2562
|
+
unsubscribe();
|
|
2563
|
+
resolve();
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
if (snapshot.state === "NOT_READY" && snapshot.errorCode) {
|
|
2567
|
+
clearTimeout(timer);
|
|
2568
|
+
unsubscribe();
|
|
2569
|
+
reject(new Error(`Facebook login failed (${snapshot.errorCode}).`));
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
void login.open().catch((error) => {
|
|
2573
|
+
clearTimeout(timer);
|
|
2574
|
+
unsubscribe();
|
|
2575
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2576
|
+
});
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
async function commandPair(runtime, options, io) {
|
|
2580
|
+
const code = options.code?.trim();
|
|
2581
|
+
if (!code) {
|
|
2582
|
+
io.error("Missing --code <pairing code>. Create one on the Desktop Agent page of the web app.");
|
|
2583
|
+
return 2;
|
|
2584
|
+
}
|
|
2585
|
+
const access = await runtime.manager.pair({
|
|
2586
|
+
agentVersion: agentVersion(),
|
|
2587
|
+
code,
|
|
2588
|
+
name: sanitizeDeviceName(options.name?.trim() || hostname()),
|
|
2589
|
+
platform: resolvePlatform()
|
|
2590
|
+
});
|
|
2591
|
+
io.log(`Paired as "${access.session.device.name}".`);
|
|
2592
|
+
io.log("Next: run `diggylab login` to connect a Facebook account.");
|
|
2593
|
+
return 0;
|
|
2594
|
+
}
|
|
2595
|
+
async function commandLogin(runtime, options, io) {
|
|
2596
|
+
const login = new FacebookLoginService({
|
|
2597
|
+
browserType: runtime.browserType,
|
|
2598
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
2599
|
+
detectSession: detectFacebookSession,
|
|
2600
|
+
setTimer: (callback, delay) => {
|
|
2601
|
+
const timer = setTimeout(callback, delay);
|
|
2602
|
+
timer.unref();
|
|
2603
|
+
return timer;
|
|
2604
|
+
},
|
|
2605
|
+
store: runtime.facebook,
|
|
2606
|
+
timeoutMs: LOGIN_TIMEOUT_MS
|
|
2607
|
+
});
|
|
2608
|
+
await login.initialize();
|
|
2609
|
+
if (login.getSnapshot().state === "READY") {
|
|
2610
|
+
io.log("Facebook is already connected.");
|
|
2611
|
+
return 0;
|
|
2612
|
+
}
|
|
2613
|
+
let display = null;
|
|
2614
|
+
try {
|
|
2615
|
+
if (process.env.DISPLAY) {
|
|
2616
|
+
io.log(`Opening the Facebook login page on display ${process.env.DISPLAY}.`);
|
|
2617
|
+
} else {
|
|
2618
|
+
display = await startVirtualDisplay({
|
|
2619
|
+
bind: options.bind,
|
|
2620
|
+
displayNumber: options.display,
|
|
2621
|
+
novncWeb: options.novncWeb,
|
|
2622
|
+
port: options.port,
|
|
2623
|
+
publicHost: options.publicHost,
|
|
2624
|
+
tokenDirectory: join4(runtime.directory, "novnc")
|
|
2625
|
+
});
|
|
2626
|
+
process.env.DISPLAY = `:${options.display ?? 99}`;
|
|
2627
|
+
io.log(terminalQr(display.url));
|
|
2628
|
+
io.log(`Open this link on your phone, or scan the QR above:
|
|
2629
|
+
${display.url}`);
|
|
2630
|
+
io.log("Log into Facebook on that screen; the agent saves the session automatically.");
|
|
2631
|
+
}
|
|
2632
|
+
await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2633
|
+
io.log("Facebook session saved.");
|
|
2634
|
+
return 0;
|
|
2635
|
+
} finally {
|
|
2636
|
+
await login.close();
|
|
2637
|
+
await display?.stop();
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
async function commandRun(runtime, io) {
|
|
2641
|
+
const stored = await runtime.manager.load();
|
|
2642
|
+
if (!stored) {
|
|
2643
|
+
io.error("This agent is not paired yet. Run `diggylab pair --code <code>` first.");
|
|
2644
|
+
return 2;
|
|
2645
|
+
}
|
|
2646
|
+
const recordActivity = (message, code) => {
|
|
2647
|
+
void runtime.activity.append({ category: "CONNECTION", code, level: "INFO", message }).catch(() => void 0);
|
|
2648
|
+
};
|
|
2649
|
+
const facebookReady = await runtime.facebook.isReady();
|
|
2650
|
+
const session = new DesktopAgentSession({
|
|
2651
|
+
agentVersion: agentVersion(),
|
|
2652
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
2653
|
+
createSocket: (url, accessToken) => new WebSocket(url, {
|
|
2654
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
2655
|
+
maxPayload: 16 * 1024
|
|
2656
|
+
}),
|
|
2657
|
+
credentials: runtime.manager,
|
|
2658
|
+
getFacebookReady: () => facebookReady,
|
|
2659
|
+
platform: resolvePlatform(),
|
|
2660
|
+
random: Math.random,
|
|
2661
|
+
reconnectDelay,
|
|
2662
|
+
setTimer: (callback, delay) => {
|
|
2663
|
+
const timer = setTimeout(callback, delay);
|
|
2664
|
+
timer.unref();
|
|
2665
|
+
return timer;
|
|
2666
|
+
},
|
|
2667
|
+
signChallenge,
|
|
2668
|
+
websocketUrl: runtime.endpoint.websocketUrl
|
|
2669
|
+
});
|
|
2670
|
+
const scanHttp = new DesktopScanHttp({
|
|
2671
|
+
getCredential: () => {
|
|
2672
|
+
const access = runtime.manager.getAccessCredential();
|
|
2673
|
+
return access ? { accessToken: access.accessToken, privateKey: access.session.privateKey } : null;
|
|
2674
|
+
},
|
|
2675
|
+
origin: runtime.endpoint.httpOrigin
|
|
2676
|
+
});
|
|
2677
|
+
const scanRunner = new DesktopScanRunner({
|
|
2678
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
2679
|
+
facebookState: runtime.facebook,
|
|
2680
|
+
http: scanHttp,
|
|
2681
|
+
isLoginActive: () => false,
|
|
2682
|
+
now: Date.now,
|
|
2683
|
+
randomUuid: () => randomUUID3(),
|
|
2684
|
+
requestFacebookLogin: async () => {
|
|
2685
|
+
io.error("The Facebook session expired. Stop this worker, run `diggylab login`, then start it again.");
|
|
2686
|
+
},
|
|
2687
|
+
scanner: new FacebookScanner({ browserType: runtime.browserType }),
|
|
2688
|
+
session,
|
|
2689
|
+
setTimer: (callback, delay) => {
|
|
2690
|
+
const timer = setTimeout(callback, delay);
|
|
2691
|
+
timer.unref();
|
|
2692
|
+
return timer;
|
|
2693
|
+
},
|
|
2694
|
+
spool: runtime.spool,
|
|
2695
|
+
wait: abortableDelay
|
|
2696
|
+
});
|
|
2697
|
+
session.subscribe((snapshot) => {
|
|
2698
|
+
io.log(`session: ${snapshot.state}`);
|
|
2699
|
+
recordActivity(`Session state changed to ${snapshot.state}.`, snapshot.state);
|
|
2700
|
+
});
|
|
2701
|
+
scanRunner.subscribe((snapshot) => {
|
|
2702
|
+
io.log(`scanner: ${snapshot.state}${snapshot.jobId ? ` (job ${snapshot.jobId})` : ""}`);
|
|
2703
|
+
recordActivity(`Scanner state changed to ${snapshot.state}.`, snapshot.state);
|
|
2704
|
+
});
|
|
2705
|
+
scanRunner.start();
|
|
2706
|
+
await scanRunner.recover();
|
|
2707
|
+
await session.start();
|
|
2708
|
+
io.log(`Worker running as "${stored.device.name}". Press Ctrl+C to stop.`);
|
|
2709
|
+
await new Promise((resolve) => {
|
|
2710
|
+
process.once("SIGINT", resolve);
|
|
2711
|
+
process.once("SIGTERM", resolve);
|
|
2712
|
+
});
|
|
2713
|
+
session.stop();
|
|
2714
|
+
scanRunner.stop();
|
|
2715
|
+
return 0;
|
|
2716
|
+
}
|
|
2717
|
+
async function commandStatus(runtime, io) {
|
|
2718
|
+
const stored = await runtime.sessions.load();
|
|
2719
|
+
const facebook = await runtime.facebook.load();
|
|
2720
|
+
const hasUser = Boolean(facebook?.cookies?.some((cookie) => cookie.name === "c_user" && cookie.value));
|
|
2721
|
+
io.log(`Config: ${runtime.directory}`);
|
|
2722
|
+
io.log(`API: ${runtime.endpoint.httpOrigin}`);
|
|
2723
|
+
io.log(`Version: ${agentVersion()}`);
|
|
2724
|
+
io.log(`Paired: ${stored ? `"${stored.device.name}" (${stored.device.id})` : "no \u2014 run `diggylab pair --code <code>`"}`);
|
|
2725
|
+
io.log(`Facebook: ${hasUser ? "connected" : "missing \u2014 run `diggylab login`"}`);
|
|
2726
|
+
return 0;
|
|
2727
|
+
}
|
|
2728
|
+
async function commandUnpair(runtime, io) {
|
|
2729
|
+
await runtime.manager.clear();
|
|
2730
|
+
await runtime.facebook.clear();
|
|
2731
|
+
await runtime.spool.clear();
|
|
2732
|
+
io.log("Device unpaired; local credentials, Facebook session, and pending scans were removed.");
|
|
2733
|
+
return 0;
|
|
2734
|
+
}
|
|
2735
|
+
async function main(argv, io = console) {
|
|
2736
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
2737
|
+
io.log(USAGE);
|
|
2738
|
+
return 0;
|
|
2739
|
+
}
|
|
2740
|
+
const parsed = parseCliArgs(argv);
|
|
2741
|
+
if (parsed.error) {
|
|
2742
|
+
io.error(parsed.error);
|
|
2743
|
+
io.error(USAGE);
|
|
2744
|
+
return 2;
|
|
2745
|
+
}
|
|
2746
|
+
const command = parsed.command ?? "help";
|
|
2747
|
+
if (command === "help" || command === "--help") {
|
|
2748
|
+
io.log(USAGE);
|
|
2749
|
+
return 0;
|
|
2750
|
+
}
|
|
2751
|
+
if (!["login", "pair", "run", "status", "unpair"].includes(command)) {
|
|
2752
|
+
io.error(`Unknown command "${command}".`);
|
|
2753
|
+
io.error(USAGE);
|
|
2754
|
+
return 2;
|
|
2755
|
+
}
|
|
2756
|
+
const runtime = createRuntime(parsed.options);
|
|
2757
|
+
switch (command) {
|
|
2758
|
+
case "pair":
|
|
2759
|
+
return commandPair(runtime, parsed.options, io);
|
|
2760
|
+
case "login":
|
|
2761
|
+
return commandLogin(runtime, parsed.options, io);
|
|
2762
|
+
case "run":
|
|
2763
|
+
return commandRun(runtime, io);
|
|
2764
|
+
case "status":
|
|
2765
|
+
return commandStatus(runtime, io);
|
|
2766
|
+
default:
|
|
2767
|
+
return commandUnpair(runtime, io);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
var entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
2771
|
+
if (import.meta.url === entry) {
|
|
2772
|
+
void main(process.argv.slice(2)).then((code) => {
|
|
2773
|
+
process.exitCode = code;
|
|
2774
|
+
}).catch((error) => {
|
|
2775
|
+
console.error(error instanceof Error ? error.message : error);
|
|
2776
|
+
process.exitCode = 1;
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
export {
|
|
2780
|
+
main
|
|
2781
|
+
};
|