130 lines
4.5 KiB
TypeScript
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' };
|
|
},
|
|
};
|
|
}
|