v1.0 · GTK 4 · WebKit 6.0

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.

No sudoNo telemetryUser-local install~200 lines of Python
Official 9292 logo
official 9292 icon · used as the .desktop launcher icon
Why this exists

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.

Native GNOME window

Built with GTK 4 and WebKit — the same engine GNOME Web (Epiphany) uses. Honors your theme, your fonts, your window manager.

Configurable title bar

Toggle reload, minimize, maximize, and close independently. Want just reload + close? One line in the config.

Startup size presets

Pick 640×480, 1280×720, maximized, or any custom WxH. The responsive 9292 site adapts to the size you choose.

Reload in the title bar

A proper reload button where an app button belongs — not buried in a menu. Shift+click bypasses the cache.

No frontend modification

Pure launcher. Zero JavaScript injection, zero DOM tweaks, zero private-API hacking. 9292 has nothing to object to.

User-local, no sudo

Installs to ~/.local/share/9292-app/ and ~/.config/9292-app/. Uninstall with one command. Nothing touches /usr.

What you get

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.

Plan je reis met het OV en deelvervoer | 9292
9292 logo
Plan je reis
met het OV en deelvervoer
Van
adres, station, halte
Naar
adres, station, halte
Vertrek / Aankomst
Nu
Datum
Vandaag
Install

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.

1
Get the installer
Download the install.sh script (or clone the repo for the full source).
One-liner:
curl -fsSL https://9292-linux.example/desktop-app/install.sh | bash
2
Run it
The installer checks deps, asks about size & buttons, writes the config and .desktop entry.
Or from a clone:
git clone https://9292-linux.example/9292-linux.git
cd 9292-linux/desktop-app
./install.sh
3
Find it in GNOME
Open the app grid and search “9292”, or run ~/.local/share/9292-app/run.sh.
Files written:
  • ~/.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
System dependencies

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).

Configuration

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.

Field reference
  • window.startup_sizestring

    "maximized" or "WxH" (e.g. "640x480")

  • window.titlestring

    "auto" (follows page) or any literal text

  • titlebar.reloadbool

    Show the reload button (left side)

  • titlebar.minimizebool

    Show the minimize button

  • titlebar.maximizebool

    Show the maximize button

  • titlebar.closebool

    Show the close button

  • web.urlstring

    URL the app loads (default: https://9292.nl/)

  • web.user_agentstring

    Override UA. Empty = WebKitGTK default. Use to force mobile/desktop site.

The default config (what the installer writes)
config.toml
# =====================================================================# 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 = ""
Example: Mobile
config.toml
# 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"
Example: Kiosk-lite
config.toml
# Kiosk-lite: maximized, only the close button[window]startup_size = "maximized"[titlebar]reload   = falseminimize = falsemaximize = falseclose    = true
Example: Tiny widget
config.toml
# Tiny corner widget: 640x480, reload + close only[window]startup_size = "640x480"[titlebar]reload   = trueminimize = falsemaximize = falseclose    = true
Source

Read every line before you run it.

Three files. No build step. No obfuscation. Copy them onto your machine, audit them, then run install.sh.

9292-app.py
#!/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())
install.sh
#!/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
config.toml
# =====================================================================# 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 = ""
Keyboard

Five shortcuts. That's the whole manual.

ReloadF5
ReloadCtrl+++R
Hard reload (bypass cache)Ctrl+++Shift+++R
Hard reloadShift+++click+reload
QuitCtrl+++Q
FAQ

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).