9292, as a real
GNOME app.
A clean, native desktop window for 9292.nl. No Electron. No browser chrome. No frontend tweaks. Just the website, in a configurable GTK window with the buttons you choose.
9292 has no Linux app. This fills the gap — cleanly.
No Electron. No Chromium. No full browser window. Just a thin GTK wrapper around the website you already use.
Built with GTK 4 and WebKit — the same engine GNOME Web (Epiphany) uses. Honors your theme, your fonts, your window manager.
Toggle reload, minimize, maximize, and close independently. Want just reload + close? One line in the config.
Pick 640×480, 1280×720, maximized, or any custom WxH. The responsive 9292 site adapts to the size you choose.
A proper reload button where an app button belongs — not buried in a menu. Shift+click bypasses the cache.
Pure launcher. Zero JavaScript injection, zero DOM tweaks, zero private-API hacking. 9292 has nothing to object to.
Installs to ~/.local/share/9292-app/ and ~/.config/9292-app/. Uninstall with one command. Nothing touches /usr.
A window that looks like an app, not a browser tab.
No URL bar. No tabs. No bookmarks. Just the page and the buttons you opted into.
Three steps. No sudo for the app itself.
The installer asks you a few questions, writes the config, drops the files in ~/.local/share/9292-app/, and registers a .desktop entry.
curl -fsSL https://9292-linux.example/desktop-app/install.sh | bashgit clone https://9292-linux.example/9292-linux.git
cd 9292-linux/desktop-app
./install.sh- ~/.local/share/9292-app/9292-app.py
- ~/.local/share/9292-app/icon.png
- ~/.local/share/9292-app/run.sh
- ~/.config/9292-app/config.toml
- ~/.local/share/applications/9292.desktop
The app needs python3-gi, gir1.2-gtk-4.0, and gir1.2-webkit-6.0. The installer detects missing ones and offers to install them with apt install (only that step needs sudo).
One TOML file. Everything you might want to tweak.
Lives at ~/.config/9292-app/config.toml. Edit it, save, restart the app. Anything you leave out falls back to the defaults.
window.startup_sizestring"maximized" or "WxH" (e.g. "640x480")
window.titlestring"auto" (follows page) or any literal text
titlebar.reloadboolShow the reload button (left side)
titlebar.minimizeboolShow the minimize button
titlebar.maximizeboolShow the maximize button
titlebar.closeboolShow the close button
web.urlstringURL the app loads (default: https://9292.nl/)
web.user_agentstringOverride UA. Empty = WebKitGTK default. Use to force mobile/desktop site.
# =====================================================================# 9292 Desktop App — user configuration# =====================================================================# This file is read by the 9292 desktop app on every launch. Edit it,# save, and restart the app to apply changes.## Location (after install): ~/.config/9292-app/config.toml## Anything you leave out falls back to the built-in defaults shown# below. You can delete this file at any time to reset the app.# =====================================================================[window]# Startup size of the window.# - "maximized" -> window starts maximised (a.k.a. 100%)# - "WxH" -> window starts at that pixel size, e.g. "640x480"# - "100%" -> alias for "maximized"# Examples: "1280x720", "640x480", "375x812" (mobile layout), "maximized"startup_size = "1280x720"# Window title shown in the title bar.# - "auto" -> follow the page's <title> (e.g. "Plan je reis | 9292")# - "9292" -> always show "9292"# - any other string -> shown verbatimtitle = "auto"[titlebar]# Toggle each button in the window's title bar.# All four can be turned off independently.reload = trueminimize = truemaximize = trueclose = true[web]# The URL the app loads. Defaults to the official 9292 homepage.url = "https://9292.nl/"# Optional user-agent override.# - "" (empty) -> use WebKitGTK's default (desktop Chrome-like UA)# - any string -> sent verbatim as the User-Agent header## Useful for forcing the mobile or desktop layout of 9292 regardless of# the window size, e.g.:# user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"user_agent = ""# Mobile layout (phone-sized window + mobile site)[window]startup_size = "375x812"title = "9292"[titlebar]reload = trueminimize = falsemaximize = falseclose = true[web]user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"# Kiosk-lite: maximized, only the close button[window]startup_size = "maximized"[titlebar]reload = falseminimize = falsemaximize = falseclose = true# Tiny corner widget: 640x480, reload + close only[window]startup_size = "640x480"[titlebar]reload = trueminimize = falsemaximize = falseclose = trueRead every line before you run it.
Three files. No build step. No obfuscation. Copy them onto your machine, audit them, then run install.sh.
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""9292 — A clean GNOME desktop web app for 9292.nl=================================================Loads https://9292.nl/ in a native GTK4 + WebKit window with aconfigurable title bar (reload / minimize / maximize / close) and aconfigurable startup size.This is a *launcher*, not a modifier. The 9292 website is shown exactlyas 9292 serves it — no JavaScript injection, no API hacking, no DOMtweaks, no telemetry added. Just a clean native window.Config: ~/.config/9292-app/config.tomlData dir: ~/.local/share/9292-app/Requires on Ubuntu / Debian: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-webkit-6.0Copyright: Public domain — do whatever you want with this.9292 and the 9292 logo are trademarks of their respective owners; thisproject is not affiliated with or endorsed by 9292."""from __future__ import annotationsimport sysimport tomllibfrom pathlib import Pathimport gigi.require_version("Gtk", "4.0")gi.require_version("WebKit", "6.0")from gi.repository import Gtk, WebKit, GLib, Gdk, Gio # noqa: E402# ---------------------------------------------------------------------------# Constants# ---------------------------------------------------------------------------APP_ID = "nl.nine2nine.desktopapp"APP_NAME = "9292"# The WM_CLASS the running window reports to the window manager.# In GTK4, set_wmclass() was removed. WM_CLASS is set via GLib.set_prgname()# (works on X11; on Wayland the app-id is used). The .desktop file's# StartupWMClass must match this value so GNOME groups the running window# under the launcher icon.WM_CLASS = "9292ov"DEFAULT_URL = "https://9292.nl/"CONFIG_DIR = Path.home() / ".config" / "9292-app"CONFIG_PATH = CONFIG_DIR / "config.toml"DEFAULT_CONFIG: dict = { "window": { # "maximized" (a.k.a. "100%") or "<width>x<height>" (e.g. "640x480") "startup_size": "1280x720", # "auto" follows the page title; any other string is used verbatim "title": "auto", }, "titlebar": { "reload": True, "minimize": True, "maximize": True, "close": True, }, "web": { "url": DEFAULT_URL, # Empty string = use WebKitGTK's default user agent. # Set this to force the mobile or desktop site regardless of size. "user_agent": "", },}# ---------------------------------------------------------------------------# Config loading (TOML, deep-merged over defaults)# ---------------------------------------------------------------------------def _deep_merge(base: dict, override: dict) -> dict: """Recursively merge ``override`` into ``base`` (override wins).""" out = dict(base) for k, v in override.items(): if k in out and isinstance(out[k], dict) and isinstance(v, dict): out[k] = _deep_merge(out[k], v) else: out[k] = v return outdef load_config() -> dict: """Load the user's config, deep-merged on top of the defaults.""" if not CONFIG_PATH.exists(): return DEFAULT_CONFIG try: with CONFIG_PATH.open("rb") as f: user_cfg = tomllib.load(f) return _deep_merge(DEFAULT_CONFIG, user_cfg) except Exception as exc: # noqa: BLE001 print( f"[9292] Warning: could not parse {CONFIG_PATH}: {exc}\n" "[9292] Falling back to default config.", file=sys.stderr, ) return DEFAULT_CONFIG# ---------------------------------------------------------------------------# Main window# ---------------------------------------------------------------------------class NineTwoNineWindow(Gtk.ApplicationWindow): """The application window: header bar + WebKit WebView.""" def __init__(self, app: "NineTwoNineApp"): super().__init__(application=app, title=APP_NAME) self.app = app self.cfg = app.cfg # ----- Header bar ------------------------------------------------ header = Gtk.HeaderBar() # We add our own window controls so per-button toggles work. header.set_show_title_buttons(False) # Reload button (left side). Shift+click bypasses the cache. if self.cfg["titlebar"].get("reload", True): self.reload_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic") self.reload_btn.set_tooltip_text( "Reload (Shift+click: bypass cache)" ) self.reload_btn.set_has_frame(False) gesture = Gtk.GestureClick.new() gesture.connect("pressed", self._on_reload_pressed) self.reload_btn.add_controller(gesture) header.pack_start(self.reload_btn) # Title label (updates with the page's <title> if configured "auto") self.title_label = Gtk.Label(label=APP_NAME) self.title_label.add_css_class("title") header.set_title_widget(self.title_label) # Window controls (right side), built from the config toggles. # Gtk.WindowControls understands a "decoration layout" string like # "minimize,maximize,close" — we build it from the enabled buttons. layout_parts: list[str] = [] if self.cfg["titlebar"].get("minimize", True): layout_parts.append("minimize") if self.cfg["titlebar"].get("maximize", True): layout_parts.append("maximize") if self.cfg["titlebar"].get("close", True): layout_parts.append("close") if layout_parts: controls = Gtk.WindowControls.new(Gtk.PackType.END) controls.set_decoration_layout(",".join(layout_parts)) header.pack_end(controls) self.set_titlebar(header) # ----- WebView --------------------------------------------------- self.webview = WebKit.WebView() self.webview.set_vexpand(True) self.webview.set_hexpand(True) settings = self.webview.get_settings() settings.set_enable_developer_extras(False) settings.set_enable_fullscreen(True) ua = str(self.cfg["web"].get("user_agent", "")).strip() if ua: settings.set_user_agent(ua) # Live title sync self.webview.connect("notify::title", self._on_notify_title) self.webview.load_uri(self.cfg["web"].get("url", DEFAULT_URL)) self.set_child(self.webview) # ----- Startup size --------------------------------------------- size = str(self.cfg["window"].get("startup_size", "1280x720")).strip().lower() if size in ("maximized", "100%", "full", "fullscreen"): # Sensible fallback size before maximise kicks in self.set_default_size(1024, 720) GLib.idle_add(lambda: self.maximize()) else: try: w_s, h_s = size.split("x") w, h = int(w_s), int(h_s) if w < 320 or h < 240: raise ValueError("size too small") self.set_default_size(w, h) except Exception: # noqa: BLE001 print( f"[9292] Invalid startup_size '{size}' — defaulting to 1280x720", file=sys.stderr, ) self.set_default_size(1280, 720) # ----- Keyboard shortcuts --------------------------------------- # F5 / Ctrl+R -> reload # Ctrl+Shift+R -> hard reload (bypass cache) # Ctrl+Q -> quit key_ctrl = Gtk.EventControllerKey.new() key_ctrl.connect("key-pressed", self._on_key_pressed) self.add_controller(key_ctrl) # ----- signal handlers ----------------------------------------------- def _on_reload_pressed(self, gesture: Gtk.GestureClick, _n_press, _x, _y): event = gesture.get_current_event() hard = False if event is not None: state = event.get_state() hard = bool(state & Gdk.ModifierType.SHIFT_MASK) if hard: self.webview.reload_bypass_cache() else: self.webview.reload() def _on_key_pressed(self, _ctrl, keyval, _keycode, state): key = Gdk.keyval_name(keyval).lower() ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) shift = bool(state & Gdk.ModifierType.SHIFT_MASK) if key == "f5": self.webview.reload_bypass_cache() if shift else self.webview.reload() return True if ctrl and key == "r": self.webview.reload_bypass_cache() if shift else self.webview.reload() return True if ctrl and key == "q": self.app.quit() return True return False def _on_notify_title(self, wv: WebKit.WebView, _param): page_title = wv.get_title() or "" configured = str(self.cfg["window"].get("title", "auto")) display = page_title or APP_NAME if configured == "auto" else configured self.title_label.set_text(display) self.set_title(display)# ---------------------------------------------------------------------------# Application# ---------------------------------------------------------------------------class NineTwoNineApp(Gtk.Application): """Single-instance GTK application.""" def __init__(self): super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE) self.cfg = load_config() self.win: NineTwoNineWindow | None = None def do_activate(self): # type: ignore[override] if self.win is not None: # Single-instance behaviour: just focus the existing window. self.win.present() return self.win = NineTwoNineWindow(self) self.win.connect("close-request", lambda _w: self.quit()) self.win.present()# ---------------------------------------------------------------------------# Entry point# ---------------------------------------------------------------------------def main() -> int: # Set the program name early — this becomes the WM_CLASS on X11 and # must match StartupWMClass in the .desktop file. GLib.set_prgname(WM_CLASS) GLib.set_application_name(APP_NAME) app = NineTwoNineApp() return app.run(sys.argv)if __name__ == "__main__": sys.exit(main())#!/usr/bin/env bash# =====================================================================# 9292 Desktop App — installer for Ubuntu / Debian / GNOME# =====================================================================# Installs the 9292 desktop web app for the CURRENT USER only:## ~/.local/share/9292-app/9292-app.py (the app)# ~/.local/share/9292-app/icon.png (official 9292 PNG icon)# ~/.local/share/9292-app/run.sh (launcher script)# ~/.config/9292-app/config.toml (your config)# ~/.local/share/applications/<APP_ID>.desktop (app menu entry)## No sudo for the app itself. To remove: ./install.sh --uninstall## Three ways to run:# 1. Piped: curl -fsSL <URL> | bash# 2. Download: curl -fsSL <URL> -o install.sh && bash install.sh# 3. Cloned: git clone ... && cd 9292/desktop-app && ./install.sh## In modes 1 and 2 the companion files (9292-app.py, icon.png,# 9292.desktop, config.toml) are not on disk — this script downloads# them automatically from the GitHub repo.# =====================================================================set -euo pipefail# ----- paths ----------------------------------------------------------APP_NAME="9292"APP_ID="nl.nine2nine.desktopapp"WM_CLASS="9292ov"DATA_DIR="${HOME}/.local/share/9292-app"CONFIG_DIR="${HOME}/.config/9292-app"APPS_DIR="${HOME}/.local/share/applications"DATA_FILE="${DATA_DIR}/9292-app.py"ICON_FILE="${DATA_DIR}/icon.png"RUN_FILE="${DATA_DIR}/run.sh"CONFIG_FILE="${CONFIG_DIR}/config.toml"DESKTOP_FILE="${APPS_DIR}/${APP_ID}.desktop"# GitHub raw base URL for fetching companion files in piped/download mode.REPO_RAW_BASE="https://raw.githubusercontent.com/samantha-agi/9292/main/desktop-app"# Where this script lives — used to find the bundled app files.# When piped via `curl ... | bash`, BASH_SOURCE[0] is empty (no file), so we# detect that and download companion files from GitHub instead.# Use ${VAR:-} form so `set -u` doesn't abort on unset BASH_SOURCE.SCRIPT_SOURCE="${BASH_SOURCE[0]:-}"if [[ -n "$SCRIPT_SOURCE" ]]; then SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"else SCRIPT_DIR=""fi# ----- pretty printing ------------------------------------------------if [[ -t 1 ]]; then C_RESET=$'\033[0m'; C_BOLD=$'\033[1m' C_BLUE=$'\033[34m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'else C_RESET=""; C_BOLD=""; C_BLUE=""; C_GREEN=""; C_YELLOW=""; C_RED=""fisay() { printf '%s\n' "${C_BOLD}${C_BLUE}[$APP_NAME]${C_RESET} $*"; }ok() { printf '%s\n' "${C_BOLD}${C_GREEN}✓${C_RESET} $*"; }warn() { printf '%s\n' "${C_BOLD}${C_YELLOW}!${C_RESET} $*"; }die() { printf '%s\n' "${C_BOLD}${C_RED}✗${C_RESET} $*" >&2; exit 1; }prompt() { printf '%s' "${C_BOLD}${C_BLUE}>>>${C_RESET} $* "; }# Read a line from the terminal (/dev/tty), NOT from stdin.# When piped via `curl ... | bash`, stdin is the curl pipe (the script# itself), so `read` would read from the pipe instead of the keyboard.# Redirecting to /dev/tty forces reads to come from the controlling# terminal, making interactive prompts work in all three modes:# curl | bash (stdin = pipe, /dev/tty = keyboard)# bash install.sh (stdin = keyboard already)# ./install.sh (stdin = keyboard already)tty_read() { read -r "$@" </dev/tty}# ----- dependency check ----------------------------------------------# Checks three things separately so we diagnose correctly:# - python3 binary# - python3-gi (PyGObject — `import gi`)# - gir1.2-gtk-4.0 typelib (gi.require_version Gtk 4.0)# - gir1.2-webkit-6.0 typelib (gi.require_version WebKit 6.0)# Each is its own apt package on Ubuntu/Debian.## If `import gi` fails but the package IS installed (per dpkg), we show the# actual error and the Python version — this usually means python3 on PATH# is a different version than the one python3-gi was built for.check_dependencies() { local missing=() command -v python3 >/dev/null 2>&1 || missing+=("python3") # Check PyGObject itself (the python3-gi package). if ! python3 -c 'import gi' 2>/dev/null; then # Is the package actually installed? if dpkg -s python3-gi >/dev/null 2>&1; then # Package is installed but import fails — diagnose. echo warn "python3-gi is installed (per dpkg) but `python3 -c 'import gi'` fails." say "This usually means the python3 on your PATH is a different version" say "than the one python3-gi was built for." echo say "Python version on PATH:" printf ' %s\n' "$(python3 --version 2>&1)" say "Python path:" printf ' %s\n' "$(command -v python3)" say "Actual import error:" python3 -c 'import gi' 2>&1 | sed 's/^/ /' echo say "Possible fixes:" say " 1. If you have multiple Pythons, install gi for the right one, e.g.:" printf ' %ssudo apt install python3-gi%s\n' "${C_BOLD}" "${C_RESET}" say " 2. Or check 'ls /usr/lib/python3*/dist-packages/gi/' to see which" say " Python version has gi installed, and use that one." say " 3. Or run the app with: python3.X ~/.local/share/9292-app/9292-app.py" echo die "Cannot proceed: python3-gi is installed but not importable by the python3 on your PATH." else missing+=("python3-gi") fi fi # Now check the GTK4 typelib. Only meaningful if python3-gi is importable. if [[ " ${missing[*]} " != *" python3-gi "* ]]; then if ! python3 -c 'import gi; gi.require_version("Gtk","4.0")' 2>/dev/null; then missing+=("gir1.2-gtk-4.0") fi if ! python3 -c 'import gi; gi.require_version("WebKit","6.0")' 2>/dev/null; then missing+=("gir1.2-webkit-6.0") fi fi if [[ ${#missing[@]} -gt 0 ]]; then echo warn "Missing system packages: ${missing[*]}" say "Install them with:" printf ' %ssudo apt install %s%s\n' \ "${C_BOLD}" "${missing[*]}" "${C_RESET}" prompt "Install them now? [Y/n]" local answer; tty_read answer if [[ "${answer:-Y}" =~ ^[Yy]$ ]]; then sudo apt update sudo apt install -y "${missing[@]}" # Re-check after install. local still_missing=() command -v python3 >/dev/null 2>&1 || still_missing+=("python3") if ! python3 -c 'import gi' 2>/dev/null; then still_missing+=("python3-gi") else if ! python3 -c 'import gi; gi.require_version("Gtk","4.0")' 2>/dev/null; then still_missing+=("gir1.2-gtk-4.0") fi if ! python3 -c 'import gi; gi.require_version("WebKit","6.0")' 2>/dev/null; then still_missing+=("gir1.2-webkit-6.0") fi fi if [[ ${#still_missing[@]} -gt 0 ]]; then die "These packages still couldn't be loaded: ${still_missing[*]}" fi ok "Dependencies installed." else die "Cannot continue without these packages. Aborting." fi fi}# ----- companion file acquisition ------------------------------------# Returns 0 if 9292-app.py is available in $SCRIPT_DIR (cloned mode),# otherwise downloads all companion files from GitHub into a temp dir# and repoints $SCRIPT_DIR at it.acquire_companion_files() { # Cloned mode: files are already on disk next to this script. if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/9292-app.py" ]]; then return 0 fi # Piped or download mode: fetch from GitHub. say "Fetching app files from GitHub…" command -v curl >/dev/null 2>&1 || die "curl is required to download the app files." local tmp; tmp="$(mktemp -d)" # Ensure the temp dir is cleaned up on exit (any exit path). trap 'rm -rf "$tmp"' EXIT local f for f in 9292-app.py 9292.desktop config.toml icon.png; do printf ' %s downloading %s\n' "${C_BOLD}${C_BLUE}→${C_RESET}" "$f" if ! curl -fsSL "$REPO_RAW_BASE/$f" -o "$tmp/$f"; then die "Failed to download $f from $REPO_RAW_BASE/$f" fi done SCRIPT_DIR="$tmp" ok "App files downloaded."}# ----- uninstall ------------------------------------------------------do_uninstall() { say "Removing the 9292 desktop app for the current user…" rm -rfv "$DATA_DIR" 2>/dev/null || true rm -fv "$DESKTOP_FILE" 2>/dev/null || true rm -fv "${APPS_DIR}/9292.desktop" 2>/dev/null || true # cleanup old name say "Your config at $CONFIG_DIR was left in place." say "To remove it too: rm -rf \"$CONFIG_DIR\"" if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$APPS_DIR" 2>/dev/null || true fi if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -f -t "$HOME/.local/share/icons" 2>/dev/null || true fi ok "Uninstalled."}# ----- interactive helpers -------------------------------------------ask_size() { cat <<EOFChoose the window startup size: 1) 640x480 (small — mobile-ish layout) 2) 800x600 3) 1024x768 4) 1280x720 (default — desktop layout) 5) 1920x1080 (large) 6) maximized (100% — fills the screen) X) custom (enter WIDTHxHEIGHT yourself)EOF while true; do prompt "Pick 1-6 or X:" local a; tty_read a case "${a,,}" in 1) SIZE="640x480"; break ;; 2) SIZE="800x600"; break ;; 3) SIZE="1024x768"; break ;; 4) SIZE="1280x720"; break ;; 5) SIZE="1920x1080"; break ;; 6) SIZE="maximized"; break ;; x) prompt "Enter WIDTHxHEIGHT (e.g. 1440x900):" tty_read custom if [[ "$custom" =~ ^[0-9]{3,5}x[0-9]{3,5}$ ]]; then SIZE="$custom"; break else warn "Must look like 1024x768. Try again." fi ;; *) warn "Please answer 1-6 or X." ;; esac done}ask_yn() { local question="$1" default="${2:-y}" local hint if [[ "$default" == "y" ]]; then hint="[Y/n]"; else hint="[y/N]"; fi while true; do prompt "$question $hint" local a; tty_read a a="${a:-$default}" case "${a,,}" in y|yes) return 0 ;; n|no) return 1 ;; *) warn "Please answer y or n." ;; esac done}ask_titlebar() { echo say "Title bar buttons:" if ask_yn "Show reload button?" y; then TB_RELOAD="true"; else TB_RELOAD="false"; fi if ask_yn "Show minimize button?" y; then TB_MIN="true"; else TB_MIN="false"; fi if ask_yn "Show maximize button?" y; then TB_MAX="true"; else TB_MAX="false"; fi if ask_yn "Show close button?" y; then TB_CLOSE="true"; else TB_CLOSE="false"; fi}ask_title() { echo prompt 'Window title: "auto" (follows page title) or type your own [auto]:' tty_read TITLE TITLE="${TITLE:-auto}"}ask_ua() { echo say "Optional user-agent override (forces the mobile/desktop site regardless of window size)." prompt "Press Enter to skip, or paste a User-Agent string:" tty_read UA UA="${UA:-}"}# ----- config file writer --------------------------------------------write_config() { mkdir -p "$CONFIG_DIR" # Escape any double-quotes in the title / UA for TOML string safety. local title_esc ua_esc title_esc="${TITLE//\"/\\\"}" ua_esc="${UA//\"/\\\"}" cat > "$CONFIG_FILE" <<EOF# 9292 Desktop App — generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)# Edit freely; restart the app to apply changes.[window]startup_size = "$SIZE"title = "$title_esc"[titlebar]reload = $TB_RELOADminimize = $TB_MINmaximize = $TB_MAXclose = $TB_CLOSE[web]url = "https://9292.nl/"user_agent = "$ua_esc"EOF ok "Config written to $CONFIG_FILE"}# ----- main install ---------------------------------------------------do_install() { say "9292 desktop app — installer" say "(user-local; no sudo needed for the app itself)" echo check_dependencies acquire_companion_files ask_size ask_titlebar ask_title ask_ua echo say "Installing…" mkdir -p "$DATA_DIR" "$APPS_DIR" # 1) The app itself cp -f "$SCRIPT_DIR/9292-app.py" "$DATA_FILE" ok "App: $DATA_FILE" # 2) The icon (official 9292 PNG — no SVG, per design) if [[ -f "$SCRIPT_DIR/icon.png" ]]; then cp -f "$SCRIPT_DIR/icon.png" "$ICON_FILE" ok "Icon: $ICON_FILE" else warn "icon.png not found — app will have a generic icon." fi # 3) Launcher wrapper cat > "$RUN_FILE" <<EOF#!/usr/bin/env bash# Launcher for the 9292 desktop app.exec python3 "$DATA_FILE" "\$@"EOF chmod +x "$RUN_FILE" ok "Launcher: $RUN_FILE" # 4) Config file write_config # 5) .desktop entry — copy the static template and substitute paths. if [[ -f "$SCRIPT_DIR/9292.desktop" ]]; then cp -f "$SCRIPT_DIR/9292.desktop" "$DESKTOP_FILE" sed -i \ -e "s|__RUN_FILE__|$RUN_FILE|g" \ -e "s|__ICON_FILE__|$ICON_FILE|g" \ "$DESKTOP_FILE" ok "Menu entry: $DESKTOP_FILE" else warn "9292.desktop template not found — skipping menu entry." fi # 6) Refresh desktop / icon databases if the tools exist command -v update-desktop-database >/dev/null 2>&1 && \ update-desktop-database "$APPS_DIR" 2>/dev/null || true command -v gtk-update-icon-cache >/dev/null 2>&1 && \ gtk-update-icon-cache -f -t "$HOME/.local/share/icons" 2>/dev/null || true echo ok "Done. Find “9292” in your GNOME app grid, or run: $RUN_FILE" say "Edit config: \$EDITOR $CONFIG_FILE"}# ----- entry point ----------------------------------------------------case "${1:-}" in -h|--help) cat <<EOF9292 desktop app installerUsage: ./install.sh Interactive install (user-local, no sudo) ./install.sh --uninstall Remove the app and its menu entry ./install.sh -h|--help Show this helpInstalled paths: $DATA_DIR/ $CONFIG_DIR/ $DESKTOP_FILEEOF ;; --uninstall|-u) do_uninstall ;; "") do_install ;; *) die "Unknown argument: $1 (try --help)" ;;esac# =====================================================================# 9292 Desktop App — user configuration# =====================================================================# This file is read by the 9292 desktop app on every launch. Edit it,# save, and restart the app to apply changes.## Location (after install): ~/.config/9292-app/config.toml## Anything you leave out falls back to the built-in defaults shown# below. You can delete this file at any time to reset the app.# =====================================================================[window]# Startup size of the window.# - "maximized" -> window starts maximised (a.k.a. 100%)# - "WxH" -> window starts at that pixel size, e.g. "640x480"# - "100%" -> alias for "maximized"# Examples: "1280x720", "640x480", "375x812" (mobile layout), "maximized"startup_size = "1280x720"# Window title shown in the title bar.# - "auto" -> follow the page's <title> (e.g. "Plan je reis | 9292")# - "9292" -> always show "9292"# - any other string -> shown verbatimtitle = "auto"[titlebar]# Toggle each button in the window's title bar.# All four can be turned off independently.reload = trueminimize = truemaximize = trueclose = true[web]# The URL the app loads. Defaults to the official 9292 homepage.url = "https://9292.nl/"# Optional user-agent override.# - "" (empty) -> use WebKitGTK's default (desktop Chrome-like UA)# - any string -> sent verbatim as the User-Agent header## Useful for forcing the mobile or desktop layout of 9292 regardless of# the window size, e.g.:# user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"user_agent = ""Five shortcuts. That's the whole manual.
Honest answers.
Not affiliated with 9292. 9292 and the 9292 logo are trademarks of their respective owners. This project is an independent launcher that shows the official 9292 website in a native GNOME window. The source code is released to the public domain (CC0).