From f457c1eb9f53fe5422472f466c6795e9061bd582 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 20 Aug 2026 23:00:21 -0400 Subject: [PATCH] Build the two applications nobody packages, on purpose rather than in passing Claude Desktop and ChatGPT Desktop ship for macOS and Windows. The Linux path for both is a community wrapper that converts the official build into an RPM -- so what lands is still a package dnf owns and can remove, which is the part of the dnf/flatpak rule that actually matters. What they need an exception for is the build itself, and there is no packaged form to prefer over it. `panama app` builds one by name, and is deliberately not part of ./install. A source build is slow, wants the network throughout, and depends on an upstream that moves -- twenty minutes in, an error, with nobody at the keyboard, which is the exact failure the interview exists to prevent. Asking for one is something you do on purpose, and it is also the rebuild path when a new version ships. Nothing is pinned. Each build takes the current default branch and the current upstream release, and reports a failure rather than working around it, leaving the tree where the error can be read. sunhat pinned versions and every pin was a 404 within a release cycle. Adding one is adding a file to setup/apps/, and the file has to say why the exception exists -- the contract fails a definition that does not, because the guard against this list growing by habit is having to write the reason down. sunhat had seventy-odd installers and a reason recorded for none of them. The contract had a bug worth recording: `while read` on the right of a pipe runs in a subshell, so two of its three per-definition checks recorded findings into an array that went out of scope at the end of the loop. It reported PASS on a definition with no description and no build function. Found by standing one in deliberately and noticing only the third check spoke up. Also: nautilus-open-any-terminal is now declared, and Panama's copy of the extension is gone. Fedora packages that extension AND its gsettings schema, and Panama shipped its own fork of the .py over the same path while declaring neither -- so a fresh machine got an extension whose schema did not exist. It worked here only because the RPM has been installed since sunhat. The fork was also 63 lines behind the packaged version, missing its newer Nautilus and Caja handling. Auditing the rest of config/copy for the same shape found nothing else: dnf.conf is a config file its package expects to be replaced, and the GPU udev rules are Panama's own. 125 contracts pass. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj --- README.md | 13 + bin/panama | 83 +++ .../extensions/nautilus_open_any_terminal.py | 614 ------------------ setup/apps/chatgpt-desktop | 19 + setup/apps/claude-desktop | 28 + setup/packages/desktop-packages | 5 + tests/setup/apps-contract | 117 ++++ 7 files changed, 265 insertions(+), 614 deletions(-) delete mode 100644 config/copy/usr/share/nautilus-python/extensions/nautilus_open_any_terminal.py create mode 100644 setup/apps/chatgpt-desktop create mode 100644 setup/apps/claude-desktop create mode 100755 tests/setup/apps-contract diff --git a/README.md b/README.md index 14e1660..f189366 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ config/ old/ Backups of whatever was replaced (gitignored) wallpapers/ Copied into ~/Pictures/Wallpapers when absent setup/ + apps/ Applications built from source, one file each packages/ One package per line; extras/ holds the optional categories scripts/ Run in order by ./install tests/ Contracts. See below @@ -126,8 +127,20 @@ panama edit # open it in Neovim panama doctor # what is actually running, not what was installed panama test # every contract, or a subset by pattern panama upgrade # re-run ./install from anywhere +panama app # applications no repository carries; build one by name ``` +`panama app` is deliberately not part of `./install`. Everything else Panama +installs comes from dnf or Flathub; these are built from source because no +packaged form exists, and a source build is slow, wants the network throughout, +and depends on an upstream that moves. That is the failure the interview exists +to prevent, so asking for one is something you do on purpose — and it is also +how you rebuild when a new version ships. Nothing is pinned: each build takes +the current upstream and reports a failure rather than working around it. + +Adding one is adding a file to `setup/apps/`, and the file has to say why the +exception exists. + None of the scripts in this repository carry a `.sh` extension. A shebang and the executable bit already select the interpreter, and the extension only becomes something to keep in sync — which it did not stay. diff --git a/bin/panama b/bin/panama index a0e7fde..aabdf61 100755 --- a/bin/panama +++ b/bin/panama @@ -10,6 +10,7 @@ # doctor Report what is actually running on this machine # test Run every contract under tests/ # upgrade Re-run the installer from anywhere +# app Build and install an application that no repository packages # help Show this help # # Designed to grow: add new subcommands as cmd_ functions and @@ -71,6 +72,8 @@ ${BOLD}Commands:${RESET} a subset: 'panama test dock' runs the ones matching 'dock'. ${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is idempotent and this is the documented upgrade path. + ${GREEN}app${RESET} Build and install an application that neither dnf nor + Flathub carries. With no name, lists what is available. ${GREEN}help${RESET} Show this help (also -h, --help). ${BOLD}Options:${RESET} @@ -83,6 +86,8 @@ ${BOLD}Examples:${RESET} $PROGRAM doctor --summary $PROGRAM test dock $PROGRAM upgrade + $PROGRAM app + $PROGRAM app claude-desktop EOF } @@ -311,6 +316,83 @@ cmd_upgrade() { exec "$installer" "$@" } +# ---------------------------------------------------------------------------- +# Command: app +# ---------------------------------------------------------------------------- +# +# The applications that neither dnf nor Flathub carries, built from source into +# a package dnf can still own and remove. +# +# Deliberately NOT part of ./install. A source build is slow, wants the network +# for the whole of it, and depends on an upstream that moves -- which is exactly +# the failure the interview exists to prevent: twenty minutes in, a prompt or an +# error, with nobody at the keyboard. Asking for one of these is a thing you do +# on purpose, and it is also the rebuild path when a new version ships. +# +# Nothing is pinned. Each build takes the current default branch and the current +# upstream release, and says so when it fails. A recorded version is a 404 +# waiting to happen -- sunhat proved that three times over. +APPS_DIR="$PANAMA_DIR/setup/apps" +APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps" + +cmd_app() { + local name="${1:-}" + + if [[ -z "$name" ]]; then + header "Applications" + printf 'Built from source, because no repository carries them.\n\n' + local file + for file in "$APPS_DIR"/*; do + [[ -f "$file" ]] || continue + local description="" + # shellcheck source=/dev/null + source "$file" + printf ' %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description" + done + printf '\nBuild one with: %s%s app %s\n' "$BOLD" "$PROGRAM" "$RESET" + return 0 + fi + + local definition="$APPS_DIR/$name" + if [[ ! -f "$definition" ]]; then + err "No such application: '$name'" + printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2 + exit 1 + fi + + local description="" repo="" + # shellcheck source=/dev/null + source "$definition" + [[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; } + + # The checkout lives in the cache because it is entirely rebuildable and + # should never be mistaken for something to keep. Existing checkouts are + # reset to upstream rather than merged: a local edit in a build tree is not + # something to preserve silently. + local tree="$APPS_WORK/$name" + if [[ -d "$tree/.git" ]]; then + info "Updating $name" + git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; } + git -C "$tree" reset --hard FETCH_HEAD >/dev/null + else + info "Cloning $name" + mkdir -p "$APPS_WORK" + rm -rf "$tree" + git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; } + fi + + info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network" + if ( cd "$tree" && build ); then + ok "$name installed" + printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)" + else + err "$name failed to build" + printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2 + printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2 + exit 1 + fi +} + # ---------------------------------------------------------------------------- # Dispatcher # ---------------------------------------------------------------------------- @@ -322,6 +404,7 @@ main() { doctor) shift; cmd_doctor "$@" ;; test) shift; cmd_test "$@" ;; upgrade) shift; cmd_upgrade "$@" ;; + app) shift; cmd_app "$@" ;; help|-h|--help|"") usage ;; --version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;; *) diff --git a/config/copy/usr/share/nautilus-python/extensions/nautilus_open_any_terminal.py b/config/copy/usr/share/nautilus-python/extensions/nautilus_open_any_terminal.py deleted file mode 100644 index d6640b8..0000000 --- a/config/copy/usr/share/nautilus-python/extensions/nautilus_open_any_terminal.py +++ /dev/null @@ -1,614 +0,0 @@ -"""nautilus extension: nautilus_open_any_terminal""" - -# based on: https://github.com/gnunn1/tilix/blob/master/data/nautilus/open-tilix.py - -import ast -import re -import shlex -from dataclasses import dataclass, field -from functools import cache -from gettext import gettext, translation -from os.path import expanduser -from subprocess import Popen -from typing import Optional -from urllib.parse import quote, unquote, urlparse - -from gi import require_version - -try: - require_version("Nautilus", "4.1") -except ValueError: - require_version("Nautilus", "4.0") - -require_version("Gtk", "4.0") - -from gi.repository import Nautilus as FileManager -API_VERSION = "4.1" - -from gi.repository import Gio, GLib, GObject, Gtk # noqa: E402 pylint: disable=wrong-import-position - - -@dataclass(frozen=True) -class Terminal: - """Data class representing a terminal configuration.""" - - name: str - workdir_arguments: Optional[list[str]] = None - new_tab_arguments: Optional[list[str]] = None - new_window_arguments: Optional[list[str]] = None - command_arguments: list[str] = field(default_factory=lambda: ["-e"]) - flatpak_package: Optional[str] = None - - -_ = gettext -for localedir in [expanduser("~/.local/share/locale"), "/usr/share/locale"]: - try: - trans = translation("nautilus-open-any-terminal", localedir) - trans.install() - _ = trans.gettext - break - except FileNotFoundError: - continue - -TERMINALS = { - "alacritty": Terminal("Alacritty"), - "app2unit-term": Terminal("app2unit-term"), - "blackbox": Terminal( - "Black Box", - workdir_arguments=["--working-directory"], - command_arguments=["-c"], - flatpak_package="com.raggesilver.BlackBox", - ), - "blackbox-terminal": Terminal( - "Black Box", - workdir_arguments=["--working-directory"], - command_arguments=["-c"], - ), - "bobcat": Terminal( - "Bobcat", - workdir_arguments=["--working-dir"], - command_arguments=["--"], - ), - "cool-retro-term": Terminal("cool-retro-term", workdir_arguments=["--workdir"]), - "custom": Terminal(_("Terminal"), command_arguments=[]), - "contour": Terminal( - "Contour", - workdir_arguments=["--working-directory"], - flatpak_package="org.contourterminal.Contour", - ), - "cosmic-term": Terminal("COSMIC Terminal"), - "deepin-terminal": Terminal("Deepin Terminal"), - "ddterm": Terminal( - "Drop down Terminal extension", - workdir_arguments=["--working-directory"], - flatpak_package="com.github.amezin.ddterm", - ), - "foot": Terminal("Foot"), - "footclient": Terminal("FootClient"), - "ghostty": Terminal("Ghostty"), - "gnome-terminal": Terminal("Terminal", new_tab_arguments=["--tab"], command_arguments=["--"]), - "guake": Terminal("Guake", workdir_arguments=["--show", "--new-tab"]), - "kermit": Terminal("Kermit"), - "kgx": Terminal("Console", new_tab_arguments=["--tab"]), - "kitty": Terminal("Kitty"), - "konsole": Terminal("Konsole", new_tab_arguments=["--new-tab"]), - "mate-terminal": Terminal("Mate Terminal", new_tab_arguments=["--tab"]), - "mlterm": Terminal("Mlterm"), - "ptyxis": Terminal( - "Ptyxis", - workdir_arguments=["-d"], - command_arguments=["--"], - new_tab_arguments=["--tab"], - new_window_arguments=["--new-window"], - flatpak_package="app.devsuite.Ptyxis", - ), - "ptyxis-nightly": Terminal( - "Ptyxis", - workdir_arguments=["-d"], - command_arguments=["--"], - new_tab_arguments=["--tab"], - new_window_arguments=["--new-window"], - flatpak_package="org.gnome.Ptyxis.Devel", - ), - "qterminal": Terminal("QTerminal"), - "rio": Terminal("Rio"), - "sakura": Terminal("Sakura"), - "st": Terminal("Simple Terminal"), - "tabby": Terminal("Tabby", command_arguments=["run"], workdir_arguments=["open"]), - "terminator": Terminal("Terminator", new_tab_arguments=["--new-tab"]), - "terminology": Terminal("Terminology"), - "terminus": Terminal("Terminus"), - "termite": Terminal("Termite"), - "tilix": Terminal("Tilix", flatpak_package="com.gexperts.Tilix"), - "urxvt": Terminal("rxvt-unicode"), - "urxvtc": Terminal("urxvtc"), - "uwsm-terminal": Terminal("uwsm-terminal"), - "uxterm": Terminal("UXTerm"), - "warp": Terminal( - "Warp", - new_tab_arguments=["--virtual-arg-for-tabs"], # This is just to indicate tab support - ), - "wezterm": Terminal( - "Wez's Terminal Emulator", - workdir_arguments=["--cwd"], - new_tab_arguments=["start", "--new-tab"], - new_window_arguments=["start"], - flatpak_package="org.wezfurlong.wezterm", - ), - "xfce4-terminal": Terminal("Xfce Terminal", new_tab_arguments=["--tab"]), - "xterm": Terminal("XTerm"), -} - -FLATPAK_PARMS = ["off", "system", "user"] - -terminal = "gnome-terminal" -terminal_cmd: list[str] = None # type: ignore -terminal_data: Terminal = TERMINALS["gnome-terminal"] -new_tab = False -flatpak = FLATPAK_PARMS[0] -custom_local_command: str -custom_remote_command: str - -GSETTINGS_PATH = "com.github.stunkymonkey.nautilus-open-any-terminal" -GSETTINGS_KEYBINDINGS = "keybindings" -GSETTINGS_BIND_REMOTE = "bind-remote" -GSETTINGS_TERMINAL = "terminal" -GSETTINGS_NEW_TAB = "new-tab" -GSETTINGS_FLATPAK = "flatpak" -GSETTINGS_USE_GENERIC_TERMINAL_NAME = "use-generic-terminal-name" -GSETTINGS_CUSTOM_LOCAL_COMMAND = "custom-local-command" -GSETTINGS_CUSTOM_REMOTE_COMMAND = "custom-remote-command" -REMOTE_URI_SCHEME = ["ftp", "sftp"] - - -# Adapted from https://www.freedesktop.org/software/systemd/man/latest/os-release.html -def read_os_release(): - """Read and parse the OS release information.""" - possible_os_release_paths = ["/etc/os-release", "/usr/lib/os-release"] - for file_path in possible_os_release_paths: - try: - with open(file_path, mode="r", encoding="utf-8") as os_release: - for line_number, line in enumerate(os_release, start=1): - line = line.rstrip() - if not line or line.startswith("#"): - continue - result = re.match(r"([A-Z][A-Z_0-9]+)=(.*)", line) - if result: - name, val = result.groups() - if val and val[0] in "\"'": - val = ast.literal_eval(val) - yield name, val - else: - raise OSError(f"{file_path}:{line_number}: bad line {line!r}") - except FileNotFoundError: - continue - - -@cache -def distro_id() -> set[str]: - """get the set of distribution ids""" - try: - os_release = dict(read_os_release()) - except OSError: - return set(["unknown"]) - ids = [os_release["ID"]] - if id_like := os_release.get("ID_LIKE"): - ids.extend(id_like.split(" ")) - return set(ids) - - -def parse_custom_command(command: str, data: str | list[str]) -> list[str]: - """Substitute every '%s' in the command with data and split it into arguments""" - if isinstance(data, str): - data = [data] - - return shlex.split(command.replace("%s", shlex.join(data))) - - -def run_command_in_terminal(command: list[str], *, cwd: str | None = None): - if terminal == "custom": - cmd = parse_custom_command(custom_remote_command, command) - else: - cmd = terminal_cmd.copy() - if cwd and terminal_data.workdir_arguments: - cmd.extend(terminal_data.workdir_arguments) - cmd.append(cwd) - cmd.extend(terminal_data.command_arguments) - cmd.extend(command) - - Popen(cmd, cwd=cwd) # pylint: disable=consider-using-with - - -def ssh_command_from_uri(uri: str, *, is_directory: bool): - """Creates an ssh command that executes or cd's into remote uri""" - result = urlparse(uri) - cmd = ["ssh", "-t"] - if result.username: - cmd.append(f"{result.username}@{result.hostname}") - else: - cmd.append(result.hostname) # type: ignore - - if result.port: - cmd.append("-p") - cmd.append(str(result.port)) - - target = shlex.quote(unquote(result.path)) - if is_directory: - cmd.extend(["cd", target, ";", "exec", "${SHELL:-/bin/sh}", "-l"]) - else: - cmd.extend(["exec", target]) - - return cmd - - -def open_remote_terminal_in_uri(uri: str): - """Open a new remote terminal""" - run_command_in_terminal(ssh_command_from_uri(uri, is_directory=True)) - - -def open_local_terminal_in_uri(uri: str): - """open the new terminal with correct path""" - result = urlparse(uri) - filename = unquote(result.path) - if result.scheme == "admin": - run_command_in_terminal(["sudo", "-s"], cwd=filename) - return - - if terminal == "warp": - # Force new_tab to be considered even without traditional tab arguments - Popen( # pylint: disable=consider-using-with - ["xdg-open", f"warp://action/new_{'tab' if new_tab else 'window'}?path={result.path}"] - ) - return - - cmd = terminal_cmd.copy() - if terminal == "custom": - cmd = parse_custom_command(custom_local_command, filename) - elif filename and terminal_data.workdir_arguments: - cmd.extend(terminal_data.workdir_arguments) - cmd.append(filename) - - Popen(cmd, cwd=filename) # pylint: disable=consider-using-with - - -def directory_menu_item_id(*, foreground: bool, remote: bool): - return f"OpenTerminal::open{'_' if foreground else '_bg_'}{'remote' if remote else 'file'}_item" - - -def executable_menu_item_id(*, remote: bool): - return f"OpenTerminal::execute{'_remote_' if remote else '_file_'}item" - - -def get_directory_menu_items( - file: FileManager.FileInfo, callback, *, foreground: bool, terminal_name: str | None = None -): - items = [] - remote = file.get_uri_scheme() in REMOTE_URI_SCHEME - terminal_name = terminal_name or terminal_data.name - - if remote: - if foreground: - REMOTE_LABEL = _("Open in Remote {}") - REMOTE_TIP = _("Open Remote {} in {}") - LOCAL_LABEL = _("Open in Local {}") - LOCAL_TIP = _("Open Local {} in {}") - tip = REMOTE_TIP.format(terminal_name, file.get_name()) - else: - REMOTE_LABEL = _("Open Remote {} Here") - REMOTE_TIP = _("Open Remote {} in This Directory") - LOCAL_LABEL = _("Open Local {} Here") - LOCAL_TIP = _("Open Local {} in This Directory") - tip = REMOTE_TIP.format(terminal_name) - - item = FileManager.MenuItem( - name=directory_menu_item_id(foreground=foreground, remote=True), - label=REMOTE_LABEL.format(terminal_name), - tip=tip, - ) - item.connect("activate", callback, file, True) - items.append(item) - elif foreground: - LOCAL_LABEL = _("Open in {}") - LOCAL_TIP = _("Open {} in {}") - else: - LOCAL_LABEL = _("Open {} Here") - LOCAL_TIP = _("Open {} in This Directory") - - # Let wezterm handle opening a local terminal - if terminal == "wezterm" and flatpak == "off": - return items - - if foreground: - tip = LOCAL_TIP.format(terminal_name, file.get_name()) - else: - tip = LOCAL_TIP.format(terminal_name) - - item = FileManager.MenuItem( - name=directory_menu_item_id(foreground=foreground, remote=False), - label=LOCAL_LABEL.format(terminal_name), - tip=tip, - ) - item.connect("activate", callback, file, False) - items.append(item) - return items - - -def get_executable_menu_items(file: FileManager.FileInfo, callback, *, terminal_name: str | None = None): - items = [] - remote = file.get_uri_scheme() in REMOTE_URI_SCHEME - terminal_name = terminal_name or terminal_data.name - - if remote: - REMOTE_LABEL = _("Execute in Remote {}") - REMOTE_TIP = _("Execute {} in {} via SSH") - LOCAL_LABEL = _("Execute in Local {}") - LOCAL_TIP = _("Execute {} in Local {}") - - tip = REMOTE_TIP.format(file.get_name(), terminal_name) - item = FileManager.MenuItem( - name=executable_menu_item_id(remote=True), - label=REMOTE_LABEL.format(terminal_name), - tip=tip, - ) - item.connect("activate", callback, file, True) - items.append(item) - else: - LOCAL_LABEL = _("Execute in {}") - LOCAL_TIP = _("Execute {} in {}") - - tip = LOCAL_TIP.format(file.get_name(), terminal_name) - item = FileManager.MenuItem( - name=executable_menu_item_id(remote=False), - label=LOCAL_LABEL.format(terminal_name), - tip=tip, - ) - item.connect("activate", callback, file, False) - items.append(item) - return items - - -def is_executable(file: Gio.File) -> bool: - try: - attributes = file.query_info("access::can-execute", Gio.FileQueryInfoFlags.NONE) - except GLib.Error: - return False - return attributes.get_attribute_boolean("access::can-execute") - - -def set_terminal_args(*_args): - # pylint: disable=possibly-used-before-assignment - """set the terminal_cmd to the correct values""" - global new_tab - global flatpak - global terminal_cmd - global terminal_data - global custom_local_command - global custom_remote_command - value = _gsettings.get_string(GSETTINGS_TERMINAL) - newer_tab = _gsettings.get_boolean(GSETTINGS_NEW_TAB) - flatpak = FLATPAK_PARMS[_gsettings.get_enum(GSETTINGS_FLATPAK)] - new_terminal_data = TERMINALS.get(value) - if not new_terminal_data: - print(f'open-any-terminal: unknown terminal "{value}"') - return - - global terminal - terminal = value - terminal_data = new_terminal_data - if newer_tab and terminal_data.new_tab_arguments: - new_tab = newer_tab - new_tab_text = "opening in a new tab" - else: - new_tab_text = "opening a new window" - if newer_tab and not terminal_data.new_tab_arguments: - new_tab_text += " (terminal does not support tabs)" - if flatpak != FLATPAK_PARMS[0] and terminal_data.flatpak_package is not None: - terminal_cmd = ["flatpak", "run", "--" + flatpak, terminal_data.flatpak_package] - flatpak_text = f"with flatpak as {flatpak}" - else: - terminal_cmd = [terminal] - if terminal == "blackbox" and "fedora" in distro_id(): - # It's called like this on fedora - terminal_cmd[0] = "blackbox-terminal" - flatpak = FLATPAK_PARMS[0] - flatpak_text = "" - - if terminal == "custom": - terminal_cmd = [] - custom_local_command = _gsettings.get_string(GSETTINGS_CUSTOM_LOCAL_COMMAND) - custom_remote_command = _gsettings.get_string(GSETTINGS_CUSTOM_REMOTE_COMMAND) - elif new_tab and terminal_data.new_tab_arguments: - terminal_cmd.extend(terminal_data.new_tab_arguments) - elif terminal_data.new_window_arguments: - terminal_cmd.extend(terminal_data.new_window_arguments) - - print(f'open-any-terminal: terminal is set to "{terminal}" {new_tab_text} {flatpak_text}') - - -if API_VERSION == ("4.0", "4.1"): - - class OpenAnyTerminalShortcutProvider(GObject.GObject, FileManager.MenuProvider): - """Provide keyboard shortcuts for opening terminals in Nautilus.""" - - def __init__(self): - super().__init__() - self.previous_cwd = expanduser("~") - - gsettings_source = Gio.SettingsSchemaSource.get_default() - if gsettings_source.lookup(GSETTINGS_PATH, True): - self._gsettings = Gio.Settings.new(GSETTINGS_PATH) - self._setup_keybindings() - - def get_background_items(self, current_folder: FileManager.FileInfo): - """Update current URI when folder changes.""" - if current_folder: - if current_folder.get_uri_scheme() in REMOTE_URI_SCHEME: - folder_path = current_folder.get_uri() - else: - folder_path = current_folder.get_location().get_path() - - if folder_path and folder_path != self.previous_cwd: - self.previous_cwd = folder_path - return [] - - def _open_terminal(self, *_args): - """Open the terminal at the specified URI.""" - if self._gsettings.get_boolean(GSETTINGS_BIND_REMOTE): - open_remote_terminal_in_uri(self.previous_cwd) - else: - open_local_terminal_in_uri(self.previous_cwd) - - def _setup_keybindings(self): - """Set up custom keybindings for the extension.""" - self.app = Gtk.Application.get_default() - if self.app is None: - print("No Gtk.Application found. Keybindings cannot be set.") - return - - action = Gio.SimpleAction.new("open_any_terminal", None) - action.connect("activate", self._open_terminal) - self.app.add_action(action) - self._bind_shortcut() - self._gsettings.connect("changed", self._update_shortcut) - - def _update_shortcut(self, _gsettings, key): - """remove keybinding""" - if key == GSETTINGS_KEYBINDINGS: - self.app.set_accels_for_action("app.open_any_terminal", []) - self._bind_shortcut() - - def _bind_shortcut(self): - """Parse and update keybindings when settings change.""" - shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS) - - if not shortcut: - self.app.set_accels_for_action("app.open_any_terminal", []) - return - - valid, key, mods = Gtk.accelerator_parse(shortcut) - if not valid: - print("Invalid shortcut in GSettings: %r", shortcut) - self.app.set_accels_for_action("app.open_any_terminal", []) - return - - normalized = Gtk.accelerator_name(key, mods) - self.app.set_accels_for_action("app.open_any_terminal", [normalized]) - -elif API_VERSION in ("3.0", "2.0"): - - class OpenAnyTerminalShortcutProviderLegacy(GObject.GObject, FileManager.LocationWidgetProvider): - """Provide keyboard shortcuts for opening terminals in Nautilus/Caja.""" - - def __init__(self): - super().__init__() - gsettings_source = Gio.SettingsSchemaSource.get_default() - if gsettings_source.lookup(GSETTINGS_PATH, True): - self._gsettings = Gio.Settings.new(GSETTINGS_PATH) - self._gsettings.connect("changed", self._bind_shortcut) - self._create_accel_group() - self._window = None - self._uri = None - - def _create_accel_group(self): - self._accel_group = Gtk.AccelGroup() - shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS) - key, mod = Gtk.accelerator_parse(shortcut) - self._accel_group.connect(key, mod, Gtk.AccelFlags.VISIBLE, self._open_terminal) - - def _bind_shortcut(self, _gsettings, key): - if key == GSETTINGS_KEYBINDINGS: - self._accel_group.disconnect(self._open_terminal) - self._create_accel_group() - - def _open_terminal(self, *_args): - if _gsettings.get_boolean(GSETTINGS_BIND_REMOTE): - open_local_terminal_in_uri(self._uri) - else: - open_remote_terminal_in_uri(self._uri) - - def get_widget(self, uri, window): - """follows uri and sets the correct window""" - self._uri = uri - if self._window: - self._window.remove_accel_group(self._accel_group) - if self._gsettings: - window.add_accel_group(self._accel_group) - self._window = window - - -class OpenAnyTerminalExtension(GObject.GObject, FileManager.MenuProvider): - """Provide context menu items for opening terminals in Nautilus.""" - - def __init__(self): - super().__init__() - gsettings_source = Gio.SettingsSchemaSource.get_default() - if gsettings_source.lookup(GSETTINGS_PATH, True): - self._gsettings = Gio.Settings.new(GSETTINGS_PATH) - - def _get_terminal_name(self): - if self._gsettings.get_boolean(GSETTINGS_USE_GENERIC_TERMINAL_NAME): - return _("Terminal") - return None - - def _menu_dir_activate_cb(self, menu, file_, remote: bool): - if remote: - open_remote_terminal_in_uri(file_.get_uri()) - else: - if file_.get_uri_scheme() == "smb": - file_uri = "file://" + quote(file_.get_location().get_path()) - else: - file_uri = file_.get_uri() - open_local_terminal_in_uri(file_uri) - - def _menu_exe_activate_cb(self, menu, file_, remote: bool): - if remote: - cmd = ssh_command_from_uri(file_.get_uri(), is_directory=False) - else: - result = urlparse(file_.get_uri()) - file = unquote(result.path) - - if result.scheme == "admin": - cmd = ["sudo", file] - elif terminal in ["xterm", "uxterm"]: - cmd = [f"exec {shlex.quote(file)}"] - else: - cmd = [file] - run_command_in_terminal(cmd) - - def get_file_items(self, *args): - """Generates a list of menu items for a file or folder in the Nautilus file manager.""" - # `args` will be `[files: List[Nautilus.FileInfo]]` in Nautilus 4.0 API, - # and `[window: Gtk.Widget, files: List[Nautilus.FileInfo]]` in Nautilus 3.0 API. - - files = args[-1] - - if len(files) != 1: - return [] - file_ = files[0] - - if file_.is_directory(): - return get_directory_menu_items( - file_, self._menu_dir_activate_cb, foreground=True, terminal_name=self._get_terminal_name() - ) - - if is_executable(file_.get_location()): - return get_executable_menu_items(file_, self._menu_exe_activate_cb, terminal_name=self._get_terminal_name()) - - return [] - - def get_background_items(self, *args): - """Generates a list of background menu items for a file or folder in the Nautilus file manager.""" - # `args` will be `[folder: Nautilus.FileInfo]` in Nautilus 4.0 API, - # and `[window: Gtk.Widget, file: Nautilus.FileInfo]` in Nautilus 3.0 API. - - file_ = args[-1] - return get_directory_menu_items( - file_, self._menu_dir_activate_cb, foreground=False, terminal_name=self._get_terminal_name() - ) - - -source = Gio.SettingsSchemaSource.get_default() -if source is not None and source.lookup(GSETTINGS_PATH, True): - _gsettings = Gio.Settings.new(GSETTINGS_PATH) - _gsettings.connect("changed", set_terminal_args) - set_terminal_args() diff --git a/setup/apps/chatgpt-desktop b/setup/apps/chatgpt-desktop new file mode 100644 index 0000000..c640613 --- /dev/null +++ b/setup/apps/chatgpt-desktop @@ -0,0 +1,19 @@ +# ChatGPT Desktop. +# +# OpenAI ships macOS and Windows only. This is a community wrapper that converts +# the upstream macOS disk image into a Linux Electron app and packages it as an +# RPM, so the installed result is again something dnf owns. +# +# Same exception, same reason: there is no packaged form to prefer. Nothing is +# pinned; `bootstrap-native` fetches the current upstream image each time and +# fails loudly when it cannot. + +description="ChatGPT Desktop, built into a Fedora RPM" +repo="https://github.com/ilysenko/codex-desktop-linux.git" + +# bootstrap-native installs build dependencies, builds, packages, and installs +# the newest artifact -- so unlike the Claude build there is no separate install +# step to do here. +build() { + make bootstrap-native +} diff --git a/setup/apps/claude-desktop b/setup/apps/claude-desktop new file mode 100644 index 0000000..ab1951c --- /dev/null +++ b/setup/apps/claude-desktop @@ -0,0 +1,28 @@ +# Claude Desktop. +# +# Anthropic ships macOS and Windows only. This is a community wrapper that +# downloads the official build, bundles Electron, and packages the result as a +# proper Fedora RPM -- so what lands on the system is still a package dnf owns +# and can remove, which is the part of the dnf/flatpak rule that matters most. +# +# The exception it needs is the build itself. There is no RPM or flatpak of +# Claude Desktop to install, so the choice is building one or not having it. +# Nothing is pinned: the default branch is built every time, and a failure is +# reported rather than worked around. sunhat pinned versions and every pin was +# a 404 within a release cycle. + +description="Claude Desktop, built into a Fedora RPM" +repo="https://github.com/dewzor/claude-desktop-fedora.git" + +# The upstream script installs its own build dependencies and prints the RPM it +# produced. It needs root because of that dependency install. +build() { + sudo ./build-fedora.sh + + local rpm + rpm="$(find build -name 'claude-desktop-*.rpm' -newermt '-1 hour' 2>/dev/null | head -1)" + [[ -n "$rpm" ]] || rpm="$(find . -name 'claude-desktop-*.rpm' 2>/dev/null | head -1)" + [[ -n "$rpm" ]] || { printf 'the build produced no RPM\n' >&2; return 1; } + + sudo dnf install -y "$rpm" +} diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages index 92fdfe8..acf23d2 100644 --- a/setup/packages/desktop-packages +++ b/setup/packages/desktop-packages @@ -62,6 +62,11 @@ mscore-fonts nautilus nautilus-extensions nautilus-python +# Ships both the Nautilus "Open in Terminal" extension and its gsettings +# schema. Panama used to copy its own fork of the extension over the same +# path, which left a fresh machine with the extension and no schema -- and +# the fork had drifted 63 lines behind the packaged one. +nautilus-open-any-terminal nextcloud-client openssl-devel opus-devel diff --git a/tests/setup/apps-contract b/tests/setup/apps-contract new file mode 100755 index 0000000..3159513 --- /dev/null +++ b/tests/setup/apps-contract @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +# The applications built from source, and the standard they have to meet. +# +# Every other application Panama installs comes from dnf or Flathub. These do +# not, and the rule for admitting one is not "it was convenient": there has to +# be no packaged form, the reason has to be written down, and what lands on the +# system still has to be a package the system owns. +# +# The rules: +# +# 1. Every definition declares a repository, a description, and a build. +# A file missing any of them is an entry that fails only when somebody +# asks for it, which is the worst moment to find out. +# 2. Every definition states why the exception exists. This is the whole +# guard against the list growing by habit -- sunhat had seventy-odd +# installers and no reason recorded for any of them. +# 3. Nothing is pinned. A recorded version is a 404 waiting to happen: every +# pinned URL sunhat carried had rotted within a release cycle, which is the +# argument this repository's package rule is built on. +# 4. `panama app` lists what the directory holds and refuses what it does not. +# +# Definitions are read, not run. Building one downloads an upstream release and +# installs a package, which is not something a test suite does. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +apps_dir="$repo_dir/setup/apps" +panama="$repo_dir/bin/panama" + +findings=() +note() { findings+=("$1"); } + +[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; } + +shopt -s nullglob +definitions=("$apps_dir"/*) + +# ── 1 & 2. Each definition is complete, and says why it exists ─────────────── + +for definition in "${definitions[@]}"; do + name="$(basename "$definition")" + [[ -f "$definition" ]] || { note "$name is not a file"; continue; } + + # Sourced in a subshell so one definition cannot leak into the next, and so + # a definition that runs something at source time is contained. + problems="$( + description="" + repo="" + unset -f build 2>/dev/null || true + # shellcheck source=/dev/null + source "$definition" >/dev/null 2>&1 + + [[ -n "$description" ]] || { printf 'no-description\n'; exit 0; } + [[ -n "$repo" ]] || { printf 'no-repo\n'; exit 0; } + declare -F build >/dev/null || { printf 'no-build\n'; exit 0; } + [[ "$repo" == https://* ]] || { printf 'insecure-repo\n'; exit 0; } + )" + + # Read back through a here-string rather than a pipe: a `while read` on the + # right of a pipe runs in a subshell, and every finding it recorded was + # being discarded at the end of the loop. Caught by standing in a broken + # definition and watching two of the three checks stay silent. + while read -r problem; do + [[ -n "$problem" ]] || continue + case "$problem" in + no-description) note "$name has no description, so it cannot be listed" ;; + no-repo) note "$name declares no repository" ;; + no-build) note "$name declares no build function" ;; + insecure-repo) note "$name is cloned over something other than https" ;; + esac + done <<<"$problems" + + # The comment block is the reason. A definition without one is an entry + # somebody added because it was easy. + reason="$(grep -c '^#' "$definition")" + (( reason >= 3 )) \ + || note "$name records no reason for being a source build rather than a package" + + # ── 3. Nothing pinned ─────────────────────────────────────────────────── + if grep -qE 'git (checkout|clone).*(-b|--branch|--tag)|checkout [0-9a-f]{7,40}|v[0-9]+\.[0-9]+\.[0-9]+' "$definition"; then + note "$name looks like it pins a version or tag, which is what goes stale" + fi +done + +# ── 4. The command agrees with the directory ──────────────────────────────── + +listing="$("$panama" app 2>&1)" +for definition in "${definitions[@]}"; do + [[ -f "$definition" ]] || continue + grep -q "$(basename "$definition")" <<<"$listing" \ + || note "$(basename "$definition") is not listed by 'panama app'" +done + +"$panama" app definitely-not-an-app >/dev/null 2>&1 \ + && note "'panama app' accepts a name that has no definition" + +# The build tree belongs in the cache: it is entirely rebuildable, and a +# checkout kept beside the repository would eventually be mistaken for one. +grep -q 'XDG_CACHE_HOME' "$panama" \ + || note 'application checkouts are not placed under the cache directory' + +# Not part of the unattended run, for the reason the interview exists. +grep -q 'app)' "$repo_dir/install" \ + && note 'the installer runs a source build, which cannot be walked away from' + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'apps contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'apps contract: PASS (%d applications)\n' "${#definitions[@]}"