@flrande/bak-extension 0.1.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/dist/background.global.js +623 -0
- package/dist/content.global.js +2042 -0
- package/dist/manifest.json +20 -0
- package/dist/popup.global.js +52 -0
- package/dist/popup.html +106 -0
- package/package.json +18 -0
- package/public/manifest.json +20 -0
- package/public/popup.html +106 -0
- package/scripts/copy-assets.mjs +16 -0
- package/src/background.ts +705 -0
- package/src/content.ts +2267 -0
- package/src/limitations.ts +38 -0
- package/src/popup.ts +65 -0
- package/src/privacy.ts +135 -0
- package/src/reconnect.ts +25 -0
- package/src/url-policy.ts +12 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Locator } from '@flrande/bak-protocol';
|
|
2
|
+
|
|
3
|
+
export interface UnsupportedLocator {
|
|
4
|
+
reason: 'shadow-dom' | 'iframe';
|
|
5
|
+
hint: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SHADOW_SELECTOR_PATTERN = /(>>>|::part|::slotted|\bshadowroot\b)/i;
|
|
9
|
+
const FRAME_SELECTOR_PATTERN = /(^|[\s>+~,(])(?:iframe|frame)(?=[$.#:[\s>+~,(]|$)/i;
|
|
10
|
+
|
|
11
|
+
export function unsupportedLocator(locator?: Locator): UnsupportedLocator | null {
|
|
12
|
+
if (!locator?.css) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const css = locator.css;
|
|
17
|
+
if (SHADOW_SELECTOR_PATTERN.test(css)) {
|
|
18
|
+
return {
|
|
19
|
+
reason: 'shadow-dom',
|
|
20
|
+
hint: 'shadow-dom selectors are not supported in v1'
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (FRAME_SELECTOR_PATTERN.test(css)) {
|
|
25
|
+
return {
|
|
26
|
+
reason: 'iframe',
|
|
27
|
+
hint: 'iframe selectors are not supported in v1'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function unsupportedLocatorHint(locator?: Locator): string | null {
|
|
35
|
+
return unsupportedLocator(locator)?.hint ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
package/src/popup.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const statusEl = document.getElementById('status') as HTMLDivElement;
|
|
2
|
+
const tokenInput = document.getElementById('token') as HTMLInputElement;
|
|
3
|
+
const portInput = document.getElementById('port') as HTMLInputElement;
|
|
4
|
+
const debugRichTextInput = document.getElementById('debugRichText') as HTMLInputElement;
|
|
5
|
+
const saveBtn = document.getElementById('save') as HTMLButtonElement;
|
|
6
|
+
const disconnectBtn = document.getElementById('disconnect') as HTMLButtonElement;
|
|
7
|
+
|
|
8
|
+
function setStatus(text: string, bad = false): void {
|
|
9
|
+
statusEl.textContent = text;
|
|
10
|
+
statusEl.style.color = bad ? '#dc2626' : '#0f172a';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function refreshState(): Promise<void> {
|
|
14
|
+
const state = (await chrome.runtime.sendMessage({ type: 'bak.getState' })) as {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
connected: boolean;
|
|
17
|
+
hasToken: boolean;
|
|
18
|
+
port: number;
|
|
19
|
+
debugRichText: boolean;
|
|
20
|
+
lastError: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (state.ok) {
|
|
24
|
+
portInput.value = String(state.port);
|
|
25
|
+
debugRichTextInput.checked = Boolean(state.debugRichText);
|
|
26
|
+
if (state.connected) {
|
|
27
|
+
setStatus('Connected to bak CLI');
|
|
28
|
+
} else if (state.lastError) {
|
|
29
|
+
setStatus(`Disconnected: ${state.lastError}`, true);
|
|
30
|
+
} else {
|
|
31
|
+
setStatus('Disconnected');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
saveBtn.addEventListener('click', async () => {
|
|
37
|
+
const token = tokenInput.value.trim();
|
|
38
|
+
const port = Number.parseInt(portInput.value.trim(), 10);
|
|
39
|
+
|
|
40
|
+
if (!token) {
|
|
41
|
+
setStatus('Pair token is required', true);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
46
|
+
setStatus('Port is invalid', true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await chrome.runtime.sendMessage({
|
|
51
|
+
type: 'bak.updateConfig',
|
|
52
|
+
token,
|
|
53
|
+
port,
|
|
54
|
+
debugRichText: debugRichTextInput.checked
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await refreshState();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
disconnectBtn.addEventListener('click', async () => {
|
|
61
|
+
await chrome.runtime.sendMessage({ type: 'bak.disconnect' });
|
|
62
|
+
await refreshState();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
void refreshState();
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export interface RedactTextOptions {
|
|
2
|
+
debugRichText?: boolean;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface NameCandidates {
|
|
6
|
+
tag: string;
|
|
7
|
+
role?: string | null;
|
|
8
|
+
inputType?: string | null;
|
|
9
|
+
ariaLabel?: string | null;
|
|
10
|
+
labelledByText?: string | null;
|
|
11
|
+
labelText?: string | null;
|
|
12
|
+
placeholder?: string | null;
|
|
13
|
+
text?: string | null;
|
|
14
|
+
nameAttr?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_SAFE_TEXT_LENGTH = 120;
|
|
18
|
+
const MAX_DEBUG_TEXT_LENGTH = 320;
|
|
19
|
+
|
|
20
|
+
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
21
|
+
const LONG_DIGIT_PATTERN = /(?:\d[ -]?){13,19}/g;
|
|
22
|
+
const OTP_PATTERN = /^\d{4,8}$/;
|
|
23
|
+
const SECRET_QUERY_PARAM_PATTERN = /(token|secret|password|passwd|otp|code|session|auth)=/i;
|
|
24
|
+
const HIGH_ENTROPY_TOKEN_PATTERN = /^(?=.*\d)(?=.*[a-zA-Z])[A-Za-z0-9~!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`]{16,}$/;
|
|
25
|
+
|
|
26
|
+
const INPUT_TEXT_ENTRY_TYPES = new Set([
|
|
27
|
+
'text',
|
|
28
|
+
'search',
|
|
29
|
+
'email',
|
|
30
|
+
'password',
|
|
31
|
+
'tel',
|
|
32
|
+
'url',
|
|
33
|
+
'number',
|
|
34
|
+
'date',
|
|
35
|
+
'datetime-local',
|
|
36
|
+
'month',
|
|
37
|
+
'week',
|
|
38
|
+
'time'
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function normalize(raw: string): string {
|
|
42
|
+
const cleaned = [...raw]
|
|
43
|
+
.map((char) => {
|
|
44
|
+
const code = char.charCodeAt(0);
|
|
45
|
+
return code < 32 || code === 127 ? ' ' : char;
|
|
46
|
+
})
|
|
47
|
+
.join('');
|
|
48
|
+
return cleaned.replace(/\s+/g, ' ').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clamp(text: string, options: RedactTextOptions): string {
|
|
52
|
+
const max = options.debugRichText ? MAX_DEBUG_TEXT_LENGTH : MAX_SAFE_TEXT_LENGTH;
|
|
53
|
+
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function redactByPattern(text: string): string {
|
|
57
|
+
let output = text;
|
|
58
|
+
|
|
59
|
+
if (SECRET_QUERY_PARAM_PATTERN.test(output)) {
|
|
60
|
+
return '[REDACTED:query-secret]';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
output = output.replace(EMAIL_PATTERN, '[REDACTED:email]');
|
|
64
|
+
output = output.replace(LONG_DIGIT_PATTERN, '[REDACTED:number]');
|
|
65
|
+
|
|
66
|
+
if (OTP_PATTERN.test(output)) {
|
|
67
|
+
return '[REDACTED:otp]';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (HIGH_ENTROPY_TOKEN_PATTERN.test(output) && !output.includes(' ')) {
|
|
71
|
+
return '[REDACTED:secret]';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return output;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function redactElementText(raw: string | null | undefined, options: RedactTextOptions = {}): string {
|
|
78
|
+
if (!raw) {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const normalized = normalize(raw);
|
|
83
|
+
if (!normalized) {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const redacted = redactByPattern(normalized);
|
|
88
|
+
return clamp(redacted, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isTextEntryField(candidates: NameCandidates): boolean {
|
|
92
|
+
const tag = candidates.tag.toLowerCase();
|
|
93
|
+
const role = (candidates.role ?? '').toLowerCase();
|
|
94
|
+
const inputType = (candidates.inputType ?? '').toLowerCase();
|
|
95
|
+
|
|
96
|
+
if (role === 'textbox') {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (tag === 'textarea') {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (tag === 'input') {
|
|
105
|
+
return INPUT_TEXT_ENTRY_TYPES.has(inputType) || inputType === '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function inferSafeName(candidates: NameCandidates, options: RedactTextOptions = {}): string {
|
|
112
|
+
const allowDebugText = Boolean(options.debugRichText);
|
|
113
|
+
const fromAria = redactElementText(candidates.ariaLabel, options);
|
|
114
|
+
const fromLabelledBy = redactElementText(candidates.labelledByText, options);
|
|
115
|
+
const fromLabel = redactElementText(candidates.labelText, options);
|
|
116
|
+
const fromPlaceholder = redactElementText(candidates.placeholder, options);
|
|
117
|
+
const fromNameAttr = redactElementText(candidates.nameAttr, options);
|
|
118
|
+
const fromText = redactElementText(candidates.text, options);
|
|
119
|
+
|
|
120
|
+
const ordered = [fromAria, fromLabelledBy, fromLabel, fromPlaceholder];
|
|
121
|
+
|
|
122
|
+
if (!isTextEntryField(candidates) || allowDebugText) {
|
|
123
|
+
ordered.push(fromText);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ordered.push(fromNameAttr);
|
|
127
|
+
|
|
128
|
+
for (const value of ordered) {
|
|
129
|
+
if (value) {
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return candidates.tag.toLowerCase();
|
|
135
|
+
}
|
package/src/reconnect.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULT_BASE_DELAY_MS = 1_500;
|
|
2
|
+
const DEFAULT_MAX_DELAY_MS = 15_000;
|
|
3
|
+
|
|
4
|
+
export interface ReconnectBackoffOptions {
|
|
5
|
+
baseDelayMs?: number;
|
|
6
|
+
maxDelayMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function clampNonNegative(value: number): number {
|
|
10
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
return Math.floor(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function computeReconnectDelayMs(
|
|
17
|
+
attempt: number,
|
|
18
|
+
options: ReconnectBackoffOptions = {}
|
|
19
|
+
): number {
|
|
20
|
+
const baseDelayMs = Math.max(100, clampNonNegative(options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS));
|
|
21
|
+
const maxDelayMs = Math.max(baseDelayMs, clampNonNegative(options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS));
|
|
22
|
+
const normalizedAttempt = Number.isFinite(attempt) ? Math.floor(attempt) : 0;
|
|
23
|
+
const safeAttempt = Math.max(0, Math.min(10, normalizedAttempt));
|
|
24
|
+
return Math.min(maxDelayMs, baseDelayMs * 2 ** safeAttempt);
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function isSupportedAutomationUrl(url?: string): boolean {
|
|
2
|
+
if (typeof url !== 'string' || !url) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const parsed = new URL(url);
|
|
8
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|