mirror of
https://github.com/vale981/tridactyl
synced 2025-03-05 17:41:40 -05:00
Merge branch 'master' of github.com:cmcaine/tridactyl into glacambre-make_scrolling_faster
This commit is contained in:
commit
ce019627ed
3 changed files with 412 additions and 64 deletions
|
@ -1,25 +1,39 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
VERSION = "0.1.5"
|
||||
DEBUG = False
|
||||
VERSION = "0.1.6"
|
||||
|
||||
|
||||
class NoConnectionError(Exception):
|
||||
""" Exception thrown when stdin cannot be read """
|
||||
|
||||
|
||||
def is_command_on_path(command):
|
||||
""" Returns 'True' if the if the specified command is found on
|
||||
user's $PATH.
|
||||
"""
|
||||
if shutil.which(command):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def eprint(*args, **kwargs):
|
||||
""" Print to stderr, which gets echoed in the browser console when run
|
||||
by Firefox
|
||||
""" Print to stderr, which gets echoed in the browser console
|
||||
when run by Firefox
|
||||
"""
|
||||
print(*args, file=sys.stderr, flush=True, **kwargs)
|
||||
|
||||
|
@ -41,8 +55,8 @@ def getMessage():
|
|||
rawLength = sys.stdin.buffer.read(4)
|
||||
if len(rawLength) == 0:
|
||||
sys.exit(0)
|
||||
messageLength = struct.unpack('@I', rawLength)[0]
|
||||
message = sys.stdin.buffer.read(messageLength).decode('utf-8')
|
||||
messageLength = struct.unpack("@I", rawLength)[0]
|
||||
message = sys.stdin.buffer.read(messageLength).decode("utf-8")
|
||||
return json.loads(message)
|
||||
|
||||
|
||||
|
@ -50,18 +64,18 @@ def getMessage():
|
|||
# given its content.
|
||||
def encodeMessage(messageContent):
|
||||
""" Encode a message for transmission, given its content."""
|
||||
encodedContent = json.dumps(messageContent).encode('utf-8')
|
||||
encodedLength = struct.pack('@I', len(encodedContent))
|
||||
return {'length': encodedLength, 'content': encodedContent}
|
||||
encodedContent = json.dumps(messageContent).encode("utf-8")
|
||||
encodedLength = struct.pack("@I", len(encodedContent))
|
||||
return {"length": encodedLength, "content": encodedContent}
|
||||
|
||||
|
||||
# Send an encoded message to stdout
|
||||
def sendMessage(encodedMessage):
|
||||
""" Send an encoded message to stdout."""
|
||||
sys.stdout.buffer.write(encodedMessage['length'])
|
||||
sys.stdout.buffer.write(encodedMessage['content'])
|
||||
sys.stdout.buffer.write(encodedMessage["length"])
|
||||
sys.stdout.buffer.write(encodedMessage["content"])
|
||||
try:
|
||||
sys.stdout.buffer.write(encodedMessage['code'])
|
||||
sys.stdout.buffer.write(encodedMessage["code"])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
@ -72,13 +86,15 @@ def findUserConfigFile():
|
|||
""" Find a user config file, if it exists. Return the file path, or None
|
||||
if not found
|
||||
"""
|
||||
home = os.path.expanduser('~')
|
||||
config_dir = getenv('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
|
||||
home = os.path.expanduser("~")
|
||||
config_dir = getenv(
|
||||
"XDG_CONFIG_HOME", os.path.expanduser("~/.config")
|
||||
)
|
||||
|
||||
# Will search for files in this order
|
||||
candidate_files = [
|
||||
os.path.join(config_dir, "tridactyl", "tridactylrc"),
|
||||
os.path.join(home, '.tridactylrc')
|
||||
os.path.join(home, ".tridactylrc"),
|
||||
]
|
||||
|
||||
config_path = None
|
||||
|
@ -102,7 +118,7 @@ def getUserConfig():
|
|||
|
||||
# for now, this is a simple file read, but if the files can
|
||||
# include other files, that will need more work
|
||||
return open(cfg_file, 'r').read()
|
||||
return open(cfg_file, "r").read()
|
||||
|
||||
|
||||
def sanitizeFilename(fn):
|
||||
|
@ -110,30 +126,292 @@ def sanitizeFilename(fn):
|
|||
|
||||
From https://stackoverflow.com/a/295466/147356"""
|
||||
|
||||
fn = unicodedata.normalize('NFKD', fn).encode(
|
||||
'ascii', 'ignore').decode('ascii')
|
||||
fn = re.sub('[^\w\s/.-]', '', fn).strip().lower()
|
||||
fn = re.sub('\.\.+', '', fn)
|
||||
fn = re.sub('[-/\s]+', '-', fn)
|
||||
fn = (
|
||||
unicodedata.normalize("NFKD", fn)
|
||||
.encode("ascii", "ignore")
|
||||
.decode("ascii")
|
||||
)
|
||||
fn = re.sub("[^\w\s/.-]", "", fn).strip().lower()
|
||||
fn = re.sub("\.\.+", "", fn)
|
||||
fn = re.sub("[-/\s]+", "-", fn)
|
||||
return fn
|
||||
|
||||
|
||||
def is_valid_firefox_profile(profile_dir):
|
||||
is_valid = False
|
||||
validity_indicator = "times.json"
|
||||
|
||||
if pathlib.WindowsPath(profile_dir).is_dir():
|
||||
test_path = "%s\\%s" % (profile_dir, validity_indicator)
|
||||
|
||||
if pathlib.WindowsPath(test_path).is_file():
|
||||
is_valid = True
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
def win_firefox_restart(message):
|
||||
"""Handle 'win_firefox_restart' message."""
|
||||
reply = {}
|
||||
profile_dir = None
|
||||
browser_cmd = None
|
||||
|
||||
try:
|
||||
profile_dir = message["profiledir"].strip()
|
||||
browser_cmd = message["browsercmd"].strip()
|
||||
except KeyError:
|
||||
reply = {
|
||||
"code": -1,
|
||||
"cmd": "error",
|
||||
"error": "Error parsing 'restart' message.",
|
||||
}
|
||||
return reply
|
||||
|
||||
if (
|
||||
profile_dir
|
||||
and profile_dir != "auto"
|
||||
and not is_valid_firefox_profile(profile_dir)
|
||||
):
|
||||
reply = {
|
||||
"code": -1,
|
||||
"cmd": "error",
|
||||
"error": "%s %s %s"
|
||||
% (
|
||||
"Invalid profile directory specified.",
|
||||
"Vaild profile directory path(s) can be found by",
|
||||
"navigating to 'about:support'.",
|
||||
),
|
||||
}
|
||||
|
||||
elif browser_cmd and not is_command_on_path(browser_cmd):
|
||||
reply = {
|
||||
"code": -1,
|
||||
"cmd": "error",
|
||||
"error": "%s %s %s"
|
||||
% (
|
||||
"'{0}' wasn't found on %PATH%.".format(browser_cmd),
|
||||
"Please set valid browser by",
|
||||
"'set browser [browser-command]'.",
|
||||
),
|
||||
}
|
||||
|
||||
else:
|
||||
# {{{
|
||||
# Native messenger can't seem to create detached process on
|
||||
# Windows while Firefox is quitting, which is essential to
|
||||
# trigger restarting Firefox. So, below we are resorting to
|
||||
# create a scheduled task with the task start-time set in
|
||||
# the near future.
|
||||
#
|
||||
|
||||
#
|
||||
# subprocess.Popen(
|
||||
# [ff_bin_path, "-profile", profile_dir],
|
||||
# shell=False,
|
||||
# creationflags=0x208 \
|
||||
# | subprocess.CREATE_NEW_PROCESS_GROUP)
|
||||
#
|
||||
|
||||
#
|
||||
# 'schtasks.exe' is limited as in it doesn't support
|
||||
# task-time with granularity in seconds. So, falling back
|
||||
# to PowerShell as the last resort.
|
||||
#
|
||||
|
||||
# out_str = ""
|
||||
# task_time = time.strftime("%H:%M",
|
||||
# time.localtime(
|
||||
# time.time() + 60))
|
||||
#
|
||||
# out_str = subprocess.check_output(
|
||||
# ["schtasks.exe",
|
||||
# "/Create",
|
||||
# "/F",
|
||||
# "/SC",
|
||||
# "ONCE",
|
||||
# "/TN",
|
||||
# "tridactyl",
|
||||
# "/TR",
|
||||
# "calc",
|
||||
# "/IT",
|
||||
# "/ST",
|
||||
# task_time],
|
||||
# shell=True)
|
||||
# }}}
|
||||
|
||||
ff_lock_name = "parent.lock"
|
||||
|
||||
ff_bin_name = browser_cmd
|
||||
ff_bin_path = '"%s"' % shutil.which(ff_bin_name)
|
||||
|
||||
ff_bin_dir = '"%s"' % str(
|
||||
pathlib.WindowsPath(shutil.which(ff_bin_name)).parent
|
||||
)
|
||||
|
||||
if profile_dir == "auto":
|
||||
ff_lock_path = ff_bin_path
|
||||
ff_args = '"%s"' % ("-foreground")
|
||||
else:
|
||||
ff_lock_path = '"%s/%s"' % (profile_dir, ff_lock_name)
|
||||
ff_args = '"%s","%s","%s"' % (
|
||||
"-foreground",
|
||||
"-profile",
|
||||
profile_dir,
|
||||
)
|
||||
|
||||
try:
|
||||
restart_ps1_content = """
|
||||
$env:PATH=$env:PATH;{ff_bin_dir}
|
||||
Set-Location -Path {ff_bin_dir}
|
||||
$profileDir = "{profile_dir}"
|
||||
if ($profileDir -ne "auto") {{
|
||||
$lockFilePath = {ff_lock_path}
|
||||
$locked = $true
|
||||
$num_try = 10
|
||||
}} else {{
|
||||
$locked = $false
|
||||
}}
|
||||
while (($locked -eq $true) -and ($num_try -gt 0)) {{
|
||||
try {{
|
||||
[IO.File]::OpenWrite($lockFilePath).close()
|
||||
$locked=$false
|
||||
}} catch {{
|
||||
$num_try-=1
|
||||
Write-Host "[+] Trial: $num_try [lock == true]"
|
||||
Start-Sleep -Seconds 1
|
||||
}}
|
||||
}}
|
||||
if ($locked -eq $true) {{
|
||||
$errorMsg = "Restarting Firefox failed. Please restart manually."
|
||||
Write-Host "$errorMsg"
|
||||
# Add-Type -AssemblyName System.Windows.Forms
|
||||
# [System.Windows.MessageBox]::Show(
|
||||
# $errorMsg,
|
||||
# "Tridactyl")
|
||||
}} else {{
|
||||
Write-Host "[+] Restarting Firefox ..."
|
||||
Start-Process `
|
||||
-WorkingDirectory {ff_bin_dir} `
|
||||
-FilePath {ff_bin_path} `
|
||||
-ArgumentList {ff_args} `
|
||||
-WindowStyle Normal
|
||||
}}
|
||||
""".format(
|
||||
ff_bin_dir=ff_bin_dir,
|
||||
profile_dir=profile_dir,
|
||||
ff_lock_path=ff_lock_path,
|
||||
ff_bin_path=ff_bin_path,
|
||||
ff_args=ff_args,
|
||||
)
|
||||
|
||||
delay_sec = 1.5
|
||||
task_name = "firefox-restart"
|
||||
native_messenger_dirname = ".tridactyl"
|
||||
|
||||
powershell_cmd = "powershell"
|
||||
powershell_args = "%s %s" % (
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy Bypass",
|
||||
)
|
||||
|
||||
restart_ps1_path = "%s\\%s\\%s" % (
|
||||
os.path.expanduser("~"),
|
||||
native_messenger_dirname,
|
||||
"win_firefox_restart.ps1",
|
||||
)
|
||||
|
||||
task_cmd = "cmd"
|
||||
task_arg = '/c "%s %s -File %s"' % (
|
||||
powershell_cmd,
|
||||
powershell_args,
|
||||
restart_ps1_path,
|
||||
)
|
||||
|
||||
open(restart_ps1_path, "w+").write(restart_ps1_content)
|
||||
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
|
||||
subprocess.check_output(
|
||||
[
|
||||
"powershell",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-WindowStyle",
|
||||
"Minimized",
|
||||
"-InputFormat",
|
||||
"None",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Register-ScheduledTask \
|
||||
-TaskName '%s' \
|
||||
-Force \
|
||||
-Action (New-ScheduledTaskAction \
|
||||
-Execute '%s' \
|
||||
-Argument '%s') \
|
||||
-Trigger (New-ScheduledTaskTrigger \
|
||||
-Once \
|
||||
-At \
|
||||
(Get-Date).AddSeconds(%d).ToString('HH:mm:ss'))"
|
||||
% (task_name, task_cmd, task_arg, delay_sec),
|
||||
],
|
||||
shell=False,
|
||||
startupinfo=startupinfo,
|
||||
)
|
||||
|
||||
reply = {
|
||||
"code": 0,
|
||||
"content": "Restarting in %d seconds..."
|
||||
% delay_sec,
|
||||
}
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
reply = {
|
||||
"code": -1,
|
||||
"cmd": "error",
|
||||
"error": "error creating restart task.",
|
||||
}
|
||||
|
||||
return reply
|
||||
|
||||
|
||||
def write_log(msg):
|
||||
debug_log_dirname = ".tridactyl"
|
||||
debug_log_filename = "native_main.log"
|
||||
|
||||
debug_log_path = "%s\\%s\\%s" % (
|
||||
os.path.expanduser("~"),
|
||||
debug_log_dirname,
|
||||
debug_log_filename,
|
||||
)
|
||||
|
||||
open(debug_log_path, "a+").write(msg)
|
||||
|
||||
|
||||
def handleMessage(message):
|
||||
""" Generate reply from incoming message. """
|
||||
cmd = message["cmd"]
|
||||
reply = {'cmd': cmd}
|
||||
reply = {"cmd": cmd}
|
||||
|
||||
if cmd == 'version':
|
||||
reply = {'version': VERSION}
|
||||
if DEBUG:
|
||||
msg = "%s %s\n" % (
|
||||
time.strftime("%H:%M:%S %p", time.localtime()),
|
||||
str(message),
|
||||
)
|
||||
write_log(msg)
|
||||
|
||||
elif cmd == 'getconfig':
|
||||
if cmd == "version":
|
||||
reply = {"version": VERSION}
|
||||
|
||||
elif cmd == "getconfig":
|
||||
file_content = getUserConfig()
|
||||
if file_content:
|
||||
reply['content'] = file_content
|
||||
reply["content"] = file_content
|
||||
else:
|
||||
reply['code'] = 'File not found'
|
||||
reply["code"] = "File not found"
|
||||
|
||||
elif cmd == 'run':
|
||||
elif cmd == "run":
|
||||
commands = message["command"]
|
||||
|
||||
try:
|
||||
|
@ -145,47 +423,56 @@ def handleMessage(message):
|
|||
reply["code"] = process.returncode
|
||||
reply["content"] = process.output.decode("utf-8")
|
||||
|
||||
elif cmd == 'eval':
|
||||
elif cmd == "eval":
|
||||
output = eval(message["command"])
|
||||
reply['content'] = output
|
||||
reply["content"] = output
|
||||
|
||||
elif cmd == 'read':
|
||||
elif cmd == "read":
|
||||
try:
|
||||
with open(os.path.expandvars(os.path.expanduser(message["file"])), "r") as file:
|
||||
reply['content'] = file.read()
|
||||
reply['code'] = 0
|
||||
with open(
|
||||
os.path.expandvars(
|
||||
os.path.expanduser(message["file"])
|
||||
),
|
||||
"r",
|
||||
) as file:
|
||||
reply["content"] = file.read()
|
||||
reply["code"] = 0
|
||||
except FileNotFoundError:
|
||||
reply['content'] = ""
|
||||
reply['code'] = 2
|
||||
reply["content"] = ""
|
||||
reply["code"] = 2
|
||||
|
||||
elif cmd == 'mkdir':
|
||||
elif cmd == "mkdir":
|
||||
os.makedirs(
|
||||
os.path.relpath(message["dir"]), exist_ok=message["exist_ok"]
|
||||
os.path.relpath(message["dir"]),
|
||||
exist_ok=message["exist_ok"],
|
||||
)
|
||||
reply['content'] = ""
|
||||
reply['code'] = 0
|
||||
reply["content"] = ""
|
||||
reply["code"] = 0
|
||||
|
||||
elif cmd == 'write':
|
||||
elif cmd == "write":
|
||||
with open(message["file"], "w") as file:
|
||||
file.write(message["content"])
|
||||
|
||||
elif cmd == 'temp':
|
||||
prefix = message.get('prefix')
|
||||
elif cmd == "temp":
|
||||
prefix = message.get("prefix")
|
||||
if prefix is None:
|
||||
prefix = ''
|
||||
prefix = 'tmp_{}_'.format(sanitizeFilename(prefix))
|
||||
prefix = ""
|
||||
prefix = "tmp_{}_".format(sanitizeFilename(prefix))
|
||||
|
||||
(handle, filepath) = tempfile.mkstemp(prefix=prefix)
|
||||
with os.fdopen(handle, "w") as file:
|
||||
file.write(message["content"])
|
||||
reply['content'] = filepath
|
||||
reply["content"] = filepath
|
||||
|
||||
elif cmd == 'env':
|
||||
reply['content'] = getenv(message["var"], "")
|
||||
elif cmd == "env":
|
||||
reply["content"] = getenv(message["var"], "")
|
||||
|
||||
elif cmd == "win_firefox_restart":
|
||||
reply = win_firefox_restart(message)
|
||||
|
||||
else:
|
||||
reply = {'cmd': 'error', 'error': 'Unhandled message'}
|
||||
eprint('Unhandled message: {}'.format(message))
|
||||
reply = {"cmd": "error", "error": "Unhandled message"}
|
||||
eprint("Unhandled message: {}".format(message))
|
||||
|
||||
return reply
|
||||
|
||||
|
|
|
@ -407,11 +407,24 @@ export async function updatenative(interactive = true) {
|
|||
*/
|
||||
//#background
|
||||
export async function restart() {
|
||||
const firefox = (await Native.ffargs()).join(" ")
|
||||
const profile = await Native.getProfileDir()
|
||||
// Wait for the lock to disappear, then wait a bit more, then start firefox
|
||||
Native.run(`while readlink ${profile}/lock ; do sleep 1 ; done ; sleep 1 ; ${firefox}`)
|
||||
qall()
|
||||
const profiledir = await Native.getProfileDir()
|
||||
const browsercmd = await config.get("browser")
|
||||
|
||||
if ((await browser.runtime.getPlatformInfo()).os === "win") {
|
||||
let reply = await Native.winFirefoxRestart(profiledir, browsercmd)
|
||||
logger.info("[+] win_firefox_restart 'reply' = " + JSON.stringify(reply))
|
||||
if (Number(reply["code"]) === 0) {
|
||||
fillcmdline("#" + reply["content"])
|
||||
qall()
|
||||
} else {
|
||||
fillcmdline("#" + reply["error"])
|
||||
}
|
||||
} else {
|
||||
const firefox = (await Native.ffargs()).join(" ")
|
||||
// Wait for the lock to disappear, then wait a bit more, then start firefox
|
||||
Native.run(`while readlink ${profiledir}/lock ; do sleep 1 ; done ; sleep 1 ; ${firefox}`)
|
||||
qall()
|
||||
}
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
|
|
@ -20,6 +20,7 @@ type MessageCommand =
|
|||
| "eval"
|
||||
| "getconfig"
|
||||
| "env"
|
||||
| "win_firefox_restart"
|
||||
interface MessageResp {
|
||||
cmd: string
|
||||
version: number | null
|
||||
|
@ -245,6 +246,19 @@ export async function temp(content: string, prefix: string) {
|
|||
return sendNativeMsg("temp", { content, prefix })
|
||||
}
|
||||
|
||||
export async function winFirefoxRestart(
|
||||
profiledir: string,
|
||||
browsercmd: string,
|
||||
) {
|
||||
let required_version = "0.1.6"
|
||||
|
||||
if (!await nativegate(required_version, false)) {
|
||||
throw `'restart' on Windows needs native messenger version >= ${required_version}.`
|
||||
}
|
||||
|
||||
return sendNativeMsg("win_firefox_restart", { profiledir, browsercmd })
|
||||
}
|
||||
|
||||
export async function run(command: string) {
|
||||
let msg = await sendNativeMsg("run", { command })
|
||||
logger.info(msg)
|
||||
|
@ -259,10 +273,12 @@ export async function pyeval(command: string): Promise<MessageResp> {
|
|||
}
|
||||
|
||||
export async function getenv(variable: string) {
|
||||
let v = await getNativeMessengerVersion()
|
||||
if (!await nativegate("0.1.2", false)) {
|
||||
throw `Error: getenv needs native messenger v>=0.1.2. Current: ${v}`
|
||||
let required_version = "0.1.2"
|
||||
|
||||
if (!await nativegate(required_version, false)) {
|
||||
throw `'getenv' needs native messenger version >= ${required_version}.`
|
||||
}
|
||||
|
||||
return (await sendNativeMsg("env", { var: variable })).content
|
||||
}
|
||||
|
||||
|
@ -270,14 +286,46 @@ export async function getenv(variable: string) {
|
|||
You'll get both firefox binary (not necessarily an absolute path) and flags */
|
||||
export async function ffargs(): Promise<string[]> {
|
||||
// Using ' and + rather that ` because we don't want newlines
|
||||
let output = await pyeval(
|
||||
'handleMessage({"cmd": "run", ' +
|
||||
'"command": "ps -p " + str(os.getppid()) + " -oargs="})["content"]',
|
||||
)
|
||||
return output.content.trim().split(" ")
|
||||
if ((await browserBg.runtime.getPlatformInfo()).os === "win") {
|
||||
throw `Error: "ffargs() is currently broken on Windows and should be avoided."`
|
||||
} else {
|
||||
let output = await pyeval(
|
||||
'handleMessage({"cmd": "run", ' +
|
||||
'"command": "ps -p " + str(os.getppid()) + " -oargs="})["content"]',
|
||||
)
|
||||
return output.content.trim().split(" ")
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfileDir() {
|
||||
// Windows users must explicitly set their Firefox profile
|
||||
// directory via 'set profiledir [directory]', or use the
|
||||
// default 'profiledir' value as 'auto' (without quotes).
|
||||
//
|
||||
// Profile directory paths on Windows must _not_ be escaped, and
|
||||
// should be used exactly as shown in the 'about:support' page.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// :set profiledir C:\Users\<User-Name>\AppData\Roaming\Mozilla\Firefox\Profiles\8s21wzbh.Default
|
||||
//
|
||||
if ((await browserBg.runtime.getPlatformInfo()).os === "win") {
|
||||
let win_profiledir = config.get("profiledir")
|
||||
win_profiledir = win_profiledir.trim()
|
||||
logger.info("[+] profiledir original: " + win_profiledir)
|
||||
|
||||
win_profiledir = win_profiledir.replace(/\\/g, "/")
|
||||
logger.info("[+] profiledir escaped: " + win_profiledir)
|
||||
|
||||
if (win_profiledir.length > 0) {
|
||||
return win_profiledir
|
||||
} else {
|
||||
throw new Error(
|
||||
"Profile directory is not set. Profile directory path(s) can be found by navigating to 'about:support'.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// First, see if we can get the profile from the arguments that were given
|
||||
// to Firefox
|
||||
let args = await ffargs()
|
||||
|
|
Loading…
Add table
Reference in a new issue