passenger-mode 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +82 -0
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/dist/demo/index.html +112 -0
- package/dist/lib/cli.d.ts +2 -0
- package/dist/lib/cli.js +158 -0
- package/dist/lib/crawl.d.ts +15 -0
- package/dist/lib/crawl.js +189 -0
- package/dist/lib/gates-text.d.ts +10 -0
- package/dist/lib/gates-text.js +48 -0
- package/dist/lib/playwright.d.ts +5 -0
- package/dist/lib/playwright.js +35 -0
- package/dist/lib/react.d.ts +8 -0
- package/dist/lib/react.js +23 -0
- package/dist/lib/runtime/driver.d.ts +101 -0
- package/dist/lib/runtime/driver.js +422 -0
- package/dist/lib/runtime/gates.d.ts +1 -0
- package/dist/lib/runtime/gates.js +56 -0
- package/dist/lib/runtime/index.d.ts +43 -0
- package/dist/lib/runtime/index.js +99 -0
- package/dist/lib/runtime/planner.d.ts +61 -0
- package/dist/lib/runtime/planner.js +107 -0
- package/dist/lib/runtime/remote-planner.d.ts +18 -0
- package/dist/lib/runtime/remote-planner.js +37 -0
- package/dist/lib/runtime/resolve.d.ts +6 -0
- package/dist/lib/runtime/resolve.js +72 -0
- package/dist/lib/runtime/routes.d.ts +13 -0
- package/dist/lib/runtime/routes.js +65 -0
- package/dist/lib/runtime/ui.d.ts +58 -0
- package/dist/lib/runtime/ui.js +267 -0
- package/dist/lib/scan.d.ts +17 -0
- package/dist/lib/scan.js +431 -0
- package/dist/lib/server/claude-planner.d.ts +43 -0
- package/dist/lib/server/claude-planner.js +159 -0
- package/dist/lib/testdrive.d.ts +12 -0
- package/dist/lib/testdrive.js +92 -0
- package/dist/lib/types.d.ts +70 -0
- package/dist/lib/types.js +3 -0
- package/dist/passenger-mode.js +52 -0
- package/package.json +95 -4
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser scan: builds the manifest from the RUNNING site, for apps the source scanner can't read
|
|
3
|
+
* (plain HTML, vanilla TypeScript, Svelte, anything). Opens pages in headless Chrome, records the
|
|
4
|
+
* links, buttons and forms a person can see, and follows same-site links.
|
|
5
|
+
*
|
|
6
|
+
* It only reads pages: it never clicks or submits anything, so it is safe to point at a dev server.
|
|
7
|
+
* Run it against a local or preview build, not production, so analytics stay clean.
|
|
8
|
+
*/
|
|
9
|
+
import { loadChromium } from "./playwright.js";
|
|
10
|
+
import { irreversibleReason, linkGateReason, humanize } from "./gates-text.js";
|
|
11
|
+
import { GENERATOR, isAdminRoute, suggestDestinations } from "./scan.js";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { canonicalPath } from "./runtime/routes.js";
|
|
14
|
+
// Runs inside the page. Kept self-contained: no imports, no closures.
|
|
15
|
+
function collect() {
|
|
16
|
+
const out = [];
|
|
17
|
+
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
18
|
+
const visible = (el) => {
|
|
19
|
+
if (el.closest("[data-passenger-mode],[hidden],[inert],[aria-hidden='true']"))
|
|
20
|
+
return false;
|
|
21
|
+
const r = el.getBoundingClientRect();
|
|
22
|
+
if (r.width === 0 && r.height === 0)
|
|
23
|
+
return false;
|
|
24
|
+
for (let n = el; n; n = n.parentElement) {
|
|
25
|
+
const cs = getComputedStyle(n);
|
|
26
|
+
if (cs.display === "none" || cs.visibility === "hidden")
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
const labelOf = (el) => norm(el.getAttribute("aria-label")) || norm(el.innerText) || norm(el.getAttribute("value")) || norm(el.getAttribute("title")) || norm(el.getAttribute("name"));
|
|
32
|
+
const sels = (el, label) => {
|
|
33
|
+
const s = [];
|
|
34
|
+
const add = (by, v) => { if (v)
|
|
35
|
+
s.push({ by, value: v }); };
|
|
36
|
+
add("testid", el.getAttribute("data-testid") ?? el.getAttribute("data-test") ?? el.getAttribute("data-cy"));
|
|
37
|
+
const id = el.getAttribute("id");
|
|
38
|
+
if (id && !/^(?:[:_]?r\d|radix-|headlessui-|mui-|:\w+:)/.test(id) && !/\d{4,}/.test(id))
|
|
39
|
+
add("id", id);
|
|
40
|
+
add("name", el.getAttribute("name"));
|
|
41
|
+
add("aria-label", el.getAttribute("aria-label"));
|
|
42
|
+
const href = el.getAttribute("href");
|
|
43
|
+
if (href && !href.startsWith("javascript:"))
|
|
44
|
+
add("href", href);
|
|
45
|
+
if (label && label.length <= 80)
|
|
46
|
+
s.push({ by: "text", value: label });
|
|
47
|
+
return s;
|
|
48
|
+
};
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const form of Array.from(document.querySelectorAll("form"))) {
|
|
51
|
+
if (!visible(form))
|
|
52
|
+
continue;
|
|
53
|
+
const submit = form.querySelector('button:not([type="button"]):not([type="reset"]), input[type="submit"]');
|
|
54
|
+
if (submit)
|
|
55
|
+
seen.add(submit);
|
|
56
|
+
const inputs = Array.from(form.querySelectorAll("input, textarea, select"))
|
|
57
|
+
.filter((f) => !["hidden", "submit", "button"].includes(f.type) && visible(f))
|
|
58
|
+
.map((f) => ({
|
|
59
|
+
tag: f.tagName.toLowerCase(),
|
|
60
|
+
type: f.type || null,
|
|
61
|
+
name: f.getAttribute("name") ?? f.getAttribute("id"),
|
|
62
|
+
label: f.getAttribute("aria-label") ?? f.getAttribute("placeholder") ?? (f.id ? norm(document.querySelector(`label[for="${f.id}"]`)?.textContent) || null : null) ?? norm(f.closest("label")?.textContent) ?? null,
|
|
63
|
+
required: f.required,
|
|
64
|
+
}));
|
|
65
|
+
const label = (submit ? labelOf(submit) : "") || labelOf(form) || "Submit form";
|
|
66
|
+
out.push({ kind: "submit", label: label.slice(0, 120), selectors: sels(form, ""), inputs, code: [form.id, form.getAttribute("name") ?? "", form.getAttribute("action") ?? ""] });
|
|
67
|
+
if (submit && !out[out.length - 1].selectors.length)
|
|
68
|
+
out[out.length - 1].selectors.push({ by: "text", value: label.slice(0, 80) });
|
|
69
|
+
}
|
|
70
|
+
for (const el of Array.from(document.querySelectorAll("a[href], button, [role='button'], input[type='submit'], input[type='button']"))) {
|
|
71
|
+
// Disabled controls are kept: many apps enable their main buttons only once they've loaded data.
|
|
72
|
+
// The driver checks each control is usable at the moment it would press it.
|
|
73
|
+
if (seen.has(el) || !visible(el))
|
|
74
|
+
continue;
|
|
75
|
+
const label = labelOf(el).slice(0, 120);
|
|
76
|
+
if (el.tagName === "A") {
|
|
77
|
+
const raw = el.getAttribute("href") ?? "";
|
|
78
|
+
if (/^(mailto:|tel:|#|javascript:)/.test(raw))
|
|
79
|
+
continue;
|
|
80
|
+
let href = raw;
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(raw, location.href);
|
|
83
|
+
if (u.origin !== location.origin)
|
|
84
|
+
continue; // other sites are out of scope
|
|
85
|
+
href = u.pathname + u.search;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out.push({ kind: "navigate", label, href, selectors: sels(el, label), code: [raw] });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
out.push({ kind: "click", label, selectors: sels(el, label), code: [el.id, el.getAttribute("name") ?? "", el.getAttribute("formaction") ?? ""] });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const STATIC_FILE = /\.(png|jpe?g|gif|webp|svg|ico|pdf|zip|mp4|mp3|wav|woff2?|css|js|json|xml|txt)$/i;
|
|
99
|
+
export async function crawl(startUrls, opts = {}) {
|
|
100
|
+
const { maxPages = 30, includeAdmin = false, settleMs = 600, log = () => { } } = opts;
|
|
101
|
+
const chromium = await loadChromium("The browser scan");
|
|
102
|
+
const browser = await chromium.launch({ executablePath: opts.executablePath ?? process.env.CHROME, args: ["--no-sandbox"] });
|
|
103
|
+
const page = await browser.newPage({ viewport: opts.viewport ?? { width: 1280, height: 900 } });
|
|
104
|
+
const origin = new URL(startUrls[0]).origin;
|
|
105
|
+
const queue = startUrls.map((u) => canonicalPath(new URL(u).pathname + new URL(u).search));
|
|
106
|
+
const visited = new Set();
|
|
107
|
+
const routes = new Map();
|
|
108
|
+
const found = new Map();
|
|
109
|
+
let excluded = 0;
|
|
110
|
+
while (queue.length && visited.size < maxPages) {
|
|
111
|
+
const path = queue.shift();
|
|
112
|
+
if (visited.has(path))
|
|
113
|
+
continue;
|
|
114
|
+
visited.add(path);
|
|
115
|
+
const pathname = path.split("?")[0] || "/";
|
|
116
|
+
if (!includeAdmin && isAdminRoute(pathname)) {
|
|
117
|
+
excluded++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const res = await page.goto(origin + path, { waitUntil: "load", timeout: 20_000 });
|
|
122
|
+
if (!res || res.status() >= 400) {
|
|
123
|
+
log(`skip ${path} (${res?.status() ?? "no response"})`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
await page.waitForTimeout(settleMs);
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
log(`skip ${path} (${e.message.split("\n")[0]})`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const landed = new URL(page.url());
|
|
133
|
+
if (landed.origin !== origin)
|
|
134
|
+
continue;
|
|
135
|
+
// Apps that switch screens with parameters (?lobby=1) get one route per screen.
|
|
136
|
+
const route = canonicalPath(landed.pathname + landed.search);
|
|
137
|
+
routes.set(route, { path: route, file: `(browser) ${route}` });
|
|
138
|
+
// Sent as source text: TypeScript runners wrap functions in a __name() helper the page doesn't have.
|
|
139
|
+
const raws = await page.evaluate(`(() => { const __name = (f) => f; return (${collect.toString()})(); })()`);
|
|
140
|
+
log(`${path}: ${raws.length} actions`);
|
|
141
|
+
for (const r of raws) {
|
|
142
|
+
const key = `${r.kind}|${r.label}|${r.selectors[0]?.by}:${r.selectors[0]?.value ?? ""}`;
|
|
143
|
+
const entry = found.get(key) ?? { raw: r, routes: new Set() };
|
|
144
|
+
entry.routes.add(route);
|
|
145
|
+
found.set(key, entry);
|
|
146
|
+
if (r.kind === "navigate" && r.href) {
|
|
147
|
+
r.href = canonicalPath(r.href);
|
|
148
|
+
if (!STATIC_FILE.test(r.href.split("?")[0]) && !r.href.startsWith("/api/") && !visited.has(r.href) && !queue.includes(r.href))
|
|
149
|
+
queue.push(r.href);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await browser.close();
|
|
154
|
+
const actions = [];
|
|
155
|
+
for (const { raw, routes: seenOn } of found.values()) {
|
|
156
|
+
// On one page: that page. On several: shared, with the pages it was seen on, so the planner
|
|
157
|
+
// routes to one of them instead of assuming it is everywhere.
|
|
158
|
+
const route = seenOn.size === 1 ? [...seenOn][0] : null;
|
|
159
|
+
const seen = seenOn.size > 1 ? { seenOn: [...seenOn].sort() } : {};
|
|
160
|
+
const reason = raw.kind === "navigate" ? linkGateReason(raw.label, raw.href) : irreversibleReason([raw.label], raw.code);
|
|
161
|
+
const slug = humanize(raw.label).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "unlabeled";
|
|
162
|
+
const h = createHash("sha1").update(`${raw.kind}|${raw.label}|${JSON.stringify(raw.selectors[0] ?? null)}|${route}`).digest("hex").slice(0, 6);
|
|
163
|
+
actions.push({
|
|
164
|
+
id: `${raw.kind}.${slug}.${h}`,
|
|
165
|
+
kind: raw.kind,
|
|
166
|
+
label: raw.label || "(unlabeled)",
|
|
167
|
+
route,
|
|
168
|
+
...seen,
|
|
169
|
+
file: "(browser scan)",
|
|
170
|
+
line: 0,
|
|
171
|
+
selectors: raw.selectors,
|
|
172
|
+
...(raw.href ? { href: raw.href } : {}),
|
|
173
|
+
...(raw.inputs ? { inputs: raw.inputs } : {}),
|
|
174
|
+
irreversible: reason !== null,
|
|
175
|
+
irreversibleReason: reason,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const routeList = [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
179
|
+
return {
|
|
180
|
+
version: 1,
|
|
181
|
+
generator: `${GENERATOR} (browser scan)`,
|
|
182
|
+
generatedAt: new Date().toISOString(),
|
|
183
|
+
framework: "unknown",
|
|
184
|
+
stats: { files: visited.size, routes: routeList.length, actions: actions.length, irreversible: actions.filter((a) => a.irreversible).length, excludedAdminRoutes: excluded },
|
|
185
|
+
routes: routeList,
|
|
186
|
+
actions,
|
|
187
|
+
destinations: suggestDestinations(actions),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A price on a button means pressing it spends money ("Try Kit · $4.99 once", "Choose · $9.99/month"). */
|
|
2
|
+
export declare const PRICE: RegExp;
|
|
3
|
+
export declare function humanize(s: string): string;
|
|
4
|
+
/** labels: what the person sees. code: names, ids, handlers, hrefs. */
|
|
5
|
+
export declare function irreversibleReason(labels: Array<string | null | undefined>, code?: Array<string | null | undefined>): string | null;
|
|
6
|
+
/**
|
|
7
|
+
* Links only change pages: "Upgrade" opens the checkout, and paying happens there behind its own gate.
|
|
8
|
+
* So a link is a gate only when it acts by itself: sign-out, delete or unsubscribe URLs.
|
|
9
|
+
*/
|
|
10
|
+
export declare function linkGateReason(label: string, href: string | null | undefined): string | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Shared by the scanner (build time) and the driver (in the page), so it must stay dependency-free.
|
|
2
|
+
/** Errs toward flagging too much: a false flag costs the passenger one click, a missed one could cost them money. */
|
|
3
|
+
const IRREVERSIBLE = [
|
|
4
|
+
[/\b(pay|payment|purchase|checkout|check ?out|buy|place (your )?order|order now|confirm (order|purchase)|donate|subscribe|upgrade)\b/, "payment"],
|
|
5
|
+
[/\b(cancel|end|stop)\b.{0,30}\b(plan|subscription|membership|club|trial|order|booking|reservation)\b|\bunsubscribe\b|\bdowngrade\b/, "cancels a plan or order"],
|
|
6
|
+
[/\b(delete|destroy|erase|wipe|purge)\b|\bremove (account|user|member|card|payment)\b|\b(close|deactivate) (my |your )?account\b/, "deletion"],
|
|
7
|
+
[/\b(change|update|reset|set)\b.{0,20}\b(password|e-?mail|2fa|passkey|two[- ]factor)\b|\bnew password\b/, "credential change"],
|
|
8
|
+
[/\b(sign ?out|log ?out)\b/, "ends the session"],
|
|
9
|
+
];
|
|
10
|
+
/** Checked against what the person sees only. Handler code is full of analytics calls like send() and share(). */
|
|
11
|
+
const IRREVERSIBLE_LABEL_ONLY = [
|
|
12
|
+
[/\b(send|invite|share with)\b/, "sends to other people"],
|
|
13
|
+
[/\b(publish|go live|make public|unpublish)\b/, "publishes"],
|
|
14
|
+
];
|
|
15
|
+
/** A price on a button means pressing it spends money ("Try Kit · $4.99 once", "Choose · $9.99/month"). */
|
|
16
|
+
export const PRICE = /[$€£¥₹]\s?\d|\d(?:[.,]\d{2})?\s?(?:usd|eur|gbp|dollars?)\b|\bper (?:month|year|seat)\b|\/\s?(?:mo|month|yr|year)\b/i;
|
|
17
|
+
/** Analytics and logging calls that look like actions but aren't. */
|
|
18
|
+
const ANALYTICS_CALL = /\b(send|track|capture|gtag|analytics|log|report|emit|posthog|plausible|umami)\s*\(/gi;
|
|
19
|
+
export function humanize(s) {
|
|
20
|
+
return s
|
|
21
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
22
|
+
.replace(/[_\-./]+/g, " ")
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
/** labels: what the person sees. code: names, ids, handlers, hrefs. */
|
|
26
|
+
export function irreversibleReason(labels, code = []) {
|
|
27
|
+
const seen = labels.filter(Boolean).map((t) => humanize(t)).join(" | ");
|
|
28
|
+
const all = [seen, ...code.filter(Boolean).map((t) => humanize(t.replace(ANALYTICS_CALL, "(")))].join(" | ");
|
|
29
|
+
for (const [re, reason] of IRREVERSIBLE)
|
|
30
|
+
if (re.test(all))
|
|
31
|
+
return reason;
|
|
32
|
+
for (const [re, reason] of IRREVERSIBLE_LABEL_ONLY)
|
|
33
|
+
if (re.test(seen))
|
|
34
|
+
return reason;
|
|
35
|
+
if (labels.some((t) => t && PRICE.test(t)))
|
|
36
|
+
return "payment";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Links only change pages: "Upgrade" opens the checkout, and paying happens there behind its own gate.
|
|
41
|
+
* So a link is a gate only when it acts by itself: sign-out, delete or unsubscribe URLs.
|
|
42
|
+
*/
|
|
43
|
+
export function linkGateReason(label, href) {
|
|
44
|
+
const h = href ?? "";
|
|
45
|
+
if (/(log-?out|sign-?out|logoff|\/delete|\/destroy|\/remove|unsubscribe)/i.test(h))
|
|
46
|
+
return irreversibleReason([label, h]) ?? "acts on its own";
|
|
47
|
+
return /\b(sign ?out|log ?out)\b/i.test(humanize(label)) ? "ends the session" : null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds Playwright's chromium. Looks in the site's own project first (where `npm i -D playwright` puts it,
|
|
3
|
+
* even when this CLI runs through npx), then next to this package, then PASSENGER_MODE_PLAYWRIGHT.
|
|
4
|
+
*/
|
|
5
|
+
export declare function loadChromium(what: string): Promise<any>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
/**
|
|
13
|
+
* Finds Playwright's chromium. Looks in the site's own project first (where `npm i -D playwright` puts it,
|
|
14
|
+
* even when this CLI runs through npx), then next to this package, then PASSENGER_MODE_PLAYWRIGHT.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadChromium(what) {
|
|
17
|
+
const tries = [];
|
|
18
|
+
if (process.env.PASSENGER_MODE_PLAYWRIGHT)
|
|
19
|
+
tries.push(process.env.PASSENGER_MODE_PLAYWRIGHT);
|
|
20
|
+
try {
|
|
21
|
+
tries.push(createRequire(join(process.cwd(), "noop.js")).resolve("playwright"));
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
tries.push("playwright");
|
|
25
|
+
for (const t of tries) {
|
|
26
|
+
try {
|
|
27
|
+
const mod = await import(__rewriteRelativeImportExtension(t.startsWith("/") ? pathToFileURL(t).href : t));
|
|
28
|
+
const chromium = mod.chromium ?? mod.default?.chromium;
|
|
29
|
+
if (chromium)
|
|
30
|
+
return chromium;
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`${what} needs Playwright: npm i -D playwright && npx playwright install chromium (or set PASSENGER_MODE_PLAYWRIGHT to its path).`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type MountOptions } from "./runtime/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* <PassengerMode publicKey="pk_..." /> in your root layout. Renders nothing itself: the switch
|
|
4
|
+
* lives in its own overlay. Client-only (add "use client" above the import in Next.js).
|
|
5
|
+
*/
|
|
6
|
+
export declare function PassengerMode(props: Omit<MountOptions, "window" | "key"> & {
|
|
7
|
+
publicKey?: string;
|
|
8
|
+
}): null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { mount } from "./runtime/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* <PassengerMode publicKey="pk_..." /> in your root layout. Renders nothing itself: the switch
|
|
5
|
+
* lives in its own overlay. Client-only (add "use client" above the import in Next.js).
|
|
6
|
+
*/
|
|
7
|
+
export function PassengerMode(props) {
|
|
8
|
+
const { publicKey, ...rest } = props;
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let handle = null;
|
|
11
|
+
let gone = false;
|
|
12
|
+
void mount({ ...rest, key: publicKey }).then((h) => {
|
|
13
|
+
if (gone)
|
|
14
|
+
h?.destroy();
|
|
15
|
+
else
|
|
16
|
+
handle = h;
|
|
17
|
+
});
|
|
18
|
+
return () => { gone = true; handle?.destroy(); };
|
|
19
|
+
// Mount once per key/endpoint; the manifest and planner are read at mount time.
|
|
20
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
21
|
+
}, [publicKey, rest.apiBase, rest.endpoint]);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Manifest } from "../types.ts";
|
|
2
|
+
import type { PageView, Planner, TripPlan } from "./planner.ts";
|
|
3
|
+
export type DriverState = "off" | "open" | "planning" | "ready" | "driving" | "paused" | "gate" | "handoff" | "arrived" | "stuck";
|
|
4
|
+
export interface TripStep {
|
|
5
|
+
actionId: string;
|
|
6
|
+
label: string;
|
|
7
|
+
route: string | null;
|
|
8
|
+
by: "driver" | "passenger";
|
|
9
|
+
at: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TripLog {
|
|
12
|
+
/** Random per trip, so a later rating updates the same record. */
|
|
13
|
+
id: string;
|
|
14
|
+
destination: string;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
endedAt?: number;
|
|
17
|
+
outcome?: "arrived" | "taken-over" | "ended" | "stuck";
|
|
18
|
+
reason?: string;
|
|
19
|
+
steps: TripStep[];
|
|
20
|
+
rating?: "up" | "down";
|
|
21
|
+
}
|
|
22
|
+
/** What the driver shows. The DOM overlay implements it; tests use a recorder. */
|
|
23
|
+
export interface DriverUI {
|
|
24
|
+
render(state: DriverState, info: {
|
|
25
|
+
plan?: TripPlan | null;
|
|
26
|
+
caption?: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
destination?: string;
|
|
29
|
+
step?: number;
|
|
30
|
+
}): void;
|
|
31
|
+
moveCursor(el: Element, caption: string): Promise<void>;
|
|
32
|
+
ring(el: Element | null, tone?: "drive" | "gate" | "handoff"): void;
|
|
33
|
+
/** True for nodes that belong to Passenger Mode's own UI (they never count as taking the wheel). */
|
|
34
|
+
owns(node: Node | null): boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface DriverOptions {
|
|
37
|
+
manifest: Manifest;
|
|
38
|
+
planner: Planner;
|
|
39
|
+
window: Window;
|
|
40
|
+
ui?: DriverUI;
|
|
41
|
+
/** Where the route lives: the pathname (default), the hash, or your router's own answer. */
|
|
42
|
+
routeFrom?: "path" | "hash" | (() => string);
|
|
43
|
+
/** Defaults to event.isTrusted. Only tests replace it. */
|
|
44
|
+
isTrusted?: (e: Event) => boolean;
|
|
45
|
+
maxSteps?: number;
|
|
46
|
+
timeLimitMs?: number;
|
|
47
|
+
settleMs?: number;
|
|
48
|
+
storage?: Storage | null;
|
|
49
|
+
onTrip?: (log: TripLog) => void;
|
|
50
|
+
}
|
|
51
|
+
export declare class Driver {
|
|
52
|
+
state: DriverState;
|
|
53
|
+
plan: TripPlan | null;
|
|
54
|
+
log: TripLog | null;
|
|
55
|
+
private o;
|
|
56
|
+
private ui;
|
|
57
|
+
private run;
|
|
58
|
+
private gateEl;
|
|
59
|
+
private gateAction;
|
|
60
|
+
private stepCount;
|
|
61
|
+
private detach;
|
|
62
|
+
constructor(opts: DriverOptions);
|
|
63
|
+
get doc(): Document;
|
|
64
|
+
private path;
|
|
65
|
+
private set;
|
|
66
|
+
/** Flip the switch on. A script cannot do this: it needs a trusted event. */
|
|
67
|
+
open(e: Event): boolean;
|
|
68
|
+
/** Switch off. Always allowed: stopping is the safe direction. */
|
|
69
|
+
close(): void;
|
|
70
|
+
planTrip(destination: string, e: Event): Promise<TripPlan | null>;
|
|
71
|
+
/** Nothing moves until the passenger presses Go. */
|
|
72
|
+
go(e: Event): boolean;
|
|
73
|
+
/** Back to choosing a destination. */
|
|
74
|
+
notThis(): void;
|
|
75
|
+
takeWheel(): void;
|
|
76
|
+
resume(e: Event): boolean;
|
|
77
|
+
end(): void;
|
|
78
|
+
rate(up: boolean): void;
|
|
79
|
+
/** What the planner may see: the destination and the manifest actions on this page. No page text. */
|
|
80
|
+
view(): PageView;
|
|
81
|
+
private find;
|
|
82
|
+
private drive;
|
|
83
|
+
private live;
|
|
84
|
+
private loop;
|
|
85
|
+
/** The driver stops at the control and waits for the passenger's own, trusted click on it. */
|
|
86
|
+
private pullOver;
|
|
87
|
+
private onGateClick;
|
|
88
|
+
private record;
|
|
89
|
+
private arrive;
|
|
90
|
+
private stuck;
|
|
91
|
+
private finish;
|
|
92
|
+
/** Waits for the page to react: a route change or DOM changes, then a short quiet period. */
|
|
93
|
+
private settle;
|
|
94
|
+
private linksHere;
|
|
95
|
+
/** Full page loads end the script; a trip in flight is saved so the next page can carry on. */
|
|
96
|
+
private markNav;
|
|
97
|
+
private clearNav;
|
|
98
|
+
private restore;
|
|
99
|
+
private listen;
|
|
100
|
+
destroy(): void;
|
|
101
|
+
}
|