assistant/electron/profile-popup.ts

130 lines
4.5 KiB
TypeScript

/** A profile popup stays in its existing managed view only after native input. */
export interface ProfilePopupDetails {
url: string;
postBody?: unknown;
}
export interface ProfilePopupDiagnostic {
taskId: string;
category: 'xhs_profile' | 'other';
action: 'deny' | 'deny_no_native_input' | 'deny_context' | 'deny_busy'
| 'open_same_view' | 'opened_same_view' | 'cancel_stale' | 'open_failed';
}
interface PopupPage {
getURL(): string;
isDestroyed(): boolean;
loadURL(url: string): Promise<unknown>;
}
interface ProfilePopupOptions {
taskId: string;
isCurrent(): boolean;
allowed(url: string): boolean;
diagnose(value: ProfilePopupDiagnostic): void;
now?: () => number;
defer?: (callback: () => void) => void;
}
const NATIVE_INPUT_WINDOW_MS = 750;
function siteURL(value: string): URL | null {
try {
const url = new URL(value);
return url.protocol === 'https:' && url.hostname === 'www.xiaohongshu.com'
&& !url.username && !url.password && !url.port ? url : null;
} catch { return null; }
}
function isNote(value: string): boolean {
const url = siteURL(value);
return Boolean(url && /^\/(?:explore|discovery\/item)\/[a-f0-9]{24}\/?$/i.test(url.pathname));
}
function isProfile(value: string): boolean {
const url = siteURL(value);
return Boolean(url && /^\/user\/profile\/[a-f0-9]{24}\/?$/i.test(url.pathname));
}
export function createProfilePopupHandler(page: PopupPage, options: ProfilePopupOptions): {
input(type: string): void;
open(details: ProfilePopupDetails): { action: 'deny' };
} {
const now = options.now ?? (() => performance.now());
const defer = options.defer ?? (callback => { setImmediate(callback); });
let nativeInput: { url: string; at: number } | null = null;
let pressed: { url: string; at: number } | null = null;
let pending = false;
return {
input(type): void {
// An earlier click cannot authorize a later mouse press or popup. The
// host event is independent of scripts in the untrusted page.
if (type === 'mouseDown') {
nativeInput = null;
pressed = null;
if (!page.isDestroyed() && isNote(page.getURL())) pressed = { url: page.getURL(), at: now() };
return;
}
if (type !== 'mouseUp') return;
const down = pressed;
pressed = null;
if (page.isDestroyed()) { nativeInput = null; return; }
const url = page.getURL();
const elapsed = down ? now() - down.at : -1;
nativeInput = down && down.url === url && isNote(url) && elapsed >= 0 && elapsed <= NATIVE_INPUT_WINDOW_MS
? { url, at: now() } : null;
},
open(details): { action: 'deny' } {
const category = isProfile(details.url) ? 'xhs_profile' : 'other';
const log = (action: ProfilePopupDiagnostic['action']): void => {
// Never spread Electron's details or exceptions into diagnostics.
options.diagnose({ taskId: options.taskId, category, action });
};
const clicked = nativeInput;
nativeInput = null; // One actual input can route at most one popup.
if (category !== 'xhs_profile') {
log('deny');
return { action: 'deny' };
}
if (pending) {
log('deny_busy');
return { action: 'deny' };
}
const elapsed = clicked ? now() - clicked.at : -1;
if (!clicked || elapsed < 0 || elapsed > NATIVE_INPUT_WINDOW_MS) {
log('deny_no_native_input');
return { action: 'deny' };
}
if (page.isDestroyed() || !options.isCurrent() || !isNote(page.getURL())
|| page.getURL() !== clicked.url || Boolean(details.postBody) || !options.allowed(details.url)) {
log('deny_context');
return { action: 'deny' };
}
pending = true;
const target = details.url;
const source = clicked.url;
log('open_same_view');
defer(() => {
if (page.isDestroyed() || !options.isCurrent() || page.getURL() !== source || !options.allowed(target)) {
pending = false;
log('cancel_stale');
return;
}
// Use the actual resolved popup URL unchanged. loadURL adds a normal
// history entry in this view; no new window or history reset is used.
void page.loadURL(target).then(() => {
pending = false;
log('opened_same_view');
}).catch(() => {
pending = false;
log('open_failed');
});
});
// Electron must never create a separate BrowserWindow, including for an
// accepted profile. Routing happens only on the next main-process turn.
return { action: 'deny' };
},
};
}