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[@]}"