@skrillex1224/android-toolkit 0.1.2 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/share.js ADDED
@@ -0,0 +1,644 @@
1
+ import {
2
+ compressImageBufferToBase64,
3
+ resolveImageCompression,
4
+ } from './internals/compression.js';
5
+ import {ActorInfo} from './constants.js';
6
+ import {Device} from './device.js';
7
+ import {Logger, sleep} from './logger.js';
8
+
9
+ const DEFAULT_SCREENSHOT_MAX_HEIGHT = 8000;
10
+ const DEFAULT_SCREENSHOT_SETTLE_MS = 900;
11
+ const DEFAULT_SHARE_TIMEOUT_MS = 50000;
12
+ const DEFAULT_POLL_INTERVAL_MS = 800;
13
+
14
+ const normalizePrefix = (value) => String(value || '').trim();
15
+
16
+ const normalizeActorInfo = (value) => {
17
+ if (value && typeof value === 'object') return value;
18
+ const key = String(value || '').trim();
19
+ return ActorInfo[key] || null;
20
+ };
21
+
22
+ const normalizeShare = (share, actorInfo) => {
23
+ const source = share && typeof share === 'object' ? share : actorInfo?.share || {};
24
+ const modeRaw = String(source.mode || 'clipboard').trim().toLowerCase();
25
+ const mode = ['clipboard', 'custom'].includes(modeRaw) ? modeRaw : 'clipboard';
26
+ return {
27
+ mode,
28
+ prefix: normalizePrefix(source.prefix),
29
+ xurl: Array.isArray(source.xurl) ? source.xurl : [],
30
+ };
31
+ };
32
+
33
+ const parseLinks = (text, options = {}) => {
34
+ const prefix = normalizePrefix(options.prefix);
35
+ const raw = String(text || '');
36
+ if (!raw) return [];
37
+ const urls = raw.match(/https?:\/\/[^\s"'<>,。]+/g) || [];
38
+ const normalized = [];
39
+ for (const candidate of urls) {
40
+ const clean = candidate.replace(/[)\].,,。;;!?!?]+$/g, '');
41
+ if (prefix && !clean.startsWith(prefix)) continue;
42
+ if (!normalized.includes(clean)) normalized.push(clean);
43
+ }
44
+ return normalized;
45
+ };
46
+
47
+ const toSnapshot = (value, maxLen = 500) => {
48
+ const text = String(value || '');
49
+ if (!text) return '';
50
+ return text.replace(/\s+/g, ' ').trim().slice(0, maxLen);
51
+ };
52
+
53
+ const abortReason = (signal) => {
54
+ const reason = signal?.reason;
55
+ if (reason instanceof Error) return reason.message;
56
+ return reason ? String(reason) : 'aborted';
57
+ };
58
+
59
+ const raceWithTimeout = async (promiseFactory, timeoutMs, signal) => {
60
+ const timeoutDisabled = timeoutMs === 0 || timeoutMs === Infinity;
61
+ let timer = null;
62
+ let abortCleanup = null;
63
+
64
+ const actionPromise = Promise.resolve()
65
+ .then(() => promiseFactory())
66
+ .then((result) => ({type: 'done', result}))
67
+ .catch((error) => ({type: 'error', error}));
68
+
69
+ const racers = [actionPromise];
70
+
71
+ if (!timeoutDisabled) {
72
+ racers.push(new Promise((resolve) => {
73
+ timer = setTimeout(() => resolve({type: 'timeout'}), Math.max(0, timeoutMs));
74
+ }));
75
+ }
76
+
77
+ if (signal) {
78
+ racers.push(new Promise((resolve) => {
79
+ if (signal.aborted) {
80
+ resolve({type: 'aborted', reason: abortReason(signal)});
81
+ return;
82
+ }
83
+ const onAbort = () => resolve({type: 'aborted', reason: abortReason(signal)});
84
+ abortCleanup = () => signal.removeEventListener('abort', onAbort);
85
+ signal.addEventListener('abort', onAbort, {once: true});
86
+ }));
87
+ }
88
+
89
+ const result = await Promise.race(racers);
90
+ if (timer) clearTimeout(timer);
91
+ if (abortCleanup) abortCleanup();
92
+ return result;
93
+ };
94
+
95
+ const clampInt = (value, min, max) => {
96
+ const n = Math.round(Number(value) || 0);
97
+ return Math.max(min, Math.min(max, n));
98
+ };
99
+
100
+ const estimateScrollableHeightScript = () => `
101
+ Java.perform(function () {
102
+ function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
103
+ var JavaObject = Java.use('java.lang.Object');
104
+ function text(value) {
105
+ if (value === null || value === undefined) return '';
106
+ try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) {}
107
+ try { return value.toString.overload().call(value) + ''; } catch (_) {}
108
+ try { return value.toString() + ''; } catch (_) {}
109
+ return '';
110
+ }
111
+ function walk(view, out, depth) {
112
+ if (!view || depth > 32 || out.length > 8000) return;
113
+ out.push(view);
114
+ try {
115
+ var ViewGroup = Java.use('android.view.ViewGroup');
116
+ var group = Java.cast(view, ViewGroup);
117
+ for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
118
+ } catch (_) {}
119
+ }
120
+ function activeActivities() {
121
+ var ActivityThread = Java.use('android.app.ActivityThread');
122
+ var Activity = Java.use('android.app.Activity');
123
+ var ArrayMap = Java.use('android.util.ArrayMap');
124
+ var thread = ActivityThread.currentActivityThread();
125
+ var field = thread.getClass().getDeclaredField('mActivities');
126
+ field.setAccessible(true);
127
+ var map = Java.cast(field.get(thread), ArrayMap);
128
+ var out = [];
129
+ for (var i = 0; i < map.size(); i++) {
130
+ var record = map.valueAt(i);
131
+ var rc = record.getClass();
132
+ var af = rc.getDeclaredField('activity');
133
+ var pf = rc.getDeclaredField('paused');
134
+ af.setAccessible(true);
135
+ pf.setAccessible(true);
136
+ if (!pf.getBoolean(record)) out.push(Java.cast(af.get(record), Activity));
137
+ }
138
+ return out;
139
+ }
140
+ function idOf(view) {
141
+ try {
142
+ var id = view.getId();
143
+ if (id <= 0) return '';
144
+ return text(view.getResources().getResourceName(id));
145
+ } catch (_) {
146
+ return '';
147
+ }
148
+ }
149
+ function descOf(view) {
150
+ try { return text(view.getContentDescription()); } catch (_) { return ''; }
151
+ }
152
+ function classOf(view) {
153
+ try { return text(view.getClass().getName()); } catch (_) { return ''; }
154
+ }
155
+ function boundsOf(view) {
156
+ try {
157
+ var Rect = Java.use('android.graphics.Rect');
158
+ var rect = Rect.$new();
159
+ view.getGlobalVisibleRect(rect);
160
+ return {
161
+ left: rect.left.value,
162
+ top: rect.top.value,
163
+ right: rect.right.value,
164
+ bottom: rect.bottom.value,
165
+ width: Math.max(0, rect.right.value - rect.left.value),
166
+ height: Math.max(0, rect.bottom.value - rect.top.value)
167
+ };
168
+ } catch (_) {
169
+ return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
170
+ }
171
+ }
172
+ Java.scheduleOnMainThread(function () {
173
+ try {
174
+ var displayHeight = 0;
175
+ var displayWidth = 0;
176
+ var views = [];
177
+ var activities = activeActivities();
178
+ for (var a = 0; a < activities.length; a++) {
179
+ try {
180
+ var decor = activities[a].getWindow().getDecorView();
181
+ displayHeight = Math.max(displayHeight, decor.getHeight());
182
+ displayWidth = Math.max(displayWidth, decor.getWidth());
183
+ walk(decor, views, 0);
184
+ } catch (_) {}
185
+ }
186
+ var candidates = [];
187
+ var maxWantedHeight = displayHeight;
188
+ for (var i = 0; i < views.length; i++) {
189
+ var view = views[i];
190
+ try { if (!view.isShown()) continue; } catch (_) {}
191
+ var bounds = boundsOf(view);
192
+ if (bounds.width <= 0 || bounds.height <= 0) continue;
193
+ var range = 0;
194
+ var extent = 0;
195
+ var offset = 0;
196
+ var canScroll = false;
197
+ try { canScroll = Boolean(view.canScrollVertically(1)) || Boolean(view.canScrollVertically(-1)); } catch (_) {}
198
+ try { range = Math.max(0, view.computeVerticalScrollRange()); } catch (_) {}
199
+ try { extent = Math.max(0, view.computeVerticalScrollExtent()); } catch (_) {}
200
+ try { offset = Math.max(0, view.computeVerticalScrollOffset()); } catch (_) {}
201
+ if (!canScroll && range <= extent + 2) continue;
202
+ var wantedHeight = Math.max(displayHeight, bounds.top + range);
203
+ maxWantedHeight = Math.max(maxWantedHeight, wantedHeight);
204
+ candidates.push({
205
+ className: classOf(view),
206
+ resourceId: idOf(view),
207
+ description: descOf(view),
208
+ bounds: bounds,
209
+ scrollRange: range,
210
+ scrollExtent: extent,
211
+ scrollOffset: offset,
212
+ wantedHeight: wantedHeight
213
+ });
214
+ }
215
+ candidates.sort(function (a, b) { return b.wantedHeight - a.wantedHeight; });
216
+ emit({
217
+ ok: true,
218
+ displayWidth: displayWidth,
219
+ displayHeight: displayHeight,
220
+ height: maxWantedHeight,
221
+ scrollableCount: candidates.length,
222
+ candidates: candidates.slice(0, 12)
223
+ });
224
+ } catch (error) {
225
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
226
+ }
227
+ });
228
+ });
229
+ `;
230
+
231
+ const resetScrollablePositionsScript = () => `
232
+ Java.perform(function () {
233
+ function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
234
+ function walk(view, out, depth) {
235
+ if (!view || depth > 32 || out.length > 8000) return;
236
+ out.push(view);
237
+ try {
238
+ var ViewGroup = Java.use('android.view.ViewGroup');
239
+ var group = Java.cast(view, ViewGroup);
240
+ for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
241
+ } catch (_) {}
242
+ }
243
+ function activeActivities() {
244
+ var ActivityThread = Java.use('android.app.ActivityThread');
245
+ var Activity = Java.use('android.app.Activity');
246
+ var ArrayMap = Java.use('android.util.ArrayMap');
247
+ var thread = ActivityThread.currentActivityThread();
248
+ var field = thread.getClass().getDeclaredField('mActivities');
249
+ field.setAccessible(true);
250
+ var map = Java.cast(field.get(thread), ArrayMap);
251
+ var out = [];
252
+ for (var i = 0; i < map.size(); i++) {
253
+ var record = map.valueAt(i);
254
+ var rc = record.getClass();
255
+ var af = rc.getDeclaredField('activity');
256
+ var pf = rc.getDeclaredField('paused');
257
+ af.setAccessible(true);
258
+ pf.setAccessible(true);
259
+ if (!pf.getBoolean(record)) out.push(Java.cast(af.get(record), Activity));
260
+ }
261
+ return out;
262
+ }
263
+ Java.scheduleOnMainThread(function () {
264
+ try {
265
+ var views = [];
266
+ var activities = activeActivities();
267
+ for (var a = 0; a < activities.length; a++) {
268
+ try { walk(activities[a].getWindow().getDecorView(), views, 0); } catch (_) {}
269
+ }
270
+ var resetCount = 0;
271
+ for (var i = 0; i < views.length; i++) {
272
+ var view = views[i];
273
+ var canScroll = false;
274
+ var offset = 0;
275
+ try { canScroll = Boolean(view.canScrollVertically(-1)) || Boolean(view.canScrollVertically(1)); } catch (_) {}
276
+ try { offset = Math.max(0, view.computeVerticalScrollOffset()); } catch (_) {}
277
+ if (!canScroll && offset <= 0) continue;
278
+ try {
279
+ view.scrollTo(0, 0);
280
+ resetCount += 1;
281
+ continue;
282
+ } catch (_) {}
283
+ try {
284
+ if (offset > 0) {
285
+ view.scrollBy(0, -offset);
286
+ resetCount += 1;
287
+ }
288
+ } catch (_) {}
289
+ }
290
+ emit({ ok: true, resetCount: resetCount });
291
+ } catch (error) {
292
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
293
+ }
294
+ });
295
+ });
296
+ `;
297
+
298
+ const clipboardLinkProbeScript = () => `
299
+ Java.perform(function () {
300
+ function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
301
+ var JavaObject = Java.use('java.lang.Object');
302
+ function text(value) {
303
+ if (value === null || value === undefined) return '';
304
+ try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) {}
305
+ try { return value.toString.overload().call(value) + ''; } catch (_) {}
306
+ try { return value.toString() + ''; } catch (_) {}
307
+ return '';
308
+ }
309
+ function addCandidate(out, source, value) {
310
+ var s = text(value);
311
+ if (!s) return;
312
+ var matches = s.match(/https?:\\/\\/[^\\s"'<>,。]+/g) || [];
313
+ for (var i = 0; i < matches.length; i++) {
314
+ var link = matches[i].replace(/[)\\].,,。;;!?!?]+$/g, '');
315
+ var exists = false;
316
+ for (var j = 0; j < out.length; j++) {
317
+ if (out[j].link === link) {
318
+ exists = true;
319
+ break;
320
+ }
321
+ }
322
+ if (!exists) out.push({ source: source, link: link, payload: s.slice(0, 1000) });
323
+ }
324
+ }
325
+ Java.scheduleOnMainThread(function () {
326
+ try {
327
+ var ActivityThread = Java.use('android.app.ActivityThread');
328
+ var app = ActivityThread.currentApplication();
329
+ var candidates = [];
330
+ if (app) {
331
+ var manager = app.getSystemService('clipboard');
332
+ var ClipboardManager = Java.use('android.content.ClipboardManager');
333
+ var clip = manager ? Java.cast(manager, ClipboardManager).getPrimaryClip() : null;
334
+ var count = clip ? clip.getItemCount() : 0;
335
+ for (var i = 0; i < count; i++) {
336
+ var item = clip.getItemAt(i);
337
+ try { addCandidate(candidates, 'clipboard.text', item.getText()); } catch (_) {}
338
+ try { addCandidate(candidates, 'clipboard.html', item.getHtmlText()); } catch (_) {}
339
+ try { addCandidate(candidates, 'clipboard.uri', item.getUri()); } catch (_) {}
340
+ try { addCandidate(candidates, 'clipboard.intent', item.getIntent()); } catch (_) {}
341
+ try { addCandidate(candidates, 'clipboard.coerceToText', item.coerceToText(app)); } catch (_) {}
342
+ }
343
+ }
344
+ emit({
345
+ ok: candidates.length > 0,
346
+ link: candidates.length > 0 ? candidates[0].link : '',
347
+ source: candidates.length > 0 ? candidates[0].source : 'clipboard',
348
+ candidates: candidates,
349
+ error: candidates.length > 0 ? '' : 'share link not found in clipboard'
350
+ });
351
+ } catch (error) {
352
+ emit({ ok: false, error: String(error), stack: String(error.stack || '') });
353
+ }
354
+ });
355
+ });
356
+ `;
357
+
358
+ async function estimateScrollableHeight(ctx, options = {}) {
359
+ const Frida = options.Frida || options.frida;
360
+ const size = await Device.screenSize(ctx).catch(() => ({width: 720, height: 1280}));
361
+ if (!Frida?.runScript) {
362
+ throw new Error('Share.captureScreen requires Frida.runScript to inspect Android View scroll ranges');
363
+ }
364
+
365
+ const event = await Frida.runScript(ctx, estimateScrollableHeightScript(), {
366
+ label: 'android-share-estimate-scrollable-height',
367
+ timeoutMs: Number(options.timeoutMs || 10000),
368
+ fridaTimeoutSeconds: Number(options.fridaTimeoutSeconds || 8),
369
+ maxLines: 2000,
370
+ }).catch((error) => ({
371
+ ok: false,
372
+ error: error?.message || String(error),
373
+ }));
374
+
375
+ if (!event?.ok) {
376
+ throw new Error(`Share.captureScreen failed to inspect Android View tree: ${event?.error || 'unknown error'}`);
377
+ }
378
+
379
+ return {
380
+ height: Math.max(size.height, Math.round(Number(event.height || 0))),
381
+ width: Math.max(size.width, Math.round(Number(event.displayWidth || size.width || 0))),
382
+ scrollableCount: Number(event.scrollableCount || 0),
383
+ candidates: Array.isArray(event.candidates) ? event.candidates : [],
384
+ source: 'frida-view-tree',
385
+ };
386
+ }
387
+
388
+ async function resetScrollablePositions(ctx, options = {}) {
389
+ const Frida = options.Frida || options.frida;
390
+ if (!Frida?.runScript) return {ok: false, resetCount: 0};
391
+ return Frida.runScript(ctx, resetScrollablePositionsScript(), {
392
+ label: 'android-share-reset-scrollable-positions',
393
+ timeoutMs: Number(options.timeoutMs || 10000),
394
+ fridaTimeoutSeconds: Number(options.fridaTimeoutSeconds || 8),
395
+ maxLines: 1000,
396
+ }).catch((error) => ({
397
+ ok: false,
398
+ resetCount: 0,
399
+ error: error?.message || String(error),
400
+ }));
401
+ }
402
+
403
+ async function probeClipboardLinks(ctx, options = {}) {
404
+ const Frida = options.Frida || options.frida;
405
+ if (!Frida?.runScript) {
406
+ throw new Error('Share.captureLink clipboard mode requires Frida.runScript');
407
+ }
408
+ return Frida.runScript(ctx, clipboardLinkProbeScript(), {
409
+ label: options.label || 'android-share-read-clipboard-link',
410
+ timeoutMs: Number(options.timeoutMs || 9000),
411
+ fridaTimeoutSeconds: Number(options.fridaTimeoutSeconds || 8),
412
+ maxLines: Number(options.maxLines || 1500),
413
+ });
414
+ }
415
+
416
+ export const Share = {
417
+ /**
418
+ * Android 单次高视口截图。
419
+ *
420
+ * API 形态对齐 playwright-toolkit 的 Share.captureScreen:
421
+ * - 默认返回 base64 image;
422
+ * - 默认压缩阈值与 playwright-toolkit 一致;
423
+ * - 通过临时 wm size 拉高视口,避免滚动拼接重复 fixed 元素。
424
+ */
425
+ async captureScreen(ctx, options = {}) {
426
+ const startedAt = Date.now();
427
+ const Frida = options.Frida || options.frida;
428
+ if (!Frida?.runScript) {
429
+ throw new Error('Share.captureScreen requires Frida.runScript to inspect Android View scroll ranges');
430
+ }
431
+ const actorInfo = normalizeActorInfo(options.actorInfo || options.actor || options.actorKey);
432
+ const compression = resolveImageCompression(options);
433
+ const maxHeight = Math.max(1, Math.round(Number(options.maxHeight || DEFAULT_SCREENSHOT_MAX_HEIGHT)));
434
+ const settleMs = Math.max(0, Math.round(Number(options.settleMs ?? DEFAULT_SCREENSHOT_SETTLE_MS)));
435
+ const restore = options.restore !== false;
436
+ const originalSize = await Device.screenSize(ctx).catch(() => ({width: 720, height: 1280}));
437
+ const originalDensity = await Device.screenDensity(ctx).catch(() => 0);
438
+ const estimated = await estimateScrollableHeight(ctx, options);
439
+ const targetWidth = Math.max(1, Math.round(Number(options.width || estimated.width || originalSize.width)));
440
+ const targetHeight = clampInt(
441
+ Number(options.height || estimated.height || originalSize.height),
442
+ originalSize.height,
443
+ maxHeight
444
+ );
445
+
446
+ Logger.info('captureScreen 准备高视口截图', {
447
+ serial: ctx.serial,
448
+ actor: actorInfo?.key || '',
449
+ original: `${originalSize.width}x${originalSize.height}`,
450
+ target: `${targetWidth}x${targetHeight}`,
451
+ density: originalDensity || '',
452
+ scrollableCount: estimated.scrollableCount || 0,
453
+ source: estimated.source,
454
+ });
455
+
456
+ let pngBuffer;
457
+ try {
458
+ if (targetHeight > originalSize.height + 2 || targetWidth !== originalSize.width) {
459
+ await Device.overrideScreenSize(ctx, targetWidth, targetHeight);
460
+ if (settleMs > 0) await sleep(settleMs);
461
+ }
462
+ if (options.scrollToTop !== false) {
463
+ const resetEvent = await resetScrollablePositions(ctx, options);
464
+ Logger.info('captureScreen 已尝试重置滚动位置', {
465
+ ok: Boolean(resetEvent?.ok),
466
+ resetCount: Number(resetEvent?.resetCount || 0),
467
+ error: resetEvent?.error || '',
468
+ });
469
+ if (settleMs > 0) await sleep(Math.min(settleMs, 500));
470
+ }
471
+ pngBuffer = await Device.screenshotPng(ctx);
472
+ } finally {
473
+ if (restore) {
474
+ await Device.resetScreenSize(ctx).catch((error) => {
475
+ Logger.warn('captureScreen 恢复 wm size 失败', {message: error?.message || String(error)});
476
+ });
477
+ }
478
+ }
479
+
480
+ const base64 = await compressImageBufferToBase64(pngBuffer, compression);
481
+ Logger.success('captureScreen 完成', {
482
+ duration: Logger.duration(startedAt),
483
+ pngBytes: pngBuffer.length,
484
+ base64Chars: base64.length,
485
+ });
486
+ return base64;
487
+ },
488
+
489
+ /**
490
+ * 捕获分享链接。
491
+ *
492
+ * Android 当前主模式是 clipboard:业务层负责点击分享/复制链接,
493
+ * toolkit 负责按 ActorInfo.share.prefix 轮询和校验剪贴板结果。
494
+ */
495
+ async captureLink(ctx, options = {}) {
496
+ const actorInfo = normalizeActorInfo(options.actorInfo || options.actor || options.actorKey);
497
+ const share = normalizeShare(options.share, actorInfo);
498
+ const timeoutMs = Math.max(0, Number(options.timeoutMs ?? DEFAULT_SHARE_TIMEOUT_MS));
499
+ const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
500
+ const payloadSnapshotMaxLen = Number(options.payloadSnapshotMaxLen || 500);
501
+ const performActions = typeof options.performActions === 'function' ? options.performActions : async () => {};
502
+ const signal = options.signal;
503
+ const isAborted = () => Boolean(signal?.aborted);
504
+ const timeoutDisabled = timeoutMs === 0;
505
+ const deadline = timeoutDisabled ? Infinity : Date.now() + timeoutMs;
506
+ const getRemainingMs = () => timeoutDisabled ? Infinity : Math.max(0, deadline - Date.now());
507
+
508
+ if (!share.prefix) {
509
+ throw new Error('Share.captureLink requires options.share.prefix or actorInfo.share.prefix');
510
+ }
511
+ if (share.mode !== 'clipboard' && share.mode !== 'custom') {
512
+ throw new Error(`Share.captureLink unsupported share.mode: ${share.mode}`);
513
+ }
514
+
515
+ Logger.start('captureLink', {
516
+ mode: share.mode,
517
+ prefix: share.prefix,
518
+ timeoutMs,
519
+ });
520
+
521
+ if (isAborted()) {
522
+ const reason = abortReason(signal);
523
+ Logger.warn('captureLink 已取消', {reason});
524
+ return {
525
+ link: null,
526
+ payloadText: '',
527
+ payloadSnapshot: reason,
528
+ source: 'none',
529
+ };
530
+ }
531
+
532
+ const actionResult = await raceWithTimeout(
533
+ () => performActions(),
534
+ getRemainingMs(),
535
+ signal
536
+ );
537
+ if (actionResult.type === 'error') {
538
+ Logger.fail('captureLink.performActions', actionResult.error);
539
+ throw actionResult.error;
540
+ }
541
+ if (actionResult.type === 'aborted') {
542
+ Logger.warn('captureLink 已取消', {reason: actionResult.reason});
543
+ return {
544
+ link: null,
545
+ payloadText: '',
546
+ payloadSnapshot: actionResult.reason,
547
+ source: 'none',
548
+ };
549
+ }
550
+ const actionTimedOut = actionResult.type === 'timeout';
551
+ if (actionTimedOut) {
552
+ Logger.warn('captureLink.performActions 超时,继续轮询已有剪贴板结果', {timeoutMs});
553
+ }
554
+ const actionValue = actionResult.type === 'done' ? actionResult.result : undefined;
555
+ if (share.mode === 'custom') {
556
+ const customLink = typeof actionValue === 'string'
557
+ ? actionValue
558
+ : actionValue?.link || actionValue?.payloadText || '';
559
+ const [link] = parseLinks(customLink, {prefix: share.prefix});
560
+ return {
561
+ link: link || null,
562
+ payloadText: String(customLink || ''),
563
+ payloadSnapshot: toSnapshot(customLink, payloadSnapshotMaxLen),
564
+ source: link ? 'custom' : 'none',
565
+ };
566
+ }
567
+
568
+ let attempt = 0;
569
+ let lastPayload = '';
570
+ while (Date.now() < deadline) {
571
+ if (isAborted()) {
572
+ const reason = abortReason(signal);
573
+ Logger.warn('captureLink 已取消', {reason});
574
+ return {
575
+ link: null,
576
+ payloadText: lastPayload,
577
+ payloadSnapshot: reason,
578
+ source: 'none',
579
+ };
580
+ }
581
+ attempt += 1;
582
+ const probeTimeoutMs = timeoutDisabled
583
+ ? 9000
584
+ : Math.max(100, Math.min(9000, getRemainingMs()));
585
+ const event = await probeClipboardLinks(ctx, {
586
+ ...options,
587
+ label: `android-share-read-clipboard-link-${attempt}`,
588
+ timeoutMs: probeTimeoutMs,
589
+ }).catch((error) => ({
590
+ ok: false,
591
+ error: error?.message || String(error),
592
+ }));
593
+ const payloadText = JSON.stringify(event || {});
594
+ lastPayload = payloadText;
595
+
596
+ const candidates = [];
597
+ if (event?.link) candidates.push(event.link);
598
+ if (Array.isArray(event?.candidates)) {
599
+ for (const candidate of event.candidates) {
600
+ if (candidate?.link) candidates.push(candidate.link);
601
+ if (candidate?.payload) candidates.push(candidate.payload);
602
+ }
603
+ }
604
+ candidates.push(payloadText);
605
+
606
+ for (const candidate of candidates) {
607
+ const [link] = parseLinks(candidate, {prefix: share.prefix});
608
+ if (!link) continue;
609
+ Logger.success('captureLink 完成', {
610
+ source: event?.source || 'clipboard',
611
+ attempt,
612
+ link,
613
+ });
614
+ return {
615
+ link,
616
+ payloadText,
617
+ payloadSnapshot: toSnapshot(payloadText, payloadSnapshotMaxLen),
618
+ source: event?.source || 'clipboard',
619
+ };
620
+ }
621
+
622
+ if (attempt === 1 || attempt % 5 === 0) {
623
+ Logger.info('captureLink 等待剪贴板', {
624
+ attempt,
625
+ candidateCount: Array.isArray(event?.candidates) ? event.candidates.length : 0,
626
+ error: event?.error || '',
627
+ });
628
+ }
629
+ await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
630
+ }
631
+
632
+ Logger.warn('captureLink 超时未拿到链接', {
633
+ mode: share.mode,
634
+ prefix: share.prefix,
635
+ attempts: attempt,
636
+ });
637
+ return {
638
+ link: null,
639
+ payloadText: lastPayload,
640
+ payloadSnapshot: toSnapshot(lastPayload, payloadSnapshotMaxLen),
641
+ source: 'none',
642
+ };
643
+ },
644
+ };