safari-mcp 2.7.9 → 2.7.10
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 +1 -1
- package/index.js +60 -4
- package/package.json +1 -1
- package/safari-helper +0 -0
- package/safari-helper.swift +80 -4
- package/safari.js +120 -0
package/README.md
CHANGED
|
@@ -538,7 +538,7 @@ Your support funds:
|
|
|
538
538
|
|
|
539
539
|
## Community
|
|
540
540
|
|
|
541
|
-
**2,000+
|
|
541
|
+
**2,000+ monthly npm downloads** — developers are using Safari MCP to build AI agents on macOS.
|
|
542
542
|
|
|
543
543
|
- [GitHub Discussions](https://github.com/achiya-automation/safari-mcp/discussions) — ask questions, share use cases
|
|
544
544
|
- [Issues](https://github.com/achiya-automation/safari-mcp/issues) — bug reports and feature requests
|
package/index.js
CHANGED
|
@@ -13,9 +13,10 @@ import { WebSocketServer } from "ws";
|
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { homedir } from "node:os";
|
|
19
20
|
|
|
20
21
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
|
|
21
22
|
|
|
@@ -51,6 +52,36 @@ try {
|
|
|
51
52
|
// ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
|
|
52
53
|
const SESSION_ID = randomUUID().slice(0, 8);
|
|
53
54
|
|
|
55
|
+
// ========== PERSISTENT TAB OWNERSHIP ==========
|
|
56
|
+
// The in-memory _ownedTabURLs set is wiped when the MCP process restarts
|
|
57
|
+
// (Claude Code periodically recycles MCP servers). Without persistence, every
|
|
58
|
+
// restart re-triggers "Tab safety: no tabs opened yet" errors forcing a
|
|
59
|
+
// re-open of every tab. Persist the set to a JSON file with a TTL so tabs
|
|
60
|
+
// remain "owned" across process restarts for up to OWNERSHIP_TTL_MS.
|
|
61
|
+
const OWNERSHIP_DIR = join(homedir(), ".safari-mcp");
|
|
62
|
+
const OWNERSHIP_FILE = join(OWNERSHIP_DIR, "owned-tabs.json");
|
|
63
|
+
const OWNERSHIP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
64
|
+
|
|
65
|
+
function _loadOwnershipFile() {
|
|
66
|
+
try {
|
|
67
|
+
if (!existsSync(OWNERSHIP_FILE)) return [];
|
|
68
|
+
const raw = readFileSync(OWNERSHIP_FILE, "utf8");
|
|
69
|
+
const data = JSON.parse(raw);
|
|
70
|
+
if (!Array.isArray(data)) return [];
|
|
71
|
+
const cutoff = Date.now() - OWNERSHIP_TTL_MS;
|
|
72
|
+
return data.filter(e => e && typeof e.url === "string" && typeof e.ts === "number" && e.ts > cutoff);
|
|
73
|
+
} catch { return []; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _saveOwnershipFile(urls) {
|
|
77
|
+
try {
|
|
78
|
+
if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const entries = Array.from(urls).map(url => ({ url, ts: now }));
|
|
81
|
+
writeFileSync(OWNERSHIP_FILE, JSON.stringify(entries), { mode: 0o600 });
|
|
82
|
+
} catch { /* best-effort */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
// ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
|
|
55
86
|
const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
|
|
56
87
|
const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
|
|
@@ -63,7 +94,8 @@ const _openedTabs = new Map();
|
|
|
63
94
|
// Tracks URLs of tabs opened by this MCP session.
|
|
64
95
|
// Any tool that modifies a tab (navigate, click, fill, etc.) is blocked
|
|
65
96
|
// unless the current tab was opened via safari_new_tab.
|
|
66
|
-
|
|
97
|
+
// Hydrated from ~/.safari-mcp/owned-tabs.json so ownership survives MCP restarts.
|
|
98
|
+
const _ownedTabURLs = new Set(_loadOwnershipFile().map(e => e.url));
|
|
67
99
|
|
|
68
100
|
function _isURLOwned(url) {
|
|
69
101
|
if (!url) return false;
|
|
@@ -96,11 +128,17 @@ function _isURLOwned(url) {
|
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
function _addOwnedURL(url) {
|
|
99
|
-
if (url && url !== 'about:blank' && url !== 'favorites://')
|
|
131
|
+
if (url && url !== 'about:blank' && url !== 'favorites://') {
|
|
132
|
+
_ownedTabURLs.add(url);
|
|
133
|
+
_saveOwnershipFile(_ownedTabURLs);
|
|
134
|
+
}
|
|
100
135
|
}
|
|
101
136
|
|
|
102
137
|
function _removeOwnedURL(url) {
|
|
103
|
-
if (url)
|
|
138
|
+
if (url) {
|
|
139
|
+
_ownedTabURLs.delete(url);
|
|
140
|
+
_saveOwnershipFile(_ownedTabURLs);
|
|
141
|
+
}
|
|
104
142
|
}
|
|
105
143
|
|
|
106
144
|
function _updateOwnedURL(oldUrl, newUrl) {
|
|
@@ -953,6 +991,24 @@ server.tool(
|
|
|
953
991
|
}
|
|
954
992
|
);
|
|
955
993
|
|
|
994
|
+
server.tool(
|
|
995
|
+
"safari_native_hover",
|
|
996
|
+
"OS-level mouse hover via macOS CGEvent — moves the real cursor to an element to trigger native :hover / mouseenter handlers. Use for obfuscated UIs where JS-dispatched mouseenter isn't enough, like Discord server sidebars (tooltips only appear on real hover) or portal-rendered tooltips. After hover, call safari_wait_for or safari_evaluate to read the tooltip. Dwells for dwellMs to let tooltips render, then restores the original cursor position by default. Requires Safari window to be visible.",
|
|
997
|
+
{
|
|
998
|
+
ref: z.string().optional().describe("Ref ID from safari_snapshot"),
|
|
999
|
+
selector: z.string().optional().describe("CSS selector"),
|
|
1000
|
+
text: z.string().optional().describe("Visible text to find and hover"),
|
|
1001
|
+
x: z.coerce.number().optional().describe("Viewport X coordinate"),
|
|
1002
|
+
y: z.coerce.number().optional().describe("Viewport Y coordinate"),
|
|
1003
|
+
dwellMs: z.coerce.number().optional().default(500).describe("Milliseconds to dwell over the element so tooltips render (clamped 0-5000)"),
|
|
1004
|
+
restoreMouse: z.boolean().optional().default(true).describe("Restore cursor to original position after dwell"),
|
|
1005
|
+
},
|
|
1006
|
+
async (args) => {
|
|
1007
|
+
const result = await safari.nativeHover(args);
|
|
1008
|
+
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
1009
|
+
}
|
|
1010
|
+
);
|
|
1011
|
+
|
|
956
1012
|
// ========== FORM INPUT ==========
|
|
957
1013
|
|
|
958
1014
|
server.tool(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.10",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari-helper
CHANGED
|
Binary file
|
package/safari-helper.swift
CHANGED
|
@@ -14,6 +14,53 @@ import Darwin
|
|
|
14
14
|
import CoreGraphics
|
|
15
15
|
import AppKit
|
|
16
16
|
|
|
17
|
+
// ========== CGEvent Native Hover ==========
|
|
18
|
+
// Moves the mouse cursor to a target position to trigger native :hover / mouseenter
|
|
19
|
+
// without clicking. Used for revealing tooltips on obfuscated UIs (Discord sidebar,
|
|
20
|
+
// virtualized server lists, custom React tooltips) where JS-dispatched mouseenter
|
|
21
|
+
// events aren't enough because the rendering depends on CSS :hover or real pointer
|
|
22
|
+
// position. Optionally restores the original cursor position after dwell.
|
|
23
|
+
func performNativeHover(x: Double, y: Double, windowId: Int64 = 0, dwellMs: Int = 500, restoreMouse: Bool = true) -> [String: Any] {
|
|
24
|
+
let point = CGPoint(x: x, y: y)
|
|
25
|
+
let savedPosition = CGEvent(source: nil)?.location ?? CGPoint.zero
|
|
26
|
+
|
|
27
|
+
// Get Safari PID for process-targeted event posting (background hover, no focus steal)
|
|
28
|
+
var safariPID: pid_t = 0
|
|
29
|
+
if windowId > 0 {
|
|
30
|
+
let ws = NSWorkspace.shared
|
|
31
|
+
for app in ws.runningApplications {
|
|
32
|
+
if app.bundleIdentifier == "com.apple.Safari" {
|
|
33
|
+
safariPID = app.processIdentifier
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let kWindowField = CGEventField(rawValue: 91)! // windowUnderMousePointer
|
|
40
|
+
let kWindowHandlerField = CGEventField(rawValue: 92)! // windowThatCanHandleThisEvent
|
|
41
|
+
|
|
42
|
+
func postMove(_ position: CGPoint) {
|
|
43
|
+
guard let ev = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: position, mouseButton: .left) else { return }
|
|
44
|
+
if windowId > 0 && safariPID > 0 {
|
|
45
|
+
ev.setIntegerValueField(kWindowField, value: windowId)
|
|
46
|
+
ev.setIntegerValueField(kWindowHandlerField, value: windowId)
|
|
47
|
+
ev.postToPid(safariPID)
|
|
48
|
+
} else {
|
|
49
|
+
ev.post(tap: .cghidEventTap)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
postMove(point)
|
|
54
|
+
let ms = max(0, min(dwellMs, 5000)) // clamp 0-5000ms to prevent runaway blocking
|
|
55
|
+
usleep(UInt32(ms * 1000))
|
|
56
|
+
if restoreMouse {
|
|
57
|
+
postMove(savedPosition)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let targetInfo = windowId > 0 ? " (window \(windowId), background)" : ""
|
|
61
|
+
return ["result": "hovered at (\(Int(x)),\(Int(y))) for \(ms)ms\(targetInfo)\(restoreMouse ? " (mouse restored)" : "")"]
|
|
62
|
+
}
|
|
63
|
+
|
|
17
64
|
// ========== CGEvent Native Click ==========
|
|
18
65
|
// Performs a REAL OS-level mouse click that produces isTrusted: true in the browser.
|
|
19
66
|
// Requires Accessibility permissions (same as AppleScript automation).
|
|
@@ -256,6 +303,18 @@ while let line = readLine(strippingNewline: true) {
|
|
|
256
303
|
continue
|
|
257
304
|
}
|
|
258
305
|
|
|
306
|
+
// Handle CGEvent hover command — native mouse move to trigger real :hover / mouseenter
|
|
307
|
+
// {"hover": {"x": 500, "y": 300, "windowId": 4127, "dwellMs": 500, "restoreMouse": true}}
|
|
308
|
+
if let hoverData = json["hover"] as? [String: Any],
|
|
309
|
+
let x = hoverData["x"] as? Double,
|
|
310
|
+
let y = hoverData["y"] as? Double {
|
|
311
|
+
let windowId = Int64((hoverData["windowId"] as? Int) ?? 0)
|
|
312
|
+
let dwellMs = (hoverData["dwellMs"] as? Int) ?? 500
|
|
313
|
+
let restoreMouse = (hoverData["restoreMouse"] as? Bool) ?? true
|
|
314
|
+
respond(performNativeHover(x: x, y: y, windowId: windowId, dwellMs: dwellMs, restoreMouse: restoreMouse))
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
|
|
259
318
|
// Handle CGEvent keyboard command
|
|
260
319
|
// {"keyboard": {"keyCode": 9, "flags": ["cmd"], "windowId": 4127}}
|
|
261
320
|
// keyCode 9 = V, flags: cmd/shift/alt/ctrl
|
|
@@ -324,13 +383,30 @@ while let line = readLine(strippingNewline: true) {
|
|
|
324
383
|
continue
|
|
325
384
|
}
|
|
326
385
|
|
|
327
|
-
|
|
328
|
-
|
|
386
|
+
// Execute on a background thread to avoid blocking stdin reading.
|
|
387
|
+
// Heavy pages (SourceForge, etc.) can cause executeAndReturnError() to block
|
|
388
|
+
// for 10-30+ seconds, preventing ALL subsequent commands from being read.
|
|
389
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
390
|
+
var scriptResult: NSAppleEventDescriptor?
|
|
391
|
+
var scriptError: NSDictionary?
|
|
392
|
+
|
|
393
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
394
|
+
var errorDict: NSDictionary?
|
|
395
|
+
scriptResult = nsScript.executeAndReturnError(&errorDict)
|
|
396
|
+
scriptError = errorDict
|
|
397
|
+
semaphore.signal()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Wait up to 30 seconds for the script to complete.
|
|
401
|
+
// If it times out, respond with error but don't block the loop forever.
|
|
402
|
+
let waitResult = semaphore.wait(timeout: .now() + 30.0)
|
|
329
403
|
|
|
330
|
-
if
|
|
404
|
+
if waitResult == .timedOut {
|
|
405
|
+
respond(["error": "AppleScript execution timed out (30s)"])
|
|
406
|
+
} else if let error = scriptError {
|
|
331
407
|
let msg = (error["NSAppleScriptErrorMessage"] as? String) ?? "AppleScript error"
|
|
332
408
|
respond(["error": msg])
|
|
333
409
|
} else {
|
|
334
|
-
respond(["result":
|
|
410
|
+
respond(["result": scriptResult?.stringValue ?? ""])
|
|
335
411
|
}
|
|
336
412
|
}
|
package/safari.js
CHANGED
|
@@ -545,6 +545,44 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
|
|
|
545
545
|
});
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
// Sends a CGEvent hover command to the Swift helper daemon.
|
|
549
|
+
// Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
|
|
550
|
+
function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
|
|
551
|
+
return new Promise((resolve, reject) => {
|
|
552
|
+
if (!_helperProc) startHelper();
|
|
553
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
554
|
+
reject(new Error("safari-helper not available for native hover"));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
let resolved = false;
|
|
558
|
+
const timer = setTimeout(() => {
|
|
559
|
+
if (resolved) return;
|
|
560
|
+
resolved = true;
|
|
561
|
+
const idx = _helperQueue.indexOf(cb);
|
|
562
|
+
if (idx >= 0) _helperQueue[idx] = () => {};
|
|
563
|
+
reject(new Error("native hover timeout"));
|
|
564
|
+
}, timeout);
|
|
565
|
+
|
|
566
|
+
function cb(line) {
|
|
567
|
+
if (resolved) return;
|
|
568
|
+
resolved = true;
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
try {
|
|
571
|
+
const parsed = JSON.parse(line);
|
|
572
|
+
if (parsed.error) reject(new Error(parsed.error));
|
|
573
|
+
else resolve(parsed.result ?? "");
|
|
574
|
+
} catch {
|
|
575
|
+
resolve(line);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
_helperQueue.push(cb);
|
|
580
|
+
const cmd = { hover: { x, y, dwellMs, restoreMouse } };
|
|
581
|
+
if (windowId) cmd.hover.windowId = windowId;
|
|
582
|
+
_helperProc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
548
586
|
// Sends a CGEvent keyboard command to the Swift helper daemon.
|
|
549
587
|
// No focus stealing — sends key events directly to the target window via PID.
|
|
550
588
|
function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
|
|
@@ -1120,6 +1158,88 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
|
|
|
1120
1158
|
return `${clickType}: ${label} at screen (${screenX},${screenY})`;
|
|
1121
1159
|
}
|
|
1122
1160
|
|
|
1161
|
+
// ========== NATIVE HOVER (OS-level CGEvent mouse move — triggers real :hover and mouseenter) ==========
|
|
1162
|
+
// JS-dispatched mouseenter events work for most React components, but some UIs
|
|
1163
|
+
// (Discord sidebar, virtualized CSS :hover tooltips, custom portal-rendered
|
|
1164
|
+
// tooltips) only respond to a real OS-level cursor position. This function
|
|
1165
|
+
// moves the physical cursor to the target, dwells for tooltips to render,
|
|
1166
|
+
// then optionally restores the cursor to its original position.
|
|
1167
|
+
export async function nativeHover({ selector, text, x, y, ref, dwellMs = 500, restoreMouse = true }) {
|
|
1168
|
+
await ensureHelpers();
|
|
1169
|
+
|
|
1170
|
+
// Step 1: Get element's viewport coordinates via JavaScript
|
|
1171
|
+
let viewportCoords;
|
|
1172
|
+
if (ref || selector || text) {
|
|
1173
|
+
let jsExpr;
|
|
1174
|
+
if (ref) {
|
|
1175
|
+
jsExpr = `(function(){
|
|
1176
|
+
var el = mcpFindRef('${ref}');
|
|
1177
|
+
if (!el) return JSON.stringify({error: 'Element not found: ref=${ref}'});
|
|
1178
|
+
el.scrollIntoView({block:'center', behavior:'instant'});
|
|
1179
|
+
var rect = el.getBoundingClientRect();
|
|
1180
|
+
return JSON.stringify({
|
|
1181
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1182
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1183
|
+
tag: el.tagName,
|
|
1184
|
+
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
|
|
1185
|
+
});
|
|
1186
|
+
})()`;
|
|
1187
|
+
} else if (selector) {
|
|
1188
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1189
|
+
jsExpr = `(function(){
|
|
1190
|
+
var el = document.querySelector('${sel}');
|
|
1191
|
+
if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
|
|
1192
|
+
el.scrollIntoView({block:'center', behavior:'instant'});
|
|
1193
|
+
var rect = el.getBoundingClientRect();
|
|
1194
|
+
return JSON.stringify({
|
|
1195
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1196
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1197
|
+
tag: el.tagName,
|
|
1198
|
+
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
|
|
1199
|
+
});
|
|
1200
|
+
})()`;
|
|
1201
|
+
} else {
|
|
1202
|
+
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1203
|
+
jsExpr = `(function(){
|
|
1204
|
+
var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
|
|
1205
|
+
if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
|
|
1206
|
+
el.scrollIntoView({block:'center', behavior:'instant'});
|
|
1207
|
+
var rect = el.getBoundingClientRect();
|
|
1208
|
+
return JSON.stringify({
|
|
1209
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1210
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1211
|
+
tag: el.tagName,
|
|
1212
|
+
text: (el.innerText || el.textContent || '').trim().substring(0, 50)
|
|
1213
|
+
});
|
|
1214
|
+
})()`;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const result = await runJS(jsExpr);
|
|
1218
|
+
try {
|
|
1219
|
+
viewportCoords = JSON.parse(result);
|
|
1220
|
+
} catch {
|
|
1221
|
+
throw new Error("Failed to get element coordinates: " + result);
|
|
1222
|
+
}
|
|
1223
|
+
if (viewportCoords.error) {
|
|
1224
|
+
throw new Error(viewportCoords.error);
|
|
1225
|
+
}
|
|
1226
|
+
} else if (x !== undefined && y !== undefined) {
|
|
1227
|
+
viewportCoords = { x: Number(x), y: Number(y), tag: 'point', text: '' };
|
|
1228
|
+
} else {
|
|
1229
|
+
throw new Error("nativeHover requires selector, text, ref, or x/y coordinates");
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const geo = await _getSafariWindowGeometry();
|
|
1233
|
+
const screenX = geo.windowX + viewportCoords.x;
|
|
1234
|
+
const screenY = geo.windowY + geo.toolbarHeight + viewportCoords.y;
|
|
1235
|
+
|
|
1236
|
+
if (!geo.windowId) throw new Error("Cannot native-hover without Safari window ID — would move mouse and steal focus");
|
|
1237
|
+
await _helperNativeHover(screenX, screenY, geo.windowId, dwellMs, restoreMouse);
|
|
1238
|
+
|
|
1239
|
+
const label = viewportCoords.tag + (viewportCoords.text ? ` "${viewportCoords.text}"` : '');
|
|
1240
|
+
return `Native hovered: ${label} at screen (${screenX},${screenY}) for ${dwellMs}ms${restoreMouse ? ' (mouse restored)' : ''}`;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1123
1243
|
// ========== FORM INPUT ==========
|
|
1124
1244
|
|
|
1125
1245
|
export async function fill({ selector, value, ref }) {
|