@hasna/contacts 0.7.0 → 0.9.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/README.md +93 -13
- package/dist/browser/cli.d.ts +4 -0
- package/dist/browser/cli.d.ts.map +1 -0
- package/dist/browser/identity.d.ts +3 -0
- package/dist/browser/identity.d.ts.map +1 -0
- package/dist/browser/install.d.ts +32 -0
- package/dist/browser/install.d.ts.map +1 -0
- package/dist/browser/native.d.ts +3 -0
- package/dist/browser/native.d.ts.map +1 -0
- package/dist/browser/native.js +822 -0
- package/dist/browser/protocol.d.ts +26 -0
- package/dist/browser/protocol.d.ts.map +1 -0
- package/dist/browser/values.d.ts +18 -0
- package/dist/browser/values.d.ts.map +1 -0
- package/dist/cli/commands/core.d.ts.map +1 -1
- package/dist/cli/index.js +479 -149
- package/dist/cli/status-domain.preload.d.ts +1 -1
- package/dist/cli/status-domain.preload.d.ts.map +1 -1
- package/dist/cli/status-fixture.d.ts +17 -0
- package/dist/cli/status-fixture.d.ts.map +1 -0
- package/dist/cloud/http-storage.d.ts +20 -12
- package/dist/cloud/http-storage.d.ts.map +1 -1
- package/dist/cloud/resolver-inputs.d.ts +51 -0
- package/dist/cloud/resolver-inputs.d.ts.map +1 -0
- package/dist/db/paths.d.ts +4 -4
- package/dist/db/paths.d.ts.map +1 -1
- package/dist/generated/storage-kit/backend.d.ts +19 -0
- package/dist/generated/storage-kit/backend.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +2 -1
- package/dist/generated/storage-kit/index.d.ts.map +1 -1
- package/dist/generated/storage-kit/migrations.d.ts +21 -0
- package/dist/generated/storage-kit/migrations.d.ts.map +1 -1
- package/dist/generated/storage-kit/own.d.ts +11 -0
- package/dist/generated/storage-kit/own.d.ts.map +1 -0
- package/dist/generated/storage-kit/pool.d.ts +5 -17
- package/dist/generated/storage-kit/pool.d.ts.map +1 -1
- package/dist/generated/storage-kit/query.d.ts +1 -1
- package/dist/generated/storage-kit/query.d.ts.map +1 -1
- package/dist/generated/storage-kit/tls.d.ts +30 -3
- package/dist/generated/storage-kit/tls.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +148 -28
- package/dist/lib/audience-contract.d.ts +9 -9
- package/dist/lib/compact-output.d.ts +67 -0
- package/dist/lib/compact-output.d.ts.map +1 -0
- package/dist/mcp/handlers/core.d.ts.map +1 -1
- package/dist/mcp/index.d.ts +11 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +490 -212
- package/dist/mcp/profile.d.ts +8 -0
- package/dist/mcp/profile.d.ts.map +1 -0
- package/dist/mcp/register-tools.d.ts +3 -1
- package/dist/mcp/register-tools.d.ts.map +1 -1
- package/dist/mcp/startup-gate.d.ts +48 -0
- package/dist/mcp/startup-gate.d.ts.map +1 -0
- package/dist/mcp/storage-tools.d.ts +2 -1
- package/dist/mcp/storage-tools.d.ts.map +1 -1
- package/dist/mcp/tools.d.ts +7 -45
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +70 -3
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +990 -7
- package/dist/server/index.js +231 -59
- package/dist/server/pg-store.d.ts +1 -0
- package/dist/server/pg-store.d.ts.map +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/docs/chrome-autofill.md +45 -0
- package/extension/background.js +284 -0
- package/extension/content.js +387 -0
- package/extension/detect.js +228 -0
- package/extension/fill.js +112 -0
- package/extension/icons/128.png +0 -0
- package/extension/icons/16.png +0 -0
- package/extension/icons/32.png +0 -0
- package/extension/icons/48.png +0 -0
- package/extension/icons/icon.svg +17 -0
- package/extension/manifest.json +54 -0
- package/extension/popup.css +78 -0
- package/extension/popup.html +47 -0
- package/extension/popup.js +166 -0
- package/hasna.contract.json +20 -6
- package/package.json +10 -6
- package/dist/lib/config.d.ts +0 -7
- package/dist/lib/config.d.ts.map +0 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { fillDocument } from "./fill.js";
|
|
2
|
+
|
|
3
|
+
const HOST = "com.hasna.contacts";
|
|
4
|
+
let port, connecting;
|
|
5
|
+
let generation = 0;
|
|
6
|
+
const pending = new Map();
|
|
7
|
+
function safeOrigin(url) {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(url);
|
|
10
|
+
return u.protocol === "https:" || (u.protocol === "http:" && ["127.0.0.1", "localhost"].includes(u.hostname))
|
|
11
|
+
? u.origin
|
|
12
|
+
: null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function nativeRequest(nativePort, op, data = {}) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const id = crypto.randomUUID();
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
pending.delete(id);
|
|
22
|
+
reject(new Error("NATIVE_TIMEOUT"));
|
|
23
|
+
}, 20000);
|
|
24
|
+
pending.set(id, { resolve, reject, timer, nativePort });
|
|
25
|
+
nativePort.postMessage({ id, op, ...data });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function rpc(op, data = {}) {
|
|
29
|
+
await connect();
|
|
30
|
+
return nativeRequest(port, op, data);
|
|
31
|
+
}
|
|
32
|
+
async function connect() {
|
|
33
|
+
if (connecting) return connecting;
|
|
34
|
+
if (port) return;
|
|
35
|
+
connecting = (async () => {
|
|
36
|
+
const saved = await chrome.storage.local.get("browserId");
|
|
37
|
+
let browserId = saved.browserId || crypto.randomUUID();
|
|
38
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
39
|
+
await chrome.storage.local.set({ browserId });
|
|
40
|
+
const nativePort = chrome.runtime.connectNative(HOST);
|
|
41
|
+
port = nativePort;
|
|
42
|
+
nativePort.onMessage.addListener((message) => {
|
|
43
|
+
const entry = pending.get(message.id);
|
|
44
|
+
if (!entry || entry.nativePort !== nativePort) return;
|
|
45
|
+
clearTimeout(entry.timer);
|
|
46
|
+
pending.delete(message.id);
|
|
47
|
+
message.ok ? entry.resolve(message.result) : entry.reject(new Error(message.error || "NATIVE_UNAVAILABLE"));
|
|
48
|
+
});
|
|
49
|
+
nativePort.onDisconnect.addListener(() => {
|
|
50
|
+
void chrome.runtime.lastError;
|
|
51
|
+
if (port === nativePort) {
|
|
52
|
+
generation++;
|
|
53
|
+
port = undefined;
|
|
54
|
+
}
|
|
55
|
+
for (const [id, entry] of pending) {
|
|
56
|
+
if (entry.nativePort !== nativePort) continue;
|
|
57
|
+
clearTimeout(entry.timer);
|
|
58
|
+
entry.reject(new Error("NATIVE_UNAVAILABLE"));
|
|
59
|
+
pending.delete(id);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await nativeRequest(nativePort, "hello", { browserId });
|
|
64
|
+
return;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (port === nativePort) {
|
|
67
|
+
generation++;
|
|
68
|
+
port = undefined;
|
|
69
|
+
}
|
|
70
|
+
nativePort.disconnect();
|
|
71
|
+
if (error.message !== "BROWSER_ALREADY_CONNECTED" || attempt !== 0) throw error;
|
|
72
|
+
browserId = crypto.randomUUID();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
try {
|
|
77
|
+
await connecting;
|
|
78
|
+
} finally {
|
|
79
|
+
connecting = undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function frames(tabId) {
|
|
83
|
+
return ((await chrome.webNavigation.getAllFrames({ tabId })) || [])
|
|
84
|
+
.map((f) => ({ frameId: f.frameId, documentId: f.documentId, origin: safeOrigin(f.url) }))
|
|
85
|
+
.filter((f) => f.origin && f.documentId);
|
|
86
|
+
}
|
|
87
|
+
// Detect only the page the popup was opened from; no fallback to another tab.
|
|
88
|
+
async function pageContext(tabId, expected) {
|
|
89
|
+
const tab = Number.isInteger(tabId)
|
|
90
|
+
? await chrome.tabs.get(tabId)
|
|
91
|
+
: (await chrome.tabs.query({ active: true, lastFocusedWindow: true }))[0];
|
|
92
|
+
const site = safeOrigin(tab?.url);
|
|
93
|
+
if (!site || !Number.isInteger(tab?.id)) return null;
|
|
94
|
+
const all = await frames(tab.id);
|
|
95
|
+
const top = all.find((f) => f.frameId === 0);
|
|
96
|
+
if (!top || top.origin !== site || (expected && (expected.origin !== site || expected.documentId !== top.documentId)))
|
|
97
|
+
throw new Error("TAB_CHANGED");
|
|
98
|
+
const detected = [];
|
|
99
|
+
const blocked = new Set();
|
|
100
|
+
for (const frame of all) {
|
|
101
|
+
try {
|
|
102
|
+
const result = await chrome.scripting.executeScript({
|
|
103
|
+
target: { tabId: tab.id, documentIds: [frame.documentId] },
|
|
104
|
+
func: fillDocument,
|
|
105
|
+
args: [frame.origin, {}, "inspect"],
|
|
106
|
+
world: "ISOLATED",
|
|
107
|
+
});
|
|
108
|
+
const fields = result[0]?.result?.fields || [];
|
|
109
|
+
if (fields.length) detected.push({ origin: frame.origin, fields });
|
|
110
|
+
} catch {
|
|
111
|
+
blocked.add(frame.origin);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
tabId: tab.id,
|
|
116
|
+
origin: site,
|
|
117
|
+
documentId: top.documentId,
|
|
118
|
+
fieldCount: detected.reduce((n, f) => n + f.fields.reduce((m, v) => m + v.count, 0), 0),
|
|
119
|
+
frameOrigins: [...new Set([site, ...detected.map((f) => f.origin)])].sort(),
|
|
120
|
+
blockedOrigins: [...blocked].sort(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function fill(input) {
|
|
124
|
+
await connect();
|
|
125
|
+
const started = generation;
|
|
126
|
+
const active = () => {
|
|
127
|
+
if (started !== generation || !port) throw new Error("FILL_REVOKED");
|
|
128
|
+
};
|
|
129
|
+
active();
|
|
130
|
+
if (!Number.isInteger(input.tabId)) throw new Error("INVALID_TAB");
|
|
131
|
+
if (typeof input.contactId !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(input.contactId))
|
|
132
|
+
throw new Error("CONTACT_NOT_FOUND");
|
|
133
|
+
const tab = await chrome.tabs.get(input.tabId);
|
|
134
|
+
active();
|
|
135
|
+
if (safeOrigin(tab.url) !== input.origin) throw new Error("TAB_CHANGED");
|
|
136
|
+
const before = await frames(input.tabId);
|
|
137
|
+
active();
|
|
138
|
+
const top = before.find((f) => f.frameId === 0);
|
|
139
|
+
if (!top || top.origin !== input.origin || top.documentId !== input.documentId) throw new Error("TAB_CHANGED");
|
|
140
|
+
const selectedOrigins = [...new Set([input.origin, ...(input.frameOrigins || [])])].sort();
|
|
141
|
+
const selected = before.filter((f) => selectedOrigins.includes(f.origin));
|
|
142
|
+
const documents = [];
|
|
143
|
+
// Inspect before resolving. Exact document IDs prevent a navigation from
|
|
144
|
+
// changing where values are injected; page text/values never leave the DOM.
|
|
145
|
+
for (const f of selected) {
|
|
146
|
+
active();
|
|
147
|
+
const result = await chrome.scripting.executeScript({
|
|
148
|
+
target: { tabId: input.tabId, documentIds: [f.documentId] },
|
|
149
|
+
func: fillDocument,
|
|
150
|
+
args: [f.origin, {}, "inspect"],
|
|
151
|
+
world: "ISOLATED",
|
|
152
|
+
});
|
|
153
|
+
const fields = result[0]?.result?.fields || [];
|
|
154
|
+
if (fields.length) documents.push({ ...f, fields: fields.filter((x) => x.count === 1).map((x) => x.field) });
|
|
155
|
+
}
|
|
156
|
+
if (!documents.length) return { ok: false, error: "NO_SUPPORTED_FIELDS", excludedFrames: before.length - selected.length };
|
|
157
|
+
active();
|
|
158
|
+
const values = await rpc("resolve", { contactId: input.contactId });
|
|
159
|
+
try {
|
|
160
|
+
active();
|
|
161
|
+
const current = await frames(input.tabId);
|
|
162
|
+
active();
|
|
163
|
+
if (!current.some((f) => f.frameId === 0 && f.documentId === top.documentId && f.origin === top.origin))
|
|
164
|
+
throw new Error("TAB_CHANGED");
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const doc of documents) {
|
|
167
|
+
active();
|
|
168
|
+
if (!current.some((f) => f.documentId === doc.documentId && f.origin === doc.origin)) throw new Error("FRAME_CHANGED");
|
|
169
|
+
const picked = {};
|
|
170
|
+
for (const field of doc.fields) {
|
|
171
|
+
const key = field === "country-name" ? "country" : field === "tel-national" ? "tel" : field;
|
|
172
|
+
if (values[key]) picked[key] = values[key];
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const result = await chrome.scripting.executeScript({
|
|
176
|
+
target: { tabId: input.tabId, documentIds: [doc.documentId] },
|
|
177
|
+
func: fillDocument,
|
|
178
|
+
args: [doc.origin, picked, "fill"],
|
|
179
|
+
world: "ISOLATED",
|
|
180
|
+
});
|
|
181
|
+
active();
|
|
182
|
+
results.push({ frameId: doc.frameId, origin: doc.origin, ...result[0]?.result });
|
|
183
|
+
} finally {
|
|
184
|
+
for (const k of Object.keys(picked)) delete picked[k];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
contactId: input.contactId,
|
|
190
|
+
tabId: input.tabId,
|
|
191
|
+
origin: input.origin,
|
|
192
|
+
filled: results.reduce((n, r) => n + (r.fields || []).filter((f) => f.status === "filled").length, 0),
|
|
193
|
+
excludedFrames: before.length - selected.length,
|
|
194
|
+
results,
|
|
195
|
+
};
|
|
196
|
+
} finally {
|
|
197
|
+
for (const k of Object.keys(values)) delete values[k];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function searchQuery(value) {
|
|
201
|
+
if (value === undefined || value === null) return "";
|
|
202
|
+
if (typeof value !== "string") throw new Error("INVALID_QUERY");
|
|
203
|
+
return value.trim().slice(0, 200);
|
|
204
|
+
}
|
|
205
|
+
// In-page picker (content.js). Everything about the target is derived from the
|
|
206
|
+
// sender Chrome attaches to the message — tab, frame, document and URL — never
|
|
207
|
+
// from the message body, so a page cannot point a fill at another tab or origin.
|
|
208
|
+
const PAGE_OPS = new Set(["page-search", "page-fill"]);
|
|
209
|
+
const pageFills = new Set();
|
|
210
|
+
function pageSender(sender) {
|
|
211
|
+
if (!sender || sender.id !== chrome.runtime.id) return null;
|
|
212
|
+
if (!sender.tab || !Number.isInteger(sender.tab.id)) return null;
|
|
213
|
+
if (!Number.isInteger(sender.frameId) || typeof sender.documentId !== "string") return null;
|
|
214
|
+
const origin = safeOrigin(sender.url);
|
|
215
|
+
if (!origin) return null;
|
|
216
|
+
return { tabId: sender.tab.id, frameId: sender.frameId, documentId: sender.documentId, origin };
|
|
217
|
+
}
|
|
218
|
+
async function pageFill(page, contactId) {
|
|
219
|
+
if (typeof contactId !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(contactId)) throw new Error("CONTACT_NOT_FOUND");
|
|
220
|
+
if (pageFills.has(page.tabId)) throw new Error("FILL_BUSY");
|
|
221
|
+
pageFills.add(page.tabId);
|
|
222
|
+
try {
|
|
223
|
+
// Same detection the popup uses: the fill covers the top document and every
|
|
224
|
+
// frame where supported fields were found, not only the frame the badge
|
|
225
|
+
// lives in. The sender's own document must still be the one it claims.
|
|
226
|
+
const context = await pageContext(page.tabId);
|
|
227
|
+
if (!context) throw new Error("TAB_CHANGED");
|
|
228
|
+
const me = (await frames(page.tabId)).find((f) => f.frameId === page.frameId);
|
|
229
|
+
if (!me || me.documentId !== page.documentId || me.origin !== page.origin) throw new Error("TAB_CHANGED");
|
|
230
|
+
const result = await fill({
|
|
231
|
+
contactId,
|
|
232
|
+
tabId: page.tabId,
|
|
233
|
+
origin: context.origin,
|
|
234
|
+
documentId: context.documentId,
|
|
235
|
+
frameOrigins: [...new Set([...context.frameOrigins, page.origin])].sort(),
|
|
236
|
+
});
|
|
237
|
+
// The page receives a receipt of counts only: no ids, origins or frames.
|
|
238
|
+
const fields = (result.results || []).flatMap((r) => r.fields || []);
|
|
239
|
+
return { ok: result.ok === true, filled: result.filled || 0, skipped: fields.filter((f) => f.status !== "filled").length };
|
|
240
|
+
} finally {
|
|
241
|
+
pageFills.delete(page.tabId);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
245
|
+
const page = message && PAGE_OPS.has(message.op) ? pageSender(sender) : null;
|
|
246
|
+
// Otherwise only the popup may talk to this worker.
|
|
247
|
+
if (!page && (sender.id !== chrome.runtime.id || sender.url?.split("?")[0] !== chrome.runtime.getURL("popup.html")))
|
|
248
|
+
return false;
|
|
249
|
+
(async () => {
|
|
250
|
+
if (page) {
|
|
251
|
+
if (message.op === "page-search") return rpc("search", { query: searchQuery(message.query) });
|
|
252
|
+
return pageFill(page, message.contactId);
|
|
253
|
+
}
|
|
254
|
+
if (message.op === "init") {
|
|
255
|
+
const [contacts, pageInfo] = await Promise.all([rpc("search", { query: "" }), pageContext(message.tabId)]);
|
|
256
|
+
return { contacts, page: pageInfo };
|
|
257
|
+
}
|
|
258
|
+
if (message.op === "search") return rpc("search", { query: searchQuery(message.query) });
|
|
259
|
+
if (message.op === "detect") return pageContext(message.input.tabId, message.input);
|
|
260
|
+
if (message.op === "fill") return fill(message.input);
|
|
261
|
+
throw new Error("UNKNOWN_COMMAND");
|
|
262
|
+
})()
|
|
263
|
+
.then((result) => reply({ ok: true, result }))
|
|
264
|
+
.catch((e) => {
|
|
265
|
+
const allowed = [
|
|
266
|
+
"NATIVE_UNAVAILABLE",
|
|
267
|
+
"NATIVE_TIMEOUT",
|
|
268
|
+
"CONTACTS_UNAVAILABLE",
|
|
269
|
+
"CONTACTS_NOT_CONFIGURED",
|
|
270
|
+
"CONTACT_NOT_FOUND",
|
|
271
|
+
"TAB_CHANGED",
|
|
272
|
+
"FRAME_CHANGED",
|
|
273
|
+
"FILL_REVOKED",
|
|
274
|
+
"FILL_BUSY",
|
|
275
|
+
"INVALID_QUERY",
|
|
276
|
+
];
|
|
277
|
+
reply({ ok: false, error: allowed.includes(e.message) ? e.message : "FILL_FAILED" });
|
|
278
|
+
});
|
|
279
|
+
return true;
|
|
280
|
+
});
|
|
281
|
+
chrome.alarms.onAlarm.addListener(() => connect().catch(() => {}));
|
|
282
|
+
chrome.runtime.onInstalled.addListener(() => chrome.alarms.create("contacts-native", { periodInMinutes: 1 }));
|
|
283
|
+
chrome.runtime.onStartup.addListener(() => connect().catch(() => {}));
|
|
284
|
+
connect().catch(() => {});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Contacts — in-page picker (content script, every https frame).
|
|
2
|
+
//
|
|
3
|
+
// Finds identity/address controls with the shared detect.js rules, draws a
|
|
4
|
+
// small Contacts badge inside the anchor fields and, on a TRUSTED and VISIBLE
|
|
5
|
+
// click, opens a picker with a search box listing contact names. Choosing a
|
|
6
|
+
// contact asks the background worker to run the human fill for this tab;
|
|
7
|
+
// contact values never enter this script — the worker injects them into exact
|
|
8
|
+
// document IDs.
|
|
9
|
+
//
|
|
10
|
+
// Never fills on load, never reads field values, never talks to the Contacts
|
|
11
|
+
// API, never acts on synthetic or deceived events. A page script can find the
|
|
12
|
+
// badge host element but cannot open the closed shadow root; the host's own
|
|
13
|
+
// styling is pinned with !important so page CSS cannot hide, fade, move or
|
|
14
|
+
// blend it; and every click must be trusted AND land on a control that
|
|
15
|
+
// IntersectionObserver v2 has reported as unoccluded and unaltered for a
|
|
16
|
+
// minimum dwell time. A covered, transparent or off-screen badge or row is
|
|
17
|
+
// refused.
|
|
18
|
+
(() => {
|
|
19
|
+
if (typeof window === "undefined" || window.__hasnaContactsPicker) return;
|
|
20
|
+
window.__hasnaContactsPicker = true;
|
|
21
|
+
const detect = window.ContactsDetect;
|
|
22
|
+
if (!detect || !document.documentElement) return;
|
|
23
|
+
|
|
24
|
+
const BADGE = 20;
|
|
25
|
+
const MIN_VISIBLE_MS = 300; // continuous unoccluded time before a click counts
|
|
26
|
+
const MAX_SHADOW_WALK = 3000; // elements inspected per scan for open shadow roots
|
|
27
|
+
const state = { host: null, layer: null, menu: null, badges: new Map(), open: null, rescan: 0, raf: 0, search: 0, seq: 0 };
|
|
28
|
+
|
|
29
|
+
// IntersectionObserver v2 (trackVisibility) reports isVisible === false when the
|
|
30
|
+
// target is covered by other content or when it, or an ancestor, carries
|
|
31
|
+
// opacity, filters or transforms. Clicks are accepted only after a control has
|
|
32
|
+
// been continuously visible for MIN_VISIBLE_MS. No observer means no clicks.
|
|
33
|
+
const visibleSince = new WeakMap();
|
|
34
|
+
const clock = () => (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now());
|
|
35
|
+
const observer =
|
|
36
|
+
typeof IntersectionObserver === "function"
|
|
37
|
+
? new IntersectionObserver(
|
|
38
|
+
(entries) => {
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.isVisible === true && entry.isIntersecting && entry.intersectionRatio >= 0.99) visibleSince.set(entry.target, visibleSince.get(entry.target) ?? clock());
|
|
41
|
+
else visibleSince.delete(entry.target);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ threshold: 1, trackVisibility: true, delay: 100 },
|
|
45
|
+
)
|
|
46
|
+
: null;
|
|
47
|
+
function watch(el) {
|
|
48
|
+
if (observer) observer.observe(el);
|
|
49
|
+
}
|
|
50
|
+
function unwatch(el) {
|
|
51
|
+
if (observer) observer.unobserve(el);
|
|
52
|
+
visibleSince.delete(el);
|
|
53
|
+
}
|
|
54
|
+
/** True only for a trusted click on a control that has been visibly unobstructed long enough. */
|
|
55
|
+
function acceptable(event, el) {
|
|
56
|
+
if (!event || event.isTrusted !== true) return false;
|
|
57
|
+
if (!observer) return false;
|
|
58
|
+
const since = visibleSince.get(el);
|
|
59
|
+
return typeof since === "number" && clock() - since >= MIN_VISIBLE_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The host is a light-DOM element the page can style; pin everything that could
|
|
63
|
+
// hide, fade, move, clip or blend it. Important shadow :host declarations win
|
|
64
|
+
// over page rules of any specificity or importance.
|
|
65
|
+
const css = `
|
|
66
|
+
:host { all: initial !important; display: block !important; position: fixed !important; top: 0 !important; left: 0 !important; width: 0 !important; height: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; overflow: visible !important; opacity: 1 !important; visibility: visible !important; transform: none !important; filter: none !important; backdrop-filter: none !important; clip-path: none !important; clip: auto !important; mask: none !important; mix-blend-mode: normal !important; pointer-events: auto !important; contain: none !important; content-visibility: visible !important; isolation: auto !important; perspective: none !important; zoom: 1 !important; translate: none !important; rotate: none !important; scale: none !important; }
|
|
67
|
+
.layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color: #172033; }
|
|
68
|
+
.badge { position: absolute; width: ${BADGE}px; height: ${BADGE}px; border: 0; border-radius: 6px; padding: 0; margin: 0; cursor: pointer; pointer-events: auto; background: #2e4a7a url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><circle cx='10' cy='7.5' r='3.2' fill='%23ffffff'/><path d='M4.5 16c.5-3.3 2.8-5 5.5-5s5 1.7 5.5 5z' fill='%23d6e1f5'/></svg>") center/16px no-repeat; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
|
69
|
+
.badge:hover, .badge:focus-visible { box-shadow: 0 0 0 2px #8fb0e6, 0 1px 2px rgba(0,0,0,.25); outline: none; }
|
|
70
|
+
.menu { position: absolute; min-width: 284px; max-width: 340px; pointer-events: auto; background: #ffffff; color: #172033; border: 1px solid #d8dfe9; border-radius: 12px; box-shadow: 0 12px 32px rgba(23, 44, 79, .18), 0 2px 6px rgba(0,0,0,.08); overflow: hidden; }
|
|
71
|
+
.menu header { display: flex; align-items: center; gap: 8px; padding: 10px 12px 8px; border-bottom: 1px solid #e6ebf2; }
|
|
72
|
+
.menu header .mark { width: 18px; height: 18px; border-radius: 5px; background: #2e4a7a; flex: none; }
|
|
73
|
+
.menu header strong { font-size: 13px; font-weight: 650; }
|
|
74
|
+
.menu header span { margin-left: auto; color: #6b7686; font-size: 11px; }
|
|
75
|
+
.search { display: block; width: 100%; box-sizing: border-box; margin: 0; padding: 9px 12px; border: 0; border-bottom: 1px solid #e6ebf2; background: #f6f8fb; color: inherit; font: inherit; outline: none; }
|
|
76
|
+
.search:focus { background: #eef3fa; }
|
|
77
|
+
.list { list-style: none; margin: 0; padding: 6px; max-height: 240px; overflow: auto; }
|
|
78
|
+
.item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 8px; border: 0; border-radius: 8px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
79
|
+
.item:hover, .item:focus-visible { background: #eef3fa; outline: none; }
|
|
80
|
+
.item .avatar { width: 28px; height: 28px; border-radius: 50%; background: linear-gradient(135deg, #2e4a7a, #172c4f); color: #fff; font: 600 11px system-ui, sans-serif; display: grid; place-items: center; flex: none; }
|
|
81
|
+
.item .meta { flex: 1; min-width: 0; }
|
|
82
|
+
.item .name { display: block; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
83
|
+
.item .sub { display: block; color: #6b7686; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
84
|
+
.note { padding: 10px 12px; color: #55606f; font-size: 12px; }
|
|
85
|
+
.note.error { color: #9b3229; }
|
|
86
|
+
.note code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; background: #f1f4f8; padding: 1px 4px; border-radius: 4px; }
|
|
87
|
+
.foot { padding: 6px 12px 9px; color: #7a8492; font-size: 11px; border-top: 1px solid #e6ebf2; }
|
|
88
|
+
@media (prefers-color-scheme: dark) {
|
|
89
|
+
.layer { color: #e6ebf3; }
|
|
90
|
+
.menu { background: #171e2b; color: #e6ebf3; border-color: #2b3546; box-shadow: 0 12px 32px rgba(0,0,0,.5); }
|
|
91
|
+
.menu header, .foot { border-color: #253046; }
|
|
92
|
+
.search { background: #1d2636; border-color: #253046; } .search:focus { background: #223047; }
|
|
93
|
+
.item:hover, .item:focus-visible { background: #223047; }
|
|
94
|
+
.note { color: #aab4c3; } .note code { background: #223047; }
|
|
95
|
+
.foot, .menu header span, .item .sub { color: #8d98a8; }
|
|
96
|
+
}`;
|
|
97
|
+
|
|
98
|
+
function parent() {
|
|
99
|
+
return document.body || document.documentElement;
|
|
100
|
+
}
|
|
101
|
+
function mount() {
|
|
102
|
+
if (state.host) {
|
|
103
|
+
// A page that removes the host does not get a silent, badge-less form.
|
|
104
|
+
if (!state.host.isConnected) parent().appendChild(state.host);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const host = document.createElement("hasna-contacts-picker");
|
|
108
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
109
|
+
const style = document.createElement("style");
|
|
110
|
+
style.textContent = css;
|
|
111
|
+
const layer = document.createElement("div");
|
|
112
|
+
layer.className = "layer";
|
|
113
|
+
shadow.append(style, layer);
|
|
114
|
+
parent().appendChild(host);
|
|
115
|
+
state.host = host;
|
|
116
|
+
state.layer = layer;
|
|
117
|
+
}
|
|
118
|
+
const trusted = (event) => !!event && event.isTrusted === true;
|
|
119
|
+
|
|
120
|
+
function place(el, badge) {
|
|
121
|
+
let rect;
|
|
122
|
+
try { rect = el.getBoundingClientRect(); } catch { return false; }
|
|
123
|
+
if (!rect || rect.width < BADGE + 12 || rect.height < BADGE) { badge.style.display = "none"; return false; }
|
|
124
|
+
badge.style.display = "";
|
|
125
|
+
badge.style.left = `${Math.round(rect.right - BADGE - 6)}px`;
|
|
126
|
+
badge.style.top = `${Math.round(rect.top + (rect.height - BADGE) / 2)}px`;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
function dropBadge(el, badge) {
|
|
130
|
+
unwatch(badge);
|
|
131
|
+
badge.remove();
|
|
132
|
+
state.badges.delete(el);
|
|
133
|
+
if (state.open && state.open.el === el) closeMenu();
|
|
134
|
+
}
|
|
135
|
+
function reposition() {
|
|
136
|
+
state.raf = 0;
|
|
137
|
+
for (const [el, badge] of state.badges) {
|
|
138
|
+
if (!el.isConnected) {
|
|
139
|
+
dropBadge(el, badge);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
place(el, badge);
|
|
143
|
+
}
|
|
144
|
+
if (state.open && state.menu) placeMenu(state.open.el);
|
|
145
|
+
}
|
|
146
|
+
function scheduleReposition() {
|
|
147
|
+
if (state.raf) return;
|
|
148
|
+
state.raf = requestAnimationFrame(reposition);
|
|
149
|
+
}
|
|
150
|
+
function decorate(hit) {
|
|
151
|
+
if (state.badges.has(hit.el)) return;
|
|
152
|
+
const badge = document.createElement("button");
|
|
153
|
+
badge.type = "button";
|
|
154
|
+
badge.className = "badge";
|
|
155
|
+
badge.setAttribute("aria-label", "Fill with Contacts");
|
|
156
|
+
badge.title = "Fill this form from your Contacts";
|
|
157
|
+
badge.addEventListener("click", (event) => {
|
|
158
|
+
if (!acceptable(event, badge)) return;
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
if (state.open && state.open.el === hit.el) closeMenu();
|
|
162
|
+
else openMenu(hit).catch(() => {});
|
|
163
|
+
});
|
|
164
|
+
state.layer.appendChild(badge);
|
|
165
|
+
state.badges.set(hit.el, badge);
|
|
166
|
+
watch(badge);
|
|
167
|
+
place(hit.el, badge);
|
|
168
|
+
}
|
|
169
|
+
function scan() {
|
|
170
|
+
const found = detect.collect(document, [], MAX_SHADOW_WALK);
|
|
171
|
+
const summary = detect.summarize(found);
|
|
172
|
+
if (!summary.contact) {
|
|
173
|
+
for (const [el, badge] of state.badges) dropBadge(el, badge);
|
|
174
|
+
return summary;
|
|
175
|
+
}
|
|
176
|
+
mount();
|
|
177
|
+
for (const hit of detect.anchors(found)) decorate(hit);
|
|
178
|
+
scheduleReposition();
|
|
179
|
+
return summary;
|
|
180
|
+
}
|
|
181
|
+
function placeMenu(el) {
|
|
182
|
+
const rect = el.getBoundingClientRect();
|
|
183
|
+
const menu = state.menu;
|
|
184
|
+
const width = Math.min(340, Math.max(284, rect.width));
|
|
185
|
+
let left = Math.round(rect.left);
|
|
186
|
+
if (left + width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - width - 8);
|
|
187
|
+
let top = Math.round(rect.bottom + 6);
|
|
188
|
+
const height = menu.offsetHeight || 220;
|
|
189
|
+
if (top + height > window.innerHeight - 8 && rect.top - height - 6 > 0) top = Math.round(rect.top - height - 6);
|
|
190
|
+
menu.style.left = `${left}px`;
|
|
191
|
+
menu.style.top = `${top}px`;
|
|
192
|
+
menu.style.width = `${width}px`;
|
|
193
|
+
}
|
|
194
|
+
function note(text, error = false) {
|
|
195
|
+
const p = document.createElement("p");
|
|
196
|
+
p.className = `note${error ? " error" : ""}`;
|
|
197
|
+
p.textContent = text;
|
|
198
|
+
return p;
|
|
199
|
+
}
|
|
200
|
+
function initials(name) {
|
|
201
|
+
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
|
|
202
|
+
return (parts.length ? parts.slice(0, 2).map((p) => p[0]).join("") : "?").toUpperCase();
|
|
203
|
+
}
|
|
204
|
+
async function search(query, body, hit) {
|
|
205
|
+
const seq = ++state.seq;
|
|
206
|
+
let r;
|
|
207
|
+
try { r = await chrome.runtime.sendMessage({ op: "page-search", query }); } catch { r = null; }
|
|
208
|
+
if (seq !== state.seq || !state.open || state.open.el !== hit.el) return;
|
|
209
|
+
if (!r || !r.ok) {
|
|
210
|
+
body.replaceChildren(note(r && r.error === "CONTACTS_NOT_CONFIGURED" ? "Contacts is not signed in on this station." : "Contacts is not connected on this station.", true));
|
|
211
|
+
const how = note("");
|
|
212
|
+
how.append("Run ");
|
|
213
|
+
const code = document.createElement("code");
|
|
214
|
+
code.textContent = "contacts browser install";
|
|
215
|
+
how.append(code, " in a terminal, then reload this page.");
|
|
216
|
+
body.append(how);
|
|
217
|
+
placeMenu(hit.el);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const contacts = r.result || [];
|
|
221
|
+
if (!contacts.length) {
|
|
222
|
+
body.replaceChildren(note(query ? `No contacts match “${query}”.` : "No contacts yet."));
|
|
223
|
+
placeMenu(hit.el);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const list = document.createElement("ul");
|
|
227
|
+
list.className = "list";
|
|
228
|
+
for (const contact of contacts) {
|
|
229
|
+
const li = document.createElement("li");
|
|
230
|
+
const btn = document.createElement("button");
|
|
231
|
+
btn.type = "button";
|
|
232
|
+
btn.className = "item";
|
|
233
|
+
const avatar = document.createElement("span");
|
|
234
|
+
avatar.className = "avatar";
|
|
235
|
+
avatar.textContent = initials(contact.name);
|
|
236
|
+
const meta = document.createElement("span");
|
|
237
|
+
meta.className = "meta";
|
|
238
|
+
const name = document.createElement("span");
|
|
239
|
+
name.className = "name";
|
|
240
|
+
name.textContent = contact.name;
|
|
241
|
+
const sub = document.createElement("span");
|
|
242
|
+
sub.className = "sub";
|
|
243
|
+
sub.textContent = [contact.company, contact.email].filter(Boolean).join(" · ");
|
|
244
|
+
meta.append(name, sub);
|
|
245
|
+
btn.append(avatar, meta);
|
|
246
|
+
btn.addEventListener("click", (event) => {
|
|
247
|
+
if (!acceptable(event, btn)) {
|
|
248
|
+
if (event && event.isTrusted === true && !body.querySelector(".note.early")) {
|
|
249
|
+
// A real click that arrived before the row was verifiably visible (one note, not one per click).
|
|
250
|
+
const early = note("Contacts could not confirm this picker is fully visible. Try again.", true);
|
|
251
|
+
early.className += " early";
|
|
252
|
+
body.append(early);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
event.preventDefault();
|
|
257
|
+
event.stopPropagation();
|
|
258
|
+
fill(contact, body, hit).catch(() => {});
|
|
259
|
+
});
|
|
260
|
+
li.appendChild(btn);
|
|
261
|
+
list.appendChild(li);
|
|
262
|
+
watch(btn);
|
|
263
|
+
}
|
|
264
|
+
for (const btn of body.querySelectorAll ? body.querySelectorAll("button") : []) unwatch(btn);
|
|
265
|
+
body.replaceChildren(list);
|
|
266
|
+
placeMenu(hit.el);
|
|
267
|
+
}
|
|
268
|
+
async function openMenu(hit) {
|
|
269
|
+
closeMenu();
|
|
270
|
+
const menu = document.createElement("div");
|
|
271
|
+
menu.className = "menu";
|
|
272
|
+
menu.setAttribute("role", "dialog");
|
|
273
|
+
menu.setAttribute("aria-label", "Contacts");
|
|
274
|
+
const header = document.createElement("header");
|
|
275
|
+
const mark = document.createElement("span");
|
|
276
|
+
mark.className = "mark";
|
|
277
|
+
const title = document.createElement("strong");
|
|
278
|
+
title.textContent = "Fill from Contacts";
|
|
279
|
+
const site = document.createElement("span");
|
|
280
|
+
site.textContent = location.hostname;
|
|
281
|
+
header.append(mark, title, site);
|
|
282
|
+
const input = document.createElement("input");
|
|
283
|
+
input.type = "search";
|
|
284
|
+
input.className = "search";
|
|
285
|
+
input.placeholder = "Search contacts…";
|
|
286
|
+
input.setAttribute("aria-label", "Search contacts");
|
|
287
|
+
input.autocomplete = "off";
|
|
288
|
+
const body = document.createElement("div");
|
|
289
|
+
body.append(note("Loading your contacts…"));
|
|
290
|
+
const foot = document.createElement("div");
|
|
291
|
+
foot.className = "foot";
|
|
292
|
+
foot.textContent = "Fills name, email, phone, company and address fields.";
|
|
293
|
+
menu.append(header, input, body, foot);
|
|
294
|
+
state.layer.appendChild(menu);
|
|
295
|
+
state.menu = menu;
|
|
296
|
+
state.open = hit;
|
|
297
|
+
placeMenu(hit.el);
|
|
298
|
+
input.addEventListener("input", (event) => {
|
|
299
|
+
if (!trusted(event)) return;
|
|
300
|
+
clearTimeout(state.search);
|
|
301
|
+
const query = input.value.trim().slice(0, 200);
|
|
302
|
+
state.search = setTimeout(() => search(query, body, hit), 250);
|
|
303
|
+
});
|
|
304
|
+
input.addEventListener("keydown", (event) => {
|
|
305
|
+
if (event.key === "Escape" && trusted(event)) closeMenu();
|
|
306
|
+
});
|
|
307
|
+
input.focus({ preventScroll: true });
|
|
308
|
+
await search("", body, hit);
|
|
309
|
+
}
|
|
310
|
+
async function fill(contact, body, hit) {
|
|
311
|
+
body.replaceChildren(note(`Filling ${contact.name}…`));
|
|
312
|
+
let r;
|
|
313
|
+
try { r = await chrome.runtime.sendMessage({ op: "page-fill", contactId: contact.id }); } catch { r = null; }
|
|
314
|
+
if (!state.open || state.open.el !== hit.el) return;
|
|
315
|
+
if (!r || !r.ok) {
|
|
316
|
+
const hints = { CONTACT_NOT_FOUND: "This contact is no longer available.", TAB_CHANGED: "The page changed. Try again.", CONTACTS_NOT_CONFIGURED: "Contacts is not signed in on this station.", FILL_BUSY: "Another fill is still running in this tab." };
|
|
317
|
+
body.replaceChildren(note(hints[r && r.error] || "Autofill did not complete. Try again.", true));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const result = r.result;
|
|
321
|
+
if (!result.ok) { body.replaceChildren(note("No supported fields were found to fill.", true)); return; }
|
|
322
|
+
body.replaceChildren(note(`Filled ${result.filled} field${result.filled === 1 ? "" : "s"}.${result.skipped ? ` ${result.skipped} need your review.` : ""}`));
|
|
323
|
+
setTimeout(() => { if (state.open && state.open.el === hit.el) closeMenu(); }, 1600);
|
|
324
|
+
}
|
|
325
|
+
function closeMenu() {
|
|
326
|
+
clearTimeout(state.search);
|
|
327
|
+
if (state.menu) {
|
|
328
|
+
for (const btn of state.menu.querySelectorAll("button")) unwatch(btn);
|
|
329
|
+
state.menu.remove();
|
|
330
|
+
}
|
|
331
|
+
state.menu = null;
|
|
332
|
+
state.open = null;
|
|
333
|
+
}
|
|
334
|
+
function onDocumentPointer(event) {
|
|
335
|
+
if (!state.open || !trusted(event)) return;
|
|
336
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
337
|
+
if (path.includes(state.host)) return;
|
|
338
|
+
closeMenu();
|
|
339
|
+
}
|
|
340
|
+
function onKey(event) {
|
|
341
|
+
if (event.key === "Escape" && state.open && trusted(event)) closeMenu();
|
|
342
|
+
}
|
|
343
|
+
function scheduleScan() {
|
|
344
|
+
clearTimeout(state.rescan);
|
|
345
|
+
state.rescan = setTimeout(scan, 350);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Rescan only for mutations that can change the set of form controls: a
|
|
349
|
+
// control's own attributes, or inserted subtrees that carry controls, forms,
|
|
350
|
+
// frames or shadow hosts. Text and layout churn on busy pages is ignored.
|
|
351
|
+
const CONTROLS = "input,select,textarea,form,iframe";
|
|
352
|
+
function relevant(mutation) {
|
|
353
|
+
if (state.host && (mutation.target === state.host || state.host.contains(mutation.target))) return false;
|
|
354
|
+
if (mutation.type === "attributes") {
|
|
355
|
+
const t = mutation.target;
|
|
356
|
+
return !!t && typeof t.matches === "function" && t.matches("input,select,textarea");
|
|
357
|
+
}
|
|
358
|
+
for (const node of mutation.addedNodes || []) {
|
|
359
|
+
if (!node || node.nodeType !== 1) continue;
|
|
360
|
+
if (node.shadowRoot) return true;
|
|
361
|
+
if (typeof node.matches === "function" && node.matches(CONTROLS)) return true;
|
|
362
|
+
if (typeof node.querySelector === "function" && node.querySelector(CONTROLS)) return true;
|
|
363
|
+
}
|
|
364
|
+
for (const node of mutation.removedNodes || []) {
|
|
365
|
+
if (node && node.nodeType === 1 && state.badges.size) return true;
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
scan();
|
|
371
|
+
const mutations = new MutationObserver((records) => {
|
|
372
|
+
for (const m of records) {
|
|
373
|
+
if (relevant(m)) {
|
|
374
|
+
scheduleScan();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
mutations.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["autocomplete", "name", "id", "disabled", "readonly", "type"] });
|
|
380
|
+
window.addEventListener("scroll", scheduleReposition, { capture: true, passive: true });
|
|
381
|
+
window.addEventListener("resize", scheduleReposition, { passive: true });
|
|
382
|
+
document.addEventListener("pointerdown", onDocumentPointer, true);
|
|
383
|
+
document.addEventListener("keydown", onKey, true);
|
|
384
|
+
setInterval(() => {
|
|
385
|
+
if (state.badges.size) scheduleReposition();
|
|
386
|
+
}, 1000);
|
|
387
|
+
})();
|