autokap 1.9.0 → 1.9.2
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/dist/analytics-blocklist.d.ts +85 -0
- package/dist/analytics-blocklist.js +201 -0
- package/dist/browser-pool.d.ts +1 -0
- package/dist/browser-pool.js +10 -1
- package/dist/browser.js +25 -7
- package/dist/cli-runner.js +1 -0
- package/dist/execution-schema.d.ts +2 -0
- package/dist/execution-schema.js +6 -0
- package/dist/execution-types.d.ts +8 -0
- package/dist/mockup.d.ts +10 -1
- package/dist/mockup.js +16 -1
- package/dist/program-signing.d.ts +1 -0
- package/dist/safari-browser-bar.d.ts +13 -5
- package/dist/safari-browser-bar.js +134 -53
- package/dist/types.d.ts +7 -0
- package/dist/video-narration-schema.d.ts +1 -0
- package/dist/web-playwright-local.js +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { BrowserContext } from 'playwright';
|
|
2
|
+
/**
|
|
3
|
+
* Privacy-signal request headers attached to every capture context.
|
|
4
|
+
*
|
|
5
|
+
* These are standard, widely-sent browser headers (Firefox sends `DNT`, Brave
|
|
6
|
+
* sends `Sec-GPC`), so they're invisible to origin anti-bot defenses — they
|
|
7
|
+
* only ever observe a normal first-party page load. Privacy-first analytics
|
|
8
|
+
* (Plausible, Fathom, …) honor them and suppress the pageview, which
|
|
9
|
+
* complements the network-level blocking below for the providers that respect
|
|
10
|
+
* the signal. Free, zero-risk belt-and-suspenders.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PRIVACY_HEADERS: Record<string, string>;
|
|
13
|
+
/**
|
|
14
|
+
* Hostnames of DEDICATED web-analytics / product-telemetry / session-replay
|
|
15
|
+
* endpoints. These domains serve ONLY analytics, so aborting requests to them
|
|
16
|
+
* during a capture has zero functional impact on the page being screenshotted —
|
|
17
|
+
* it only prevents AutoKap's automated visit from registering as a phantom
|
|
18
|
+
* "visitor" in the site owner's analytics (AUT-234).
|
|
19
|
+
*
|
|
20
|
+
* Matched by exact host OR sub-domain suffix
|
|
21
|
+
* (`host === h || host.endsWith('.' + h)`), so regional shards
|
|
22
|
+
* (`eu.i.posthog.com`, `region1.google-analytics.com`, `*.matomo.cloud`, …) are
|
|
23
|
+
* covered without enumerating each one.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately ABSENT: `googletagmanager.com`. GTM can inject functional tags,
|
|
26
|
+
* and loading `gtm.js` does NOT itself record a visit — the GA *beacon* to
|
|
27
|
+
* `google-analytics.com` does, and that host IS blocked. So we neutralize the
|
|
28
|
+
* GA visit without risking a broken page.
|
|
29
|
+
*
|
|
30
|
+
* Self-hosted / first-party-proxied analytics (e.g. Plausible reverse-proxied
|
|
31
|
+
* on the site's own domain) is intentionally NOT covered: it reads as
|
|
32
|
+
* first-party (see the `isFirstPartyUrl` guard in {@link installAnalyticsBlock})
|
|
33
|
+
* and is impossible to detect universally. That edge case is out of scope by
|
|
34
|
+
* design — catching it would depend on per-site configuration.
|
|
35
|
+
*/
|
|
36
|
+
export declare const ANALYTICS_HOSTS: readonly string[];
|
|
37
|
+
/**
|
|
38
|
+
* True when `url` targets a dedicated web-analytics *ingestion* endpoint (see
|
|
39
|
+
* {@link ANALYTICS_HOSTS}). Host-suffix aware so regional/sub-domain shards
|
|
40
|
+
* match their parent. Fail-CLOSED on unparseable or non-http(s) URLs (returns
|
|
41
|
+
* `false`): we never abort a request we can't confidently classify.
|
|
42
|
+
*
|
|
43
|
+
* For hosts that co-serve functional config on the same domain as their
|
|
44
|
+
* analytics (PostHog), only the event-capture paths count — feature-flag /
|
|
45
|
+
* config / library paths are preserved so the captured UI never changes
|
|
46
|
+
* (see {@link POSTHOG_FUNCTIONAL_PATH_RE}).
|
|
47
|
+
*/
|
|
48
|
+
export declare function isAnalyticsRequest(url: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The per-request block decision, factored out of {@link installAnalyticsBlock}
|
|
51
|
+
* so the guard composition is unit-testable without a real browser context.
|
|
52
|
+
*
|
|
53
|
+
* Blocks ONLY a third-party analytics request. A first-party one — analytics
|
|
54
|
+
* self-hosted or reverse-proxied on the captured site's OWN domain — is
|
|
55
|
+
* preserved so we can never break the site's own functionality.
|
|
56
|
+
*
|
|
57
|
+
* `pageUrl` is the request's frame URL. When it's empty/unknown (a request in
|
|
58
|
+
* flight before the first navigation commits, a detached/teardown frame, some
|
|
59
|
+
* worker-originated requests) `isFirstPartyUrl` fail-OPENS to first-party, so
|
|
60
|
+
* the beacon is NOT aborted. That's the deliberate safe direction — never break
|
|
61
|
+
* a page — at the cost of a rare phantom-visit leak in that narrow window; real
|
|
62
|
+
* analytics beacons fire after navigation commits, so the frame URL is present.
|
|
63
|
+
*/
|
|
64
|
+
export declare function shouldBlockAnalyticsRequest(pageUrl: string, requestUrl: string): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Install a context-level route that aborts outgoing requests to dedicated
|
|
67
|
+
* third-party analytics endpoints, so capturing a site never registers a
|
|
68
|
+
* phantom "visit" in its analytics (AUT-234).
|
|
69
|
+
*
|
|
70
|
+
* Only THIRD-party analytics is blocked (the `!isFirstPartyUrl` guard): a
|
|
71
|
+
* first-party request is never aborted, so we can never break the captured
|
|
72
|
+
* site's own functionality. Aborting a third-party beacon is invisible to the
|
|
73
|
+
* origin's anti-bot (it only ever sees a normal first-party page load) — exactly
|
|
74
|
+
* what an ad-blocker does — so this carries no risk of tripping bot defenses.
|
|
75
|
+
*
|
|
76
|
+
* Registered at the CONTEXT level so it (a) covers every page/frame in the
|
|
77
|
+
* context, (b) survives `page.unrouteAll()` from `clearRouteInterception()`
|
|
78
|
+
* (which only clears page-level routes), and (c) composes with the page-level
|
|
79
|
+
* mock routes from `setupRouteInterception()` — page routes run first; this
|
|
80
|
+
* catch-all `fallback()`s every non-analytics request back to the network (or
|
|
81
|
+
* the next handler). Aborting also lowers in-flight count, which only helps
|
|
82
|
+
* `networkidle` settle. The adaptive-wait progress signal already ignores
|
|
83
|
+
* third-party traffic (AUT-240), so there's no interaction there.
|
|
84
|
+
*/
|
|
85
|
+
export declare function installAnalyticsBlock(context: BrowserContext): Promise<void>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { isFirstPartyUrl } from './security.js';
|
|
2
|
+
/**
|
|
3
|
+
* Privacy-signal request headers attached to every capture context.
|
|
4
|
+
*
|
|
5
|
+
* These are standard, widely-sent browser headers (Firefox sends `DNT`, Brave
|
|
6
|
+
* sends `Sec-GPC`), so they're invisible to origin anti-bot defenses — they
|
|
7
|
+
* only ever observe a normal first-party page load. Privacy-first analytics
|
|
8
|
+
* (Plausible, Fathom, …) honor them and suppress the pageview, which
|
|
9
|
+
* complements the network-level blocking below for the providers that respect
|
|
10
|
+
* the signal. Free, zero-risk belt-and-suspenders.
|
|
11
|
+
*/
|
|
12
|
+
export const PRIVACY_HEADERS = {
|
|
13
|
+
DNT: '1',
|
|
14
|
+
'Sec-GPC': '1',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Hostnames of DEDICATED web-analytics / product-telemetry / session-replay
|
|
18
|
+
* endpoints. These domains serve ONLY analytics, so aborting requests to them
|
|
19
|
+
* during a capture has zero functional impact on the page being screenshotted —
|
|
20
|
+
* it only prevents AutoKap's automated visit from registering as a phantom
|
|
21
|
+
* "visitor" in the site owner's analytics (AUT-234).
|
|
22
|
+
*
|
|
23
|
+
* Matched by exact host OR sub-domain suffix
|
|
24
|
+
* (`host === h || host.endsWith('.' + h)`), so regional shards
|
|
25
|
+
* (`eu.i.posthog.com`, `region1.google-analytics.com`, `*.matomo.cloud`, …) are
|
|
26
|
+
* covered without enumerating each one.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately ABSENT: `googletagmanager.com`. GTM can inject functional tags,
|
|
29
|
+
* and loading `gtm.js` does NOT itself record a visit — the GA *beacon* to
|
|
30
|
+
* `google-analytics.com` does, and that host IS blocked. So we neutralize the
|
|
31
|
+
* GA visit without risking a broken page.
|
|
32
|
+
*
|
|
33
|
+
* Self-hosted / first-party-proxied analytics (e.g. Plausible reverse-proxied
|
|
34
|
+
* on the site's own domain) is intentionally NOT covered: it reads as
|
|
35
|
+
* first-party (see the `isFirstPartyUrl` guard in {@link installAnalyticsBlock})
|
|
36
|
+
* and is impossible to detect universally. That edge case is out of scope by
|
|
37
|
+
* design — catching it would depend on per-site configuration.
|
|
38
|
+
*/
|
|
39
|
+
export const ANALYTICS_HOSTS = [
|
|
40
|
+
// Google Analytics / GA4 / Universal Analytics collection endpoints
|
|
41
|
+
'google-analytics.com',
|
|
42
|
+
'analytics.google.com',
|
|
43
|
+
'ssl.google-analytics.com',
|
|
44
|
+
'region1.google-analytics.com',
|
|
45
|
+
'stats.g.doubleclick.net',
|
|
46
|
+
// Plausible
|
|
47
|
+
'plausible.io',
|
|
48
|
+
// PostHog — only its event-ingestion paths are blocked; feature-flag / config
|
|
49
|
+
// / library paths (/decide, /flags, /static, /array) are preserved so a
|
|
50
|
+
// flag-gated app never renders with default flags (see isAnalyticsRequest).
|
|
51
|
+
'posthog.com',
|
|
52
|
+
'i.posthog.com',
|
|
53
|
+
// Matomo / Piwik (cloud)
|
|
54
|
+
'matomo.cloud',
|
|
55
|
+
'matomo.org',
|
|
56
|
+
// Segment
|
|
57
|
+
'segment.io',
|
|
58
|
+
'segment.com',
|
|
59
|
+
// Mixpanel
|
|
60
|
+
'mixpanel.com',
|
|
61
|
+
'mxpnl.com',
|
|
62
|
+
// Amplitude
|
|
63
|
+
'amplitude.com',
|
|
64
|
+
// Heap
|
|
65
|
+
'heapanalytics.com',
|
|
66
|
+
'heap.io',
|
|
67
|
+
// Hotjar (heatmaps / session replay)
|
|
68
|
+
'hotjar.com',
|
|
69
|
+
'hotjar.io',
|
|
70
|
+
// Microsoft Clarity (session replay)
|
|
71
|
+
'clarity.ms',
|
|
72
|
+
// Cloudflare Web Analytics
|
|
73
|
+
'cloudflareinsights.com',
|
|
74
|
+
// Vercel Analytics / Speed Insights
|
|
75
|
+
'vercel-insights.com',
|
|
76
|
+
'va.vercel-scripts.com',
|
|
77
|
+
// Fathom
|
|
78
|
+
'usefathom.com',
|
|
79
|
+
// Session replay / heatmaps
|
|
80
|
+
'fullstory.com',
|
|
81
|
+
'mouseflow.com',
|
|
82
|
+
'crazyegg.com',
|
|
83
|
+
// Yandex Metrica
|
|
84
|
+
'mc.yandex.ru',
|
|
85
|
+
// Quantcast
|
|
86
|
+
'quantserve.com',
|
|
87
|
+
'quantcount.com',
|
|
88
|
+
// Adobe Analytics (Omniture)
|
|
89
|
+
'omtrdc.net',
|
|
90
|
+
'2o7.net',
|
|
91
|
+
// Misc product analytics
|
|
92
|
+
'pendo.io',
|
|
93
|
+
'woopra.com',
|
|
94
|
+
'kissmetrics.io',
|
|
95
|
+
];
|
|
96
|
+
const ANALYTICS_HOST_SET = new Set(ANALYTICS_HOSTS.map((h) => h.toLowerCase()));
|
|
97
|
+
/**
|
|
98
|
+
* Path prefixes on PostHog hosts that are NOT analytics ingestion: feature
|
|
99
|
+
* flags (`/decide`, `/flags`), the JS library and its remote config
|
|
100
|
+
* (`/static`, `/array`). PostHog co-serves these from the SAME host as its
|
|
101
|
+
* event capture, so blocking them would make a flag-gated app render with
|
|
102
|
+
* default flags during capture — a visible change to the captured UI. We only
|
|
103
|
+
* block the ingestion paths (`/e/`, `/i/v0/e/`, `/batch/`, `/capture/`, `/s/`),
|
|
104
|
+
* which is what records the phantom visit.
|
|
105
|
+
*/
|
|
106
|
+
const POSTHOG_FUNCTIONAL_PATH_RE = /^\/(decide|flags|static|array)\b/i;
|
|
107
|
+
function matchesAnalyticsHost(host) {
|
|
108
|
+
if (ANALYTICS_HOST_SET.has(host))
|
|
109
|
+
return true;
|
|
110
|
+
for (const h of ANALYTICS_HOST_SET) {
|
|
111
|
+
if (host.endsWith(`.${h}`))
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
function isPosthogHost(host) {
|
|
117
|
+
return host === 'posthog.com' || host.endsWith('.posthog.com');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* True when `url` targets a dedicated web-analytics *ingestion* endpoint (see
|
|
121
|
+
* {@link ANALYTICS_HOSTS}). Host-suffix aware so regional/sub-domain shards
|
|
122
|
+
* match their parent. Fail-CLOSED on unparseable or non-http(s) URLs (returns
|
|
123
|
+
* `false`): we never abort a request we can't confidently classify.
|
|
124
|
+
*
|
|
125
|
+
* For hosts that co-serve functional config on the same domain as their
|
|
126
|
+
* analytics (PostHog), only the event-capture paths count — feature-flag /
|
|
127
|
+
* config / library paths are preserved so the captured UI never changes
|
|
128
|
+
* (see {@link POSTHOG_FUNCTIONAL_PATH_RE}).
|
|
129
|
+
*/
|
|
130
|
+
export function isAnalyticsRequest(url) {
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = new URL(url);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
139
|
+
return false;
|
|
140
|
+
const host = parsed.hostname.toLowerCase();
|
|
141
|
+
if (!matchesAnalyticsHost(host))
|
|
142
|
+
return false;
|
|
143
|
+
if (isPosthogHost(host) && POSTHOG_FUNCTIONAL_PATH_RE.test(parsed.pathname)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The per-request block decision, factored out of {@link installAnalyticsBlock}
|
|
150
|
+
* so the guard composition is unit-testable without a real browser context.
|
|
151
|
+
*
|
|
152
|
+
* Blocks ONLY a third-party analytics request. A first-party one — analytics
|
|
153
|
+
* self-hosted or reverse-proxied on the captured site's OWN domain — is
|
|
154
|
+
* preserved so we can never break the site's own functionality.
|
|
155
|
+
*
|
|
156
|
+
* `pageUrl` is the request's frame URL. When it's empty/unknown (a request in
|
|
157
|
+
* flight before the first navigation commits, a detached/teardown frame, some
|
|
158
|
+
* worker-originated requests) `isFirstPartyUrl` fail-OPENS to first-party, so
|
|
159
|
+
* the beacon is NOT aborted. That's the deliberate safe direction — never break
|
|
160
|
+
* a page — at the cost of a rare phantom-visit leak in that narrow window; real
|
|
161
|
+
* analytics beacons fire after navigation commits, so the frame URL is present.
|
|
162
|
+
*/
|
|
163
|
+
export function shouldBlockAnalyticsRequest(pageUrl, requestUrl) {
|
|
164
|
+
return isAnalyticsRequest(requestUrl) && !isFirstPartyUrl(pageUrl, requestUrl);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Install a context-level route that aborts outgoing requests to dedicated
|
|
168
|
+
* third-party analytics endpoints, so capturing a site never registers a
|
|
169
|
+
* phantom "visit" in its analytics (AUT-234).
|
|
170
|
+
*
|
|
171
|
+
* Only THIRD-party analytics is blocked (the `!isFirstPartyUrl` guard): a
|
|
172
|
+
* first-party request is never aborted, so we can never break the captured
|
|
173
|
+
* site's own functionality. Aborting a third-party beacon is invisible to the
|
|
174
|
+
* origin's anti-bot (it only ever sees a normal first-party page load) — exactly
|
|
175
|
+
* what an ad-blocker does — so this carries no risk of tripping bot defenses.
|
|
176
|
+
*
|
|
177
|
+
* Registered at the CONTEXT level so it (a) covers every page/frame in the
|
|
178
|
+
* context, (b) survives `page.unrouteAll()` from `clearRouteInterception()`
|
|
179
|
+
* (which only clears page-level routes), and (c) composes with the page-level
|
|
180
|
+
* mock routes from `setupRouteInterception()` — page routes run first; this
|
|
181
|
+
* catch-all `fallback()`s every non-analytics request back to the network (or
|
|
182
|
+
* the next handler). Aborting also lowers in-flight count, which only helps
|
|
183
|
+
* `networkidle` settle. The adaptive-wait progress signal already ignores
|
|
184
|
+
* third-party traffic (AUT-240), so there's no interaction there.
|
|
185
|
+
*/
|
|
186
|
+
export async function installAnalyticsBlock(context) {
|
|
187
|
+
await context.route('**/*', (route) => {
|
|
188
|
+
const request = route.request();
|
|
189
|
+
const url = request.url();
|
|
190
|
+
// Derive the page origin from the request's own frame so analytics
|
|
191
|
+
// self-hosted on the captured site's domain reads as first-party and is
|
|
192
|
+
// left untouched. `'blockedbyclient'` mimics an ad-blocker (the page just
|
|
193
|
+
// sees a failed beacon, like every uBlock user).
|
|
194
|
+
const pageUrl = request.frame()?.url() || '';
|
|
195
|
+
if (shouldBlockAnalyticsRequest(pageUrl, url)) {
|
|
196
|
+
return route.abort('blockedbyclient').catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
return route.fallback();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=analytics-blocklist.js.map
|
package/dist/browser-pool.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ declare class BrowserPool {
|
|
|
20
20
|
colorScheme?: 'light' | 'dark';
|
|
21
21
|
storageState?: BrowserStorageState;
|
|
22
22
|
extraHttpHeaders?: Record<string, string>;
|
|
23
|
+
blockAnalytics?: boolean;
|
|
23
24
|
}): Promise<BrowserContext>;
|
|
24
25
|
/**
|
|
25
26
|
* Release a context back to the pool. Closes the context and unblocks
|
package/dist/browser-pool.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
+
import { installAnalyticsBlock, PRIVACY_HEADERS } from './analytics-blocklist.js';
|
|
2
3
|
/** Chromium flags for server-side headless operation (used by pool and standalone launches). */
|
|
3
4
|
export const CHROMIUM_ARGS = [
|
|
4
5
|
// Linux/Docker-only: required when running Chromium as root or with limited /dev/shm
|
|
@@ -74,8 +75,16 @@ class BrowserPool {
|
|
|
74
75
|
locale: options?.lang ? options.lang : 'en-US',
|
|
75
76
|
colorScheme: options?.colorScheme ?? 'light',
|
|
76
77
|
storageState: options?.storageState,
|
|
77
|
-
|
|
78
|
+
// Privacy signals first, then merge user/env auth headers (which win on
|
|
79
|
+
// any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
|
|
80
|
+
extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
|
|
78
81
|
});
|
|
82
|
+
// Block third-party analytics so pooled (server-side) captures don't
|
|
83
|
+
// register a phantom visit in the captured site's analytics (AUT-234).
|
|
84
|
+
// Skippable per-project via blockAnalytics === false.
|
|
85
|
+
if (options?.blockAnalytics !== false) {
|
|
86
|
+
await installAnalyticsBlock(context);
|
|
87
|
+
}
|
|
79
88
|
this.activeContexts++;
|
|
80
89
|
this.captureCount++;
|
|
81
90
|
return context;
|
package/dist/browser.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from 'path';
|
|
|
6
6
|
import { DOM_QUIET_WINDOW_MS, GLOBAL_WAIT_CAP_MS, PIXEL_FALLBACK_DIFF_THRESHOLD, PIXEL_FALLBACK_MAX_PASSES, } from './wait-contract.js';
|
|
7
7
|
import { buildAKNodeRuntimeIndex, deriveInteractiveElementsFromAKTree, disambiguateFingerprint, focusAKTree, fingerprintAKNode, serializeAKTree, } from './ak-tree.js';
|
|
8
8
|
import { isFirstPartyUrl } from './security.js';
|
|
9
|
+
import { installAnalyticsBlock, PRIVACY_HEADERS } from './analytics-blocklist.js';
|
|
9
10
|
/**
|
|
10
11
|
* Set-of-Marks (SoM) annotation: overlays colored [N] badges on each visible
|
|
11
12
|
* interactive element so the vision model can reference elements by their badge index.
|
|
@@ -949,6 +950,7 @@ export class Browser {
|
|
|
949
950
|
colorScheme: options.colorScheme ?? 'light',
|
|
950
951
|
storageState: options.storageState,
|
|
951
952
|
extraHttpHeaders: options.extraHttpHeaders,
|
|
953
|
+
blockAnalytics: options.blockAnalytics,
|
|
952
954
|
});
|
|
953
955
|
instance.page = await instance.context.newPage();
|
|
954
956
|
instance.poolContext = true;
|
|
@@ -1068,9 +1070,9 @@ export class Browser {
|
|
|
1068
1070
|
locale: langToLocale(options.lang ?? 'en'),
|
|
1069
1071
|
colorScheme: options.colorScheme ?? 'light',
|
|
1070
1072
|
storageState: options.storageState,
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1073
|
+
// Privacy signals first, then merge user/env auth headers (which win on
|
|
1074
|
+
// any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
|
|
1075
|
+
extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(options.extraHttpHeaders ?? {}) },
|
|
1074
1076
|
};
|
|
1075
1077
|
// Dedicated browser process for clip capture. Not pooled because clip
|
|
1076
1078
|
// capture installs context-level init scripts (cursor overlay). Cloud Run
|
|
@@ -1109,6 +1111,13 @@ export class Browser {
|
|
|
1109
1111
|
});
|
|
1110
1112
|
instance.context = await instance.browser.newContext(contextOptions);
|
|
1111
1113
|
}
|
|
1114
|
+
// Block third-party analytics beacons so clip/video capture doesn't
|
|
1115
|
+
// register a phantom visit either (AUT-234). Context-level so it covers
|
|
1116
|
+
// every page in both the persistent (cloud) and incognito (local) paths.
|
|
1117
|
+
// Skippable per-project via blockAnalytics === false.
|
|
1118
|
+
if (options.blockAnalytics !== false) {
|
|
1119
|
+
await installAnalyticsBlock(instance.context);
|
|
1120
|
+
}
|
|
1112
1121
|
// Cloud Run only: inject the notranslate meta on every navigation so
|
|
1113
1122
|
// Chromium's translate UI never prompts. The --disable-features=Translate*
|
|
1114
1123
|
// launch flags are unreliable across Chromium versions (some translate
|
|
@@ -1244,6 +1253,9 @@ export class Browser {
|
|
|
1244
1253
|
args: CHROMIUM_ARGS,
|
|
1245
1254
|
});
|
|
1246
1255
|
this.context = await this.browser.newContext(this.buildContextOptions());
|
|
1256
|
+
if (this.options.blockAnalytics !== false) {
|
|
1257
|
+
await installAnalyticsBlock(this.context);
|
|
1258
|
+
}
|
|
1247
1259
|
this.page = await this.context.newPage();
|
|
1248
1260
|
this.attachDebugLifecycleListeners();
|
|
1249
1261
|
}
|
|
@@ -1333,6 +1345,9 @@ export class Browser {
|
|
|
1333
1345
|
this.context = null;
|
|
1334
1346
|
}
|
|
1335
1347
|
this.context = await this.browser.newContext(this.buildContextOptions());
|
|
1348
|
+
if (this.options.blockAnalytics !== false) {
|
|
1349
|
+
await installAnalyticsBlock(this.context);
|
|
1350
|
+
}
|
|
1336
1351
|
this.page = await this.context.newPage();
|
|
1337
1352
|
this.elementMap.clear();
|
|
1338
1353
|
this.attachDebugLifecycleListeners();
|
|
@@ -5631,10 +5646,11 @@ export class Browser {
|
|
|
5631
5646
|
async setLanguage(lang) {
|
|
5632
5647
|
const context = this.ensureContext();
|
|
5633
5648
|
const page = this.ensurePage();
|
|
5634
|
-
// `setExtraHTTPHeaders` REPLACES the header map — merge with the
|
|
5635
|
-
// environment-level auth headers so a SET_LOCALE opcode
|
|
5636
|
-
// them mid-run.
|
|
5649
|
+
// `setExtraHTTPHeaders` REPLACES the header map — merge with the privacy
|
|
5650
|
+
// signals and the environment-level auth headers so a SET_LOCALE opcode
|
|
5651
|
+
// doesn't strip them mid-run.
|
|
5637
5652
|
await context.setExtraHTTPHeaders({
|
|
5653
|
+
...PRIVACY_HEADERS,
|
|
5638
5654
|
...(this.options.extraHttpHeaders ?? {}),
|
|
5639
5655
|
'Accept-Language': lang,
|
|
5640
5656
|
});
|
|
@@ -5761,7 +5777,9 @@ export class Browser {
|
|
|
5761
5777
|
locale: langToLocale(this.options.lang ?? 'en'),
|
|
5762
5778
|
colorScheme: this.options.colorScheme ?? 'light',
|
|
5763
5779
|
storageState: this.options.storageState,
|
|
5764
|
-
|
|
5780
|
+
// Privacy signals first, then merge user/env auth headers (which win on
|
|
5781
|
+
// any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
|
|
5782
|
+
extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
|
|
5765
5783
|
};
|
|
5766
5784
|
}
|
|
5767
5785
|
}
|
package/dist/cli-runner.js
CHANGED
|
@@ -258,6 +258,7 @@ export async function runCapture(options) {
|
|
|
258
258
|
colorScheme: variant.theme,
|
|
259
259
|
storageState: program.preconditions.storageState,
|
|
260
260
|
extraHttpHeaders: program.environmentHttpHeaders,
|
|
261
|
+
blockAnalytics: program.blockAnalytics,
|
|
261
262
|
};
|
|
262
263
|
let recordingDir;
|
|
263
264
|
let browser;
|
|
@@ -2233,6 +2233,7 @@ export declare const ExecutionProgramSchema: z.ZodObject<{
|
|
|
2233
2233
|
deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
2234
2234
|
publicUrl: z.ZodOptional<z.ZodString>;
|
|
2235
2235
|
environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
2236
|
+
blockAnalytics: z.ZodOptional<z.ZodBoolean>;
|
|
2236
2237
|
}, z.core.$strict>;
|
|
2237
2238
|
export declare const HealerPatchSchema: z.ZodObject<{
|
|
2238
2239
|
opcodeIndex: z.ZodNumber;
|
|
@@ -4936,6 +4937,7 @@ export declare function safeParseProgramResult(data: unknown): z.ZodSafeParseRes
|
|
|
4936
4937
|
deviceConfigs?: Record<string, Record<string, unknown>> | undefined;
|
|
4937
4938
|
publicUrl?: string | undefined;
|
|
4938
4939
|
environmentHttpHeaders?: Record<string, string> | undefined;
|
|
4940
|
+
blockAnalytics?: boolean | undefined;
|
|
4939
4941
|
}>;
|
|
4940
4942
|
export interface ClipNavigationViolation {
|
|
4941
4943
|
/** Index of the offending NAVIGATE opcode in `program.steps`. */
|
package/dist/execution-schema.js
CHANGED
|
@@ -682,6 +682,12 @@ export const ExecutionProgramSchema = z.object({
|
|
|
682
682
|
// pairs that Playwright will inject as `extraHTTPHeaders` on the
|
|
683
683
|
// BrowserContext so protected staging/preview URLs load successfully.
|
|
684
684
|
environmentHttpHeaders: z.record(z.string().min(1), z.string().min(1)).optional(),
|
|
685
|
+
// Per-project opt-out for analytics blocking (AUT-234). Optional and WITHOUT a
|
|
686
|
+
// Zod default for the same signing reason as `programSchemaVersion` above:
|
|
687
|
+
// this schema is reused in signature verification, so a default would mutate
|
|
688
|
+
// the signed payload and break symmetry for programs signed without the field.
|
|
689
|
+
// Absent / true ⇒ block (engine default); only an explicit false disables.
|
|
690
|
+
blockAnalytics: z.boolean().optional(),
|
|
685
691
|
}).strict().superRefine((value, ctx) => {
|
|
686
692
|
if (value.mediaMode !== value.artifactPlan.mediaMode) {
|
|
687
693
|
ctx.addIssue({
|
|
@@ -614,6 +614,14 @@ export interface ExecutionProgram {
|
|
|
614
614
|
* and embedded in the signed program envelope.
|
|
615
615
|
*/
|
|
616
616
|
environmentHttpHeaders?: Record<string, string>;
|
|
617
|
+
/**
|
|
618
|
+
* Per-project opt-out for third-party analytics blocking (AUT-234). Default
|
|
619
|
+
* behavior (field absent / `true`) blocks analytics beacons during capture so
|
|
620
|
+
* a run never registers a phantom "visit". Set to `false` only when the
|
|
621
|
+
* project disabled it (`projects.block_analytics_enabled = false`). Server-set
|
|
622
|
+
* BEFORE signing, so it lives inside the signed envelope.
|
|
623
|
+
*/
|
|
624
|
+
blockAnalytics?: boolean;
|
|
617
625
|
}
|
|
618
626
|
export interface CircuitBreakerConfig {
|
|
619
627
|
/** Max recovery attempts per opcode. Default: 3 */
|
package/dist/mockup.d.ts
CHANGED
|
@@ -204,18 +204,27 @@ export interface MockupOptions {
|
|
|
204
204
|
height: number;
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Status bar default: shown only on phone mockups. Tablet, laptop, and browser
|
|
209
|
+
* categories default to off (browser is additionally excluded at render time).
|
|
210
|
+
* An explicit user choice (`mockupOptions.showStatusBar`) always overrides this.
|
|
211
|
+
*/
|
|
212
|
+
export declare function defaultShowStatusBar(category: DeviceCategory): boolean;
|
|
207
213
|
/**
|
|
208
214
|
* Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
|
|
209
215
|
* framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
|
|
210
216
|
* back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
|
|
211
217
|
* Pure + exported so the precedence is tested once instead of in two duplicated call sites.
|
|
218
|
+
*
|
|
219
|
+
* `showStatusBar` is left `undefined` when neither the variant nor the legacy fallback set it,
|
|
220
|
+
* so `applyDeviceFrame` can apply the category-aware default (phones on, everything else off).
|
|
212
221
|
*/
|
|
213
222
|
export declare function resolveVariantFrameOptions(mockupOptions: MockupOptions | undefined, fallback: {
|
|
214
223
|
orientation?: MockupOrientation;
|
|
215
224
|
showStatusBar?: boolean;
|
|
216
225
|
}): {
|
|
217
226
|
orientation?: MockupOrientation;
|
|
218
|
-
showStatusBar
|
|
227
|
+
showStatusBar?: boolean;
|
|
219
228
|
};
|
|
220
229
|
export interface ResolvedDeviceFrameDescriptor {
|
|
221
230
|
id: string;
|
package/dist/mockup.js
CHANGED
|
@@ -17,21 +17,33 @@ function getSupabaseMockupConfig() {
|
|
|
17
17
|
serviceKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Status bar default: shown only on phone mockups. Tablet, laptop, and browser
|
|
22
|
+
* categories default to off (browser is additionally excluded at render time).
|
|
23
|
+
* An explicit user choice (`mockupOptions.showStatusBar`) always overrides this.
|
|
24
|
+
*/
|
|
25
|
+
export function defaultShowStatusBar(category) {
|
|
26
|
+
return category === 'phone';
|
|
27
|
+
}
|
|
20
28
|
/**
|
|
21
29
|
* Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
|
|
22
30
|
* framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
|
|
23
31
|
* back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
|
|
24
32
|
* Pure + exported so the precedence is tested once instead of in two duplicated call sites.
|
|
33
|
+
*
|
|
34
|
+
* `showStatusBar` is left `undefined` when neither the variant nor the legacy fallback set it,
|
|
35
|
+
* so `applyDeviceFrame` can apply the category-aware default (phones on, everything else off).
|
|
25
36
|
*/
|
|
26
37
|
export function resolveVariantFrameOptions(mockupOptions, fallback) {
|
|
27
38
|
return {
|
|
28
39
|
orientation: mockupOptions?.orientation ?? fallback.orientation,
|
|
29
|
-
showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar
|
|
40
|
+
showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar,
|
|
30
41
|
};
|
|
31
42
|
}
|
|
32
43
|
const DEFAULT_MOCKUP_OPTIONS = {
|
|
33
44
|
orientation: 'portrait',
|
|
34
45
|
outputScale: 2,
|
|
46
|
+
// Placeholder only — applyDeviceFrame re-resolves this per device category (see defaultShowStatusBar).
|
|
35
47
|
showStatusBar: true,
|
|
36
48
|
showSafeAreaTop: true,
|
|
37
49
|
showSafeAreaBottom: true,
|
|
@@ -523,6 +535,9 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
|
|
|
523
535
|
if (!config)
|
|
524
536
|
throw new Error(`Unknown device frame: ${deviceId}`);
|
|
525
537
|
const opts = { ...DEFAULT_MOCKUP_OPTIONS, ...options };
|
|
538
|
+
// Status bar defaults to on for phones only; tablet/laptop/browser default to off.
|
|
539
|
+
// An explicit user choice wins; browser is additionally excluded at render time below.
|
|
540
|
+
opts.showStatusBar = options?.showStatusBar ?? defaultShowStatusBar(config.category);
|
|
526
541
|
const requestedOrientation = opts.orientation ?? 'portrait';
|
|
527
542
|
// Normalize against supported orientations so stale extra configs in Supabase
|
|
528
543
|
// do not override landscape-only desktop/tablet/browser frames.
|
|
@@ -1139,6 +1139,7 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
|
|
|
1139
1139
|
deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
1140
1140
|
publicUrl: z.ZodOptional<z.ZodString>;
|
|
1141
1141
|
environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
1142
|
+
blockAnalytics: z.ZodOptional<z.ZodBoolean>;
|
|
1142
1143
|
}, z.core.$strict>;
|
|
1143
1144
|
signature: z.ZodString;
|
|
1144
1145
|
meta: z.ZodOptional<z.ZodObject<{
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Safari macOS browser bar —
|
|
2
|
+
* Safari macOS browser bar — RECONSTRUCTED layout (not a stretched asset).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* The Figma export (1280×52 visible toolbar) used to be drawn with
|
|
5
|
+
* `preserveAspectRatio="none"` on a FIXED viewBox, so any target width whose
|
|
6
|
+
* aspect ratio differed from 1280:52 horizontally squished every icon — worse
|
|
7
|
+
* the wider the mockup got. This now mirrors the Chrome generator
|
|
8
|
+
* (`browser-bar.ts`): a height-driven UNIFORM scale + a DYNAMIC-width viewBox,
|
|
9
|
+
* with the toolbar's icon GLYPHS reused verbatim from the asset but re-anchored
|
|
10
|
+
* - left group (traffic lights, sidebar, back/forward) pinned left,
|
|
11
|
+
* - right group (share, open, new tab, tab overview) pinned right via `dx`,
|
|
12
|
+
* - center address pill: dynamic width, centered, inner icons follow its edges.
|
|
13
|
+
* Only the container and pill backgrounds are redrawn; the Figma blur/blend
|
|
14
|
+
* layers are dropped (resvg ignores them anyway and they are invisible on the
|
|
15
|
+
* white/dark toolbar). Colors and glyph shapes are preserved from the asset.
|
|
8
16
|
*
|
|
9
17
|
* Two outputs:
|
|
10
18
|
* - HTML (Playwright preview / React) — inline SVG inside a scaled wrapper
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Safari macOS browser bar —
|
|
2
|
+
* Safari macOS browser bar — RECONSTRUCTED layout (not a stretched asset).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* The Figma export (1280×52 visible toolbar) used to be drawn with
|
|
5
|
+
* `preserveAspectRatio="none"` on a FIXED viewBox, so any target width whose
|
|
6
|
+
* aspect ratio differed from 1280:52 horizontally squished every icon — worse
|
|
7
|
+
* the wider the mockup got. This now mirrors the Chrome generator
|
|
8
|
+
* (`browser-bar.ts`): a height-driven UNIFORM scale + a DYNAMIC-width viewBox,
|
|
9
|
+
* with the toolbar's icon GLYPHS reused verbatim from the asset but re-anchored
|
|
10
|
+
* - left group (traffic lights, sidebar, back/forward) pinned left,
|
|
11
|
+
* - right group (share, open, new tab, tab overview) pinned right via `dx`,
|
|
12
|
+
* - center address pill: dynamic width, centered, inner icons follow its edges.
|
|
13
|
+
* Only the container and pill backgrounds are redrawn; the Figma blur/blend
|
|
14
|
+
* layers are dropped (resvg ignores them anyway and they are invisible on the
|
|
15
|
+
* white/dark toolbar). Colors and glyph shapes are preserved from the asset.
|
|
8
16
|
*
|
|
9
17
|
* Two outputs:
|
|
10
18
|
* - HTML (Playwright preview / React) — inline SVG inside a scaled wrapper
|
|
11
19
|
* - SVG (sharp / resvg server compositing)
|
|
12
20
|
*/
|
|
13
21
|
import { SF_PRO_TEXT_REGULAR, SF_PRO_TEXT_SEMIBOLD, } from './sf-pro-fonts.js';
|
|
14
|
-
import { SAFARI_TOOLBAR_SVG, SAFARI_TOOLBAR_VIEWBOX, SAFARI_URL_PILL, } from './safari-toolbar-asset.js';
|
|
22
|
+
import { SAFARI_TOOLBAR_SVG, SAFARI_TOOLBAR_VIEWBOX, SAFARI_TOOLBAR_REF_W, SAFARI_TOOLBAR_REF_H, SAFARI_URL_PILL, } from './safari-toolbar-asset.js';
|
|
15
23
|
// ── Fonts ────────────────────────────────────────────────────────────────
|
|
16
24
|
const FONT_CSS_HTML = `<style>
|
|
17
25
|
@font-face{font-family:'SF Pro Text';src:local('SF Pro Text'),local('.SFNSText'),url('${SF_PRO_TEXT_REGULAR}') format('woff2');font-weight:400;font-style:normal}
|
|
@@ -19,12 +27,71 @@ const FONT_CSS_HTML = `<style>
|
|
|
19
27
|
</style>`;
|
|
20
28
|
// SVG output: fonts are NOT embedded inline. Resvg-js 2.6.2 does not honor
|
|
21
29
|
// `@font-face url(data:font/woff2;base64,…)` declarations inside SVG style
|
|
22
|
-
// blocks; fonts
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// the URL <text> element below remains for any consumer that DOES honor
|
|
26
|
-
// CSS @font-face (a future Resvg release, a different SVG renderer, etc.).
|
|
30
|
+
// blocks; fonts are supplied via `font.fontFiles` to the Resvg constructor by
|
|
31
|
+
// `mockup.ts::rasterizeSvg` (see `sf-pro-resvg-fonts.ts`). The `font-family`
|
|
32
|
+
// hint on the URL <text> element remains for renderers that DO honor it.
|
|
27
33
|
const FF = "'SF Pro Text',-apple-system,BlinkMacSystemFont,system-ui,sans-serif";
|
|
34
|
+
// ── Reference geometry (asset coordinate space, original 88-tall viewBox) ──
|
|
35
|
+
// The cropped viewBox is `0 18 1280 52`; pills/glyphs keep their native y so
|
|
36
|
+
// the vertical layout is identical to the asset — only X is reflowed.
|
|
37
|
+
const REF_W = SAFARI_TOOLBAR_REF_W; // 1280
|
|
38
|
+
const REF_H = SAFARI_TOOLBAR_REF_H; // 52
|
|
39
|
+
const VB_Y = SAFARI_TOOLBAR_VIEWBOX.y; // 18
|
|
40
|
+
const PILL_Y = SAFARI_URL_PILL.y; // 26
|
|
41
|
+
const PILL_H = SAFARI_URL_PILL.height; // 36
|
|
42
|
+
const PILL_RX = 18;
|
|
43
|
+
const SIDEBAR_PILL = { x: 95, w: 54 };
|
|
44
|
+
const NAV_PILL = { x: 163, w: 67 };
|
|
45
|
+
const RIGHT_PILL = { x: 1134, w: 139 };
|
|
46
|
+
const REF_URL_PILL = { x: SAFARI_URL_PILL.x, w: SAFARI_URL_PILL.width }; // 447 / 385
|
|
47
|
+
// macOS traffic lights (cx derived from x + r), y=37 d=14 → cy=44 r=7.
|
|
48
|
+
const TRAFFIC = [
|
|
49
|
+
{ x: 17, color: '#FF736A' },
|
|
50
|
+
{ x: 40, color: '#FEBC2E' },
|
|
51
|
+
{ x: 63, color: '#19C332' },
|
|
52
|
+
];
|
|
53
|
+
// End of the fixed left cluster (back/forward pill right edge).
|
|
54
|
+
const LEFT_BOUND = NAV_PILL.x + NAV_PILL.w; // 230
|
|
55
|
+
// Horizontal breathing room between the address pill and the side clusters.
|
|
56
|
+
const PILL_GAP = 28;
|
|
57
|
+
// ── Icon glyph extraction (reuse the asset's vector paths verbatim) ────────
|
|
58
|
+
// We pull only the icon glyphs (specific fills) out of the baked asset and
|
|
59
|
+
// drop the pill-background / blur / blend layers, which we redraw ourselves.
|
|
60
|
+
const ICON_FILLS = new Set(['#4C4C4C', '#808080', '#C6C6C6', '#E6E6E6']);
|
|
61
|
+
// Light → dark recolor for the reused glyphs (mirrors the asset's dark intent).
|
|
62
|
+
const DARK_ICON = {
|
|
63
|
+
'#4C4C4C': '#E4E4E4',
|
|
64
|
+
'#808080': '#8E8E93',
|
|
65
|
+
'#C6C6C6': '#6E6E73',
|
|
66
|
+
'#E6E6E6': '#4A4A4C',
|
|
67
|
+
};
|
|
68
|
+
let cachedGlyphs = null;
|
|
69
|
+
function assetGlyphs() {
|
|
70
|
+
if (cachedGlyphs)
|
|
71
|
+
return cachedGlyphs;
|
|
72
|
+
const glyphs = [];
|
|
73
|
+
const tagRe = /<path\b[^>]*?\/>/g;
|
|
74
|
+
let m;
|
|
75
|
+
while ((m = tagRe.exec(SAFARI_TOOLBAR_SVG)) !== null) {
|
|
76
|
+
const tag = m[0];
|
|
77
|
+
const dM = tag.match(/\sd="([^"]+)"/);
|
|
78
|
+
const fM = tag.match(/\sfill="([^"]+)"/);
|
|
79
|
+
if (!dM || !fM || !ICON_FILLS.has(fM[1]))
|
|
80
|
+
continue;
|
|
81
|
+
const xM = dM[1].match(/M\s*(-?[\d.]+)/);
|
|
82
|
+
glyphs.push({ d: dM[1], fill: fM[1], x0: xM ? parseFloat(xM[1]) : 0 });
|
|
83
|
+
}
|
|
84
|
+
cachedGlyphs = glyphs;
|
|
85
|
+
return glyphs;
|
|
86
|
+
}
|
|
87
|
+
const r = (n) => Math.round(n * 100) / 100;
|
|
88
|
+
function emitGlyph(g, isDark, tx = 0) {
|
|
89
|
+
if (!g)
|
|
90
|
+
return '';
|
|
91
|
+
const fill = isDark ? (DARK_ICON[g.fill] ?? g.fill) : g.fill;
|
|
92
|
+
const t = tx ? ` transform="translate(${r(tx)} 0)"` : '';
|
|
93
|
+
return `<path d="${g.d}" fill="${fill}"${t}/>`;
|
|
94
|
+
}
|
|
28
95
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
29
96
|
function escText(s) {
|
|
30
97
|
return s
|
|
@@ -36,62 +103,76 @@ function escText(s) {
|
|
|
36
103
|
function cleanUrl(raw) {
|
|
37
104
|
return (raw || 'apple.com').replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
38
105
|
}
|
|
39
|
-
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
.replace(/fill="white"/g, 'fill="#2C2C2E"')
|
|
60
|
-
.replace(/fill="#FFFFFF"/gi, 'fill="#2C2C2E"')
|
|
61
|
-
.replace(/fill="#F7F7F7"/gi, 'fill="#3A3A3C"')
|
|
62
|
-
.replace(/fill="#4C4C4C"/gi, 'fill="#E4E4E4"')
|
|
63
|
-
.replace(/fill="#808080"/gi, 'fill="#8E8E93"')
|
|
64
|
-
.replace(/fill="#C6C6C6"/gi, 'fill="#6E6E73"')
|
|
65
|
-
.replace(/fill="#E6E6E6"/gi, 'fill="#4A4A4C"');
|
|
66
|
-
}
|
|
106
|
+
// ── Core: reflowed toolbar SVG at the requested pixel dimensions ───────────
|
|
107
|
+
function buildSafariBarSvg(svgW, svgH, isDark, url) {
|
|
108
|
+
// Uniform scale driven by height; the viewBox width grows with the target so
|
|
109
|
+
// icons never stretch — only spacing and the address pill width adapt.
|
|
110
|
+
const s = svgH / REF_H;
|
|
111
|
+
const iw = Math.max(REF_W, Math.round(svgW / s));
|
|
112
|
+
const dx = iw - REF_W; // right-cluster shift
|
|
113
|
+
// Address pill: centered, dynamic width, clamped so it never collides with
|
|
114
|
+
// the fixed side clusters.
|
|
115
|
+
const rightBound = RIGHT_PILL.x + dx; // left edge of the right cluster
|
|
116
|
+
const maxPillW = Math.max(120, (rightBound - PILL_GAP) - (LEFT_BOUND + PILL_GAP));
|
|
117
|
+
let pillW = Math.min(720, Math.max(360, Math.round(iw * 0.36)));
|
|
118
|
+
pillW = Math.min(pillW, maxPillW);
|
|
119
|
+
let pillX = Math.round(iw / 2 - pillW / 2);
|
|
120
|
+
pillX = Math.max(LEFT_BOUND + PILL_GAP, Math.min(pillX, rightBound - PILL_GAP - pillW));
|
|
121
|
+
const pillRight = pillX + pillW;
|
|
122
|
+
// Tokens
|
|
123
|
+
const barBg = isDark ? '#2C2C2E' : '#FFFFFF';
|
|
124
|
+
const pillBg = isDark ? '#3A3A3C' : '#F0F0F0';
|
|
125
|
+
const border = isDark ? '#1F1F22' : '#E4E4E4';
|
|
67
126
|
const urlColor = isDark ? '#E4E4E4' : '#4C4C4C';
|
|
68
|
-
|
|
127
|
+
const gl = assetGlyphs();
|
|
128
|
+
const leftGlyphs = gl.filter(g => g.x0 < 240);
|
|
129
|
+
const readerGlyph = gl.find(g => g.fill === '#808080' && g.x0 < 640);
|
|
130
|
+
const reloadGlyph = gl.find(g => g.fill === '#808080' && g.x0 >= 640);
|
|
131
|
+
const rightGlyphs = gl.filter(g => g.x0 >= 1000);
|
|
132
|
+
const cy = PILL_Y + PILL_H / 2; // 44
|
|
133
|
+
const trafficLights = TRAFFIC.map(t => `<circle cx="${t.x + 7}" cy="${cy}" r="7" fill="${t.color}"/>`
|
|
134
|
+
+ `<circle cx="${t.x + 7}" cy="${cy}" r="6.75" fill="none" stroke="#000000" stroke-opacity="0.1" stroke-width="0.5"/>`).join('');
|
|
135
|
+
const pill = (x, w) => `<rect x="${r(x)}" y="${PILL_Y}" width="${r(w)}" height="${PILL_H}" rx="${PILL_RX}" fill="${pillBg}"/>`;
|
|
136
|
+
const leftLayer = leftGlyphs.map(g => emitGlyph(g, isDark)).join('');
|
|
137
|
+
const rightLayer = rightGlyphs.map(g => emitGlyph(g, isDark, dx)).join('');
|
|
138
|
+
const body = [
|
|
139
|
+
// Toolbar background (top corners are rounded by the wrapper's clip).
|
|
140
|
+
`<rect x="0" y="${VB_Y}" width="${iw}" height="${REF_H + 1}" fill="${barBg}"/>`,
|
|
141
|
+
// Bottom hairline separator.
|
|
142
|
+
`<rect x="0" y="${VB_Y + REF_H - 0.5}" width="${iw}" height="0.5" fill="${border}"/>`,
|
|
143
|
+
// Pill backgrounds
|
|
144
|
+
pill(SIDEBAR_PILL.x, SIDEBAR_PILL.w),
|
|
145
|
+
pill(NAV_PILL.x, NAV_PILL.w),
|
|
146
|
+
pill(pillX, pillW),
|
|
147
|
+
pill(RIGHT_PILL.x + dx, RIGHT_PILL.w),
|
|
148
|
+
// Glyphs
|
|
149
|
+
trafficLights,
|
|
150
|
+
leftLayer,
|
|
151
|
+
emitGlyph(readerGlyph, isDark, pillX - REF_URL_PILL.x),
|
|
152
|
+
emitGlyph(reloadGlyph, isDark, pillRight - (REF_URL_PILL.x + REF_URL_PILL.w)),
|
|
153
|
+
rightLayer,
|
|
154
|
+
// URL text (centered in the address pill)
|
|
155
|
+
`<text x="${r(pillX + pillW / 2)}" y="${cy}" font-family="${FF}" font-size="14" font-weight="510" fill="${urlColor}" text-anchor="middle" dominant-baseline="central">${escText(url)}</text>`,
|
|
156
|
+
].join('\n');
|
|
157
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${svgW}" height="${svgH}" viewBox="0 ${VB_Y} ${iw} ${REF_H}" preserveAspectRatio="none" fill="none">
|
|
158
|
+
${body}
|
|
159
|
+
</svg>`;
|
|
69
160
|
}
|
|
70
161
|
// ── Generator (HTML, for Playwright client preview) ──────────────────────
|
|
71
162
|
export function generateSafariBrowserBarHtml(options) {
|
|
72
163
|
const { config, width, height, pixelScale = 1 } = options;
|
|
73
164
|
const url = cleanUrl(config.url);
|
|
74
165
|
const isDark = config.colorScheme === 'dark';
|
|
75
|
-
const inner = buildSafariSvgInner(url, isDark);
|
|
76
|
-
// The asset's reference is 1299×88. We render it at the target width×height
|
|
77
|
-
// by setting the SVG width/height directly — viewBox handles the scaling.
|
|
78
|
-
// pixelScale lets the server multiply for high-DPI raster output.
|
|
79
166
|
const w = width * pixelScale;
|
|
80
167
|
const h = height * pixelScale;
|
|
81
|
-
const
|
|
82
|
-
return `${FONT_CSS_HTML}<div style="width:${w}px;height:${h}px;position:relative;overflow:hidden;line-height:0;font-size:0"
|
|
83
|
-
<svg width="${w}" height="${h}" viewBox="${vb.x} ${vb.y} ${vb.width} ${vb.height}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" fill="none" style="display:block">${inner}</svg>
|
|
84
|
-
</div>`;
|
|
168
|
+
const svg = buildSafariBarSvg(w, h, isDark, url);
|
|
169
|
+
return `${FONT_CSS_HTML}<div style="width:${w}px;height:${h}px;position:relative;overflow:hidden;line-height:0;font-size:0">${svg}</div>`;
|
|
85
170
|
}
|
|
86
171
|
// ── Generator (SVG, for sharp / resvg server compositing) ────────────────
|
|
87
172
|
export function generateSafariBrowserBarSvg(options) {
|
|
88
173
|
const { config, width, height } = options;
|
|
89
174
|
const url = cleanUrl(config.url);
|
|
90
175
|
const isDark = config.colorScheme === 'dark';
|
|
91
|
-
|
|
92
|
-
const vb = SAFARI_TOOLBAR_VIEWBOX;
|
|
93
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="${vb.x} ${vb.y} ${vb.width} ${vb.height}" preserveAspectRatio="none" fill="none">
|
|
94
|
-
${inner}
|
|
95
|
-
</svg>`;
|
|
176
|
+
return buildSafariBarSvg(width, height, isDark, url);
|
|
96
177
|
}
|
|
97
178
|
//# sourceMappingURL=safari-browser-bar.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -257,6 +257,13 @@ export interface BrowserOptions {
|
|
|
257
257
|
* secrets here.
|
|
258
258
|
*/
|
|
259
259
|
extraHttpHeaders?: Record<string, string>;
|
|
260
|
+
/**
|
|
261
|
+
* When `false`, the engine does NOT block third-party web-analytics beacons
|
|
262
|
+
* during capture (AUT-234). Default behavior (`undefined` / `true`) blocks
|
|
263
|
+
* them so a capture never registers a phantom "visit" in the site's
|
|
264
|
+
* analytics. Opt-out is a per-project setting (`projects.block_analytics_enabled`).
|
|
265
|
+
*/
|
|
266
|
+
blockAnalytics?: boolean;
|
|
260
267
|
}
|
|
261
268
|
export interface OutscaleConfig {
|
|
262
269
|
/** Uniform padding on all 4 sides (pixels). */
|
|
@@ -1158,6 +1158,7 @@ export declare const VideoIngestPayloadSchema: z.ZodObject<{
|
|
|
1158
1158
|
deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
1159
1159
|
publicUrl: z.ZodOptional<z.ZodString>;
|
|
1160
1160
|
environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
1161
|
+
blockAnalytics: z.ZodOptional<z.ZodBoolean>;
|
|
1161
1162
|
}, z.core.$strict>;
|
|
1162
1163
|
narration: z.ZodOptional<z.ZodObject<{
|
|
1163
1164
|
voice: z.ZodString;
|
|
Binary file
|