Add Firefox cookie import and stealth mode
- Firefox cookie importer: reads cookies.sqlite with WAL-safe copy, profile detection via profiles.ini, cross-platform paths, domain filtering - Stealth mode: opt-in via launch(stealth: true), patches navigator.webdriver, plugins/mimeTypes, permissions API, WebGL renderer, iframe isolation, languages, plus realistic Safari UA and context hardening - Import tool now accepts 'safari' | 'firefox' source - STEALTH.md reference documentation - Upgraded @types/node to v25 for node:sqlite support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+138
-2
@@ -4,6 +4,7 @@ import { promisify } from 'node:util';
|
||||
import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import * as firefox from './firefox.js';
|
||||
import * as image from './image.js';
|
||||
import * as safari from './safari.js';
|
||||
import type {
|
||||
@@ -49,17 +50,39 @@ export class ClaudeBrowser {
|
||||
fullscreen: options.fullscreen ?? false,
|
||||
preview: options.preview ?? false,
|
||||
previewDelay: options.previewDelay ?? 2000,
|
||||
stealth: options.stealth ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
async launch(): Promise<void> {
|
||||
this.browser = await webkit.launch({ headless: this.options.headless });
|
||||
this.context = await this.browser.newContext({
|
||||
|
||||
const contextOptions: Record<string, unknown> = {
|
||||
viewport: {
|
||||
width: this.options.width,
|
||||
height: this.options.height,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (this.options.stealth) {
|
||||
Object.assign(contextOptions, {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||
locale: 'en-US',
|
||||
timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
colorScheme: 'light' as const,
|
||||
extraHTTPHeaders: {
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
|
||||
if (this.options.stealth) {
|
||||
await this.applyStealthPatches();
|
||||
}
|
||||
|
||||
this.page = await this.context.newPage();
|
||||
this.setupConsoleListener(this.page);
|
||||
this.setupNetworkListener(this.page);
|
||||
@@ -71,6 +94,91 @@ export class ClaudeBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stealth patches via addInitScript.
|
||||
* These run before any page script in all Playwright engines (WebKit included).
|
||||
* Scripts are passed as strings since they execute in browser context, not Node.
|
||||
* See STEALTH.md for full documentation.
|
||||
*/
|
||||
private async applyStealthPatches(): Promise<void> {
|
||||
if (!this.context) return;
|
||||
|
||||
// 1. WebDriver flag — set to undefined, not false
|
||||
// Some detectors specifically check for false as a signal of patching
|
||||
await this.context.addInitScript(`
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
`);
|
||||
|
||||
// 2. Plugins & MimeTypes — headless reports empty arrays
|
||||
await this.context.addInitScript(`
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||
],
|
||||
});
|
||||
Object.defineProperty(navigator, 'mimeTypes', {
|
||||
get: () => [
|
||||
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
|
||||
],
|
||||
});
|
||||
`);
|
||||
|
||||
// 3. Permissions API — fix notifications query inconsistency
|
||||
await this.context.addInitScript(`
|
||||
const __origQuery = navigator.permissions.query.bind(navigator.permissions);
|
||||
Object.defineProperty(navigator.permissions, 'query', {
|
||||
value: (params) =>
|
||||
params.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: __origQuery(params),
|
||||
});
|
||||
`);
|
||||
|
||||
// 4. WebGL renderer masking — spoof GPU vendor/renderer
|
||||
// Params 37445 (UNMASKED_VENDOR_WEBGL) and 37446 (UNMASKED_RENDERER_WEBGL)
|
||||
await this.context.addInitScript(`
|
||||
const __origGetParam = WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter = function(p) {
|
||||
if (p === 37445) return 'Apple GPU';
|
||||
if (p === 37446) return 'Apple M1 Pro';
|
||||
return __origGetParam.call(this, p);
|
||||
};
|
||||
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||
const __origGetParam2 = WebGL2RenderingContext.prototype.getParameter;
|
||||
WebGL2RenderingContext.prototype.getParameter = function(p) {
|
||||
if (p === 37445) return 'Apple GPU';
|
||||
if (p === 37446) return 'Apple M1 Pro';
|
||||
return __origGetParam2.call(this, p);
|
||||
};
|
||||
}
|
||||
`);
|
||||
|
||||
// 5. iframe contentWindow isolation — apply webdriver patch in child frames
|
||||
await this.context.addInitScript(`
|
||||
const __iframeDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
||||
if (__iframeDesc) {
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||||
get: function() {
|
||||
const win = __iframeDesc.get?.call(this);
|
||||
if (win) {
|
||||
try {
|
||||
Object.defineProperty(win.navigator, 'webdriver', { get: () => undefined });
|
||||
} catch(e) {}
|
||||
}
|
||||
return win;
|
||||
},
|
||||
});
|
||||
}
|
||||
`);
|
||||
|
||||
// 6. Languages consistency
|
||||
await this.context.addInitScript(`
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
`);
|
||||
}
|
||||
|
||||
private async enterFullscreen(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
console.warn('Native fullscreen only supported on macOS');
|
||||
@@ -797,6 +905,34 @@ export class ClaudeBrowser {
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd.source === 'firefox') {
|
||||
const cookies = firefox.importFirefoxCookies({
|
||||
domain: cmd.domain,
|
||||
profile: cmd.profile,
|
||||
});
|
||||
|
||||
if (cookies.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
imported: 0,
|
||||
source: 'firefox',
|
||||
domains: [],
|
||||
};
|
||||
}
|
||||
|
||||
const playwrightCookies = cookies.map(firefox.toPlaywrightCookie);
|
||||
await context.addCookies(playwrightCookies);
|
||||
|
||||
const domains = [...new Set(cookies.map((c) => c.domain))];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
imported: cookies.length,
|
||||
source: 'firefox',
|
||||
domains,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, error: `Unknown import source: ${cmd.source}` };
|
||||
}
|
||||
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Firefox cookie importer
|
||||
*
|
||||
* Reads cookies from Firefox's cookies.sqlite database.
|
||||
* Firefox stores cookies as plain unencrypted SQLite — no binary parsing needed.
|
||||
*
|
||||
* Database schema (moz_cookies table):
|
||||
* id, originAttributes, name, value, host, path, expiry,
|
||||
* lastAccessed, creationTime, isSecure, isHttpOnly,
|
||||
* inBrowserElement, sameSite, rawSameSite, schemeMap
|
||||
*
|
||||
* Note: expiry is Unix seconds, but lastAccessed/creationTime are microseconds.
|
||||
*
|
||||
* Firefox holds an exclusive WAL lock while running, so we copy the database
|
||||
* files (cookies.sqlite + WAL + SHM) to a temp directory before reading.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { homedir, platform, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export interface FirefoxCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number; // Unix timestamp (seconds)
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'None' | 'Lax' | 'Strict';
|
||||
}
|
||||
|
||||
interface FirefoxProfile {
|
||||
name: string;
|
||||
path: string;
|
||||
isRelative: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a partial profile into a full FirefoxProfile if valid
|
||||
*/
|
||||
function finalizeProfile(partial: Partial<FirefoxProfile> | null): FirefoxProfile | null {
|
||||
if (!partial?.name || !partial?.path) return null;
|
||||
return {
|
||||
name: partial.name,
|
||||
path: partial.path,
|
||||
isRelative: partial.isRelative ?? true,
|
||||
isDefault: partial.isDefault ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a key=value line to a partial profile
|
||||
*/
|
||||
function applyProfileField(profile: Partial<FirefoxProfile>, key: string, value: string): void {
|
||||
if (key === 'Name') profile.name = value;
|
||||
else if (key === 'Path') profile.path = value;
|
||||
else if (key === 'IsRelative') profile.isRelative = value === '1';
|
||||
else if (key === 'Default') profile.isDefault = value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single line of profiles.ini, updating state
|
||||
*/
|
||||
function processIniLine(
|
||||
line: string,
|
||||
current: Partial<FirefoxProfile> | null,
|
||||
profiles: FirefoxProfile[]
|
||||
): Partial<FirefoxProfile> | null {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[Profile') || trimmed.startsWith('[Install')) {
|
||||
const finalized = finalizeProfile(current);
|
||||
if (finalized) profiles.push(finalized);
|
||||
return trimmed.startsWith('[Profile') ? {} : null;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx !== -1) applyProfileField(current, trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Firefox profiles.ini to find available profiles
|
||||
*/
|
||||
function parseProfilesIni(iniPath: string): FirefoxProfile[] {
|
||||
if (!existsSync(iniPath)) return [];
|
||||
|
||||
const lines = readFileSync(iniPath, 'utf-8').split('\n');
|
||||
const profiles: FirefoxProfile[] = [];
|
||||
let current: Partial<FirefoxProfile> | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
current = processIniLine(line, current, profiles);
|
||||
}
|
||||
|
||||
const last = finalizeProfile(current);
|
||||
if (last) profiles.push(last);
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Firefox profiles root directory for the current platform
|
||||
*/
|
||||
function getFirefoxRoot(): string {
|
||||
const home = homedir();
|
||||
|
||||
switch (platform()) {
|
||||
case 'darwin':
|
||||
return join(home, 'Library/Application Support/Firefox');
|
||||
case 'linux':
|
||||
return join(home, '.mozilla/firefox');
|
||||
case 'win32':
|
||||
return join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Mozilla/Firefox');
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available Firefox profiles
|
||||
*/
|
||||
export function listFirefoxProfiles(): FirefoxProfile[] {
|
||||
const root = getFirefoxRoot();
|
||||
const iniPath = join(root, 'profiles.ini');
|
||||
return parseProfilesIni(iniPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a FirefoxProfile to its absolute path
|
||||
*/
|
||||
function profileToAbsolutePath(root: string, p: FirefoxProfile): string {
|
||||
return p.isRelative ? join(root, p.path) : p.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full path to a Firefox profile directory
|
||||
*/
|
||||
function resolveProfilePath(profile?: string): string {
|
||||
const root = getFirefoxRoot();
|
||||
const profiles = listFirefoxProfiles();
|
||||
|
||||
if (!profile) {
|
||||
const defaultProfile = profiles.find((p) => p.isDefault) || profiles[0];
|
||||
if (!defaultProfile) throw new Error('No Firefox profiles found. Is Firefox installed?');
|
||||
return profileToAbsolutePath(root, defaultProfile);
|
||||
}
|
||||
|
||||
// Try exact match by name or path
|
||||
const match = profiles.find((p) => p.name === profile || p.path === profile);
|
||||
if (match) return profileToAbsolutePath(root, match);
|
||||
|
||||
// Try as direct path fragment in Profiles dir
|
||||
const directPath = join(root, 'Profiles', profile);
|
||||
if (existsSync(directPath)) return directPath;
|
||||
|
||||
// Try as absolute path
|
||||
if (existsSync(profile)) return profile;
|
||||
|
||||
const available = profiles.map((p) => p.name).join(', ');
|
||||
throw new Error(`Firefox profile not found: "${profile}". Available: ${available}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely copy the Firefox cookies database to a temp directory.
|
||||
* Copies cookies.sqlite + WAL + SHM files to avoid lock conflicts.
|
||||
*/
|
||||
function copyDatabaseSafely(dbPath: string): { tmpDir: string; tmpDbPath: string } {
|
||||
if (!existsSync(dbPath)) {
|
||||
throw new Error(
|
||||
`Firefox cookies database not found at: ${dbPath}\nMake sure Firefox has been used at least once.`
|
||||
);
|
||||
}
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-fx-'));
|
||||
const dbName = 'cookies.sqlite';
|
||||
|
||||
try {
|
||||
// Copy main database
|
||||
copyFileSync(dbPath, join(tmpDir, dbName));
|
||||
|
||||
// Copy WAL and SHM if they exist (needed for up-to-date reads)
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const src = `${dbPath}${ext}`;
|
||||
if (existsSync(src)) {
|
||||
copyFileSync(src, join(tmpDir, `${dbName}${ext}`));
|
||||
}
|
||||
}
|
||||
|
||||
return { tmpDir, tmpDbPath: join(tmpDir, dbName) };
|
||||
} catch (err) {
|
||||
// Clean up on failure
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Firefox sameSite integer to string
|
||||
* 0 = None, 1 = Lax, 2 = Strict
|
||||
*/
|
||||
function sameSiteToString(value: number): 'None' | 'Lax' | 'Strict' {
|
||||
switch (value) {
|
||||
case 2:
|
||||
return 'Strict';
|
||||
case 1:
|
||||
return 'Lax';
|
||||
default:
|
||||
return 'None';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import cookies from Firefox's cookies.sqlite database
|
||||
*/
|
||||
export function importFirefoxCookies(options?: {
|
||||
profile?: string;
|
||||
domain?: string;
|
||||
}): FirefoxCookie[] {
|
||||
const profilePath = resolveProfilePath(options?.profile);
|
||||
const dbPath = join(profilePath, 'cookies.sqlite');
|
||||
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
|
||||
|
||||
try {
|
||||
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
|
||||
|
||||
let query =
|
||||
'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies';
|
||||
const params: string[] = [];
|
||||
|
||||
if (options?.domain) {
|
||||
const domain = options.domain.toLowerCase();
|
||||
query += ' WHERE LOWER(host) = ? OR LOWER(host) = ? OR LOWER(host) LIKE ?';
|
||||
params.push(domain, `.${domain}`, `%.${domain}`);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
|
||||
db.close();
|
||||
|
||||
return (rows as Record<string, unknown>[]).map((row) => ({
|
||||
name: row.name as string,
|
||||
value: row.value as string,
|
||||
domain: row.host as string,
|
||||
path: row.path as string,
|
||||
expires: row.expiry as number,
|
||||
secure: (row.isSecure as number) === 1,
|
||||
httpOnly: (row.isHttpOnly as number) === 1,
|
||||
sameSite: sameSiteToString(row.sameSite as number),
|
||||
}));
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert FirefoxCookie to Playwright cookie format
|
||||
*/
|
||||
export function toPlaywrightCookie(cookie: FirefoxCookie): {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
} {
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
expires: cookie.expires,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
};
|
||||
}
|
||||
+54
-38
@@ -20,6 +20,7 @@ let browserOptions = {
|
||||
fullscreen: false,
|
||||
preview: false,
|
||||
previewDelay: 2000,
|
||||
stealth: false,
|
||||
};
|
||||
let browser = new ClaudeBrowser(browserOptions);
|
||||
let launched = false;
|
||||
@@ -86,43 +87,55 @@ server.tool(
|
||||
previewDelay: z.number().optional().default(2000).describe('Preview highlight duration in ms'),
|
||||
width: z.number().optional().default(1280).describe('Viewport width'),
|
||||
height: z.number().optional().default(800).describe('Viewport height'),
|
||||
stealth: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Enable stealth mode to reduce bot detection. Patches navigator.webdriver, plugins, WebGL, permissions, and sets a realistic Safari user-agent. See STEALTH.md for details.'
|
||||
),
|
||||
},
|
||||
withLogging('launch', async ({ headed, fullscreen, preview, previewDelay, width, height }) => {
|
||||
// Close existing browser if launched
|
||||
if (launched) {
|
||||
await browser.close();
|
||||
launched = false;
|
||||
withLogging(
|
||||
'launch',
|
||||
async ({ headed, fullscreen, preview, previewDelay, width, height, stealth }) => {
|
||||
// Close existing browser if launched
|
||||
if (launched) {
|
||||
await browser.close();
|
||||
launched = false;
|
||||
}
|
||||
|
||||
// Update options - fullscreen/preview imply headed
|
||||
browserOptions = {
|
||||
headless: fullscreen || preview ? false : !headed,
|
||||
width,
|
||||
height,
|
||||
fullscreen,
|
||||
preview,
|
||||
previewDelay,
|
||||
stealth,
|
||||
};
|
||||
|
||||
// Create new browser with updated options
|
||||
browser = new ClaudeBrowser(browserOptions);
|
||||
await browser.launch();
|
||||
launched = true;
|
||||
|
||||
return textResult(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
message: `Browser launched${stealth ? ' (stealth mode)' : ''}`,
|
||||
options: {
|
||||
headed: !browserOptions.headless,
|
||||
fullscreen,
|
||||
preview,
|
||||
previewDelay,
|
||||
stealth,
|
||||
viewport: { width, height },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update options - fullscreen/preview imply headed
|
||||
browserOptions = {
|
||||
headless: fullscreen || preview ? false : !headed,
|
||||
width,
|
||||
height,
|
||||
fullscreen,
|
||||
preview,
|
||||
previewDelay,
|
||||
};
|
||||
|
||||
// Create new browser with updated options
|
||||
browser = new ClaudeBrowser(browserOptions);
|
||||
await browser.launch();
|
||||
launched = true;
|
||||
|
||||
return textResult(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
message: 'Browser launched',
|
||||
options: {
|
||||
headed: !browserOptions.headless,
|
||||
fullscreen,
|
||||
preview,
|
||||
previewDelay,
|
||||
viewport: { width, height },
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Navigation
|
||||
@@ -680,14 +693,17 @@ server.tool(
|
||||
// Browser import
|
||||
server.tool(
|
||||
'import',
|
||||
'Import cookies from Safari browser (macOS only). Requires Full Disk Access permission.',
|
||||
'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.',
|
||||
{
|
||||
source: z.enum(['safari']).describe('Browser to import from (currently only Safari supported)'),
|
||||
source: z.enum(['safari', 'firefox']).describe('Browser to import from'),
|
||||
domain: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Filter cookies to specific domain (e.g., "github.com")'),
|
||||
profile: z.string().optional().describe('Safari profile/WebKit data store ID (optional)'),
|
||||
profile: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Safari profile/WebKit data store ID, or Firefox profile name (optional)'),
|
||||
},
|
||||
withLogging('import', async ({ source, domain, profile }) => {
|
||||
await ensureLaunched();
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ export interface BrowserOptions {
|
||||
fullscreen?: boolean;
|
||||
preview?: boolean;
|
||||
previewDelay?: number;
|
||||
stealth?: boolean;
|
||||
}
|
||||
|
||||
export interface ElementInfo {
|
||||
@@ -301,10 +302,10 @@ export interface EmulateCommand {
|
||||
device: string;
|
||||
}
|
||||
|
||||
// Safari import
|
||||
// Browser import (Safari, Firefox)
|
||||
export interface ImportCommand {
|
||||
cmd: 'import';
|
||||
source: 'safari';
|
||||
source: 'safari' | 'firefox';
|
||||
domain?: string;
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user