fish-shell/share/tools/web_config/webconfig.py
Fabian Boehm 0520b56ea9 webconfig: Don't allow exceptions from chromeos workaround
This except clause was too narrow, so it would fail here even on other
systems just because webbrowser.get() returned nothing usable

Now it will fail *later* with "could not locate runnable browser", but
at least it won't say anything about chromeos on non-chromeos systems.
2024-08-13 15:41:44 +02:00

1810 lines
55 KiB
Python
Executable file

from __future__ import unicode_literals
from __future__ import print_function
import binascii
try:
from html import escape as escape_html
except ImportError:
from cgi import escape as escape_html
import errno
import glob
import multiprocessing.pool
import operator
import os
import platform
import re
import select
import socket
import subprocess
import sys
import tempfile
import threading
from itertools import chain
COMMON_WSL_CMD_PATHS = (
"/mnt/c/Windows/System32",
"/windir/c/Windows/System32",
"/c/Windows/System32",
)
FISH_BIN_PATH = False # will be set later
IS_PY2 = sys.version_info[0] == 2
if IS_PY2:
import SimpleHTTPServer
import SocketServer
from urlparse import parse_qs
else:
import http.server as SimpleHTTPServer
import socketserver as SocketServer
from urllib.parse import parse_qs
try:
import json
except ImportError:
import simplejson as json
# Disable CLI web browsers
term = os.environ.pop("TERM", None)
# This import must be done with an empty $TERM, otherwise a command-line browser may be started
# which will block the whole process - see https://docs.python.org/3/library/webbrowser.html
import webbrowser
if term:
os.environ["TERM"] = term
def find_executable(exe, paths=()):
final_path = os.environ["PATH"].split(os.pathsep)
if paths:
final_path.extend(paths)
for p in final_path:
proposed_path = os.path.join(p, exe)
if os.access(proposed_path, os.X_OK):
return proposed_path
def isMacOS10_12_5_OrLater():
"""Return whether this system is macOS 10.12.5 or a later version."""
try:
return [int(x) for x in platform.mac_ver()[0].split(".")] >= [10, 12, 5]
except ValueError:
return False
def is_wsl():
"""Return whether we are running under the Windows Subsystem for Linux"""
if "linux" in platform.system().lower() and os.access("/proc/version", os.R_OK):
with open("/proc/version", "r") as f:
# Find 'Microsoft' for wsl1 and 'microsoft' for wsl2
if "microsoft" in f.read().lower():
return True
return False
def is_windows():
"""Return whether we are running under the Windows"""
return sys.platform.startswith("win")
def is_sailfish_os():
"""Return whether we are running on Sailfish OS"""
if "linux" in platform.system().lower() and os.access(
"/etc/sailfish-release", os.R_OK
):
with open("/etc/sailfish-release", "r") as f:
# Find 'ID=sailfishos'
if "sailfishos" in f.read():
return True
return False
def is_termux():
"""Return whether we are running under the Termux application for Android"""
return "com.termux" in os.environ["PATH"] and find_executable("termux-open-url")
def is_chromeos_garcon():
"""Return whether we are running in Chrome OS and the browser can't see local files"""
# In Crostini Chrome OS Linux, the default browser opens URLs in Chrome
# running outside the linux VM. This browser does not have access to the
# Linux filesystem. This uses Garcon, see for example
# https://chromium.googlesource.com/chromiumos/platform2/+/master/vm_tools/garcon/#opening-urls
# https://source.chromium.org/search?q=garcon-url-handler
try:
return "garcon-url-handler" in webbrowser.get().name
except:
return False
def run_fish_cmd(text):
# Ensure that fish is using UTF-8.
ctype = os.environ.get("LC_ALL", os.environ.get("LC_CTYPE", os.environ.get("LANG")))
env = None
if ctype is None or re.search(r"\.utf-?8$", ctype, flags=re.I) is None:
# override LC_CTYPE with en_US.UTF-8
# We're assuming this locale exists.
# Fish makes the same assumption in config.fish
env = os.environ.copy()
env.update(LC_CTYPE="en_US.UTF-8", LANG="en_US.UTF-8")
print("$ " + text)
p = subprocess.Popen(
[FISH_BIN_PATH],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
out, err = p.communicate(text.encode("utf-8"))
out = out.decode("utf-8", "replace")
err = err.decode("utf-8", "replace")
return out, err
def escape_fish_cmd(text):
# Replace one backslash with two, and single quotes with backslash-quote
escaped = text.replace("\\", "\\\\").replace("'", "\\'")
return "'" + escaped + "'"
def strip_one_layer(text, char):
# Strip the text from one layer of a given character
if text[-1] == char:
text = text[:-1]
if text[0] == char:
text = text[1:]
return text
named_colors = {
"black": "000000",
"red": "800000",
"green": "008000",
"brown": "725000",
"yellow": "808000",
"blue": "000080",
"magenta": "800080",
"purple": "800080",
"cyan": "008080",
"grey": "e5e5e5",
"brgrey": "555555",
"white": "c0c0c0",
"brblack": "808080",
"brred": "ff0000",
"brgreen": "00ff00",
"brbrown": "ffff00",
"bryellow": "ffff00",
"brblue": "0000ff",
"brmagenta": "ff00ff",
"brpurple": "ff00ff",
"brcyan": "00ffff",
"brwhite": "ffffff",
}
bindings_blacklist = set(["self-insert", "'begin;end'"])
def parse_one_color(comp):
"""A basic function to parse a single color value like 'FFA000'"""
if comp in named_colors:
# Named color
return named_colors[comp]
elif (
re.match(r"[0-9a-fA-F]{3}", comp) is not None
or re.match(r"[0-9a-fA-F]{6}", comp) is not None
):
# Hex color
return comp
else:
# Unknown
return ""
def better_color(c1, c2):
"""Indicate which color is "better", i.e. prefer term256 colors"""
if not c2:
return c1
if not c1:
return c2
if c1 == "normal":
return c2
if c2 == "normal":
return c1
if c2 in named_colors:
return c1
if c1 in named_colors:
return c2
return c1
def parse_color(color_str):
"""A basic function to parse a color string, for example, 'red' '--bold'."""
comps = color_str.split(" ")
color = ""
background_color = ""
bold, underline, italics, dim, reverse = False, False, False, False, False
for comp in comps:
# Remove quotes
comp = comp.strip("'\" ")
if comp == "--bold" or comp == "-o":
bold = True
elif comp == "--underline" or comp == "-u":
underline = True
elif comp == "--italics" or comp == "-i":
italics = True
elif comp == "--dim" or comp == "-d":
dim = True
elif comp == "--reverse" or comp == "-r":
reverse = True
elif comp.startswith("--background"):
# Background color
c = comp[len("--background=") :]
parsed_c = parse_one_color(c)
# We prefer the unparsed version - if it says "brgreen", we use brgreen,
# instead of 00ff00
if better_color(background_color, parsed_c) == parsed_c:
background_color = c
elif comp.startswith("-b"):
# Background color in short.
skip = len("-b")
if comp[len("-b=")] in ["=", " "]:
skip += 1
c = comp[skip:]
parsed_c = parse_one_color(c)
if better_color(background_color, parsed_c) == parsed_c:
background_color = c
else:
# Regular color
parsed_c = parse_one_color(comp)
if better_color(color, parsed_c) == parsed_c:
color = comp
return {
"color": color,
"background": background_color,
"bold": bold,
"underline": underline,
"italics": italics,
"dim": dim,
"reverse": reverse,
}
def unparse_color(col):
"""A basic function to return the fish version of a color dict"""
if isinstance(col, str):
return col
ret = ""
if col["color"]:
ret += col["color"]
if col["bold"]:
ret += " --bold"
if col["underline"]:
ret += " --underline"
if col["italics"]:
ret += " --italics"
if col["dim"]:
ret += " --dim"
if col["reverse"]:
ret += " --reverse"
if col["background"]:
ret += " --background=" + col["background"]
return ret
def parse_bool(val):
val = val.lower()
if val.startswith("f") or val.startswith("0"):
return False
if val.startswith("t") or val.startswith("1"):
return True
return bool(val)
def html_color_for_ansi_color_index(val):
arr = [
"black",
"#FF0000",
"#00FF00",
"#AA5500",
"#0000FF",
"#AA00AA",
"#00AAAA",
"#AAAAAA",
"#555555",
"#FF5555",
"#55FF55",
"#FFFF55",
"#5555FF",
"#FF55FF",
"#55FFFF",
"white",
"#000000",
"#00005f",
"#000087",
"#0000af",
"#0000d7",
"#0000ff",
"#005f00",
"#005f5f",
"#005f87",
"#005faf",
"#005fd7",
"#005fff",
"#008700",
"#00875f",
"#008787",
"#0087af",
"#0087d7",
"#0087ff",
"#00af00",
"#00af5f",
"#00af87",
"#00afaf",
"#00afd7",
"#00afff",
"#00d700",
"#00d75f",
"#00d787",
"#00d7af",
"#00d7d7",
"#00d7ff",
"#00ff00",
"#00ff5f",
"#00ff87",
"#00ffaf",
"#00ffd7",
"#00ffff",
"#5f0000",
"#5f005f",
"#5f0087",
"#5f00af",
"#5f00d7",
"#5f00ff",
"#5f5f00",
"#5f5f5f",
"#5f5f87",
"#5f5faf",
"#5f5fd7",
"#5f5fff",
"#5f8700",
"#5f875f",
"#5f8787",
"#5f87af",
"#5f87d7",
"#5f87ff",
"#5faf00",
"#5faf5f",
"#5faf87",
"#5fafaf",
"#5fafd7",
"#5fafff",
"#5fd700",
"#5fd75f",
"#5fd787",
"#5fd7af",
"#5fd7d7",
"#5fd7ff",
"#5fff00",
"#5fff5f",
"#5fff87",
"#5fffaf",
"#5fffd7",
"#5fffff",
"#870000",
"#87005f",
"#870087",
"#8700af",
"#8700d7",
"#8700ff",
"#875f00",
"#875f5f",
"#875f87",
"#875faf",
"#875fd7",
"#875fff",
"#878700",
"#87875f",
"#878787",
"#8787af",
"#8787d7",
"#8787ff",
"#87af00",
"#87af5f",
"#87af87",
"#87afaf",
"#87afd7",
"#87afff",
"#87d700",
"#87d75f",
"#87d787",
"#87d7af",
"#87d7d7",
"#87d7ff",
"#87ff00",
"#87ff5f",
"#87ff87",
"#87ffaf",
"#87ffd7",
"#87ffff",
"#af0000",
"#af005f",
"#af0087",
"#af00af",
"#af00d7",
"#af00ff",
"#af5f00",
"#af5f5f",
"#af5f87",
"#af5faf",
"#af5fd7",
"#af5fff",
"#af8700",
"#af875f",
"#af8787",
"#af87af",
"#af87d7",
"#af87ff",
"#afaf00",
"#afaf5f",
"#afaf87",
"#afafaf",
"#afafd7",
"#afafff",
"#afd700",
"#afd75f",
"#afd787",
"#afd7af",
"#afd7d7",
"#afd7ff",
"#afff00",
"#afff5f",
"#afff87",
"#afffaf",
"#afffd7",
"#afffff",
"#d70000",
"#d7005f",
"#d70087",
"#d700af",
"#d700d7",
"#d700ff",
"#d75f00",
"#d75f5f",
"#d75f87",
"#d75faf",
"#d75fd7",
"#d75fff",
"#d78700",
"#d7875f",
"#d78787",
"#d787af",
"#d787d7",
"#d787ff",
"#d7af00",
"#d7af5f",
"#d7af87",
"#d7afaf",
"#d7afd7",
"#d7afff",
"#d7d700",
"#d7d75f",
"#d7d787",
"#d7d7af",
"#d7d7d7",
"#d7d7ff",
"#d7ff00",
"#d7ff5f",
"#d7ff87",
"#d7ffaf",
"#d7ffd7",
"#d7ffff",
"#ff0000",
"#ff005f",
"#ff0087",
"#ff00af",
"#ff00d7",
"#ff00ff",
"#ff5f00",
"#ff5f5f",
"#ff5f87",
"#ff5faf",
"#ff5fd7",
"#ff5fff",
"#ff8700",
"#ff875f",
"#ff8787",
"#ff87af",
"#ff87d7",
"#ff87ff",
"#ffaf00",
"#ffaf5f",
"#ffaf87",
"#ffafaf",
"#ffafd7",
"#ffafff",
"#ffd700",
"#ffd75f",
"#ffd787",
"#ffd7af",
"#ffd7d7",
"#ffd7ff",
"#ffff00",
"#ffff5f",
"#ffff87",
"#ffffaf",
"#ffffd7",
"#ffffff",
"#080808",
"#121212",
"#1c1c1c",
"#262626",
"#303030",
"#3a3a3a",
"#444444",
"#4e4e4e",
"#585858",
"#626262",
"#6c6c6c",
"#767676",
"#808080",
"#8a8a8a",
"#949494",
"#9e9e9e",
"#a8a8a8",
"#b2b2b2",
"#bcbcbc",
"#c6c6c6",
"#d0d0d0",
"#dadada",
"#e4e4e4",
"#eeeeee",
]
if val < 0 or val >= len(arr):
return ""
else:
return arr[val]
# Function to return special ANSI escapes like exit_attribute_mode
g_special_escapes_dict = None
def get_special_ansi_escapes():
global g_special_escapes_dict
if g_special_escapes_dict is None:
try:
import curses
g_special_escapes_dict = {}
curses.setupterm()
# Helper function to get a value for a tparm
def get_tparm(key):
val = None
key = curses.tigetstr(key)
if key:
val = curses.tparm(key)
if val:
val = val.decode("utf-8")
# Use an empty string instead of None.
return "" if val is None else val
# Just a few for now
g_special_escapes_dict["exit_attribute_mode"] = get_tparm("sgr0")
g_special_escapes_dict["bold"] = get_tparm("bold")
g_special_escapes_dict["underline"] = get_tparm("smul")
except ImportError:
print("WARNING: The python curses module is missing.")
print("WARNING: Falling back to xterm-256color settings.")
print("WARNING: Rebuild python with curses headers!")
g_special_escapes_dict = {
"exit_attribute_mode": "\x1b(B\x1b[m",
"bold": "\x1b[1m",
"underline": "\x1b[4m",
}
return g_special_escapes_dict
# Given a known ANSI escape sequence, convert it to HTML and append to the list
# Returns whether we have an open <span>
def append_html_for_ansi_escape(full_val, result, span_open):
# Strip off the initial \x1b[ and terminating m
val = full_val[2:-1]
# Helper function to close a span if it's open
def close_span():
if span_open:
result.append("</span>")
# term24bit foreground color
match = re.match(r"38;2;(\d+);(\d+);(\d+)", val)
if match is not None:
close_span()
# Just use the rgb values directly
html_color = "#%02x%02x%02x" % (
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
)
result.append('<span style="color: ' + html_color + '">')
return True # span now open
# term256 foreground color
match = re.match(r"38;5;(\d+)", val)
if match is not None:
close_span()
html_color = html_color_for_ansi_color_index(int(match.group(1)))
result.append('<span style="color: ' + html_color + '">')
return True # span now open
# term16 foreground color
if val in (str(x) for x in chain(range(90, 97), range(30, 38))):
close_span()
html_color = html_color_for_ansi_color_index(
int(val) - (30 if int(val) < 90 else 82)
)
result.append('<span style="color: ' + html_color + '">')
return True # span now open
# Try special escapes
special_escapes = get_special_ansi_escapes()
if full_val == special_escapes["exit_attribute_mode"]:
close_span()
return False
# TODO We don't handle bold, underline, italics, dim, or reverse yet
# Do nothing on failure
return span_open
def strip_ansi(val):
# Make a half-assed effort to strip ANSI control sequences
# We assume that all such sequences start with 0x1b and end with m or ctrl-o,
# which catches most cases
return re.sub("\x1b[^m]*m\x0f?", "", val)
def ansi_prompt_line_width(val):
# Given an ANSI prompt, return the length of its longest line, as in the
# number of characters it takes up. Start by stripping off ANSI.
stripped_val = strip_ansi(val)
# Now count the longest line
return max([len(x) for x in stripped_val.split("\n")])
def ansi_to_html(val):
# Split us up by ANSI escape sequences. We want to catch not only the
# standard color codes, but also things like sgr0. Hence this lame check.
# Note that Python 2.6 doesn't have a flag param to re.split, so we have to
# compile it first.
reg = re.compile(
"""
( # Capture
\x1b # Escape
[^m]* # Zero or more non-'m's
m # Literal m terminates the sequence
\x0f? # HACK: A ctrl-o - this is how tmux' sgr0 ends
) # End capture
""",
re.VERBOSE,
)
separated = reg.split(val)
# We have to HTML escape the text and convert ANSI escapes into HTML
# Collect it all into this array
result = []
span_open = False
# Text is at even indexes, escape sequences at odd indexes
for i in range(len(separated)):
component = separated[i]
if i % 2 == 0:
# It's text, possibly empty
# Clean up other ANSI junk
result.append(escape_html(strip_ansi(component)))
else:
# It's an escape sequence. Close the previous escape.
span_open = append_html_for_ansi_escape(component, result, span_open)
# Close final escape
if span_open:
result.append("</span>")
# Remove empty elements
result = [x for x in result if x]
# Clean up empty spans, the nasty way
idx = len(result) - 1
while idx >= 1:
if result[idx] == "</span>" and result[idx - 1].startswith("<span"):
# Empty span, delete these two
result[idx - 1 : idx + 1] = []
idx = idx - 1
idx = idx - 1
return "".join(result)
class FishVar:
"""A class that represents a variable"""
def __init__(self, name, value):
self.name = name
self.value = value
self.universal = False
self.exported = False
def get_json_obj(self):
# Return an array(3): name, value, flags
flags = []
if self.universal:
flags.append("universal")
if self.exported:
flags.append("exported")
return {"name": self.name, "value": self.value, "Flags": ", ".join(flags)}
class FishBinding:
"""A class that represents keyboard binding"""
def __init__(self, command, raw_binding, readable_binding, description=None):
self.command = command
self.bindings = []
self.description = description
self.add_binding(raw_binding, readable_binding)
def add_binding(self, raw_binding, readable_binding):
for i in self.bindings:
if i["readable_binding"] == readable_binding:
i["raw_bindings"].append(raw_binding)
break
else:
self.bindings.append(
{"readable_binding": readable_binding, "raw_bindings": [raw_binding]}
)
def get_json_obj(self):
return {
"command": self.command,
"bindings": self.bindings,
"description": self.description,
}
class BindingParser:
"""Class to parse codes for bind command"""
# TODO: What does snext and sprevious mean ?
readable_keys = {
"dc": "Delete",
"npage": "Page Up",
"ppage": "Page Down",
"sdc": "Shift Delete",
"shome": "Shift Home",
"left": "Left Arrow",
"right": "Right Arrow",
"up": "Up Arrow",
"down": "Down Arrow",
"sleft": "Shift Left",
"sright": "Shift Right",
"btab": "Shift Tab",
}
def set_buffer(self, buffer):
"""Sets code to parse"""
self.buffer = buffer or b""
self.index = 0
def get_char(self):
"""Gets next character from buffer"""
if self.index >= len(self.buffer):
return "\0"
c = self.buffer[self.index]
self.index += 1
return c
def unget_char(self):
"""Goes back by one character for parsing"""
self.index -= 1
def end(self):
"""Returns true if reached end of buffer"""
return self.index >= len(self.buffer)
def parse_control_sequence(self):
"""Parses terminal specifiec control sequences"""
result = ""
c = self.get_char()
# \e0 is used to denote start of control sequence
if c == "O":
c = self.get_char()
# \[1\; is start of control sequence
if c == "1":
b = self.get_char()
c = self.get_char()
if b == "\\" and c == "~":
result += "Home"
elif c == ";":
c = self.get_char()
# 3 is Alt
if c == "3":
result += "ALT - "
c = self.get_char()
# \[4\~ is End
if c == "4":
b = self.get_char()
c = self.get_char()
if b == "\\" and c == "~":
result += "End"
# 5 is Ctrl
if c == "5":
result += "CTRL - "
c = self.get_char()
# 9 is Alt
if c == "9":
result += "ALT - "
c = self.get_char()
if c == "A":
result += "Up Arrow"
elif c == "B":
result += "Down Arrow"
elif c == "C":
result += "Right Arrow"
elif c == "D":
result += "Left Arrow"
elif c == "F":
result += "End"
elif c == "H":
result += "Home"
return result
def get_readable_binding(self):
"""Gets a readable representation of binding"""
try:
result = BindingParser.readable_keys[self.buffer.lower()]
except KeyError:
result = self.parse_binding()
return result
def parse_binding(self):
readable_command = ""
result = ""
alt = ctrl = False
while not self.end():
c = self.get_char()
if c == "\\":
c = self.get_char()
if c == "e":
d = self.get_char()
if d == "O":
self.unget_char()
result += self.parse_control_sequence()
elif d == "\\":
if self.get_char() == "[":
result += self.parse_control_sequence()
else:
self.unget_char()
self.unget_char()
alt = True
elif d == "\0":
result += "ESC"
else:
alt = True
self.unget_char()
elif c == "c":
ctrl = True
elif c == "n":
result += "Enter"
elif c == "t":
result += "Tab"
elif c == "b":
result += "Backspace"
elif c.isalpha():
result += "\\" + c
else:
result += c
elif c == "\x7f":
result += "Backspace"
else:
result += c
if ctrl:
readable_command += "CTRL - "
if alt:
readable_command += "ALT - "
if result == "":
return "unknown-control-sequence"
return readable_command + result
class FishConfigTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
"""TCPServer that only accepts connections from localhost (IPv4/IPv6)."""
WHITELIST = set(["::1", "::ffff:127.0.0.1", "127.0.0.1"])
address_family = socket.AF_INET6 if socket.has_ipv6 else socket.AF_INET
def verify_request(self, request, client_address):
return client_address[0] in FishConfigTCPServer.WHITELIST
class FishConfigHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
def write_to_wfile(self, txt):
self.wfile.write(txt.encode("utf-8"))
def do_get_colors(self, path=None):
"""Read the colors from a .theme file in path, or the current shell if no path has been given"""
# Looks for fish_color_*.
# Returns an array of lists [color_name, color_description, color_value]
result = []
# Make sure we return at least these
remaining = set(
[
"normal",
"error",
"command",
"end",
"param",
"comment",
"match",
"selection",
"search_match",
"operator",
"escape",
"quote",
"redirection",
"valid_path",
"autosuggestion",
"user",
"host",
"cancel",
]
)
# Here are our color descriptions
descriptions = {
"normal": "Default text",
"command": "Ordinary commands",
"quote": "Text within quotes",
"redirection": "Like | and >",
"end": "Like ; and &",
"error": "Potential errors",
"param": "Command parameters",
"comment": "Comments start with #",
"match": "Matching parenthesis",
"selection": "Selected text",
"search_match": "History searching",
"history_current": "Directory history",
"operator": "Like * and ~",
"escape": "Escapes like \\n",
"cwd": "Current directory",
"cwd_root": "cwd for root user",
"valid_path": "Valid paths",
"autosuggestion": "Suggested completion",
"user": "Username in the prompt",
"host": "Hostname in the prompt",
"cancel": "The ^C cancel indicator",
}
# If we don't have a path, we get the current theme.
if not path:
out, err = run_fish_cmd("set -L")
else:
with open(path) as f:
out = f.read()
extrainfo = {}
for line in out.split("\n"):
# Ignore empty lines
if not line:
continue
# Lines starting with "#" can contain metadata.
if line.startswith("#"):
if not ":" in line:
continue
key, value = line.split(":", maxsplit=1)
key = key.strip("# '")
value = value.strip(" '\"")
# Only use keys we know
if not key in ("name", "preferred_background", "url"):
continue
if key == "preferred_background":
if value not in named_colors and not value.startswith("#"):
value = "#" + value
extrainfo[key] = value
for match in re.finditer(r"^fish_(pager_)?color_(\S+) ?(.*)", line):
color_name, color_value = [x.strip() for x in match.group(2, 3)]
if match.group(1):
color_name = "fish_pager_color_" + color_name
color_desc = descriptions.get(color_name, "")
data = {"name": color_name, "description": color_desc}
data.update(parse_color(color_value))
result.append(data)
remaining.discard(color_name)
# Sort our result (by their keys)
result.sort(key=operator.itemgetter("name"))
# Ensure that we have all the color names we know about, so that if the
# user deletes one he can still set it again via the web interface
for color_name in remaining:
color_desc = descriptions.get(color_name, "")
result.append([color_name, color_desc, parse_color("")])
return result, extrainfo
def do_get_functions(self):
out, err = run_fish_cmd("functions")
out = out.strip()
# Not sure why fish sometimes returns this with newlines
if "\n" in out:
return out.split("\n")
else:
return out.strip().split(", ")
def do_get_variable_names(self, cmd):
"Given a command like 'set -U' return all the variable names"
out, err = run_fish_cmd(cmd)
return out.split("\n")
def do_get_variables(self):
out, err = run_fish_cmd("set -L")
# Put all the variables into a dictionary
vars = {}
for line in out.split("\n"):
comps = line.split(" ", 1)
if len(comps) < 2:
continue
fish_var = FishVar(comps[0], comps[1])
vars[fish_var.name] = fish_var
# Mark universal variables. L means don't abbreviate.
for name in self.do_get_variable_names("set -nUL"):
if name in vars:
vars[name].universal = True
# Mark exported variables. L means don't abbreviate.
for name in self.do_get_variable_names("set -nxL"):
if name in vars:
vars[name].exported = True
# Do not return history as a variable, it may be so large the browser hangs.
vars.pop("history", None)
return [
vars[key].get_json_obj()
for key in sorted(vars.keys(), key=lambda x: x.lower())
]
def do_get_bindings(self):
"""Get key bindings"""
# Running __fish_config_interactive print fish greeting and
# loads key bindings
greeting, err = run_fish_cmd(" __fish_config_interactive")
# Load the key bindings and then list them with bind
out, err = run_fish_cmd("__fish_config_interactive; bind")
# Remove fish greeting from output
out = out[len(greeting) :]
# Put all the bindings into a list
bindings = []
command_to_binding = {}
binding_parser = BindingParser()
for line in out.split("\n"):
comps = line.split(" ", 2)
# If we don't have "bind", a sequence and a mapping,
# it's not a valid binding.
if len(comps) < 3:
continue
# Store the "--preset" value for later
if comps[1] == "--preset":
preset = True
# There's possibly a way to do this faster, but it's not important.
comps = line.split(" ", 3)[1:]
elif comps[1] == "--user":
preset = False
comps = line.split(" ", 3)[1:]
# Check again if we removed the level.
if len(comps) < 3:
continue
if comps[1] == "-k":
key_name, command = comps[2].split(" ", 1)
binding_parser.set_buffer(key_name.capitalize())
else:
key_name = None
command = comps[2]
binding_parser.set_buffer(comps[1])
if command in bindings_blacklist:
continue
readable_binding = binding_parser.get_readable_binding()
if command in command_to_binding:
fish_binding = command_to_binding[command]
fish_binding.add_binding(line, readable_binding)
else:
fish_binding = FishBinding(command, line, readable_binding)
bindings.append(fish_binding)
command_to_binding[command] = fish_binding
return [binding.get_json_obj() for binding in bindings]
def do_get_history(self):
# Use NUL to distinguish between history items.
out, err = run_fish_cmd("builtin history -z")
result = out.split("\0")
if result:
result.pop() # trim off the trailing element
return result
def do_get_color_for_variable(self, name):
"Return the color with the given name, or the empty string if there is none."
out, err = run_fish_cmd("echo -n $" + name)
return out
def do_set_color_for_variable(self, name, color):
"Sets a color for a fish color name, like 'autosuggestion'"
if not name:
raise ValueError
if not color and not color == "":
color = "normal"
else:
color = unparse_color(color)
if not name.startswith("fish_pager_color_"):
varname = "fish_color_" + name
# If the name already starts with "fish_", use it as the varname
# This is needed for 'fish_pager_color' vars.
if name.startswith("fish_"):
varname = name
# Check if the varname is allowable.
varname = varname.strip()
if not re.match("^[a-zA-Z0-9_]+$", varname):
print("Refusing to use variable name: '", varname, "'")
return
color = color.strip()
if not re.match("^[a-zA-Z0-9_= -]*$", color):
print("Refusing to use color value: ", color)
return
command = "set -U " + varname
command += " " + color
out, err = run_fish_cmd(command)
return out
def do_get_function(self, func_name):
out, err = run_fish_cmd("functions " + func_name + " | fish_indent --html")
return out
def do_delete_history_item(self, history_item_text):
# It's really lame that we always return success here
cmd = (
"builtin history delete --case-sensitive --exact -- %s; builtin history save"
% escape_fish_cmd(history_item_text)
)
out, err = run_fish_cmd(cmd)
return True
def do_set_prompt_function(self, prompt_func):
cmd = (
"functions -e fish_right_prompt; "
+ prompt_func
+ "\n"
+ "funcsave fish_prompt && funcsave fish_right_prompt 2>/dev/null"
)
out, err = run_fish_cmd(cmd)
return len(err) == 0
def do_get_prompt(self, prompt_function_text, extras_dict):
# Return the prompt output by the given command
cmd = (
prompt_function_text
+ '\n builtin cd "'
+ initial_wd
+ '" \n false \n fish_prompt\n'
)
prompt_demo_ansi, err = run_fish_cmd(cmd)
prompt_demo_html = ansi_to_html(prompt_demo_ansi)
right_demo_ansi, err = run_fish_cmd(
"functions -e fish_right_prompt; "
+ prompt_function_text
+ '\n builtin cd "'
+ initial_wd
+ '" \n false \n functions -q fish_right_prompt && fish_right_prompt\n'
)
right_demo_html = ansi_to_html(right_demo_ansi)
prompt_demo_font_size = self.font_size_for_ansi_prompt(
prompt_demo_ansi + right_demo_ansi
)
result = {
"function": prompt_function_text,
"demo": prompt_demo_html,
"font_size": prompt_demo_font_size,
"right": right_demo_html,
}
if extras_dict:
result.update(extras_dict)
return result
def do_get_current_prompt(self):
# Return the current prompt. We run 'false' to demonstrate how the
# prompt shows the command status (#1624).
prompt_func, err = run_fish_cmd(
"functions fish_prompt; functions fish_right_prompt"
)
result = self.do_get_prompt(
prompt_func.strip(),
{"name": "Current"},
)
return result
def do_get_sample_prompt(self, text, extras_dict):
# Return the prompt you get from the given text. Extras_dict is a
# dictionary whose values get merged in. We run 'false' to demonstrate
# how the prompt shows the command status (#1624)
return self.do_get_prompt(text.strip(), extras_dict)
def parse_one_sample_prompt_hash(self, line, result_dict):
# Allow us to skip whitespace, etc.
if not line:
return True
if line.isspace():
return True
# Parse a comment hash like '# name: Classic'
match = re.match(r"#\s*(\w+?): (.+)", line, re.IGNORECASE)
if match:
key = match.group(1).strip()
value = match.group(2).strip()
result_dict[key] = value
return True
# Skip other hash comments
return line.startswith("#")
def read_one_sample_prompt(self, path):
try:
with open(path, "rb") as fd:
extras_dict = {}
# Read one sample prompt from fd
function_lines = []
parsing_hashes = True
unicode_lines = (line.decode("utf-8") for line in fd)
for line in unicode_lines:
# Parse hashes until parse_one_sample_prompt_hash return
# False.
if parsing_hashes:
parsing_hashes = self.parse_one_sample_prompt_hash(
line, extras_dict
)
# Maybe not we're not parsing hashes, or maybe we already
# were not.
if not parsing_hashes:
function_lines.append(line)
func = "".join(function_lines).strip()
result = self.do_get_sample_prompt(func, extras_dict)
return result
except IOError:
# Ignore unreadable files, etc.
return None
def do_get_sample_prompts_list(self):
paths = sorted(glob.iglob("sample_prompts/*.fish"))
result = []
try:
pool = multiprocessing.pool.ThreadPool(processes=8)
# Kick off the "Current" meta-sample
current_metasample_async = pool.apply_async(self.do_get_current_prompt)
# Read all of the prompts in sample_prompts
sample_results = pool.map(self.read_one_sample_prompt, paths, 1)
result.append(current_metasample_async.get())
result.extend([r for r in sample_results if r])
except ImportError:
# If the platform doesn't support multiprocessing, we just do it one at a time.
# This happens e.g. on Termux.
print(
"Platform doesn't support multiprocessing, running one at a time. This may take a while."
)
result.append(self.do_get_current_prompt())
result.extend([self.read_one_sample_prompt(path) for path in paths])
return result
def secure_startswith(self, haystack, needle):
if len(haystack) < len(needle):
return False
bits = 0
for x, y in zip(haystack, needle):
bits |= ord(x) ^ ord(y)
return bits == 0
def font_size_for_ansi_prompt(self, prompt_demo_ansi):
width = ansi_prompt_line_width(prompt_demo_ansi)
# Pick a font size
if width >= 70:
font_size = "8pt"
if width >= 60:
font_size = "10pt"
elif width >= 50:
font_size = "11pt"
elif width >= 40:
font_size = "13pt"
elif width >= 30:
font_size = "15pt"
elif width >= 25:
font_size = "16pt"
elif width >= 20:
font_size = "17pt"
else:
font_size = "18pt"
return font_size
def do_GET(self):
p = self.path
authpath = "/" + authkey
if self.secure_startswith(p, authpath):
p = p[len(authpath) :]
else:
return self.send_error(403)
self.path = p
if p == "/colors/":
# Construct our colorschemes.
# Add the current scheme first, then the default.
# The rest in alphabetical order.
curcolors, curinfo = self.do_get_colors()
defcolors, definfo = self.do_get_colors("themes/fish default.theme")
curinfo.update({"theme": "Current", "colors": curcolors})
definfo.update({"theme": "fish default", "colors": defcolors})
output = [curinfo, definfo]
confighome = (
os.environ["XDG_CONFIG_HOME"]
if "XDG_CONFIG_HOME" in os.environ
else os.path.expanduser("~")
)
paths = list(glob.iglob(os.path.join(confighome, "fish", "themes/*.theme")))
paths.extend(list(glob.iglob("themes/*.theme")))
paths.sort(key=str.casefold)
for p in paths:
theme = os.path.splitext(os.path.basename(p))[0]
if any(theme == d["theme"] for d in output):
continue
out, outinfo = self.do_get_colors(p)
outinfo.update({"theme": theme, "colors": out})
output.append(outinfo)
elif p == "/functions/":
output = self.do_get_functions()
elif p == "/variables/":
output = self.do_get_variables()
elif p == "/history/":
# start = time.time()
output = self.do_get_history()
# end = time.time()
# print "History: ", end - start
elif p == "/sample_prompts/":
output = self.do_get_sample_prompts_list()
elif re.match(r"/color/(\w+)/", p):
name = re.match(r"/color/(\w+)/", p).group(1)
output = self.do_get_color_for_variable(name)
elif p == "/bindings/":
output = self.do_get_bindings()
else:
return SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self)
# Return valid output
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.write_to_wfile("\n")
# Output JSON
self.write_to_wfile(json.dumps(output))
def do_POST(self):
p = self.path
authpath = "/" + authkey
if self.secure_startswith(p, authpath):
p = p[len(authpath) :]
else:
return self.send_error(403)
self.path = p
# This is cheesy, we want just the actual content-type.
# In some cases it'll give us the encoding as well,
# ("application/json;charset=utf-8")
# but we don't currently care.
ctype = self.headers["content-type"].split(";")[0]
if ctype == "application/x-www-form-urlencoded":
length = int(self.headers["content-length"])
url_str = self.rfile.read(length).decode("utf-8")
postvars = parse_qs(url_str, keep_blank_values=1)
elif ctype == "application/json":
length = int(self.headers["content-length"])
# This used to use the provided encoding, but we use utf-8
# all around the place and nobody has ever complained.
#
# If any other encoding is received this will raise a UnicodeError,
# which will throw us out of the function and should at most exit webconfig.
# If that happens to anyone we expect bug reports.
url_str = self.rfile.read(length).decode("utf-8")
postvars = json.loads(url_str)
elif ctype == "multipart/form-data":
# This used to be a thing, as far as I could find there's
# no use anymore, but let's keep an error around just in case.
return self.send_error(500)
else:
postvars = {}
if p == "/set_color/":
print("# Colorscheme: " + postvars.get("theme"))
have_colors = set()
known_colors = set(
(
"fish_color_normal",
"fish_color_command",
"fish_color_keyword",
"fish_color_quote",
"fish_color_redirection",
"fish_color_end",
"fish_color_error",
"fish_color_param",
"fish_color_option",
"fish_color_comment",
"fish_color_selection",
"fish_color_operator",
"fish_color_escape",
"fish_color_autosuggestion",
"fish_color_cwd",
"fish_color_user",
"fish_color_host",
"fish_color_host_remote",
"fish_color_cancel",
"fish_color_search_match",
"fish_pager_color_progress",
"fish_pager_color_background",
"fish_pager_color_prefix",
"fish_pager_color_completion",
"fish_pager_color_description",
"fish_pager_color_selected_background",
"fish_pager_color_selected_prefix",
"fish_pager_color_selected_completion",
"fish_pager_color_selected_description",
"fish_pager_color_secondary_background",
"fish_pager_color_secondary_prefix",
"fish_pager_color_secondary_completion",
"fish_pager_color_secondary_description",
)
)
output = ""
for item in postvars.get("colors"):
what = item.get("what")
color = item.get("color")
if what:
if not what.startswith("fish_pager_color_") and not what.startswith(
"fish_color_"
):
have_colors.add("fish_color_" + what)
else:
have_colors.add(what)
output = self.do_set_color_for_variable(what, color)
# Set all known colors that weren't defined in this theme
# to empty, to avoid keeping around coloration from an earlier theme.
for what in known_colors - have_colors:
output += "\n" + self.do_set_color_for_variable(what, "")
elif p == "/get_function/":
what = postvars.get("what")
output = [self.do_get_function(what[0])]
elif p == "/delete_history_item/":
what = postvars.get("what")
if self.do_delete_history_item(what[0]):
output = ["OK"]
else:
output = ["Unable to delete history item"]
elif p == "/set_prompt/":
what = postvars.get("fish_prompt")
if self.do_set_prompt_function(what):
output = ["OK"]
else:
output = ["Unable to set prompt"]
else:
return self.send_error(404)
# Return valid output
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.write_to_wfile("\n")
# Output JSON
self.write_to_wfile(json.dumps(output))
def log_request(self, code="-", size="-"):
"""Disable request logging"""
pass
def log_error(self, format, *args):
if format == "code %d, message %s" and hasattr(self, "path"):
# This appears to be a send_error() message
# We want to include the path (if we have one)
(code, msg) = args
format = "code %d, message %s, path %s"
args = (code, msg, self.path)
SimpleHTTPServer.SimpleHTTPRequestHandler.log_error(self, format, *args)
redirect_template_html = """
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0;URL='%s'" />
</head>
<body>
<p><a href="%s">Start the Fish Web config</a></p>
</body>
</html>
"""
# find fish
fish_bin_dir = os.environ.get("__fish_bin_dir")
fish_bin_path = None
# only need the '.exe' extension on Windows
fish_bin_name = "fish.exe" if is_windows() else "fish"
if not fish_bin_dir:
print("The $__fish_bin_dir environment variable is not set. " "Looking in $PATH...")
fish_bin_path = find_executable(fish_bin_name)
if not fish_bin_path:
print("fish could not be found. Is fish installed correctly?")
sys.exit(-1)
else:
print("fish found at '%s'" % fish_bin_path)
else:
fish_bin_path = os.path.join(fish_bin_dir, fish_bin_name)
if not os.access(fish_bin_path, os.X_OK):
print(
"fish could not be executed at path '%s'. "
"Is fish installed correctly?" % fish_bin_path
)
sys.exit(-1)
FISH_BIN_PATH = fish_bin_path
# We want to show the demo prompts in the directory from which this was invoked,
# so get the current working directory
initial_wd = os.getcwd()
# Make sure that the working directory is the one that contains the script
# server file, because the document root is the working directory.
where = os.path.dirname(sys.argv[0])
os.chdir(where)
# Generate a 16-byte random key as a hexadecimal string
authkey = binascii.b2a_hex(os.urandom(16)).decode("ascii")
# Try to find a suitable port
PORT = 8000
HOST = "::" if socket.has_ipv6 else "localhost"
while PORT <= 9000:
try:
Handler = FishConfigHTTPRequestHandler
httpd = FishConfigTCPServer((HOST, PORT), Handler)
# Success
break
except (OSError, socket.error) as e:
if e.errno == errno.EAFNOSUPPORT and HOST == "::":
print("Ipv6 seems to be unavailable, trying ipv4")
# We can't do ipv6, let's try v4.
HOST = "127.0.0.1"
PORT = 8000
# Override the class address_family variable (yes, this is a hack)
FishConfigTCPServer.address_family = socket.AF_INET
continue
if e.errno != errno.EADDRINUSE:
print(str(e))
sys.exit(-1)
PORT += 1
if PORT > 9000:
# Nobody say it
print("Unable to find an open port between 8000 and 9000")
sys.exit(-1)
# Get any initial tab (functions, colors, etc)
# Just look at the first letter
initial_tab = ""
if len(sys.argv) > 1:
for tab in [
"functions",
"prompt",
"colors",
"variables",
"history",
"bindings",
]:
if tab.startswith(sys.argv[1]):
initial_tab = "#!/" + tab
break
url = "http://localhost:%d/%s/%s" % (PORT, authkey, initial_tab)
# Create temporary file to hold redirect to real server. This prevents exposing
# the URL containing the authentication key on the command line (see
# CVE-2014-2914 or https://github.com/fish-shell/fish-shell/issues/1438).
#
# If on Windows, the file needs to be closed after writing, otherwise, the browser won't be able to open it."
# unfortunately this was added in python 3.12, so we don't add it on other platforms
# to support older python versions there.
kwargs = {}
if is_windows():
kwargs["delete_on_close"] = False
f = tempfile.NamedTemporaryFile(
prefix="web_config",
suffix=".html",
mode="w",
delete=True,
**kwargs,
)
f.write(redirect_template_html % (url, url))
f.flush()
if is_windows():
f.close()
# Open temporary file as URL
# Use open on macOS >= 10.12.5 to work around #4035.
fileurl = "file://" + f.name
if is_windows():
fileurl = fileurl.replace("\\", "/")
esc = get_special_ansi_escapes()
print(
"Web config started at %s%s%s"
% (esc["underline"], fileurl, esc["exit_attribute_mode"])
)
print(
"If that doesn't work, try opening %s%s%s"
% (esc["underline"], url, esc["exit_attribute_mode"])
)
print("%sHit ENTER to stop.%s" % (esc["bold"], esc["exit_attribute_mode"]))
def runThing():
if isMacOS10_12_5_OrLater():
subprocess.check_call(["open", fileurl])
elif is_wsl():
cmd_path = find_executable("cmd.exe", COMMON_WSL_CMD_PATHS)
if cmd_path:
subprocess.call([cmd_path, "/c", "start %s" % url])
else:
print("Please add the directory containing cmd.exe to your $PATH")
sys.exit(-1)
elif is_termux():
subprocess.call(["termux-open-url", url])
elif is_chromeos_garcon():
webbrowser.open(url)
elif is_sailfish_os():
subprocess.call(["xdg-open", url])
else:
webbrowser.open(fileurl)
# Some browsers still block webbrowser.open if they haven't been opened before,
# so we just spawn it in a thread.
thread = threading.Thread(target=runThing)
thread.start()
# Safari will open sockets and not write to them, causing potential hangs
# on shutdown.
httpd.block_on_close = False
httpd.daemon_threads = True
# Select on stdin and httpd
stdin_no = sys.stdin.fileno()
def create_socket(start_port, end_port):
"""Attempt to create a socket from a range of ports."""
for port in range(start_port, end_port + 1):
try:
sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sck.bind(("localhost", port))
sck.listen()
return (sck, port)
except socket.error:
pass
print("Unable to find an open port between {} and {}.".format(start_port, end_port))
sys.exit(-1)
def capture_enter(port):
"""Read keyboard events and establish a socket connection when pressing the Enter."""
import msvcrt
while True:
if msvcrt.kbhit():
key = msvcrt.getch().decode()
if key == "\r" or key == "\n":
break
sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sck.connect(("localhost", port))
def get_windows_signal():
"""Using socket as a replacement for stdin on Windows."""
(sig, sig_port) = create_socket(8000, 9000)
threading.Thread(target=capture_enter, args=(sig_port,)).start()
return sig
try:
httpd_fileno = httpd.fileno()
sig = get_windows_signal() if is_windows() else sys.stdin
sig_fileno = sig.fileno()
while True:
ready_read = select.select([sig_fileno, httpd_fileno], [], [])
if ready_read[0][0] != httpd_fileno:
print("Shutting down.")
# On windows the newline has already been consumed by the capture_enter function.
if not is_windows():
# Consume the newline so it doesn't get printed by the caller
sys.stdin.readline()
break
else:
httpd.handle_request()
except KeyboardInterrupt:
print("\nShutting down.")
# Clean up temporary file
# If on Windows, the file already closed
if not is_windows():
f.close()
thread.join()