wren-linux / bin / firefox-session
#!/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