#!/bin/bash
# firefox-session: written by Wren Linux extras/firefox-sandbox.sh
# Runs as the user "firefox", or as "fx-NAME" for a Firefox profile with its own
# user (the firefox command starts it through sudo; it is the only thing your
# user may run as those users). Opens Firefox as a normal
# window on your sway, through the restricted Wayland socket the firefox
# command made: it can show windows and use the clipboard while it has focus,
# but sway does not give it the screen-reading, typing or session-lock
# protocols. The profile lives in this user's own home and is kept.
# Sound: its own PipeWire whose only output is a pipe into your user's player,
# as in incognito; it never reaches your audio server or microphone.
#   firefox-session start SOCKET_DIR WAYLAND_DISPLAY AUDIO DOWNLOADS [URL...]
#   firefox-session open [URL...]     opens the URLs in the Firefox that is running
set -euo pipefail
umask 077
export PATH=/usr/local/bin:/usr/bin:/bin

die() { printf 'firefox: %s\n' "$*" >&2; exit 1; }

# Keeps it off XWayland's abstract socket, which any user can connect to
noabs=/usr/local/libexec/wren-no-abstract-sockets
need_noabs() { [ -x "$noabs" ] || die "$noabs is missing. Run: wrenctl install firefox-sandbox"; }
# The firewall that keeps it off your computer and network, seen from this user:
# no rule, no Firefox (bin/sandbox-net-check); and while it runs, it is watched
netcheck=/usr/local/libexec/wren-sandbox-net-check
need_net() {
    [ -x "$netcheck" ] || die "$netcheck is missing. Run: wrenctl install firefox-sandbox"
    "$netcheck" || die "the firewall that keeps it off your network isn't active, so it wasn't started. Run: sudo systemctl restart wren-firefox-firewall"
}

# Who this is: "firefox" (tag empty), or "fx-NAME" for the profile NAME
user=$(id -un)
case $user in
    firefox) tag= ;;
    fx-*)
        tag=${user#fx-}
        [[ $tag =~ ^[a-z][a-z0-9-]{0,27}$ ]] || die "unexpected user name."
        ;;
    *) die "this runs as the user firefox (or fx-NAME); use the firefox command." ;;
esac
home=$(getent passwd "$user" | cut -d: -f6)
[ -d "$home" ] || die "$user's home folder is missing."
profile=$home/profile
# Its windows' name in sway (and the name the running one is found by): each
# Firefox its own, so the Wren menu can go to this one's window, not another
# profile's or Firefox as you
app_id=wren-firefox${tag:+-$tag}
# Its folder in memory for what only lives while it runs: the private message
# bus (how links from other programs reach the running Firefox), the runtime
# folder and the temporary files. A new name at each start, as incognito's, and
# found again by its owner, this user: never by a name another user could have
# made first (which would have stopped Firefox, or been handed your links).
# (From /: sudo keeps the folder it was started from, which this user may not
# enter, and find then fails after finding it: tried for real.)
run_dir() { (cd / && find /dev/shm -maxdepth 1 -name "$user-run.*" -type d -uid "$(id -u)" -print -quit 2>/dev/null); }

# Only plain web addresses are accepted from the caller, and files in a
# downloads folder (file:///home/NAME/Downloads/..., or .../Downloads/PROFILE/...
# for a profile), the real path inside it: what this user may read anyway
check_urls() {
    local a p real top
    for a in "$@"; do
        [[ $a =~ ^https?://[^[:space:]]+$ ]] && continue
        if [[ $a =~ ^file://(/home/[a-z_][a-z0-9_-]{0,31}/Downloads${tag:+/$tag})(/[^[:space:]%]+)$ ]]; then
            top=${BASH_REMATCH[1]} p=${BASH_REMATCH[1]}${BASH_REMATCH[2]}
            case /$p/ in */../* | */./*) ;; *)
                real=$(realpath -e -- "$p" 2>/dev/null) && top=$(realpath -e -- "$top" 2>/dev/null) &&
                    [[ $real == "$top"/* ]] && continue ;;
            esac
        fi
        die "only http:// and https:// addresses, and files in your Downloads folder, can be opened."
    done
}

cmd=${1:-}
[ $# -eq 0 ] || shift

case $cmd in
open)
    check_urls "$@"
    run=$(run_dir)
    [ -n "$run" ] && [ -S "$run/run/bus" ] || die "Firefox is not running."
    bus="unix:path=$run/run/bus"
    # The window it runs in, which the start wrote down: Firefox wants a screen
    # before it looks for the running one to hand the link to ("no DISPLAY
    # environment variable specified" without it: tried for real)
    wl=$(head -c 200 "$run/wayland" 2>/dev/null) || true
    [[ $wl =~ ^/dev/shm/wren-firefox${tag:+-$tag}\.[A-Za-z0-9]+/[A-Za-z0-9._-]+$ ]] && [ -S "$wl" ] ||
        die "can't reach the running Firefox's window."
    need_noabs
    if [ $# -eq 0 ]; then set -- --new-window; else
        urls=()
        for a in "$@"; do urls+=(--new-tab "$a"); done
        set -- "${urls[@]}"
    fi
    exec env -i PATH="$PATH" LANG=C.UTF-8 HOME="$home" XDG_RUNTIME_DIR="$run/run" \
        XDG_CONFIG_HOME="$home/.config" XDG_CACHE_HOME="$home/.cache" XDG_DATA_HOME="$home/.local/share" \
        WAYLAND_DISPLAY="$wl" MOZ_ENABLE_WAYLAND=1 MOZ_DBUS_REMOTE=1 DBUS_SESSION_BUS_ADDRESS="$bus" \
        "$noabs" /usr/lib/firefox-esr/firefox-esr --name "$app_id" --profile "$profile" "$@" ;;
start) need_net ;;
*) die "usage: firefox-session start SOCKET_DIR WAYLAND_DISPLAY AUDIO DOWNLOADS THEME PICTURE [URL...] | open [URL...]" ;;
esac

[ $# -ge 6 ] || die "usage: firefox-session start SOCKET_DIR WAYLAND_DISPLAY AUDIO DOWNLOADS THEME PICTURE [URL...]"
host_dir=$1 host_wl=$2 audio=$3 downloads=$4 theme=$5 picture=$6
shift 6

# These come from the user who called us, so nothing else is accepted
[[ $host_dir =~ ^/dev/shm/wren-firefox${tag:+-$tag}\.[A-Za-z0-9]+$ ]] || die "unexpected socket folder."
[[ $host_wl =~ ^[A-Za-z0-9._-]+$ ]] || die "unexpected Wayland display name."
[ -S "$host_dir/$host_wl" ] || die "can't reach the Wayland socket."
[[ $audio == - || $audio == "firefox${tag:+-$tag}-audio.fifo" ]] || die "unexpected sound pipe name."
if [ -z "$tag" ]; then
    [[ $downloads =~ ^/home/[A-Za-z0-9._-]+/Downloads$ ]] || die "unexpected downloads folder."
else
    # a profile gets its own folder, so one profile cannot read another's downloads
    [[ $downloads =~ ^/home/[A-Za-z0-9._-]+/Downloads/$tag$ ]] || die "unexpected downloads folder."
fi
[[ $theme == light || $theme == dark ]] || die "unexpected theme."
# the New Tab picture: only the main Firefox's, under the one name the firefox command gives it
[[ $picture == - || ( -z $tag && $picture =~ ^firefox-newtab\.(jpg|png)$ ) ]] || die "unexpected picture name."
check_urls "$@"
if [ "$audio" != - ]; then
    [ -p "$host_dir/$audio" ] || audio=-
    for c in pipewire wireplumber pipewire-pulse; do
        command -v "$c" >/dev/null || audio=-
    done
fi
command -v dbus-daemon >/dev/null || die "dbus-daemon is missing (package dbus-daemon)."
need_noabs
[ -d /dev/shm ] || die "/dev/shm (memory) isn't available."

# Left behind by a crash or a power cut (the firefox command allows one at a time)
# (from /, as run_dir: in a folder this user may not enter, find removed nothing)
(cd / && find /dev/shm -maxdepth 1 -name "$user-run.*" -uid "$(id -u)" -exec rm -rf {} + 2>/dev/null) || true
run=$(mktemp -d "/dev/shm/$user-run.XXXXXXXX")
bus="unix:path=$run/run/bus"
mkdir "$run/run" "$run/tmp"
# (for open: the window a link goes to)
printf '%s' "$host_dir/$host_wl" >"$run/wayland"
mkdir -p "$profile"   # (umask 077: only the firefox user can enter it)
pid='' sharer=''

# share_downloads: what this user saved in its downloads folder, readable by
# you and nobody else. You are named in the folder's default ACL (bin/firefox),
# but a named user's ACL entry counts only as far as the file's mask allows,
# and the mask comes from the mode the file was created with: a download
# Firefox saves as private (600) gets an empty mask, and you can't read it.
# This sets the mask to rw, and the file's group and others entries to
# nothing (another profile's user shares this one's group, and can pass
# through ~/Downloads). Only files this user owns, straight in that folder.
share_downloads() {
    command -v setfacl >/dev/null || return 0
    find "$downloads" -maxdepth 1 -type f -user "$user" \( ! -perm -g=r -o -perm /g=x,o=rwx \) \
        -exec setfacl -m g::-,o::-,m::rw {} + 2>/dev/null || true
}

cleanup() {
    trap - EXIT INT TERM HUP
    [ -z "$sharer" ] || kill "$sharer" 2>/dev/null || true
    share_downloads
    if [ -n "$pid" ]; then
        kill -TERM -- "-$pid" 2>/dev/null || true
        sleep 1
        kill -KILL -- "-$pid" 2>/dev/null || true
    fi
    rm -rf "$run"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP

# The profile is kept; its settings are put in again at every start
cp /usr/local/share/wren/firefox-user.js "$profile/user.js"
{
    echo 'user_pref("browser.download.folderList", 2);'
    echo "user_pref(\"browser.download.dir\", \"$downloads\");"
    echo 'user_pref("browser.download.useDownloadDir", true);'
} >>"$profile/user.js"
# doh_prefs normal|signin: Firefox's own DNS, set at every start. While secure
# DNS is on (parts/secure-dns.sh; /etc/resolv.conf says so) Firefox uses the
# system's, which is encrypted; while it is off, its own DNS over HTTPS to
# Quad9: strict (never plain DNS, so a hostile network can't push it back to
# plain DNS by blocking the encrypted kind) unless fallback was chosen at the
# sandboxed Firefox's install. The Wi-Fi sign-in window (signin) uses the
# network's own DNS: that is what it is for. (The same in firefox-session and
# incognito-session; a test checks.)
doh_prefs() {
    local mode=3
    if [ "$1" = signin ] ||
        { [ ! -L /etc/resolv.conf ] && grep -qsE '^# Written by Wren Linux (parts|extras)/secure-dns\.sh' /etc/resolv.conf; }; then
        mode=5
    elif [ "$(sed -n 's/^dns=//p' /etc/wren/firefox.conf 2>/dev/null | head -1)" = fallback ]; then
        mode=2
    fi
    echo "user_pref(\"network.trr.mode\", $mode);"
    echo 'user_pref("network.trr.uri", "https://dns.quad9.net/dns-query");'
    echo 'user_pref("network.trr.custom_uri", "https://dns.quad9.net/dns-query");'
}
doh_prefs normal >>"$profile/user.js"
# Light or dark (bin/theme): Firefox's own theme, what websites are told you
# prefer, and the New Tab fox to match
if [ "$theme" = light ]; then
    {
        echo 'user_pref("extensions.activeThemeID", "firefox-compact-light@mozilla.org");'
        echo 'user_pref("layout.css.prefers-color-scheme.content-override", 1);'
        echo 'user_pref("browser.newtabpage.activity-stream.newtabWallpapers.wallpaper", "light-fox-anniversary");'
    } >>"$profile/user.js"
else
    {
        echo 'user_pref("extensions.activeThemeID", "firefox-compact-dark@mozilla.org");'
        echo 'user_pref("layout.css.prefers-color-scheme.content-override", 0);'
        echo 'user_pref("browser.newtabpage.activity-stream.newtabWallpapers.wallpaper", "dark-fox-anniversary");'
    } >>"$profile/user.js"
fi
# Once the profile has run, Firefox shows the theme its add-on list
# (extensions.json) says is active, whatever the setting above says: the theme
# asked for is marked active there before it starts, the other themes not
# (nothing else in the list changes; no list yet: the setting does it). Firefox
# isn't running now (one at a time), so it doesn't write the list meanwhile.
set_theme_addon() { # THEME-ID
    local list=$profile/extensions.json
    [ -f "$list" ] && [ ! -L "$list" ] || return 0
    python3 -I - "$list" "$1" 2>/dev/null <<'EOF' || true
import json, os, sys
path, want = sys.argv[1], sys.argv[2]
with open(path) as f:
    d = json.load(f)
themes = [a for a in d.get("addons", []) if a.get("type") == "theme"]
if any(a.get("id") == want for a in themes) and any(
        a.get("active") != (a.get("id") == want) for a in themes):
    for a in themes:
        a["active"] = a.get("id") == want
        a["userDisabled"] = not a["active"]
    with open(path + ".wren", "w") as f:
        json.dump(d, f)
    os.replace(path + ".wren", path)
EOF
}
set_theme_addon "firefox-compact-$theme@mozilla.org"
# Your picture behind the New Tab page, instead of the fox: checked again here
# (a plain file, a real JPEG or PNG, at most 10 MB), and put in the stylesheet
# itself, since Firefox won't load a file from outside into that page
newtab_css=$profile/chrome/userContent.css
mark='/* Written by Wren Linux (firefox-session): your New Tab picture. */'
pic=$host_dir/$picture
kind=
if [ "$picture" != - ] && [ -f "$pic" ] && [ ! -L "$pic" ] && [ "$(stat -c %s -- "$pic")" -le $((10 * 1024 * 1024)) ]; then
    case $(head -c 8 -- "$pic" | od -An -tx1 | tr -d ' \n') in
        89504e470d0a1a0a) kind=png ;;
        ffd8ff*) kind=jpeg ;;
    esac
fi
if [ -n "$kind" ]; then
    mkdir -p "$profile/chrome"
    {
        echo "$mark"
        echo '@-moz-document url("about:newtab"), url("about:home") {'
        printf '    body { background: url("data:image/%s;base64,%s") center / cover no-repeat fixed !important; }\n' "$kind" "$(base64 -w0 -- "$pic")"
        echo '}'
    } >"$newtab_css"
    echo 'user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);' >>"$profile/user.js"
elif [ "$(head -1 "$newtab_css" 2>/dev/null)" = "$mark" ]; then
    rm -f "$newtab_css"
fi
# fast JavaScript: the JIT back on (parts/firefox-hardening.sh turns it off)
jit_on() {
    echo 'user_pref("javascript.options.ion", true);'
    echo 'user_pref("javascript.options.baselinejit", true);'
    echo 'user_pref("javascript.options.native_regexp", true);'
    echo 'user_pref("javascript.options.asmjs", true);'
}
# The main Firefox: the choices made at its install, in a file only root can
# write (/etc/wren/firefox.conf)
if [ -z "$tag" ] && [ "$(sed -n 's/^fast_js=//p' /etc/wren/firefox.conf 2>/dev/null | head -1)" = yes ]; then
    jit_on >>"$profile/user.js"
fi
# Cookies, site data and the cache deleted when it closes (COOKIES delete), or
# kept. Only those: Firefox's own defaults would take history and downloads too.
cookies_on_close() { # delete | keep
    if [ "$1" = delete ]; then
        echo 'user_pref("privacy.sanitize.sanitizeOnShutdown", true);'
        echo 'user_pref("privacy.clearOnShutdown_v2.cookiesAndStorage", true);'
        echo 'user_pref("privacy.clearOnShutdown_v2.cache", true);'
        echo 'user_pref("privacy.clearOnShutdown_v2.historyFormDataAndDownloads", false);'
        echo 'user_pref("privacy.clearOnShutdown_v2.browsingHistoryAndDownloads", false);'
        echo 'user_pref("privacy.clearOnShutdown_v2.formdata", false);'
        echo 'user_pref("privacy.clearOnShutdown_v2.siteSettings", false);'
    else
        echo 'user_pref("privacy.sanitize.sanitizeOnShutdown", false);'
    fi
}
# The main Firefox: as chosen at its install (the question's default is
# delete; an install from before the question keeps them until it is asked)
if [ -z "$tag" ]; then
    cookies_on_close "$(sed -n 's/^cookies=//p' /etc/wren/firefox.conf 2>/dev/null | head -1)" >>"$profile/user.js"
fi
# A profile with its own user: its address and its choices are in a file
# only root can write (/etc/wren/firefox-profiles/NAME.conf). Its cookies are
# kept unless it says cookies=delete: staying logged in to one site is what
# most profiles are for.
if [ -n "$tag" ]; then
    conf=/etc/wren/firefox-profiles/$tag.conf
    [ -r "$conf" ] || die "no settings for the profile $tag ($conf)."
    cookies_on_close "$(sed -n 's/^cookies=//p' "$conf" | head -1)" >>"$profile/user.js"
    url=$(sed -n 's/^url=//p' "$conf" | head -1)
    fast=$(sed -n 's/^fast_js=//p' "$conf" | head -1)
    toolbars=$(sed -n 's/^toolbars=//p' "$conf" | head -1)
    [[ $url =~ ^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/?#=%+,@-]*)?$ ]] || die "the address in $conf isn't a plain web address."
    {
        echo "// The profile $tag: opens $url on start; keeps its logins."
        echo 'user_pref("browser.startup.page", 1);'
        echo "user_pref(\"browser.startup.homepage\", \"$url\");"
        if [ "$fast" = yes ]; then jit_on; fi
        if [ "$toolbars" != shown ]; then
            # Only the page: no tabs, address bar or bookmarks (userChrome.css
            # below). Links open in the same tab, so nothing piles up out of sight.
            echo 'user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);'
            echo 'user_pref("browser.toolbars.bookmarks.visibility", "never");'
            echo 'user_pref("browser.link.open_newwindow", 1);'
        fi
    } >>"$profile/user.js"
    # A profile for one site shows just the page, unless its settings say
    # toolbars=shown. Written at every start, so the setting is what counts.
    if [ "$toolbars" != shown ]; then
        mkdir -p "$profile/chrome"
        printf '%s\n' '/* Written by Wren Linux (firefox-session): this profile shows the page only. */' \
            '#navigator-toolbox { visibility: collapse !important; }' >"$profile/chrome/userChrome.css"
    else
        rm -f "$profile/chrome/userChrome.css"
    fi
fi

if [ "$audio" != - ]; then
    mkdir -p "$run/home-config/pipewire/pipewire.conf.d"
    cat >"$run/home-config/pipewire/pipewire.conf.d/10-firefox.conf" <<EOT
context.modules = [
  { name = libpipewire-module-pipe-tunnel
    args = {
      tunnel.mode = sink
      pipe.filename = "$host_dir/$audio"
      audio.format = S16LE
      audio.rate = 48000
      audio.channels = 2
      audio.position = [ FL FR ]
      node.name = firefox${tag:+-$tag}-out
      node.description = "firefox${tag:+ $tag}"
    }
  }
]
EOT
fi

# One session: the message bus, the sound server (if any), then Firefox.
# Closing it all is one signal to the group.
# shellcheck disable=SC2016  # expanded by the inner bash, on purpose
start='
    waitfor() { for _ in $(seq 50); do [ -S "$1" ] && return 0; sleep 0.1; done; return 1; }
    dbus-daemon --session --address="$DBUS_SESSION_BUS_ADDRESS" --nofork --nopidfile &
    waitfor "$XDG_RUNTIME_DIR/bus" || exit 1
    if [ -n "$FIREFOX_AUDIO" ]; then
        export XDG_CONFIG_HOME="$FIREFOX_PIPEWIRE_CONFIG"
        pipewire &
        if waitfor "$XDG_RUNTIME_DIR/pipewire-0"; then
            wireplumber -p policy &
            pipewire-pulse &
            waitfor "$XDG_RUNTIME_DIR/pulse/native" || true
        fi
        export XDG_CONFIG_HOME="$HOME/.config"
    fi
    exec /usr/lib/firefox-esr/firefox-esr --name "$FIREFOX_APP_ID" --profile "$FIREFOX_PROFILE" "$@"'
setsid env -i \
    PATH="$PATH" LANG=C.UTF-8 HOME="$home" TMPDIR="$run/tmp" XDG_RUNTIME_DIR="$run/run" \
    XDG_CONFIG_HOME="$home/.config" XDG_CACHE_HOME="$home/.cache" XDG_DATA_HOME="$home/.local/share" \
    WAYLAND_DISPLAY="$host_dir/$host_wl" MOZ_ENABLE_WAYLAND=1 MOZ_DBUS_REMOTE=1 GTK_USE_PORTAL=0 \
    LIBGL_ALWAYS_SOFTWARE=1 DBUS_SESSION_BUS_ADDRESS="$bus" \
    PULSE_SERVER="unix:$run/run/pulse/native" \
    FIREFOX_AUDIO="$([ "$audio" != - ] && echo 1)" FIREFOX_PROFILE="$profile" FIREFOX_APP_ID="$app_id" \
    FIREFOX_PIPEWIRE_CONFIG="$run/home-config" \
    "$noabs" bash -c "$start" firefox-start "$@" &
pid=$!
"$netcheck" --watch "$pid" &
# Downloads become readable for you as they land, and those of earlier sessions too
share_downloads
while kill -0 "$pid" 2>/dev/null; do sleep 2; share_downloads; done &
sharer=$!
wait "$pid" || true
