From 7287d56cbe0b6ed82a2aa9d3a22ffa15a630a63d Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 11:45:50 -0300 Subject: [PATCH 01/15] feat(release): Enable overwriting of existing files when attaching release assets --- .github/workflows/release-artifacts.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 8dda372..3798e7d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -94,3 +94,4 @@ jobs: files: | screenux-screenshot.flatpak screenux-screenshot.flatpak.sha256 + overwrite_files: true From c4b08972cb18d169891f56781eb282ad50c1b9ac Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 11:53:12 -0300 Subject: [PATCH 02/15] feat(editor): Add zoom controls with presets and best fit option --- README.md | 1 + src/screenux_editor.py | 110 ++++++++++++++++++++++++++++++++++--- tests/test_editor_logic.py | 41 +++++++++++++- 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e388227..83c0c79 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Capture with `Take Screenshot` - Status updates: `Ready`, `Capturing...`, `Saved: `, `Cancelled`, `Failed: ` - Built-in editor for quick annotations (shapes/text) +- Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) - Timestamped output names with safe, non-overwriting writes ## 🚀 Quick start diff --git a/src/screenux_editor.py b/src/screenux_editor.py index a451649..0ee4c56 100644 --- a/src/screenux_editor.py +++ b/src/screenux_editor.py @@ -22,6 +22,11 @@ _SELECTION_COLOR = (0.2, 0.5, 1.0, 0.8) _HANDLE_SIZE = 6.0 +_ZOOM_MIN = 0.33 +_ZOOM_MAX = 20.0 +_ZOOM_BUTTON_STEP = 1.25 +_ZOOM_SCROLL_STEP = 1.15 +_ZOOM_PRESETS = (0.33, 0.5, 1.0, 1.33, 2.0, 5.0, 10.0, 15.0, 20.0) def load_image_surface(file_path: str): @@ -206,11 +211,14 @@ def __init__( self._base_scale = 1.0 self._zoom = 1.0 + self._zoom_mode = "best-fit" self._scale = 1.0 self._offset_x = 0.0 self._offset_y = 0.0 self._pan_x = 0.0 self._pan_y = 0.0 + self._zoom_preset_buttons: dict[Any, Gtk.CheckButton] = {} + self._syncing_zoom_controls = False self._build_toolbar() self._build_canvas() @@ -313,11 +321,45 @@ def _tool_btn(icon_file: str, fallback_label: str, tooltip: str, tool_name: str) zoom_out_btn.connect("clicked", self._on_zoom_out) toolbar.append(zoom_out_btn) + self._zoom_menu_btn = Gtk.MenuButton() + self._zoom_menu_btn.set_tooltip_text("Zoom") + + zoom_btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._zoom_label = Gtk.Label(label="100%") + zoom_btn_box.append(self._zoom_label) + zoom_btn_box.append(Gtk.Image.new_from_icon_name("pan-down-symbolic")) + self._zoom_menu_btn.set_child(zoom_btn_box) + + zoom_popover = Gtk.Popover() + zoom_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + zoom_list.set_margin_start(8) + zoom_list.set_margin_end(8) + zoom_list.set_margin_top(8) + zoom_list.set_margin_bottom(8) + + best_fit_btn = Gtk.CheckButton(label="Best fit") + best_fit_btn.connect("toggled", self._on_zoom_best_fit_toggled) + zoom_list.append(best_fit_btn) + self._zoom_preset_buttons["best-fit"] = best_fit_btn + + for preset in _ZOOM_PRESETS: + preset_btn = Gtk.CheckButton(label=f"{int(round(preset * 100))}%") + preset_btn.set_group(best_fit_btn) + preset_btn.connect("toggled", self._on_zoom_preset_toggled, preset) + zoom_list.append(preset_btn) + self._zoom_preset_buttons[preset] = preset_btn + + zoom_popover.set_child(zoom_list) + self._zoom_menu_btn.set_popover(zoom_popover) + toolbar.append(self._zoom_menu_btn) + zoom_in_btn = Gtk.Button.new_from_icon_name("zoom-in-symbolic") zoom_in_btn.set_tooltip_text("Zoom In") zoom_in_btn.connect("clicked", self._on_zoom_in) toolbar.append(zoom_in_btn) + AnnotationEditor._sync_zoom_controls(self) + self.append(toolbar) def _toolbar_icon_color(self) -> str: @@ -753,11 +795,8 @@ def _on_scroll(self, ctrl, dx: float, dy: float) -> bool: state = ctrl.get_current_event_state() if state & Gdk.ModifierType.CONTROL_MASK: - factor = 1.15 if dy < 0 else (1 / 1.15) - new_zoom = max(0.25, min(4.0, self._zoom * factor)) - if new_zoom != self._zoom: - self._zoom = new_zoom - self._drawing_area.queue_draw() + factor = _ZOOM_SCROLL_STEP if dy < 0 else (1 / _ZOOM_SCROLL_STEP) + AnnotationEditor._set_zoom(self, self._zoom * factor, mode="manual") return True if state & Gdk.ModifierType.SHIFT_MASK: @@ -769,13 +808,66 @@ def _on_scroll(self, ctrl, dx: float, dy: float) -> bool: self._drawing_area.queue_draw() return True - def _on_zoom_in(self, _btn) -> None: - self._zoom = min(4.0, self._zoom * 1.25) + def _clamp_zoom(self, zoom: float) -> float: + return max(_ZOOM_MIN, min(_ZOOM_MAX, zoom)) + + def _zoom_text(self, zoom: float) -> str: + return f"{int(round(zoom * 100))}%" + + def _sync_zoom_controls(self) -> None: + if hasattr(self, "_zoom_label"): + self._zoom_label.set_text(AnnotationEditor._zoom_text(self, self._zoom)) + + zoom_buttons = getattr(self, "_zoom_preset_buttons", None) + if not zoom_buttons or getattr(self, "_syncing_zoom_controls", False): + return + + selected: Any = None + if self._zoom_mode == "best-fit": + selected = "best-fit" + else: + for preset in _ZOOM_PRESETS: + if abs(self._zoom - preset) < 0.001: + selected = preset + break + + self._syncing_zoom_controls = True + try: + for key, btn in zoom_buttons.items(): + btn.set_active(key == selected) + finally: + self._syncing_zoom_controls = False + + def _set_zoom(self, zoom: float, mode: str = "manual", reset_pan: bool = False) -> None: + self._zoom = AnnotationEditor._clamp_zoom(self, zoom) + self._zoom_mode = mode + if reset_pan: + self._pan_x = 0.0 + self._pan_y = 0.0 + AnnotationEditor._sync_zoom_controls(self) self._drawing_area.queue_draw() + def _on_zoom_best_fit_toggled(self, button: Gtk.CheckButton) -> None: + if getattr(self, "_syncing_zoom_controls", False) or not button.get_active(): + return + AnnotationEditor._on_zoom_best_fit(self, button) + + def _on_zoom_preset_toggled(self, button: Gtk.CheckButton, preset: float) -> None: + if getattr(self, "_syncing_zoom_controls", False) or not button.get_active(): + return + AnnotationEditor._on_zoom_preset(self, button, preset) + + def _on_zoom_best_fit(self, _btn) -> None: + AnnotationEditor._set_zoom(self, 1.0, mode="best-fit", reset_pan=True) + + def _on_zoom_preset(self, _btn, preset: float) -> None: + AnnotationEditor._set_zoom(self, preset, mode="manual") + + def _on_zoom_in(self, _btn) -> None: + AnnotationEditor._set_zoom(self, self._zoom * _ZOOM_BUTTON_STEP, mode="manual") + def _on_zoom_out(self, _btn) -> None: - self._zoom = max(0.25, self._zoom / 1.25) - self._drawing_area.queue_draw() + AnnotationEditor._set_zoom(self, self._zoom / _ZOOM_BUTTON_STEP, mode="manual") def _do_save(self) -> None: try: diff --git a/tests/test_editor_logic.py b/tests/test_editor_logic.py index bcfe9ba..195fdd9 100644 --- a/tests/test_editor_logic.py +++ b/tests/test_editor_logic.py @@ -115,11 +115,15 @@ def __init__(self): self._pan_start_values = None self._base_scale = 1.0 self._zoom = 1.0 + self._zoom_mode = "best-fit" self._scale = 1.0 self._offset_x = 0.0 self._offset_y = 0.0 self._pan_x = 0.0 self._pan_y = 0.0 + self._zoom_label_text = "100%" + self._zoom_label = SimpleNamespace(set_text=lambda text: setattr(self, "_zoom_label_text", text)) + self._zoom_preset_buttons = {} self._surface = FakeSurface() self._source_uri = "file:///tmp/source.png" self._build_output_path = lambda _uri: Path(f"/tmp/out_{id(self)}.png") @@ -451,10 +455,12 @@ class DummyRect: editor.AnnotationEditor._on_scroll(self, ctrl_none, 0, 1) # zoom - self._zoom = 3.9 + self._zoom = 19.9 editor.AnnotationEditor._on_zoom_in(self, None) + assert self._zoom == 20.0 self._zoom = 0.2 editor.AnnotationEditor._on_zoom_out(self, None) + assert self._zoom == 0.33 # save rendered = [] @@ -507,3 +513,36 @@ def write_to_png(self, _path): editor.AnnotationEditor._do_save(self) assert self.error == "could not save image (disk full)" + + +def test_zoom_presets_and_best_fit_state(): + self = FakeEditorSelf() + + class Toggle: + def __init__(self): + self.active = False + + def set_active(self, state): + self.active = state + + best_fit = Toggle() + preset_133 = Toggle() + self._zoom_preset_buttons = {"best-fit": best_fit, 1.33: preset_133} + + editor.AnnotationEditor._sync_zoom_controls(self) + assert self._zoom_label_text == "100%" + assert best_fit.active is True + + editor.AnnotationEditor._on_zoom_preset(self, None, 1.33) + assert self._zoom == 1.33 + assert self._zoom_mode == "manual" + assert self._zoom_label_text == "133%" + assert preset_133.active is True + + self._pan_x = 20 + self._pan_y = 10 + editor.AnnotationEditor._on_zoom_best_fit(self, None) + assert self._zoom == 1.0 + assert self._zoom_mode == "best-fit" + assert self._pan_x == 0.0 and self._pan_y == 0.0 + assert self._zoom_label_text == "100%" From 6b0e629ca418f102799c7495296587820a48097d Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 11:54:52 -0300 Subject: [PATCH 03/15] feat: Add installation script for Flatpak bundle and GNOME shortcut configuration --- README.md | 14 ++++ install-screenux.sh | 191 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100755 install-screenux.sh diff --git a/README.md b/README.md index 83c0c79..8c82ee2 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,20 @@ cd Screenux ./screenux-screenshot ``` +### 4) Install Flatpak bundle + optional GNOME shortcut + +If you built or downloaded a Screenux Flatpak bundle, use the installer helper: + +```bash +./install-screenux.sh ./screenux-screenshot.flatpak "['s']" +``` + +Notes: + +- The first argument is required and must be a local `.flatpak` file. +- The second argument is optional and uses GNOME `gsettings` binding syntax. +- On non-GNOME desktops, install still completes and shortcut setup is skipped. + ## 🖱️ Usage 1. Launch the app. diff --git a/install-screenux.sh b/install-screenux.sh new file mode 100755 index 0000000..1d4ba12 --- /dev/null +++ b/install-screenux.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_ID="io.github.rafa.ScreenuxScreenshot" +APP_NAME="Screenux Screenshot" +WRAPPER_DIR="${HOME}/.local/bin" +WRAPPER_PATH="${WRAPPER_DIR}/screenux-screenshot" +DESKTOP_DIR="${HOME}/.local/share/applications" +DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" + +SCHEMA="org.gnome.settings-daemon.plugins.media-keys" +CUSTOM_SCHEMA="${SCHEMA}.custom-keybinding" +KEY="custom-keybindings" +BASE_PATH="/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings" + +usage() { + cat <<'EOF' +Usage: + ./install-screenux.sh /path/to/screenux-screenshot.flatpak "['s']" + +Arguments: + 1) Flatpak bundle path (required) + 2) GNOME keybinding list syntax (optional, default: ['s']) + +Examples: + ./install-screenux.sh ./screenux-screenshot.flatpak + ./install-screenux.sh ./screenux-screenshot.flatpak "['Print']" +EOF +} + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +check_command() { + command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" +} + +strip_single_quotes() { + local value="$1" + value="${value#\'}" + value="${value%\'}" + printf '%s' "${value}" +} + +path_in_array() { + local needle="$1" + shift + local item + for item in "$@"; do + if [[ "${item}" == "${needle}" ]]; then + return 0 + fi + done + return 1 +} + +build_gsettings_list() { + local items=("$@") + local out="[" + local i + for ((i = 0; i < ${#items[@]}; i++)); do + if ((i > 0)); then + out+=", " + fi + out+="'${items[i]}'" + done + out+="]" + printf '%s' "${out}" +} + +configure_gnome_shortcut() { + local binding="$1" + + if ! command -v gsettings >/dev/null 2>&1; then + echo "NOTE: gsettings not available; skipping shortcut setup." + return 0 + fi + + if ! gsettings list-schemas | grep -qx "${SCHEMA}"; then + echo "NOTE: GNOME media-keys schema not found; skipping shortcut setup." + return 0 + fi + + if [[ "${binding}" != \[*\] ]]; then + fail "Keybinding must be a gsettings list, e.g. \"['Print']\" or \"['s']\"" + fi + + echo "==> Configuring GNOME custom shortcut: ${binding}" + + local existing + existing="$(gsettings get "${SCHEMA}" "${KEY}")" + + local path_array=() + mapfile -t path_array < <(printf '%s\n' "${existing}" | grep -oE "'${BASE_PATH}/custom[0-9]+/'" | tr -d "'" || true) + + local target_path="" + local p current_name current_command + for p in "${path_array[@]}"; do + current_name="$(gsettings get "${CUSTOM_SCHEMA}:${p}" name 2>/dev/null || true)" + current_command="$(gsettings get "${CUSTOM_SCHEMA}:${p}" command 2>/dev/null || true)" + current_name="$(strip_single_quotes "${current_name}")" + current_command="$(strip_single_quotes "${current_command}")" + + if [[ "${current_name}" == "${APP_NAME}" || "${current_command}" == "${WRAPPER_PATH} --capture" ]]; then + target_path="${p}" + break + fi + done + + if [[ -z "${target_path}" ]]; then + local idx=0 + while :; do + local candidate="${BASE_PATH}/custom${idx}/" + if ! path_in_array "${candidate}" "${path_array[@]}"; then + target_path="${candidate}" + break + fi + ((idx += 1)) + done + fi + + if ! path_in_array "${target_path}" "${path_array[@]}"; then + path_array+=("${target_path}") + gsettings set "${SCHEMA}" "${KEY}" "$(build_gsettings_list "${path_array[@]}")" + fi + + gsettings set "${CUSTOM_SCHEMA}:${target_path}" name "${APP_NAME}" + gsettings set "${CUSTOM_SCHEMA}:${target_path}" command "${WRAPPER_PATH} --capture" + gsettings set "${CUSTOM_SCHEMA}:${target_path}" binding "${binding}" + + echo "==> GNOME shortcut configured" + echo " Name: ${APP_NAME}" + echo " Command: ${WRAPPER_PATH} --capture" + echo " Binding: ${binding}" +} + +main() { + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 + fi + + local flatpak_file="${1:-}" + local keybinding="${2:-['s']}" + + [[ -n "${flatpak_file}" ]] || fail "Provide the .flatpak bundle path as first argument." + [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" + + check_command flatpak + + echo "==> Installing Flatpak bundle: ${flatpak_file}" + flatpak install -y --user "${flatpak_file}" + + echo "==> Creating wrapper command: ${WRAPPER_PATH}" + mkdir -p "${WRAPPER_DIR}" + cat >"${WRAPPER_PATH}" < Creating desktop entry: ${DESKTOP_FILE}" + mkdir -p "${DESKTOP_DIR}" + cat >"${DESKTOP_FILE}" < Done." + echo "Run:" + echo " ${WRAPPER_PATH}" + echo "Or capture directly:" + echo " ${WRAPPER_PATH} --capture" +} + +main "$@" From 9ea3d0ef192acdc081eb8b61d1688cdb5fc204f1 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 11:57:45 -0300 Subject: [PATCH 04/15] feat: Enhance installation script with Print Screen integration and restore functionality --- README.md | 13 +++ install-screenux.sh | 199 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 189 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8c82ee2..33a6649 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,24 @@ If you built or downloaded a Screenux Flatpak bundle, use the installer helper: ./install-screenux.sh ./screenux-screenshot.flatpak "['s']" ``` +Use Print Screen directly with Screenux (GNOME): + +```bash +./install-screenux.sh --print-screen ./screenux-screenshot.flatpak +``` + +Restore GNOME native Print Screen behavior: + +```bash +./install-screenux.sh --restore-native-print +``` + Notes: - The first argument is required and must be a local `.flatpak` file. - The second argument is optional and uses GNOME `gsettings` binding syntax. - On non-GNOME desktops, install still completes and shortcut setup is skipped. +- `--restore-native-print` removes the Screenux custom GNOME shortcut and resets GNOME screenshot keybindings. ## 🖱️ Usage diff --git a/install-screenux.sh b/install-screenux.sh index 1d4ba12..4946c9b 100755 --- a/install-screenux.sh +++ b/install-screenux.sh @@ -12,19 +12,28 @@ SCHEMA="org.gnome.settings-daemon.plugins.media-keys" CUSTOM_SCHEMA="${SCHEMA}.custom-keybinding" KEY="custom-keybindings" BASE_PATH="/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings" +SHELL_SCHEMA="org.gnome.shell.keybindings" usage() { cat <<'EOF' Usage: - ./install-screenux.sh /path/to/screenux-screenshot.flatpak "['s']" + ./install-screenux.sh [--print-screen] /path/to/screenux-screenshot.flatpak "['s']" + ./install-screenux.sh --restore-native-print Arguments: - 1) Flatpak bundle path (required) + 1) Flatpak bundle path (required for install mode) 2) GNOME keybinding list syntax (optional, default: ['s']) +Options: + --print-screen Bind Screenux to ['Print'] and disable GNOME native Print keys + --restore-native-print Remove Screenux shortcut (if present) and restore native GNOME Print keys + -h, --help Show this help + Examples: ./install-screenux.sh ./screenux-screenshot.flatpak + ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak ./install-screenux.sh ./screenux-screenshot.flatpak "['Print']" + ./install-screenux.sh --restore-native-print EOF } @@ -70,6 +79,121 @@ build_gsettings_list() { printf '%s' "${out}" } +schema_exists() { + local schema="$1" + gsettings list-schemas | grep -qx "${schema}" +} + +key_exists() { + local schema="$1" + local key="$2" + gsettings list-keys "${schema}" | grep -qx "${key}" +} + +get_custom_paths() { + local existing + existing="$(gsettings get "${SCHEMA}" "${KEY}")" + grep -oE "'${BASE_PATH}/custom[0-9]+/'" <<<"${existing}" | tr -d "'" || true +} + +find_screenux_path() { + local p current_name current_command + while IFS= read -r p; do + [[ -n "${p}" ]] || continue + current_name="$(gsettings get "${CUSTOM_SCHEMA}:${p}" name 2>/dev/null || true)" + current_command="$(gsettings get "${CUSTOM_SCHEMA}:${p}" command 2>/dev/null || true)" + current_name="$(strip_single_quotes "${current_name}")" + current_command="$(strip_single_quotes "${current_command}")" + if [[ "${current_name}" == "${APP_NAME}" || "${current_command}" == "${WRAPPER_PATH} --capture" ]]; then + printf '%s' "${p}" + return 0 + fi + done < <(get_custom_paths) + return 1 +} + +remove_screenux_shortcut() { + if ! command -v gsettings >/dev/null 2>&1; then + return 0 + fi + if ! schema_exists "${SCHEMA}"; then + return 0 + fi + + local screenux_path + screenux_path="$(find_screenux_path || true)" + [[ -n "${screenux_path}" ]] || return 0 + + local path_array=() + mapfile -t path_array < <(get_custom_paths) + + local updated_paths=() + local p + for p in "${path_array[@]}"; do + if [[ "${p}" != "${screenux_path}" ]]; then + updated_paths+=("${p}") + fi + done + + gsettings set "${SCHEMA}" "${KEY}" "$(build_gsettings_list "${updated_paths[@]}")" + echo "==> Removed Screenux GNOME custom shortcut: ${screenux_path}" +} + +set_key_if_exists() { + local schema="$1" + local key="$2" + local value="$3" + if schema_exists "${schema}" && key_exists "${schema}" "${key}"; then + gsettings set "${schema}" "${key}" "${value}" + return 0 + fi + return 1 +} + +reset_key_if_exists() { + local schema="$1" + local key="$2" + if schema_exists "${schema}" && key_exists "${schema}" "${key}"; then + gsettings reset "${schema}" "${key}" + return 0 + fi + return 1 +} + +disable_native_print_keys() { + if ! command -v gsettings >/dev/null 2>&1; then + echo "NOTE: gsettings not available; cannot disable native Print bindings." + return 0 + fi + + set_key_if_exists "${SHELL_SCHEMA}" "show-screenshot" "[]" || true + set_key_if_exists "${SHELL_SCHEMA}" "show-screenshot-ui" "[]" || true + set_key_if_exists "${SHELL_SCHEMA}" "show-screen-recording-ui" "[]" || true + + set_key_if_exists "${SCHEMA}" "screenshot" "[]" || true + set_key_if_exists "${SCHEMA}" "window-screenshot" "[]" || true + set_key_if_exists "${SCHEMA}" "area-screenshot" "[]" || true + + echo "==> Native GNOME Print Screen bindings disabled" +} + +restore_native_print_keys() { + if ! command -v gsettings >/dev/null 2>&1; then + echo "NOTE: gsettings not available; cannot restore native Print bindings." + return 0 + fi + + reset_key_if_exists "${SHELL_SCHEMA}" "show-screenshot" || true + reset_key_if_exists "${SHELL_SCHEMA}" "show-screenshot-ui" || true + reset_key_if_exists "${SHELL_SCHEMA}" "show-screen-recording-ui" || true + + reset_key_if_exists "${SCHEMA}" "screenshot" || true + reset_key_if_exists "${SCHEMA}" "window-screenshot" || true + reset_key_if_exists "${SCHEMA}" "area-screenshot" || true + + echo "==> Native GNOME Print Screen bindings restored" +} + configure_gnome_shortcut() { local binding="$1" @@ -78,7 +202,7 @@ configure_gnome_shortcut() { return 0 fi - if ! gsettings list-schemas | grep -qx "${SCHEMA}"; then + if ! schema_exists "${SCHEMA}"; then echo "NOTE: GNOME media-keys schema not found; skipping shortcut setup." return 0 fi @@ -89,25 +213,11 @@ configure_gnome_shortcut() { echo "==> Configuring GNOME custom shortcut: ${binding}" - local existing - existing="$(gsettings get "${SCHEMA}" "${KEY}")" - local path_array=() - mapfile -t path_array < <(printf '%s\n' "${existing}" | grep -oE "'${BASE_PATH}/custom[0-9]+/'" | tr -d "'" || true) + mapfile -t path_array < <(get_custom_paths) local target_path="" - local p current_name current_command - for p in "${path_array[@]}"; do - current_name="$(gsettings get "${CUSTOM_SCHEMA}:${p}" name 2>/dev/null || true)" - current_command="$(gsettings get "${CUSTOM_SCHEMA}:${p}" command 2>/dev/null || true)" - current_name="$(strip_single_quotes "${current_name}")" - current_command="$(strip_single_quotes "${current_command}")" - - if [[ "${current_name}" == "${APP_NAME}" || "${current_command}" == "${WRAPPER_PATH} --capture" ]]; then - target_path="${p}" - break - fi - done + target_path="$(find_screenux_path || true)" if [[ -z "${target_path}" ]]; then local idx=0 @@ -137,13 +247,52 @@ configure_gnome_shortcut() { } main() { - if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage + local use_print_screen="false" + local restore_native_print="false" + local positional=() + while (($# > 0)); do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --print-screen) + use_print_screen="true" + ;; + --restore-native-print) + restore_native_print="true" + ;; + --) + shift + while (($# > 0)); do + positional+=("$1") + shift + done + break + ;; + -*) + fail "Unknown option: $1" + ;; + *) + positional+=("$1") + ;; + esac + shift + done + + if [[ "${restore_native_print}" == "true" ]]; then + remove_screenux_shortcut + restore_native_print_keys + echo "==> Done. Native Print Screen behavior restored (GNOME)." exit 0 fi - local flatpak_file="${1:-}" - local keybinding="${2:-['s']}" + local flatpak_file="${positional[0]:-}" + local keybinding="${positional[1]:-['s']}" + + if [[ "${use_print_screen}" == "true" ]]; then + keybinding="['Print']" + fi [[ -n "${flatpak_file}" ]] || fail "Provide the .flatpak bundle path as first argument." [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" @@ -181,6 +330,10 @@ EOF configure_gnome_shortcut "${keybinding}" + if [[ "${keybinding}" == "['Print']" ]]; then + disable_native_print_keys + fi + echo "==> Done." echo "Run:" echo " ${WRAPPER_PATH}" From d5b3f23cf51c0e3744478f6f0a5a7d694249e56c Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 12:00:57 -0300 Subject: [PATCH 05/15] feat: Add Makefile and installation scripts for improved setup and GNOME shortcut configuration --- Makefile | 31 +++ README.md | 13 + install-screenux.sh | 343 +------------------------ scripts/install/install-screenux.sh | 109 ++++++++ scripts/install/lib/common.sh | 54 ++++ scripts/install/lib/gnome_shortcuts.sh | 208 +++++++++++++++ 6 files changed, 417 insertions(+), 341 deletions(-) create mode 100644 Makefile create mode 100644 scripts/install/install-screenux.sh create mode 100644 scripts/install/lib/common.sh create mode 100644 scripts/install/lib/gnome_shortcuts.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c81a19b --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +SHELL := /usr/bin/env bash + +INSTALLER := ./install-screenux.sh +DEFAULT_KEYBINDING := ['s'] + +.PHONY: help install-flatpak install-print-screen restore-native-print check-install-scripts + +help: + @echo "Screenux helper targets" + @echo "" + @echo " make install-flatpak BUNDLE=./screenux-screenshot.flatpak [KEYBINDING=\"['Print']\"]" + @echo " make install-print-screen BUNDLE=./screenux-screenshot.flatpak" + @echo " make restore-native-print" + @echo " make check-install-scripts" + +install-flatpak: + @test -n "$(BUNDLE)" || (echo "Usage: make install-flatpak BUNDLE=./screenux-screenshot.flatpak [KEYBINDING=\"['Print']\"]" && exit 1) + @binding="$(DEFAULT_KEYBINDING)"; \ + if [[ -n "$(KEYBINDING)" ]]; then binding="$(KEYBINDING)"; fi; \ + $(INSTALLER) "$(BUNDLE)" "$$binding" + +install-print-screen: + @test -n "$(BUNDLE)" || (echo "Usage: make install-print-screen BUNDLE=./screenux-screenshot.flatpak" && exit 1) + @$(INSTALLER) --print-screen "$(BUNDLE)" + +restore-native-print: + @$(INSTALLER) --restore-native-print + +check-install-scripts: + @bash -n install-screenux.sh scripts/install/install-screenux.sh scripts/install/lib/common.sh scripts/install/lib/gnome_shortcuts.sh + @echo "Installer scripts syntax: OK" diff --git a/README.md b/README.md index 33a6649..1d918e0 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,28 @@ If you built or downloaded a Screenux Flatpak bundle, use the installer helper: ./install-screenux.sh ./screenux-screenshot.flatpak "['s']" ``` +Or with Make targets: + +```bash +make install-flatpak BUNDLE=./screenux-screenshot.flatpak +``` + Use Print Screen directly with Screenux (GNOME): ```bash ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak + +# or +make install-print-screen BUNDLE=./screenux-screenshot.flatpak ``` Restore GNOME native Print Screen behavior: ```bash ./install-screenux.sh --restore-native-print + +# or +make restore-native-print ``` Notes: @@ -67,6 +79,7 @@ Notes: - The second argument is optional and uses GNOME `gsettings` binding syntax. - On non-GNOME desktops, install still completes and shortcut setup is skipped. - `--restore-native-print` removes the Screenux custom GNOME shortcut and resets GNOME screenshot keybindings. +- Installer internals are organized under `scripts/install/` with reusable helper modules. ## 🖱️ Usage diff --git a/install-screenux.sh b/install-screenux.sh index 4946c9b..f26d46b 100755 --- a/install-screenux.sh +++ b/install-screenux.sh @@ -1,344 +1,5 @@ #!/usr/bin/env bash set -euo pipefail -APP_ID="io.github.rafa.ScreenuxScreenshot" -APP_NAME="Screenux Screenshot" -WRAPPER_DIR="${HOME}/.local/bin" -WRAPPER_PATH="${WRAPPER_DIR}/screenux-screenshot" -DESKTOP_DIR="${HOME}/.local/share/applications" -DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" - -SCHEMA="org.gnome.settings-daemon.plugins.media-keys" -CUSTOM_SCHEMA="${SCHEMA}.custom-keybinding" -KEY="custom-keybindings" -BASE_PATH="/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings" -SHELL_SCHEMA="org.gnome.shell.keybindings" - -usage() { - cat <<'EOF' -Usage: - ./install-screenux.sh [--print-screen] /path/to/screenux-screenshot.flatpak "['s']" - ./install-screenux.sh --restore-native-print - -Arguments: - 1) Flatpak bundle path (required for install mode) - 2) GNOME keybinding list syntax (optional, default: ['s']) - -Options: - --print-screen Bind Screenux to ['Print'] and disable GNOME native Print keys - --restore-native-print Remove Screenux shortcut (if present) and restore native GNOME Print keys - -h, --help Show this help - -Examples: - ./install-screenux.sh ./screenux-screenshot.flatpak - ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak - ./install-screenux.sh ./screenux-screenshot.flatpak "['Print']" - ./install-screenux.sh --restore-native-print -EOF -} - -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -check_command() { - command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" -} - -strip_single_quotes() { - local value="$1" - value="${value#\'}" - value="${value%\'}" - printf '%s' "${value}" -} - -path_in_array() { - local needle="$1" - shift - local item - for item in "$@"; do - if [[ "${item}" == "${needle}" ]]; then - return 0 - fi - done - return 1 -} - -build_gsettings_list() { - local items=("$@") - local out="[" - local i - for ((i = 0; i < ${#items[@]}; i++)); do - if ((i > 0)); then - out+=", " - fi - out+="'${items[i]}'" - done - out+="]" - printf '%s' "${out}" -} - -schema_exists() { - local schema="$1" - gsettings list-schemas | grep -qx "${schema}" -} - -key_exists() { - local schema="$1" - local key="$2" - gsettings list-keys "${schema}" | grep -qx "${key}" -} - -get_custom_paths() { - local existing - existing="$(gsettings get "${SCHEMA}" "${KEY}")" - grep -oE "'${BASE_PATH}/custom[0-9]+/'" <<<"${existing}" | tr -d "'" || true -} - -find_screenux_path() { - local p current_name current_command - while IFS= read -r p; do - [[ -n "${p}" ]] || continue - current_name="$(gsettings get "${CUSTOM_SCHEMA}:${p}" name 2>/dev/null || true)" - current_command="$(gsettings get "${CUSTOM_SCHEMA}:${p}" command 2>/dev/null || true)" - current_name="$(strip_single_quotes "${current_name}")" - current_command="$(strip_single_quotes "${current_command}")" - if [[ "${current_name}" == "${APP_NAME}" || "${current_command}" == "${WRAPPER_PATH} --capture" ]]; then - printf '%s' "${p}" - return 0 - fi - done < <(get_custom_paths) - return 1 -} - -remove_screenux_shortcut() { - if ! command -v gsettings >/dev/null 2>&1; then - return 0 - fi - if ! schema_exists "${SCHEMA}"; then - return 0 - fi - - local screenux_path - screenux_path="$(find_screenux_path || true)" - [[ -n "${screenux_path}" ]] || return 0 - - local path_array=() - mapfile -t path_array < <(get_custom_paths) - - local updated_paths=() - local p - for p in "${path_array[@]}"; do - if [[ "${p}" != "${screenux_path}" ]]; then - updated_paths+=("${p}") - fi - done - - gsettings set "${SCHEMA}" "${KEY}" "$(build_gsettings_list "${updated_paths[@]}")" - echo "==> Removed Screenux GNOME custom shortcut: ${screenux_path}" -} - -set_key_if_exists() { - local schema="$1" - local key="$2" - local value="$3" - if schema_exists "${schema}" && key_exists "${schema}" "${key}"; then - gsettings set "${schema}" "${key}" "${value}" - return 0 - fi - return 1 -} - -reset_key_if_exists() { - local schema="$1" - local key="$2" - if schema_exists "${schema}" && key_exists "${schema}" "${key}"; then - gsettings reset "${schema}" "${key}" - return 0 - fi - return 1 -} - -disable_native_print_keys() { - if ! command -v gsettings >/dev/null 2>&1; then - echo "NOTE: gsettings not available; cannot disable native Print bindings." - return 0 - fi - - set_key_if_exists "${SHELL_SCHEMA}" "show-screenshot" "[]" || true - set_key_if_exists "${SHELL_SCHEMA}" "show-screenshot-ui" "[]" || true - set_key_if_exists "${SHELL_SCHEMA}" "show-screen-recording-ui" "[]" || true - - set_key_if_exists "${SCHEMA}" "screenshot" "[]" || true - set_key_if_exists "${SCHEMA}" "window-screenshot" "[]" || true - set_key_if_exists "${SCHEMA}" "area-screenshot" "[]" || true - - echo "==> Native GNOME Print Screen bindings disabled" -} - -restore_native_print_keys() { - if ! command -v gsettings >/dev/null 2>&1; then - echo "NOTE: gsettings not available; cannot restore native Print bindings." - return 0 - fi - - reset_key_if_exists "${SHELL_SCHEMA}" "show-screenshot" || true - reset_key_if_exists "${SHELL_SCHEMA}" "show-screenshot-ui" || true - reset_key_if_exists "${SHELL_SCHEMA}" "show-screen-recording-ui" || true - - reset_key_if_exists "${SCHEMA}" "screenshot" || true - reset_key_if_exists "${SCHEMA}" "window-screenshot" || true - reset_key_if_exists "${SCHEMA}" "area-screenshot" || true - - echo "==> Native GNOME Print Screen bindings restored" -} - -configure_gnome_shortcut() { - local binding="$1" - - if ! command -v gsettings >/dev/null 2>&1; then - echo "NOTE: gsettings not available; skipping shortcut setup." - return 0 - fi - - if ! schema_exists "${SCHEMA}"; then - echo "NOTE: GNOME media-keys schema not found; skipping shortcut setup." - return 0 - fi - - if [[ "${binding}" != \[*\] ]]; then - fail "Keybinding must be a gsettings list, e.g. \"['Print']\" or \"['s']\"" - fi - - echo "==> Configuring GNOME custom shortcut: ${binding}" - - local path_array=() - mapfile -t path_array < <(get_custom_paths) - - local target_path="" - target_path="$(find_screenux_path || true)" - - if [[ -z "${target_path}" ]]; then - local idx=0 - while :; do - local candidate="${BASE_PATH}/custom${idx}/" - if ! path_in_array "${candidate}" "${path_array[@]}"; then - target_path="${candidate}" - break - fi - ((idx += 1)) - done - fi - - if ! path_in_array "${target_path}" "${path_array[@]}"; then - path_array+=("${target_path}") - gsettings set "${SCHEMA}" "${KEY}" "$(build_gsettings_list "${path_array[@]}")" - fi - - gsettings set "${CUSTOM_SCHEMA}:${target_path}" name "${APP_NAME}" - gsettings set "${CUSTOM_SCHEMA}:${target_path}" command "${WRAPPER_PATH} --capture" - gsettings set "${CUSTOM_SCHEMA}:${target_path}" binding "${binding}" - - echo "==> GNOME shortcut configured" - echo " Name: ${APP_NAME}" - echo " Command: ${WRAPPER_PATH} --capture" - echo " Binding: ${binding}" -} - -main() { - local use_print_screen="false" - local restore_native_print="false" - local positional=() - while (($# > 0)); do - case "$1" in - -h|--help) - usage - exit 0 - ;; - --print-screen) - use_print_screen="true" - ;; - --restore-native-print) - restore_native_print="true" - ;; - --) - shift - while (($# > 0)); do - positional+=("$1") - shift - done - break - ;; - -*) - fail "Unknown option: $1" - ;; - *) - positional+=("$1") - ;; - esac - shift - done - - if [[ "${restore_native_print}" == "true" ]]; then - remove_screenux_shortcut - restore_native_print_keys - echo "==> Done. Native Print Screen behavior restored (GNOME)." - exit 0 - fi - - local flatpak_file="${positional[0]:-}" - local keybinding="${positional[1]:-['s']}" - - if [[ "${use_print_screen}" == "true" ]]; then - keybinding="['Print']" - fi - - [[ -n "${flatpak_file}" ]] || fail "Provide the .flatpak bundle path as first argument." - [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" - - check_command flatpak - - echo "==> Installing Flatpak bundle: ${flatpak_file}" - flatpak install -y --user "${flatpak_file}" - - echo "==> Creating wrapper command: ${WRAPPER_PATH}" - mkdir -p "${WRAPPER_DIR}" - cat >"${WRAPPER_PATH}" < Creating desktop entry: ${DESKTOP_FILE}" - mkdir -p "${DESKTOP_DIR}" - cat >"${DESKTOP_FILE}" < Done." - echo "Run:" - echo " ${WRAPPER_PATH}" - echo "Or capture directly:" - echo " ${WRAPPER_PATH} --capture" -} - -main "$@" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/scripts/install/install-screenux.sh" "$@" diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh new file mode 100644 index 0000000..7084556 --- /dev/null +++ b/scripts/install/install-screenux.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" + +usage() { + cat <<'EOF' +Usage: + ./install-screenux.sh [--print-screen] /path/to/screenux-screenshot.flatpak "['s']" + ./install-screenux.sh --restore-native-print + +Arguments: + 1) Flatpak bundle path (required for install mode) + 2) GNOME keybinding list syntax (optional, default: ['s']) + +Options: + --print-screen Bind Screenux to ['Print'] and disable GNOME native Print keys + --restore-native-print Remove Screenux shortcut (if present) and restore native GNOME Print keys + -h, --help Show this help + +Examples: + ./install-screenux.sh ./screenux-screenshot.flatpak + ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak + ./install-screenux.sh ./screenux-screenshot.flatpak "['Print']" + ./install-screenux.sh --restore-native-print +EOF +} + +install_bundle() { + local flatpak_file="$1" + + [[ -n "${flatpak_file}" ]] || fail "Provide the .flatpak bundle path as first argument." + [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" + + check_command flatpak + + echo "==> Installing Flatpak bundle: ${flatpak_file}" + flatpak install -y --user "${flatpak_file}" + + create_wrapper + create_desktop_entry +} + +main() { + local use_print_screen="false" + local restore_native_print="false" + local positional=() + + while (($# > 0)); do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --print-screen) + use_print_screen="true" + ;; + --restore-native-print) + restore_native_print="true" + ;; + --) + shift + while (($# > 0)); do + positional+=("$1") + shift + done + break + ;; + -*) + fail "Unknown option: $1" + ;; + *) + positional+=("$1") + ;; + esac + shift + done + + if [[ "${restore_native_print}" == "true" ]]; then + remove_screenux_shortcut + restore_native_print_keys + echo "==> Done. Native Print Screen behavior restored (GNOME)." + exit 0 + fi + + local flatpak_file="${positional[0]:-}" + local keybinding="${positional[1]:-${DEFAULT_KEYBINDING}}" + + if [[ "${use_print_screen}" == "true" ]]; then + keybinding="${PRINT_KEYBINDING}" + fi + + install_bundle "${flatpak_file}" + + configure_gnome_shortcut "${keybinding}" + if [[ "${keybinding}" == "${PRINT_KEYBINDING}" ]]; then + disable_native_print_keys + fi + + echo "==> Done." + echo "Run:" + echo " ${WRAPPER_PATH}" + echo "Or capture directly:" + echo " ${WRAPPER_PATH} --capture" +} + +main "$@" diff --git a/scripts/install/lib/common.sh b/scripts/install/lib/common.sh new file mode 100644 index 0000000..0f63a62 --- /dev/null +++ b/scripts/install/lib/common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_ID="io.github.rafa.ScreenuxScreenshot" +APP_NAME="Screenux Screenshot" +WRAPPER_DIR="${HOME}/.local/bin" +WRAPPER_PATH="${WRAPPER_DIR}/screenux-screenshot" +DESKTOP_DIR="${HOME}/.local/share/applications" +DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" + +DEFAULT_KEYBINDING="['s']" +PRINT_KEYBINDING="['Print']" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +check_command() { + command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" +} + +ensure_wrapper_path_notice() { + if ! printf '%s\n' "${PATH}" | tr ':' '\n' | grep -qx "${WRAPPER_DIR}"; then + echo "NOTE: ${WRAPPER_DIR} is not in PATH for this session." + echo " Add this to your shell profile (e.g. ~/.bashrc or ~/.zshrc):" + echo " export PATH=\"${WRAPPER_DIR}:\$PATH\"" + fi +} + +create_wrapper() { + echo "==> Creating wrapper command: ${WRAPPER_PATH}" + mkdir -p "${WRAPPER_DIR}" + cat >"${WRAPPER_PATH}" < Creating desktop entry: ${DESKTOP_FILE}" + mkdir -p "${DESKTOP_DIR}" + cat >"${DESKTOP_FILE}" < 0)); then + out+=", " + fi + out+="'${items[i]}'" + done + out+="]" + printf '%s' "${out}" +} + +schema_exists() { + local schema="$1" + gsettings list-schemas | grep -qx "${schema}" +} + +key_exists() { + local schema="$1" + local key="$2" + gsettings list-keys "${schema}" | grep -qx "${key}" +} + +get_custom_paths() { + local existing + existing="$(gsettings get "${SCHEMA}" "${KEY}")" + grep -oE "'${BASE_PATH}/custom[0-9]+/'" <<<"${existing}" | tr -d "'" || true +} + +find_screenux_path() { + local p current_name current_command + while IFS= read -r p; do + [[ -n "${p}" ]] || continue + current_name="$(gsettings get "${CUSTOM_SCHEMA}:${p}" name 2>/dev/null || true)" + current_command="$(gsettings get "${CUSTOM_SCHEMA}:${p}" command 2>/dev/null || true)" + current_name="$(strip_single_quotes "${current_name}")" + current_command="$(strip_single_quotes "${current_command}")" + if [[ "${current_name}" == "${APP_NAME}" || "${current_command}" == "${WRAPPER_PATH} --capture" ]]; then + printf '%s' "${p}" + return 0 + fi + done < <(get_custom_paths) + return 1 +} + +remove_screenux_shortcut() { + if ! command -v gsettings >/dev/null 2>&1; then + return 0 + fi + if ! schema_exists "${SCHEMA}"; then + return 0 + fi + + local screenux_path + screenux_path="$(find_screenux_path || true)" + [[ -n "${screenux_path}" ]] || return 0 + + local path_array=() + mapfile -t path_array < <(get_custom_paths) + + local updated_paths=() + local p + for p in "${path_array[@]}"; do + if [[ "${p}" != "${screenux_path}" ]]; then + updated_paths+=("${p}") + fi + done + + gsettings set "${SCHEMA}" "${KEY}" "$(build_gsettings_list "${updated_paths[@]}")" + echo "==> Removed Screenux GNOME custom shortcut: ${screenux_path}" +} + +set_key_if_exists() { + local schema="$1" + local key="$2" + local value="$3" + if schema_exists "${schema}" && key_exists "${schema}" "${key}"; then + gsettings set "${schema}" "${key}" "${value}" + return 0 + fi + return 1 +} + +reset_key_if_exists() { + local schema="$1" + local key="$2" + if schema_exists "${schema}" && key_exists "${schema}" "${key}"; then + gsettings reset "${schema}" "${key}" + return 0 + fi + return 1 +} + +disable_native_print_keys() { + if ! command -v gsettings >/dev/null 2>&1; then + echo "NOTE: gsettings not available; cannot disable native Print bindings." + return 0 + fi + + set_key_if_exists "${SHELL_SCHEMA}" "show-screenshot" "[]" || true + set_key_if_exists "${SHELL_SCHEMA}" "show-screenshot-ui" "[]" || true + set_key_if_exists "${SHELL_SCHEMA}" "show-screen-recording-ui" "[]" || true + + set_key_if_exists "${SCHEMA}" "screenshot" "[]" || true + set_key_if_exists "${SCHEMA}" "window-screenshot" "[]" || true + set_key_if_exists "${SCHEMA}" "area-screenshot" "[]" || true + + echo "==> Native GNOME Print Screen bindings disabled" +} + +restore_native_print_keys() { + if ! command -v gsettings >/dev/null 2>&1; then + echo "NOTE: gsettings not available; cannot restore native Print bindings." + return 0 + fi + + reset_key_if_exists "${SHELL_SCHEMA}" "show-screenshot" || true + reset_key_if_exists "${SHELL_SCHEMA}" "show-screenshot-ui" || true + reset_key_if_exists "${SHELL_SCHEMA}" "show-screen-recording-ui" || true + + reset_key_if_exists "${SCHEMA}" "screenshot" || true + reset_key_if_exists "${SCHEMA}" "window-screenshot" || true + reset_key_if_exists "${SCHEMA}" "area-screenshot" || true + + echo "==> Native GNOME Print Screen bindings restored" +} + +configure_gnome_shortcut() { + local binding="$1" + + if ! command -v gsettings >/dev/null 2>&1; then + echo "NOTE: gsettings not available; skipping shortcut setup." + return 0 + fi + + if ! schema_exists "${SCHEMA}"; then + echo "NOTE: GNOME media-keys schema not found; skipping shortcut setup." + return 0 + fi + + if [[ "${binding}" != \[*\] ]]; then + fail "Keybinding must be a gsettings list, e.g. \"['Print']\" or \"['s']\"" + fi + + echo "==> Configuring GNOME custom shortcut: ${binding}" + + local path_array=() + mapfile -t path_array < <(get_custom_paths) + + local target_path="" + target_path="$(find_screenux_path || true)" + + if [[ -z "${target_path}" ]]; then + local idx=0 + while :; do + local candidate="${BASE_PATH}/custom${idx}/" + if ! path_in_array "${candidate}" "${path_array[@]}"; then + target_path="${candidate}" + break + fi + ((idx += 1)) + done + fi + + if ! path_in_array "${target_path}" "${path_array[@]}"; then + path_array+=("${target_path}") + gsettings set "${SCHEMA}" "${KEY}" "$(build_gsettings_list "${path_array[@]}")" + fi + + gsettings set "${CUSTOM_SCHEMA}:${target_path}" name "${APP_NAME}" + gsettings set "${CUSTOM_SCHEMA}:${target_path}" command "${WRAPPER_PATH} --capture" + gsettings set "${CUSTOM_SCHEMA}:${target_path}" binding "${binding}" + + echo "==> GNOME shortcut configured" + echo " Name: ${APP_NAME}" + echo " Command: ${WRAPPER_PATH} --capture" + echo " Binding: ${binding}" +} From 0e2d7433f1e2b6b1cc8aa2817aa5d6e05e227465 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 12:02:15 -0300 Subject: [PATCH 06/15] feat(ci): Add script security and integrity checks to CI workflow --- .github/workflows/ci.yml | 69 ++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 70 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9ec239..8d2a2f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,75 @@ permissions: contents: read jobs: + script-security-integrity: + name: Script Security & Integrity + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install shell tooling + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends shellcheck shfmt + + - name: ShellCheck (static analysis) + run: | + mapfile -t script_files < <(git ls-files '*.sh') + [[ ${#script_files[@]} -gt 0 ]] || { echo "No shell scripts found."; exit 0; } + shellcheck -x "${script_files[@]}" + + - name: shfmt (format enforcement) + run: | + mapfile -t script_files < <(git ls-files '*.sh') + [[ ${#script_files[@]} -gt 0 ]] || { echo "No shell scripts found."; exit 0; } + shfmt -d -i 2 -ci -sr "${script_files[@]}" + + - name: Policy checks (high-risk patterns) + run: | + set -euo pipefail + + mapfile -t script_files < <(git ls-files '*.sh') + [[ ${#script_files[@]} -gt 0 ]] || { echo "No shell scripts found."; exit 0; } + + fail=0 + + check_forbidden_pattern() { + local label="$1" + local regex="$2" + if grep -RInE -- "${regex}" "${script_files[@]}"; then + echo "::error::Forbidden pattern detected (${label})" + fail=1 + fi + } + + check_forbidden_pattern "curl pipe to shell" '(^|[;&|[:space:]])curl([^\n]|\\\n)*\|[[:space:]]*(ba)?sh([[:space:]]|$)' + check_forbidden_pattern "wget pipe to shell" '(^|[;&|[:space:]])wget([^\n]|\\\n)*\|[[:space:]]*(ba)?sh([[:space:]]|$)' + check_forbidden_pattern "sudo usage in scripts" '^[[:space:]]*sudo[[:space:]]+' + + if grep -RInE -- '(^|[;&|[:space:]])(curl|wget)[[:space:]][^#\n]*(http|https)://' "${script_files[@]}"; then + echo "::error::Potential unpinned remote download detected (curl/wget URL usage)." + fail=1 + fi + + if [[ "${fail}" -ne 0 ]]; then + echo "Policy checks failed. Remove forbidden patterns or harden download verification." + exit 1 + fi + + - name: Generate installer SHA256 checksum + run: | + mkdir -p artifacts + sha256sum scripts/install/install-screenux.sh > artifacts/install-screenux.sh.sha256 + + - name: Upload checksum artifact + uses: actions/upload-artifact@v4 + with: + name: install-screenux-sha256 + path: artifacts/install-screenux.sh.sha256 + quality-and-security: name: Quality & Security runs-on: ubuntu-latest diff --git a/README.md b/README.md index 1d918e0..ac7cc95 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Quality gates include: - Compile validation (`python -m compileall -q src`) - Automated tests (`pytest -q`) - Security checks (`bandit`, `pip-audit`) +- Shell script hardening (`ShellCheck`, `shfmt`, policy checks, installer SHA256 artifact) - Dependency checks (`pip check`, dependency review action) - Build/package validation (launcher, Flatpak manifest, desktop entry, Docker Compose, Docker build) From 3cc289d1771046e0c4c5d1439ae68d91421e9c6c Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 12:16:02 -0300 Subject: [PATCH 07/15] feat: Enhance installation process with Flatpak bundle support and improved usage instructions --- Makefile | 34 ++++++++++++++++++--- README.md | 47 +++++++++++++++++++++++++++-- install-screenux.sh | 2 +- scripts/install/install-screenux.sh | 23 +++++++++----- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index c81a19b..e3e208a 100644 --- a/Makefile +++ b/Makefile @@ -2,25 +2,49 @@ SHELL := /usr/bin/env bash INSTALLER := ./install-screenux.sh DEFAULT_KEYBINDING := ['s'] +FLATPAK_MANIFEST := flatpak/io.github.rafa.ScreenuxScreenshot.json +FLATPAK_BUILD_DIR ?= build-dir +FLATPAK_REPO_DIR ?= repo +FLATPAK_BUNDLE ?= ./screenux-screenshot.flatpak +BUNDLE ?= $(FLATPAK_BUNDLE) -.PHONY: help install-flatpak install-print-screen restore-native-print check-install-scripts +.PHONY: help build-flatpak-bundle install install-flatpak install-print-screen restore-native-print check-install-scripts help: @echo "Screenux helper targets" @echo "" - @echo " make install-flatpak BUNDLE=./screenux-screenshot.flatpak [KEYBINDING=\"['Print']\"]" - @echo " make install-print-screen BUNDLE=./screenux-screenshot.flatpak" + @echo " make build-flatpak-bundle [FLATPAK_BUNDLE=./screenux-screenshot.flatpak]" + @echo " make install [BUNDLE=./screenux-screenshot.flatpak]" + @echo " make install-flatpak [BUNDLE=./screenux-screenshot.flatpak] [KEYBINDING=\"['Print']\"]" + @echo " make install-print-screen [BUNDLE=./screenux-screenshot.flatpak]" @echo " make restore-native-print" @echo " make check-install-scripts" +build-flatpak-bundle: + @command -v flatpak-builder >/dev/null 2>&1 || ( \ + echo "flatpak-builder not found."; \ + echo "Install it, then retry:"; \ + echo " Debian/Ubuntu: sudo apt-get install -y flatpak-builder flatpak"; \ + echo " Fedora: sudo dnf install -y flatpak-builder flatpak"; \ + echo " Arch: sudo pacman -S --needed flatpak-builder flatpak"; \ + exit 1) + @flatpak-builder --force-clean --repo="$(FLATPAK_REPO_DIR)" "$(FLATPAK_BUILD_DIR)" "$(FLATPAK_MANIFEST)" + @flatpak build-bundle "$(FLATPAK_REPO_DIR)" "$(FLATPAK_BUNDLE)" io.github.rafa.ScreenuxScreenshot + @echo "Bundle created: $(FLATPAK_BUNDLE)" + install-flatpak: - @test -n "$(BUNDLE)" || (echo "Usage: make install-flatpak BUNDLE=./screenux-screenshot.flatpak [KEYBINDING=\"['Print']\"]" && exit 1) @binding="$(DEFAULT_KEYBINDING)"; \ if [[ -n "$(KEYBINDING)" ]]; then binding="$(KEYBINDING)"; fi; \ $(INSTALLER) "$(BUNDLE)" "$$binding" +install: + @if [[ -f "$(BUNDLE)" ]]; then \ + $(INSTALLER) --print-screen "$(BUNDLE)"; \ + else \ + $(INSTALLER) --print-screen; \ + fi + install-print-screen: - @test -n "$(BUNDLE)" || (echo "Usage: make install-print-screen BUNDLE=./screenux-screenshot.flatpak" && exit 1) @$(INSTALLER) --print-screen "$(BUNDLE)" restore-native-print: diff --git a/README.md b/README.md index ac7cc95..d8178fd 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,23 @@ cd Screenux If you built or downloaded a Screenux Flatpak bundle, use the installer helper: +To build a local bundle first: + +```bash +make build-flatpak-bundle FLATPAK_BUNDLE=./screenux-screenshot.flatpak +``` + ```bash ./install-screenux.sh ./screenux-screenshot.flatpak "['s']" + +# if already installed for current user +./install-screenux.sh ``` Or with Make targets: ```bash -make install-flatpak BUNDLE=./screenux-screenshot.flatpak +make install-flatpak [BUNDLE=./screenux-screenshot.flatpak] ``` Use Print Screen directly with Screenux (GNOME): @@ -60,8 +69,14 @@ Use Print Screen directly with Screenux (GNOME): ```bash ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak +# if already installed for current user +./install-screenux.sh --print-screen + # or -make install-print-screen BUNDLE=./screenux-screenshot.flatpak +make install [BUNDLE=./screenux-screenshot.flatpak] + +# or (explicit target) +make install-print-screen [BUNDLE=./screenux-screenshot.flatpak] ``` Restore GNOME native Print Screen behavior: @@ -75,8 +90,9 @@ make restore-native-print Notes: -- The first argument is required and must be a local `.flatpak` file. +- The first argument is optional only when Screenux is already installed for the current user; otherwise provide a local `.flatpak` file. - The second argument is optional and uses GNOME `gsettings` binding syntax. +- If Screenux is already installed for the current user, bundle install is skipped and only wrapper/shortcut setup is applied. - On non-GNOME desktops, install still completes and shortcut setup is skipped. - `--restore-native-print` removes the Screenux custom GNOME shortcut and resets GNOME screenshot keybindings. - Installer internals are organized under `scripts/install/` with reusable helper modules. @@ -192,6 +208,31 @@ Notes: ### Flatpak +Requirements: + +- `flatpak` +- `flatpak-builder` + +Install tools (examples): + +```bash +# Debian/Ubuntu +sudo apt-get install -y flatpak flatpak-builder + +# Fedora +sudo dnf install -y flatpak flatpak-builder + +# Arch +sudo pacman -S --needed flatpak flatpak-builder +``` + +Build a local bundle and install with Print Screen mapping: + +```bash +make build-flatpak-bundle FLATPAK_BUNDLE=./screenux-screenshot.flatpak +make install-print-screen BUNDLE=./screenux-screenshot.flatpak +``` + ```bash flatpak-builder --force-clean build-dir flatpak/io.github.rafa.ScreenuxScreenshot.json flatpak-builder --run build-dir flatpak/io.github.rafa.ScreenuxScreenshot.json screenux-screenshot diff --git a/install-screenux.sh b/install-screenux.sh index f26d46b..5aafb57 100755 --- a/install-screenux.sh +++ b/install-screenux.sh @@ -2,4 +2,4 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -exec "${SCRIPT_DIR}/scripts/install/install-screenux.sh" "$@" +exec bash "${SCRIPT_DIR}/scripts/install/install-screenux.sh" "$@" diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index 7084556..c70cb91 100644 --- a/scripts/install/install-screenux.sh +++ b/scripts/install/install-screenux.sh @@ -8,11 +8,11 @@ source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" usage() { cat <<'EOF' Usage: - ./install-screenux.sh [--print-screen] /path/to/screenux-screenshot.flatpak "['s']" + ./install-screenux.sh [--print-screen] [/path/to/screenux-screenshot.flatpak] "['s']" ./install-screenux.sh --restore-native-print Arguments: - 1) Flatpak bundle path (required for install mode) + 1) Flatpak bundle path (optional if Screenux is already installed for user) 2) GNOME keybinding list syntax (optional, default: ['s']) Options: @@ -22,7 +22,9 @@ Options: Examples: ./install-screenux.sh ./screenux-screenshot.flatpak + ./install-screenux.sh ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak + ./install-screenux.sh --print-screen ./install-screenux.sh ./screenux-screenshot.flatpak "['Print']" ./install-screenux.sh --restore-native-print EOF @@ -31,13 +33,18 @@ EOF install_bundle() { local flatpak_file="$1" - [[ -n "${flatpak_file}" ]] || fail "Provide the .flatpak bundle path as first argument." - [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" - - check_command flatpak + if ! command -v flatpak >/dev/null 2>&1; then + fail "Required command not found: flatpak. Install it first (Debian/Ubuntu): apt-get update && sudo apt-get install -y flatpak" + fi - echo "==> Installing Flatpak bundle: ${flatpak_file}" - flatpak install -y --user "${flatpak_file}" + if flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + echo "==> ${APP_ID} is already installed for this user; skipping bundle install" + else + [[ -n "${flatpak_file}" ]] || fail "Screenux is not installed. Provide a local .flatpak bundle path as first argument." + [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" + echo "==> Installing Flatpak bundle: ${flatpak_file}" + flatpak install -y --user "${flatpak_file}" + fi create_wrapper create_desktop_entry From 71c9ecbb09d4fd4e6b5b7ad0a9fa64006d2d1b29 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 12:16:06 -0300 Subject: [PATCH 08/15] feat: Update Makefile and README for improved installation process and shortcut configuration --- Makefile | 14 ++++++++++++-- README.md | 6 ++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index e3e208a..03c401b 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ SHELL := /usr/bin/env bash INSTALLER := ./install-screenux.sh DEFAULT_KEYBINDING := ['s'] +APP_ID := io.github.rafa.ScreenuxScreenshot FLATPAK_MANIFEST := flatpak/io.github.rafa.ScreenuxScreenshot.json FLATPAK_BUILD_DIR ?= build-dir FLATPAK_REPO_DIR ?= repo @@ -14,7 +15,7 @@ help: @echo "Screenux helper targets" @echo "" @echo " make build-flatpak-bundle [FLATPAK_BUNDLE=./screenux-screenshot.flatpak]" - @echo " make install [BUNDLE=./screenux-screenshot.flatpak]" + @echo " make install [BUNDLE=./screenux-screenshot.flatpak] (auto-builds bundle if needed)" @echo " make install-flatpak [BUNDLE=./screenux-screenshot.flatpak] [KEYBINDING=\"['Print']\"]" @echo " make install-print-screen [BUNDLE=./screenux-screenshot.flatpak]" @echo " make restore-native-print" @@ -40,8 +41,17 @@ install-flatpak: install: @if [[ -f "$(BUNDLE)" ]]; then \ $(INSTALLER) --print-screen "$(BUNDLE)"; \ - else \ + elif command -v flatpak >/dev/null 2>&1 && flatpak info --user "$(APP_ID)" >/dev/null 2>&1; then \ $(INSTALLER) --print-screen; \ + elif command -v flatpak-builder >/dev/null 2>&1; then \ + $(MAKE) build-flatpak-bundle FLATPAK_BUNDLE="$(BUNDLE)"; \ + $(INSTALLER) --print-screen "$(BUNDLE)"; \ + else \ + echo "Screenux is not installed and bundle not found: $(BUNDLE)"; \ + echo "Install flatpak-builder to auto-build, or pass an existing bundle:"; \ + echo " make install BUNDLE=./screenux-screenshot.flatpak"; \ + echo " make build-flatpak-bundle FLATPAK_BUNDLE=./screenux-screenshot.flatpak"; \ + exit 1; \ fi install-print-screen: diff --git a/README.md b/README.md index d8178fd..254462f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ make install [BUNDLE=./screenux-screenshot.flatpak] make install-print-screen [BUNDLE=./screenux-screenshot.flatpak] ``` +`make install` behavior: + +- Uses the bundle if it exists. +- If Screenux is already installed for the user, it configures shortcuts without a bundle. +- If neither is true and `flatpak-builder` is available, it auto-builds the bundle and continues. + Restore GNOME native Print Screen behavior: ```bash From b11b12a36819f00cf51b4cf24eccd1f39e4ed885 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 12:18:48 -0300 Subject: [PATCH 09/15] feat: Add Flatpak dependency checks and installation to Makefile; include tests for build process --- Makefile | 25 +++++- README.md | 2 + tests/test_makefile_flatpak_deps.py | 116 ++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/test_makefile_flatpak_deps.py diff --git a/Makefile b/Makefile index 03c401b..9a10f8e 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,13 @@ FLATPAK_BUILD_DIR ?= build-dir FLATPAK_REPO_DIR ?= repo FLATPAK_BUNDLE ?= ./screenux-screenshot.flatpak BUNDLE ?= $(FLATPAK_BUNDLE) +FLATPAK_REMOTE ?= flathub +FLATPAK_REMOTE_URL ?= https://flathub.org/repo/flathub.flatpakrepo +FLATPAK_RUNTIME_VERSION ?= 47 +FLATPAK_PLATFORM_REF ?= org.gnome.Platform//$(FLATPAK_RUNTIME_VERSION) +FLATPAK_SDK_REF ?= org.gnome.Sdk//$(FLATPAK_RUNTIME_VERSION) -.PHONY: help build-flatpak-bundle install install-flatpak install-print-screen restore-native-print check-install-scripts +.PHONY: help build-flatpak-bundle ensure-flatpak-build-deps install install-flatpak install-print-screen restore-native-print check-install-scripts help: @echo "Screenux helper targets" @@ -21,7 +26,7 @@ help: @echo " make restore-native-print" @echo " make check-install-scripts" -build-flatpak-bundle: +build-flatpak-bundle: ensure-flatpak-build-deps @command -v flatpak-builder >/dev/null 2>&1 || ( \ echo "flatpak-builder not found."; \ echo "Install it, then retry:"; \ @@ -33,6 +38,22 @@ build-flatpak-bundle: @flatpak build-bundle "$(FLATPAK_REPO_DIR)" "$(FLATPAK_BUNDLE)" io.github.rafa.ScreenuxScreenshot @echo "Bundle created: $(FLATPAK_BUNDLE)" +ensure-flatpak-build-deps: + @command -v flatpak >/dev/null 2>&1 || ( \ + echo "flatpak not found."; \ + echo "Install it, then retry:"; \ + echo " Debian/Ubuntu: sudo apt-get install -y flatpak"; \ + echo " Fedora: sudo dnf install -y flatpak"; \ + echo " Arch: sudo pacman -S --needed flatpak"; \ + exit 1) + @if flatpak info "$(FLATPAK_PLATFORM_REF)" >/dev/null 2>&1 && flatpak info "$(FLATPAK_SDK_REF)" >/dev/null 2>&1; then \ + echo "Flatpak runtime deps already installed ($(FLATPAK_RUNTIME_VERSION))."; \ + else \ + echo "Installing missing Flatpak runtime deps: $(FLATPAK_PLATFORM_REF), $(FLATPAK_SDK_REF)"; \ + flatpak remote-add --user --if-not-exists "$(FLATPAK_REMOTE)" "$(FLATPAK_REMOTE_URL)"; \ + flatpak install -y --user "$(FLATPAK_REMOTE)" "$(FLATPAK_PLATFORM_REF)" "$(FLATPAK_SDK_REF)"; \ + fi + install-flatpak: @binding="$(DEFAULT_KEYBINDING)"; \ if [[ -n "$(KEYBINDING)" ]]; then binding="$(KEYBINDING)"; fi; \ diff --git a/README.md b/README.md index 254462f..4bd8d73 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,8 @@ make build-flatpak-bundle FLATPAK_BUNDLE=./screenux-screenshot.flatpak make install-print-screen BUNDLE=./screenux-screenshot.flatpak ``` +`make build-flatpak-bundle` now auto-checks Flatpak build deps and, when missing, installs `org.gnome.Platform//47` and `org.gnome.Sdk//47` from Flathub in user scope. + ```bash flatpak-builder --force-clean build-dir flatpak/io.github.rafa.ScreenuxScreenshot.json flatpak-builder --run build-dir flatpak/io.github.rafa.ScreenuxScreenshot.json screenux-screenshot diff --git a/tests/test_makefile_flatpak_deps.py b/tests/test_makefile_flatpak_deps.py new file mode 100644 index 0000000..6199f5d --- /dev/null +++ b/tests/test_makefile_flatpak_deps.py @@ -0,0 +1,116 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _run_make_with_mocks(info_should_succeed: bool) -> tuple[int, str]: + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + log_file = tmp_path / "commands.log" + mock_bin = tmp_path / "bin" + mock_bin.mkdir() + + _write_executable( + mock_bin / "flatpak", + """#!/usr/bin/env bash +set -euo pipefail + +echo "flatpak $*" >> \"${MOCK_LOG_FILE}\" + +if [[ \"$1\" == \"info\" ]]; then + if [[ \"${MOCK_INFO_SUCCESS:-0}\" == \"1\" ]]; then + exit 0 + fi + exit 1 +fi + +if [[ \"$1\" == \"remote-add\" ]]; then + exit 0 +fi + +if [[ \"$1\" == \"install\" ]]; then + exit 0 +fi + +if [[ \"$1\" == \"build-bundle\" ]]; then + exit 0 +fi + +exit 0 +""", + ) + + _write_executable( + mock_bin / "flatpak-builder", + """#!/usr/bin/env bash +set -euo pipefail + +echo "flatpak-builder $*" >> \"${MOCK_LOG_FILE}\" +exit 0 +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{mock_bin}:{env['PATH']}" + env["MOCK_LOG_FILE"] = str(log_file) + env["MOCK_INFO_SUCCESS"] = "1" if info_should_succeed else "0" + + result = subprocess.run( + [ + "make", + "build-flatpak-bundle", + f"FLATPAK_BUILD_DIR={tmp_path / 'build-dir'}", + f"FLATPAK_REPO_DIR={tmp_path / 'repo'}", + f"FLATPAK_BUNDLE={tmp_path / 'screenux.flatpak'}", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + if not log_file.exists(): + return result.returncode, "" + + return result.returncode, log_file.read_text(encoding="utf-8") + + +class BuildFlatpakBundleDepsTests(unittest.TestCase): + def test_build_flatpak_bundle_installs_runtime_when_missing(self): + code, log = _run_make_with_mocks(info_should_succeed=False) + + self.assertEqual(code, 0) + self.assertIn( + "flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo", + log, + ) + self.assertIn( + "flatpak install -y --user flathub org.gnome.Platform//47 org.gnome.Sdk//47", + log, + ) + self.assertIn("flatpak-builder --force-clean", log) + + def test_build_flatpak_bundle_skips_runtime_install_when_present(self): + code, log = _run_make_with_mocks(info_should_succeed=True) + + self.assertEqual(code, 0) + self.assertNotIn("flatpak remote-add", log) + self.assertNotIn( + "flatpak install -y --user flathub org.gnome.Platform//47 org.gnome.Sdk//47", + log, + ) + self.assertIn("flatpak-builder --force-clean", log) + + +if __name__ == "__main__": + unittest.main() From ce8fa519955bc52ff84f6fa46b2633bf358cd615 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 13:22:16 -0300 Subject: [PATCH 10/15] feat: Implement uninstall functionality with user data preservation option; enhance installer and README for clarity --- Makefile | 45 ++-- README.md | 72 +------ scripts/install/install-screenux.sh | 140 +++++++++---- scripts/install/lib/common.sh | 23 +++ scripts/install/uninstall-screenux.sh | 96 +++++++++ tests/test_install_uninstall_scripts.py | 261 ++++++++++++++++++++++++ uninstall-screenux.sh | 5 + 7 files changed, 512 insertions(+), 130 deletions(-) create mode 100755 scripts/install/uninstall-screenux.sh create mode 100644 tests/test_install_uninstall_scripts.py create mode 100755 uninstall-screenux.sh diff --git a/Makefile b/Makefile index 9a10f8e..7469cb5 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := /usr/bin/env bash INSTALLER := ./install-screenux.sh -DEFAULT_KEYBINDING := ['s'] +UNINSTALLER := ./uninstall-screenux.sh APP_ID := io.github.rafa.ScreenuxScreenshot FLATPAK_MANIFEST := flatpak/io.github.rafa.ScreenuxScreenshot.json FLATPAK_BUILD_DIR ?= build-dir @@ -14,16 +14,16 @@ FLATPAK_RUNTIME_VERSION ?= 47 FLATPAK_PLATFORM_REF ?= org.gnome.Platform//$(FLATPAK_RUNTIME_VERSION) FLATPAK_SDK_REF ?= org.gnome.Sdk//$(FLATPAK_RUNTIME_VERSION) -.PHONY: help build-flatpak-bundle ensure-flatpak-build-deps install install-flatpak install-print-screen restore-native-print check-install-scripts +.PHONY: help build-flatpak-bundle ensure-flatpak-build-deps install install-flatpak install-print-screen uninstall uninstall-preserve-data check-install-scripts help: @echo "Screenux helper targets" @echo "" @echo " make build-flatpak-bundle [FLATPAK_BUNDLE=./screenux-screenshot.flatpak]" - @echo " make install [BUNDLE=./screenux-screenshot.flatpak] (auto-builds bundle if needed)" - @echo " make install-flatpak [BUNDLE=./screenux-screenshot.flatpak] [KEYBINDING=\"['Print']\"]" + @echo " make install [BUNDLE=./screenux-screenshot.flatpak]" @echo " make install-print-screen [BUNDLE=./screenux-screenshot.flatpak]" - @echo " make restore-native-print" + @echo " make uninstall" + @echo " make uninstall-preserve-data" @echo " make check-install-scripts" build-flatpak-bundle: ensure-flatpak-build-deps @@ -54,33 +54,28 @@ ensure-flatpak-build-deps: flatpak install -y --user "$(FLATPAK_REMOTE)" "$(FLATPAK_PLATFORM_REF)" "$(FLATPAK_SDK_REF)"; \ fi -install-flatpak: - @binding="$(DEFAULT_KEYBINDING)"; \ - if [[ -n "$(KEYBINDING)" ]]; then binding="$(KEYBINDING)"; fi; \ - $(INSTALLER) "$(BUNDLE)" "$$binding" - install: @if [[ -f "$(BUNDLE)" ]]; then \ - $(INSTALLER) --print-screen "$(BUNDLE)"; \ - elif command -v flatpak >/dev/null 2>&1 && flatpak info --user "$(APP_ID)" >/dev/null 2>&1; then \ - $(INSTALLER) --print-screen; \ - elif command -v flatpak-builder >/dev/null 2>&1; then \ - $(MAKE) build-flatpak-bundle FLATPAK_BUNDLE="$(BUNDLE)"; \ - $(INSTALLER) --print-screen "$(BUNDLE)"; \ + $(INSTALLER) --bundle "$(BUNDLE)"; \ else \ - echo "Screenux is not installed and bundle not found: $(BUNDLE)"; \ - echo "Install flatpak-builder to auto-build, or pass an existing bundle:"; \ - echo " make install BUNDLE=./screenux-screenshot.flatpak"; \ - echo " make build-flatpak-bundle FLATPAK_BUNDLE=./screenux-screenshot.flatpak"; \ - exit 1; \ + $(INSTALLER); \ fi install-print-screen: - @$(INSTALLER) --print-screen "$(BUNDLE)" + @if [[ -f "$(BUNDLE)" ]]; then \ + $(INSTALLER) --bundle "$(BUNDLE)" --print-screen; \ + else \ + $(INSTALLER) --print-screen; \ + fi + +install-flatpak: install + +uninstall: + @$(UNINSTALLER) -restore-native-print: - @$(INSTALLER) --restore-native-print +uninstall-preserve-data: + @$(UNINSTALLER) --preserve-user-data check-install-scripts: - @bash -n install-screenux.sh scripts/install/install-screenux.sh scripts/install/lib/common.sh scripts/install/lib/gnome_shortcuts.sh + @bash -n install-screenux.sh uninstall-screenux.sh scripts/install/install-screenux.sh scripts/install/uninstall-screenux.sh scripts/install/lib/common.sh scripts/install/lib/gnome_shortcuts.sh @echo "Installer scripts syntax: OK" diff --git a/README.md b/README.md index 4bd8d73..575bed4 100644 --- a/README.md +++ b/README.md @@ -19,90 +19,36 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) - Timestamped output names with safe, non-overwriting writes -## 🚀 Quick start - -### 1) Install system dependencies - -- `python3` -- `python3-gi` -- GTK4 introspection (`gir1.2-gtk-4.0` on Debian/Ubuntu) -- `xdg-desktop-portal` plus a desktop backend (GNOME/KDE/etc.) - -### 2) Clone the project +## Install ```bash -git clone https://github.com/rafaself/Screenux.git -cd Screenux +./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak ``` -### 3) Run Screenux +Optional GNOME Print Screen shortcut: ```bash -./screenux-screenshot +./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak --print-screen ``` -### 4) Install Flatpak bundle + optional GNOME shortcut - -If you built or downloaded a Screenux Flatpak bundle, use the installer helper: - -To build a local bundle first: +If Screenux is already installed for your user, you can rerun: ```bash -make build-flatpak-bundle FLATPAK_BUNDLE=./screenux-screenshot.flatpak -``` - -```bash -./install-screenux.sh ./screenux-screenshot.flatpak "['s']" - -# if already installed for current user ./install-screenux.sh ``` -Or with Make targets: - -```bash -make install-flatpak [BUNDLE=./screenux-screenshot.flatpak] -``` - -Use Print Screen directly with Screenux (GNOME): +## Uninstall ```bash -./install-screenux.sh --print-screen ./screenux-screenshot.flatpak - -# if already installed for current user -./install-screenux.sh --print-screen - -# or -make install [BUNDLE=./screenux-screenshot.flatpak] - -# or (explicit target) -make install-print-screen [BUNDLE=./screenux-screenshot.flatpak] +./uninstall-screenux.sh ``` -`make install` behavior: - -- Uses the bundle if it exists. -- If Screenux is already installed for the user, it configures shortcuts without a bundle. -- If neither is true and `flatpak-builder` is available, it auto-builds the bundle and continues. - -Restore GNOME native Print Screen behavior: +Preserve app data in `~/.var/app/io.github.rafa.ScreenuxScreenshot`: ```bash -./install-screenux.sh --restore-native-print - -# or -make restore-native-print +./uninstall-screenux.sh --preserve-user-data ``` -Notes: - -- The first argument is optional only when Screenux is already installed for the current user; otherwise provide a local `.flatpak` file. -- The second argument is optional and uses GNOME `gsettings` binding syntax. -- If Screenux is already installed for the current user, bundle install is skipped and only wrapper/shortcut setup is applied. -- On non-GNOME desktops, install still completes and shortcut setup is skipped. -- `--restore-native-print` removes the Screenux custom GNOME shortcut and resets GNOME screenshot keybindings. -- Installer internals are organized under `scripts/install/` with reusable helper modules. - ## 🖱️ Usage 1. Launch the app. diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index c70cb91..9813b03 100644 --- a/scripts/install/install-screenux.sh +++ b/scripts/install/install-screenux.sh @@ -8,51 +8,99 @@ source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" usage() { cat <<'EOF' Usage: - ./install-screenux.sh [--print-screen] [/path/to/screenux-screenshot.flatpak] "['s']" - ./install-screenux.sh --restore-native-print - -Arguments: - 1) Flatpak bundle path (optional if Screenux is already installed for user) - 2) GNOME keybinding list syntax (optional, default: ['s']) + ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] [--shortcut "['s']"] + ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] --print-screen Options: - --print-screen Bind Screenux to ['Print'] and disable GNOME native Print keys - --restore-native-print Remove Screenux shortcut (if present) and restore native GNOME Print keys + --bundle PATH Flatpak bundle path. If omitted and app is not installed, tries ./screenux-screenshot.flatpak + --shortcut BINDING Configure GNOME shortcut with gsettings list syntax + --print-screen Shortcut preset for ['Print'] + disable native GNOME Print bindings + --no-shortcut Skip shortcut setup (default) -h, --help Show this help Examples: - ./install-screenux.sh ./screenux-screenshot.flatpak ./install-screenux.sh - ./install-screenux.sh --print-screen ./screenux-screenshot.flatpak - ./install-screenux.sh --print-screen - ./install-screenux.sh ./screenux-screenshot.flatpak "['Print']" - ./install-screenux.sh --restore-native-print + ./install-screenux.sh --bundle ./screenux-screenshot.flatpak + ./install-screenux.sh --bundle ./screenux-screenshot.flatpak --print-screen + ./install-screenux.sh --bundle ./screenux-screenshot.flatpak --shortcut "['s']" EOF } +resolve_default_bundle() { + if [[ -f "./${DEFAULT_BUNDLE_NAME}" ]]; then + printf '%s' "./${DEFAULT_BUNDLE_NAME}" + return 0 + fi + + local repo_bundle="${SCRIPT_DIR}/../../${DEFAULT_BUNDLE_NAME}" + if [[ -f "${repo_bundle}" ]]; then + printf '%s' "${repo_bundle}" + return 0 + fi + + return 1 +} + install_bundle() { local flatpak_file="$1" - if ! command -v flatpak >/dev/null 2>&1; then - fail "Required command not found: flatpak. Install it first (Debian/Ubuntu): apt-get update && sudo apt-get install -y flatpak" + fail "Required command not found: flatpak. Install Flatpak first, then rerun." fi - if flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + if [[ -n "${flatpak_file}" ]]; then + [[ -f "${flatpak_file}" ]] || fail "Flatpak bundle not found: ${flatpak_file}" + echo "==> Installing Flatpak bundle: ${flatpak_file}" + flatpak install -y --user --or-update "${flatpak_file}" + elif flatpak info --user "${APP_ID}" >/dev/null 2>&1; then echo "==> ${APP_ID} is already installed for this user; skipping bundle install" else - [[ -n "${flatpak_file}" ]] || fail "Screenux is not installed. Provide a local .flatpak bundle path as first argument." - [[ -f "${flatpak_file}" ]] || fail "File not found: ${flatpak_file}" - echo "==> Installing Flatpak bundle: ${flatpak_file}" - flatpak install -y --user "${flatpak_file}" + local inferred_bundle="" + inferred_bundle="$(resolve_default_bundle || true)" + [[ -n "${inferred_bundle}" ]] || fail "Bundle not provided and ${APP_ID} is not installed. Use --bundle /path/to/${DEFAULT_BUNDLE_NAME}." + echo "==> Installing Flatpak bundle: ${inferred_bundle}" + flatpak install -y --user --or-update "${inferred_bundle}" fi create_wrapper create_desktop_entry } +validate_installation() { + echo "==> Validating installation" + + if ! flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + fail "Validation failed: ${APP_ID} is not installed for current user." + fi + [[ -x "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper not executable at ${WRAPPER_PATH}" + [[ -f "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry missing at ${DESKTOP_FILE}" +} + +configure_shortcut() { + local shortcut_mode="$1" + local keybinding="$2" + + case "${shortcut_mode}" in + none) + echo "==> Shortcut setup skipped" + ;; + custom) + configure_gnome_shortcut "${keybinding}" + ;; + print) + configure_gnome_shortcut "${PRINT_KEYBINDING}" + disable_native_print_keys + ;; + *) + fail "Unexpected shortcut mode: ${shortcut_mode}" + ;; + esac +} + main() { - local use_print_screen="false" - local restore_native_print="false" + local bundle_path="" + local shortcut_mode="none" + local keybinding="" + local shortcut_option_seen="false" local positional=() while (($# > 0)); do @@ -61,11 +109,28 @@ main() { usage exit 0 ;; + --bundle) + shift + (($# > 0)) || fail "--bundle requires a path" + bundle_path="$1" + ;; + --shortcut) + shift + (($# > 0)) || fail "--shortcut requires a binding list value" + [[ "${shortcut_option_seen}" == "false" ]] || fail "Only one of --shortcut, --print-screen, or --no-shortcut can be used." + shortcut_option_seen="true" + shortcut_mode="custom" + keybinding="$1" + ;; --print-screen) - use_print_screen="true" + [[ "${shortcut_option_seen}" == "false" ]] || fail "Only one of --shortcut, --print-screen, or --no-shortcut can be used." + shortcut_option_seen="true" + shortcut_mode="print" ;; - --restore-native-print) - restore_native_print="true" + --no-shortcut) + [[ "${shortcut_option_seen}" == "false" ]] || fail "Only one of --shortcut, --print-screen, or --no-shortcut can be used." + shortcut_option_seen="true" + shortcut_mode="none" ;; --) shift @@ -85,28 +150,19 @@ main() { shift done - if [[ "${restore_native_print}" == "true" ]]; then - remove_screenux_shortcut - restore_native_print_keys - echo "==> Done. Native Print Screen behavior restored (GNOME)." - exit 0 + if ((${#positional[@]} > 1)); then + fail "Unexpected positional arguments. Use --bundle PATH and optional shortcut flags." fi - local flatpak_file="${positional[0]:-}" - local keybinding="${positional[1]:-${DEFAULT_KEYBINDING}}" - - if [[ "${use_print_screen}" == "true" ]]; then - keybinding="${PRINT_KEYBINDING}" + if [[ -z "${bundle_path}" && ${#positional[@]} -eq 1 ]]; then + bundle_path="${positional[0]}" fi - install_bundle "${flatpak_file}" - - configure_gnome_shortcut "${keybinding}" - if [[ "${keybinding}" == "${PRINT_KEYBINDING}" ]]; then - disable_native_print_keys - fi + install_bundle "${bundle_path}" + configure_shortcut "${shortcut_mode}" "${keybinding}" + validate_installation - echo "==> Done." + echo "==> Installation complete" echo "Run:" echo " ${WRAPPER_PATH}" echo "Or capture directly:" diff --git a/scripts/install/lib/common.sh b/scripts/install/lib/common.sh index 0f63a62..31d9e2f 100644 --- a/scripts/install/lib/common.sh +++ b/scripts/install/lib/common.sh @@ -7,6 +7,8 @@ WRAPPER_DIR="${HOME}/.local/bin" WRAPPER_PATH="${WRAPPER_DIR}/screenux-screenshot" DESKTOP_DIR="${HOME}/.local/share/applications" DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" +APP_DATA_DIR="${HOME}/.var/app/${APP_ID}" +DEFAULT_BUNDLE_NAME="screenux-screenshot.flatpak" DEFAULT_KEYBINDING="['s']" PRINT_KEYBINDING="['Print']" @@ -52,3 +54,24 @@ Terminal=false Categories=Utility;Graphics; EOF } + +remove_wrapper() { + if [[ -e "${WRAPPER_PATH}" || -L "${WRAPPER_PATH}" ]]; then + echo "==> Removing wrapper command: ${WRAPPER_PATH}" + rm -f -- "${WRAPPER_PATH}" + fi +} + +remove_desktop_entry() { + if [[ -e "${DESKTOP_FILE}" || -L "${DESKTOP_FILE}" ]]; then + echo "==> Removing desktop entry: ${DESKTOP_FILE}" + rm -f -- "${DESKTOP_FILE}" + fi +} + +remove_app_data() { + if [[ -d "${APP_DATA_DIR}" ]]; then + echo "==> Removing user data: ${APP_DATA_DIR}" + rm -rf -- "${APP_DATA_DIR}" + fi +} diff --git a/scripts/install/uninstall-screenux.sh b/scripts/install/uninstall-screenux.sh new file mode 100755 index 0000000..f22cf1c --- /dev/null +++ b/scripts/install/uninstall-screenux.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" + +usage() { + cat <<'EOF' +Usage: + ./uninstall-screenux.sh [--preserve-user-data] + +Options: + --preserve-user-data Keep ~/.var/app/io.github.rafa.ScreenuxScreenshot + -h, --help Show this help +EOF +} + +remove_flatpak_app() { + if ! command -v flatpak >/dev/null 2>&1; then + echo "NOTE: flatpak not found; skipping Flatpak uninstall." + return 0 + fi + + if flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + echo "==> Uninstalling Flatpak app: ${APP_ID}" + flatpak uninstall -y --user "${APP_ID}" + else + echo "==> ${APP_ID} is not installed for this user; skipping Flatpak uninstall" + fi +} + +cleanup_local_entries() { + remove_wrapper + remove_desktop_entry +} + +cleanup_shortcuts() { + remove_screenux_shortcut + restore_native_print_keys +} + +validate_uninstall() { + local preserve_user_data="$1" + + echo "==> Validating uninstall" + + if command -v flatpak >/dev/null 2>&1 && flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + fail "Validation failed: ${APP_ID} is still installed for current user." + fi + + [[ ! -e "${WRAPPER_PATH}" && ! -L "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper still exists at ${WRAPPER_PATH}" + [[ ! -e "${DESKTOP_FILE}" && ! -L "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry still exists at ${DESKTOP_FILE}" + + if [[ "${preserve_user_data}" == "false" && -d "${APP_DATA_DIR}" ]]; then + fail "Validation failed: user data still exists at ${APP_DATA_DIR}" + fi +} + +main() { + local preserve_user_data="false" + + while (($# > 0)); do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --preserve-user-data) + preserve_user_data="true" + ;; + -*) + fail "Unknown option: $1" + ;; + *) + fail "Unexpected positional argument: $1" + ;; + esac + shift + done + + remove_flatpak_app + cleanup_local_entries + cleanup_shortcuts + + if [[ "${preserve_user_data}" == "true" ]]; then + echo "==> Preserving user data: ${APP_DATA_DIR}" + else + remove_app_data + fi + + validate_uninstall "${preserve_user_data}" + echo "==> Uninstall complete" +} + +main "$@" diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py new file mode 100644 index 0000000..84764a2 --- /dev/null +++ b/tests/test_install_uninstall_scripts.py @@ -0,0 +1,261 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "install-screenux.sh" +UNINSTALLER = ROOT / "uninstall-screenux.sh" +APP_ID = "io.github.rafa.ScreenuxScreenshot" + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _setup_mock_environment( + *, + with_gsettings: bool = False, + installed: bool = False, + gsettings_has_shortcut: bool = False, +) -> tuple[Path, dict[str, str], Path]: + tmpdir = Path(tempfile.mkdtemp(prefix="screenux-install-tests-")) + home = tmpdir / "home" + home.mkdir(parents=True, exist_ok=True) + + mock_bin = tmpdir / "bin" + mock_bin.mkdir(parents=True, exist_ok=True) + + log_file = tmpdir / "commands.log" + state_file = tmpdir / "flatpak-installed" + if installed: + state_file.write_text("1", encoding="utf-8") + + _write_executable( + mock_bin / "flatpak", + """#!/usr/bin/env bash +set -euo pipefail +echo "flatpak $*" >> "${MOCK_LOG_FILE}" + +if [[ "${1:-}" == "info" && "${2:-}" == "--user" ]]; then + if [[ -f "${MOCK_FLATPAK_STATE_FILE}" ]]; then + exit 0 + fi + exit 1 +fi + +if [[ "${1:-}" == "install" ]]; then + touch "${MOCK_FLATPAK_STATE_FILE}" + exit 0 +fi + +if [[ "${1:-}" == "uninstall" ]]; then + rm -f "${MOCK_FLATPAK_STATE_FILE}" + exit 0 +fi + +exit 0 +""", + ) + + if with_gsettings: + _write_executable( + mock_bin / "gsettings", + """#!/usr/bin/env bash +set -euo pipefail +echo "gsettings $*" >> "${MOCK_LOG_FILE}" + +case "${1:-}" in + list-schemas) + cat <<'EOF' +org.gnome.settings-daemon.plugins.media-keys +org.gnome.settings-daemon.plugins.media-keys.custom-keybinding +org.gnome.shell.keybindings +EOF + ;; + list-keys) + case "${2:-}" in + org.gnome.settings-daemon.plugins.media-keys) + cat <<'EOF' +custom-keybindings +screenshot +window-screenshot +area-screenshot +EOF + ;; + org.gnome.shell.keybindings) + cat <<'EOF' +show-screenshot +show-screenshot-ui +show-screen-recording-ui +EOF + ;; + *) + cat <<'EOF' +name +command +binding +EOF + ;; + esac + ;; + get) + schema="${2:-}" + key="${3:-}" + if [[ "${schema}" == "org.gnome.settings-daemon.plugins.media-keys" && "${key}" == "custom-keybindings" ]]; then + if [[ "${MOCK_GSETTINGS_HAS_SHORTCUT:-0}" == "1" ]]; then + echo "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/']" + else + echo "[]" + fi + exit 0 + fi + + if [[ "${schema}" == org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:* && "${key}" == "name" ]]; then + echo "'Screenux Screenshot'" + exit 0 + fi + + if [[ "${schema}" == org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:* && "${key}" == "command" ]]; then + echo "'${HOME}/.local/bin/screenux-screenshot --capture'" + exit 0 + fi + + echo "[]" + ;; + set|reset) + ;; +esac +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{mock_bin}:{env['PATH']}" + env["HOME"] = str(home) + env["MOCK_LOG_FILE"] = str(log_file) + env["MOCK_FLATPAK_STATE_FILE"] = str(state_file) + env["MOCK_GSETTINGS_HAS_SHORTCUT"] = "1" if gsettings_has_shortcut else "0" + + return tmpdir, env, log_file + + +def _run_command(command: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + +class InstallScriptTests(unittest.TestCase): + def test_installer_installs_bundle_and_creates_local_entries(self): + tmpdir, env, log_file = _setup_mock_environment() + bundle = tmpdir / "screenux.flatpak" + bundle.write_text("bundle", encoding="utf-8") + + result = _run_command([str(INSTALLER), "--bundle", str(bundle)], env) + log = log_file.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn(f"flatpak install -y --user --or-update {bundle}", log) + self.assertGreaterEqual(log.count(f"flatpak info --user {APP_ID}"), 1) + + wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" + desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" + + self.assertTrue(wrapper_path.exists()) + self.assertTrue(os.access(wrapper_path, os.X_OK)) + self.assertIn(f"flatpak run {APP_ID}", wrapper_path.read_text(encoding="utf-8")) + + self.assertTrue(desktop_file.exists()) + self.assertIn( + f"Exec={wrapper_path}", + desktop_file.read_text(encoding="utf-8"), + ) + + def test_installer_skips_bundle_install_when_app_already_installed(self): + _, env, log_file = _setup_mock_environment(installed=True) + + result = _run_command([str(INSTALLER)], env) + log = log_file.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertNotIn("flatpak install -y --user --or-update", log) + + def test_installer_can_configure_print_screen_shortcut(self): + tmpdir, env, log_file = _setup_mock_environment(with_gsettings=True) + bundle = tmpdir / "screenux.flatpak" + bundle.write_text("bundle", encoding="utf-8") + + result = _run_command([str(INSTALLER), "--bundle", str(bundle), "--print-screen"], env) + log = log_file.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn( + "gsettings set org.gnome.shell.keybindings show-screenshot []", + log, + ) + self.assertIn( + "gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/ binding ['Print']", + log, + ) + + +class UninstallScriptTests(unittest.TestCase): + def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): + _, env, log_file = _setup_mock_environment( + with_gsettings=True, + installed=True, + gsettings_has_shortcut=True, + ) + wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" + desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" + data_dir = Path(env["HOME"]) / f".var/app/{APP_ID}" + data_dir.mkdir(parents=True, exist_ok=True) + (data_dir / "config.json").write_text("{}", encoding="utf-8") + wrapper_path.parent.mkdir(parents=True, exist_ok=True) + wrapper_path.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + desktop_file.parent.mkdir(parents=True, exist_ok=True) + desktop_file.write_text("[Desktop Entry]\n", encoding="utf-8") + + result = _run_command([str(UNINSTALLER)], env) + log = log_file.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn(f"flatpak uninstall -y --user {APP_ID}", log) + self.assertFalse(wrapper_path.exists()) + self.assertFalse(desktop_file.exists()) + self.assertFalse(data_dir.exists()) + self.assertIn( + "gsettings reset org.gnome.shell.keybindings show-screenshot", + log, + ) + + def test_uninstaller_preserves_user_data_when_requested(self): + _, env, _ = _setup_mock_environment(installed=True) + data_dir = Path(env["HOME"]) / f".var/app/{APP_ID}" + data_dir.mkdir(parents=True, exist_ok=True) + (data_dir / "settings.json").write_text("{}", encoding="utf-8") + + result = _run_command([str(UNINSTALLER), "--preserve-user-data"], env) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertTrue(data_dir.exists()) + + def test_uninstaller_is_idempotent_when_app_is_absent(self): + _, env, log_file = _setup_mock_environment() + + result = _run_command([str(UNINSTALLER)], env) + log = log_file.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertNotIn(f"flatpak uninstall -y --user {APP_ID}", log) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall-screenux.sh b/uninstall-screenux.sh new file mode 100755 index 0000000..21494d8 --- /dev/null +++ b/uninstall-screenux.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec bash "${SCRIPT_DIR}/scripts/install/uninstall-screenux.sh" "$@" From 7c9a340d66e1d4d0aedb9a8bea557d3325b42f46 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 13:27:56 -0300 Subject: [PATCH 11/15] feat: Add auto-capture feature and enhance screenshot functionality; update README and tests accordingly --- README.md | 2 ++ src/screenux_screenshot.py | 26 +++++++++++++++++++++++--- src/screenux_window.py | 5 ++++- tests/test_window_and_screenshot.py | 21 +++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 575bed4..8c6ea97 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Optional GNOME Print Screen shortcut: ./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak --print-screen ``` +This maps `Print` to `screenux-screenshot --capture`, which opens Screenux and immediately starts the capture flow. + If Screenux is already installed for your user, you can rerun: ```bash diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index db36223..05c28d3 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -139,6 +139,17 @@ def format_status_saved(path: Path) -> str: return f"Saved: {path}" +def _parse_cli_args(argv: list[str]) -> tuple[list[str], bool]: + filtered = [argv[0]] if argv else [] + auto_capture = False + for arg in argv[1:]: + if arg == "--capture": + auto_capture = True + continue + filtered.append(arg) + return filtered, auto_capture + + MainWindow = None if Gtk is not None: try: @@ -149,8 +160,13 @@ def format_status_saved(path: Path) -> str: if Gtk is not None: class ScreenuxScreenshotApp(Gtk.Application): # type: ignore[misc] - def __init__(self) -> None: + def __init__(self, auto_capture: bool = False) -> None: super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE) + self._auto_capture_pending = auto_capture + + def _trigger_auto_capture(self, window: MainWindow) -> bool: + window.take_screenshot() + return False def do_activate(self) -> None: window = self.props.active_window @@ -164,6 +180,9 @@ def do_activate(self) -> None: format_status_saved=format_status_saved, ) window.present() + if self._auto_capture_pending: + self._auto_capture_pending = False + GLib.idle_add(self._trigger_auto_capture, window) else: class ScreenuxScreenshotApp: # pragma: no cover def run(self, _argv: list[str]) -> int: @@ -175,8 +194,9 @@ def main(argv: list[str]) -> int: if GI_IMPORT_ERROR is not None or Gtk is None or MainWindow is None: print(f"Missing GTK4/PyGObject dependencies: {GI_IMPORT_ERROR}", file=sys.stderr) return 1 - app = ScreenuxScreenshotApp() - return app.run(argv) + app_argv, auto_capture = _parse_cli_args(argv) + app = ScreenuxScreenshotApp(auto_capture=auto_capture) + return app.run(app_argv) if __name__ == "__main__": diff --git a/src/screenux_window.py b/src/screenux_window.py index a618a0e..8a575e6 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -89,7 +89,7 @@ def __init__( self._main_box.set_margin_end(16) self._button = Gtk.Button(label="Take Screenshot") - self._button.connect("clicked", self._on_take_screenshot) + self._button.connect("clicked", lambda _button: self.take_screenshot()) self._main_box.append(self._button) self._status_label = Gtk.Label(label="Ready") @@ -112,6 +112,9 @@ def __init__( self._main_box.append(folder_row) self.set_child(self._main_box) + def take_screenshot(self) -> None: + self._on_take_screenshot(self._button) + def _build_handle_token(self) -> str: self._request_counter += 1 return f"screenux_{os.getpid()}_{self._request_counter}_{int(time.time() * 1000)}" diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 77de3e8..1a6f1c6 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -93,6 +93,16 @@ def set_child(self, child): self._set_child_value = child +def test_window_take_screenshot_public_method(): + self = FakeWindowSelf() + called = [] + self._on_take_screenshot = lambda button: called.append(button) + + window.MainWindow.take_screenshot(self) + + assert called == [self._button] + + class DummyError(Exception): def __init__(self, message): self.message = message @@ -384,8 +394,14 @@ def test_screenshot_main_and_extension_helpers(monkeypatch, capsys, tmp_path): assert screenshot.main(["app"]) == 1 assert "Missing GTK4/PyGObject dependencies" in capsys.readouterr().err + seen = {} + class App: + def __init__(self, auto_capture=False): + seen["auto_capture"] = auto_capture + def run(self, argv): + seen["argv"] = argv return len(argv) monkeypatch.setattr(screenshot, "GI_IMPORT_ERROR", None) @@ -393,6 +409,11 @@ def run(self, argv): monkeypatch.setattr(screenshot, "MainWindow", object()) monkeypatch.setattr(screenshot, "ScreenuxScreenshotApp", App) assert screenshot.main(["a", "b"]) == 2 + assert seen == {"auto_capture": False, "argv": ["a", "b"]} + + seen.clear() + assert screenshot.main(["a", "--capture"]) == 1 + assert seen == {"auto_capture": True, "argv": ["a"]} class FakeGLib: @staticmethod From 23eb60f7322344533fdcea1aa57bde82a77a3724 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 13:29:43 -0300 Subject: [PATCH 12/15] feat: Add optional global CLI command for easier access to Screenux --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 8c6ea97..07127f1 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,17 @@ If Screenux is already installed for your user, you can rerun: ./install-screenux.sh ``` +Optional global CLI command (`screenux`): + +```bash +sudo tee /usr/local/bin/screenux >/dev/null <<'EOF' +#!/usr/bin/env bash + +/home/${USER}/dev/Screenux/screenux-screenshot +EOF +sudo chmod +x /usr/local/bin/screenux +``` + ## Uninstall ```bash From efa5c006b4ca1c9518a7fc76cfb18727d675b802 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 16:16:04 -0300 Subject: [PATCH 13/15] feat: Add app icon for desktop launcher integration and update installation scripts --- README.md | 3 ++ .../io.github.rafa.ScreenuxScreenshot.svg | 10 ++++++ .../io.github.rafa.ScreenuxScreenshot.json | 3 +- io.github.rafa.ScreenuxScreenshot.desktop | 1 + scripts/install/install-screenux.sh | 3 ++ scripts/install/lib/common.sh | 34 ++++++++++++++++++ scripts/install/uninstall-screenux.sh | 3 ++ src/screenux_screenshot.py | 1 + src/screenux_window.py | 1 + tests/test_install_uninstall_scripts.py | 6 ++++ tests/test_packaging_icon_metadata.py | 35 +++++++++++++++++++ 11 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 assets/icons/io.github.rafa.ScreenuxScreenshot.svg create mode 100644 tests/test_packaging_icon_metadata.py diff --git a/README.md b/README.md index 07127f1..973fe78 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate - Built-in editor for quick annotations (shapes/text) - Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`) - Timestamped output names with safe, non-overwriting writes +- Packaged app icon for desktop launcher integration ## Install @@ -25,6 +26,8 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate ./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak ``` +The installer creates a desktop entry and installs the app icon at `~/.local/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot.svg` so launcher/taskbar icon lookup works reliably. It also refreshes the local icon cache when GTK cache tools are available. + Optional GNOME Print Screen shortcut: ```bash diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot.svg b/assets/icons/io.github.rafa.ScreenuxScreenshot.svg new file mode 100644 index 0000000..2823255 --- /dev/null +++ b/assets/icons/io.github.rafa.ScreenuxScreenshot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/flatpak/io.github.rafa.ScreenuxScreenshot.json b/flatpak/io.github.rafa.ScreenuxScreenshot.json index cd1bfd1..e6e485a 100644 --- a/flatpak/io.github.rafa.ScreenuxScreenshot.json +++ b/flatpak/io.github.rafa.ScreenuxScreenshot.json @@ -20,7 +20,8 @@ "install -Dm755 src/screenux_screenshot.py /app/share/screenux/screenux_screenshot.py", "install -Dm755 src/screenux_editor.py /app/share/screenux/screenux_editor.py", "install -Dm755 src/screenux_window.py /app/share/screenux/screenux_window.py", - "install -Dm644 io.github.rafa.ScreenuxScreenshot.desktop /app/share/applications/io.github.rafa.ScreenuxScreenshot.desktop" + "install -Dm644 io.github.rafa.ScreenuxScreenshot.desktop /app/share/applications/io.github.rafa.ScreenuxScreenshot.desktop", + "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot.svg" ], "sources": [ { diff --git a/io.github.rafa.ScreenuxScreenshot.desktop b/io.github.rafa.ScreenuxScreenshot.desktop index 7f5ccce..1ad75a8 100644 --- a/io.github.rafa.ScreenuxScreenshot.desktop +++ b/io.github.rafa.ScreenuxScreenshot.desktop @@ -1,6 +1,7 @@ [Desktop Entry] Name=Screenux Screenshot Exec=screenux-screenshot +Icon=io.github.rafa.ScreenuxScreenshot Type=Application Terminal=false Categories=Utility;Graphics; diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index 9813b03..38a2615 100644 --- a/scripts/install/install-screenux.sh +++ b/scripts/install/install-screenux.sh @@ -63,6 +63,8 @@ install_bundle() { create_wrapper create_desktop_entry + create_icon_asset + refresh_icon_cache } validate_installation() { @@ -73,6 +75,7 @@ validate_installation() { fi [[ -x "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper not executable at ${WRAPPER_PATH}" [[ -f "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry missing at ${DESKTOP_FILE}" + [[ -f "${ICON_FILE}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE}" } configure_shortcut() { diff --git a/scripts/install/lib/common.sh b/scripts/install/lib/common.sh index 31d9e2f..d757891 100644 --- a/scripts/install/lib/common.sh +++ b/scripts/install/lib/common.sh @@ -7,8 +7,12 @@ WRAPPER_DIR="${HOME}/.local/bin" WRAPPER_PATH="${WRAPPER_DIR}/screenux-screenshot" DESKTOP_DIR="${HOME}/.local/share/applications" DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" +ICON_DIR="${HOME}/.local/share/icons/hicolor/scalable/apps" +ICON_FILE="${ICON_DIR}/${APP_ID}.svg" APP_DATA_DIR="${HOME}/.var/app/${APP_ID}" DEFAULT_BUNDLE_NAME="screenux-screenshot.flatpak" +COMMON_LIB_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +APP_ICON_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}.svg" DEFAULT_KEYBINDING="['s']" PRINT_KEYBINDING="['Print']" @@ -55,6 +59,13 @@ Categories=Utility;Graphics; EOF } +create_icon_asset() { + [[ -f "${APP_ICON_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_SOURCE}" + echo "==> Installing app icon: ${ICON_FILE}" + mkdir -p "${ICON_DIR}" + cp -f -- "${APP_ICON_SOURCE}" "${ICON_FILE}" +} + remove_wrapper() { if [[ -e "${WRAPPER_PATH}" || -L "${WRAPPER_PATH}" ]]; then echo "==> Removing wrapper command: ${WRAPPER_PATH}" @@ -69,9 +80,32 @@ remove_desktop_entry() { fi } +remove_icon_asset() { + if [[ -e "${ICON_FILE}" || -L "${ICON_FILE}" ]]; then + echo "==> Removing app icon: ${ICON_FILE}" + rm -f -- "${ICON_FILE}" + fi +} + remove_app_data() { if [[ -d "${APP_DATA_DIR}" ]]; then echo "==> Removing user data: ${APP_DATA_DIR}" rm -rf -- "${APP_DATA_DIR}" fi } + +refresh_icon_cache() { + local icon_theme_root="${HOME}/.local/share/icons/hicolor" + if [[ ! -d "${icon_theme_root}" ]]; then + return 0 + fi + + if command -v gtk4-update-icon-cache >/dev/null 2>&1; then + gtk4-update-icon-cache -f -t "${icon_theme_root}" >/dev/null 2>&1 || true + return 0 + fi + + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -f -t "${icon_theme_root}" >/dev/null 2>&1 || true + fi +} diff --git a/scripts/install/uninstall-screenux.sh b/scripts/install/uninstall-screenux.sh index f22cf1c..2df1797 100755 --- a/scripts/install/uninstall-screenux.sh +++ b/scripts/install/uninstall-screenux.sh @@ -33,6 +33,8 @@ remove_flatpak_app() { cleanup_local_entries() { remove_wrapper remove_desktop_entry + remove_icon_asset + refresh_icon_cache } cleanup_shortcuts() { @@ -51,6 +53,7 @@ validate_uninstall() { [[ ! -e "${WRAPPER_PATH}" && ! -L "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper still exists at ${WRAPPER_PATH}" [[ ! -e "${DESKTOP_FILE}" && ! -L "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry still exists at ${DESKTOP_FILE}" + [[ ! -e "${ICON_FILE}" && ! -L "${ICON_FILE}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE}" if [[ "${preserve_user_data}" == "false" && -d "${APP_DATA_DIR}" ]]; then fail "Validation failed: user data still exists at ${APP_DATA_DIR}" diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 05c28d3..7c24986 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -162,6 +162,7 @@ def _parse_cli_args(argv: list[str]) -> tuple[list[str], bool]: class ScreenuxScreenshotApp(Gtk.Application): # type: ignore[misc] def __init__(self, auto_capture: bool = False) -> None: super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE) + Gtk.Window.set_default_icon_name(APP_ID) self._auto_capture_pending = auto_capture def _trigger_auto_capture(self, window: MainWindow) -> bool: diff --git a/src/screenux_window.py b/src/screenux_window.py index 8a575e6..c6b43a6 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -70,6 +70,7 @@ def __init__( format_status_saved: Callable[[Path], str], ): super().__init__(application=app, title="Screenux Screenshot") + self.set_icon_name(app.get_application_id() or "application-default-icon") self.set_default_size(360, 180) self._resolve_save_dir = resolve_save_dir diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py index 84764a2..760ebde 100644 --- a/tests/test_install_uninstall_scripts.py +++ b/tests/test_install_uninstall_scripts.py @@ -167,6 +167,7 @@ def test_installer_installs_bundle_and_creates_local_entries(self): wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" + icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" self.assertTrue(wrapper_path.exists()) self.assertTrue(os.access(wrapper_path, os.X_OK)) @@ -177,6 +178,7 @@ def test_installer_installs_bundle_and_creates_local_entries(self): f"Exec={wrapper_path}", desktop_file.read_text(encoding="utf-8"), ) + self.assertTrue(icon_file.exists()) def test_installer_skips_bundle_install_when_app_already_installed(self): _, env, log_file = _setup_mock_environment(installed=True) @@ -215,6 +217,7 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): ) wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" + icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" data_dir = Path(env["HOME"]) / f".var/app/{APP_ID}" data_dir.mkdir(parents=True, exist_ok=True) (data_dir / "config.json").write_text("{}", encoding="utf-8") @@ -222,6 +225,8 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): wrapper_path.write_text("#!/usr/bin/env bash\n", encoding="utf-8") desktop_file.parent.mkdir(parents=True, exist_ok=True) desktop_file.write_text("[Desktop Entry]\n", encoding="utf-8") + icon_file.parent.mkdir(parents=True, exist_ok=True) + icon_file.write_text("", encoding="utf-8") result = _run_command([str(UNINSTALLER)], env) log = log_file.read_text(encoding="utf-8") @@ -230,6 +235,7 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): self.assertIn(f"flatpak uninstall -y --user {APP_ID}", log) self.assertFalse(wrapper_path.exists()) self.assertFalse(desktop_file.exists()) + self.assertFalse(icon_file.exists()) self.assertFalse(data_dir.exists()) self.assertIn( "gsettings reset org.gnome.shell.keybindings show-screenshot", diff --git a/tests/test_packaging_icon_metadata.py b/tests/test_packaging_icon_metadata.py new file mode 100644 index 0000000..6da35cf --- /dev/null +++ b/tests/test_packaging_icon_metadata.py @@ -0,0 +1,35 @@ +import json +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP_ID = "io.github.rafa.ScreenuxScreenshot" + + +class PackagingIconMetadataTests(unittest.TestCase): + def test_desktop_entry_declares_app_icon(self) -> None: + desktop_file = ROOT / f"{APP_ID}.desktop" + content = desktop_file.read_text(encoding="utf-8") + + self.assertIn(f"Icon={APP_ID}", content) + + def test_flatpak_manifest_installs_app_icon_asset(self) -> None: + manifest_file = ROOT / "flatpak" / f"{APP_ID}.json" + manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + + build_commands = manifest["modules"][0]["build-commands"] + self.assertTrue( + any( + command.endswith( + f" /app/share/icons/hicolor/scalable/apps/{APP_ID}.svg" + ) + for command in build_commands + ) + ) + + icon_file = ROOT / "assets" / "icons" / f"{APP_ID}.svg" + self.assertTrue(icon_file.exists()) + + +if __name__ == "__main__": + unittest.main() From 6c22d846fe7060a6708774ba1ba250169deed246 Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 16:20:13 -0300 Subject: [PATCH 14/15] feat: Add support for light and dark theme icons; update installer and README --- README.md | 2 +- ...io.github.rafa.ScreenuxScreenshot-dark.svg | 5 ++ ...o.github.rafa.ScreenuxScreenshot-light.svg | 5 ++ .../io.github.rafa.ScreenuxScreenshot.json | 4 +- scripts/install/install-screenux.sh | 2 + scripts/install/lib/common.sh | 14 ++++-- scripts/install/uninstall-screenux.sh | 2 + src/screenux_screenshot.py | 24 +++++++++- src/screenux_window.py | 3 +- tests/test_install_uninstall_scripts.py | 10 ++++ tests/test_packaging_icon_metadata.py | 22 +++++---- tests/test_theme_icon_selection.py | 46 +++++++++++++++++++ 12 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg create mode 100644 assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg create mode 100644 tests/test_theme_icon_selection.py diff --git a/README.md b/README.md index 973fe78..5c9212f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate ./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak ``` -The installer creates a desktop entry and installs the app icon at `~/.local/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot.svg` so launcher/taskbar icon lookup works reliably. It also refreshes the local icon cache when GTK cache tools are available. +The installer creates a desktop entry and installs app icons at `~/.local/share/icons/hicolor/scalable/apps/` so launcher/taskbar icon lookup works reliably. It includes theme variants (`io.github.rafa.ScreenuxScreenshot-light.svg` and `io.github.rafa.ScreenuxScreenshot-dark.svg`) and refreshes the local icon cache when GTK cache tools are available. Optional GNOME Print Screen shortcut: diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg b/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg new file mode 100644 index 0000000..12b81be --- /dev/null +++ b/assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg b/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg new file mode 100644 index 0000000..082fea6 --- /dev/null +++ b/assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/flatpak/io.github.rafa.ScreenuxScreenshot.json b/flatpak/io.github.rafa.ScreenuxScreenshot.json index e6e485a..4588aff 100644 --- a/flatpak/io.github.rafa.ScreenuxScreenshot.json +++ b/flatpak/io.github.rafa.ScreenuxScreenshot.json @@ -21,7 +21,9 @@ "install -Dm755 src/screenux_editor.py /app/share/screenux/screenux_editor.py", "install -Dm755 src/screenux_window.py /app/share/screenux/screenux_window.py", "install -Dm644 io.github.rafa.ScreenuxScreenshot.desktop /app/share/applications/io.github.rafa.ScreenuxScreenshot.desktop", - "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot.svg" + "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot.svg", + "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot-light.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot-light.svg", + "install -Dm644 assets/icons/io.github.rafa.ScreenuxScreenshot-dark.svg /app/share/icons/hicolor/scalable/apps/io.github.rafa.ScreenuxScreenshot-dark.svg" ], "sources": [ { diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index 38a2615..023e1fc 100644 --- a/scripts/install/install-screenux.sh +++ b/scripts/install/install-screenux.sh @@ -76,6 +76,8 @@ validate_installation() { [[ -x "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper not executable at ${WRAPPER_PATH}" [[ -f "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry missing at ${DESKTOP_FILE}" [[ -f "${ICON_FILE}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE}" + [[ -f "${ICON_FILE_LIGHT}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE_LIGHT}" + [[ -f "${ICON_FILE_DARK}" ]] || fail "Validation failed: icon asset missing at ${ICON_FILE_DARK}" } configure_shortcut() { diff --git a/scripts/install/lib/common.sh b/scripts/install/lib/common.sh index d757891..319de7e 100644 --- a/scripts/install/lib/common.sh +++ b/scripts/install/lib/common.sh @@ -9,10 +9,14 @@ DESKTOP_DIR="${HOME}/.local/share/applications" DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop" ICON_DIR="${HOME}/.local/share/icons/hicolor/scalable/apps" ICON_FILE="${ICON_DIR}/${APP_ID}.svg" +ICON_FILE_LIGHT="${ICON_DIR}/${APP_ID}-light.svg" +ICON_FILE_DARK="${ICON_DIR}/${APP_ID}-dark.svg" APP_DATA_DIR="${HOME}/.var/app/${APP_ID}" DEFAULT_BUNDLE_NAME="screenux-screenshot.flatpak" COMMON_LIB_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" APP_ICON_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}.svg" +APP_ICON_LIGHT_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}-light.svg" +APP_ICON_DARK_SOURCE="${COMMON_LIB_DIR}/../../../assets/icons/${APP_ID}-dark.svg" DEFAULT_KEYBINDING="['s']" PRINT_KEYBINDING="['Print']" @@ -61,9 +65,13 @@ EOF create_icon_asset() { [[ -f "${APP_ICON_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_SOURCE}" + [[ -f "${APP_ICON_LIGHT_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_LIGHT_SOURCE}" + [[ -f "${APP_ICON_DARK_SOURCE}" ]] || fail "App icon source file not found: ${APP_ICON_DARK_SOURCE}" echo "==> Installing app icon: ${ICON_FILE}" mkdir -p "${ICON_DIR}" cp -f -- "${APP_ICON_SOURCE}" "${ICON_FILE}" + cp -f -- "${APP_ICON_LIGHT_SOURCE}" "${ICON_FILE_LIGHT}" + cp -f -- "${APP_ICON_DARK_SOURCE}" "${ICON_FILE_DARK}" } remove_wrapper() { @@ -81,9 +89,9 @@ remove_desktop_entry() { } remove_icon_asset() { - if [[ -e "${ICON_FILE}" || -L "${ICON_FILE}" ]]; then - echo "==> Removing app icon: ${ICON_FILE}" - rm -f -- "${ICON_FILE}" + if [[ -e "${ICON_FILE}" || -L "${ICON_FILE}" || -e "${ICON_FILE_LIGHT}" || -L "${ICON_FILE_LIGHT}" || -e "${ICON_FILE_DARK}" || -L "${ICON_FILE_DARK}" ]]; then + echo "==> Removing app icon files from: ${ICON_DIR}" + rm -f -- "${ICON_FILE}" "${ICON_FILE_LIGHT}" "${ICON_FILE_DARK}" fi } diff --git a/scripts/install/uninstall-screenux.sh b/scripts/install/uninstall-screenux.sh index 2df1797..c8f7b2f 100755 --- a/scripts/install/uninstall-screenux.sh +++ b/scripts/install/uninstall-screenux.sh @@ -54,6 +54,8 @@ validate_uninstall() { [[ ! -e "${WRAPPER_PATH}" && ! -L "${WRAPPER_PATH}" ]] || fail "Validation failed: wrapper still exists at ${WRAPPER_PATH}" [[ ! -e "${DESKTOP_FILE}" && ! -L "${DESKTOP_FILE}" ]] || fail "Validation failed: desktop entry still exists at ${DESKTOP_FILE}" [[ ! -e "${ICON_FILE}" && ! -L "${ICON_FILE}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE}" + [[ ! -e "${ICON_FILE_LIGHT}" && ! -L "${ICON_FILE_LIGHT}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE_LIGHT}" + [[ ! -e "${ICON_FILE_DARK}" && ! -L "${ICON_FILE_DARK}" ]] || fail "Validation failed: icon asset still exists at ${ICON_FILE_DARK}" if [[ "${preserve_user_data}" == "false" && -d "${APP_DATA_DIR}" ]]; then fail "Validation failed: user data still exists at ${APP_DATA_DIR}" diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 7c24986..3968220 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -22,10 +22,28 @@ Gtk = None # type: ignore[assignment] # pragma: no cover APP_ID = "io.github.rafa.ScreenuxScreenshot" +LIGHT_ICON_NAME = f"{APP_ID}-light" +DARK_ICON_NAME = f"{APP_ID}-dark" _MAX_CONFIG_SIZE = 64 * 1024 _ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} +def _prefers_dark_theme() -> bool: + if Gtk is None: + return False + settings = Gtk.Settings.get_default() + if settings is None: + return False + try: + return bool(settings.get_property("gtk-application-prefer-dark-theme")) + except Exception: + return False + + +def select_icon_name() -> str: + return DARK_ICON_NAME if _prefers_dark_theme() else LIGHT_ICON_NAME + + def enforce_offline_mode() -> None: blocked = RuntimeError("network access is disabled for this application") @@ -162,7 +180,6 @@ def _parse_cli_args(argv: list[str]) -> tuple[list[str], bool]: class ScreenuxScreenshotApp(Gtk.Application): # type: ignore[misc] def __init__(self, auto_capture: bool = False) -> None: super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE) - Gtk.Window.set_default_icon_name(APP_ID) self._auto_capture_pending = auto_capture def _trigger_auto_capture(self, window: MainWindow) -> bool: @@ -170,16 +187,21 @@ def _trigger_auto_capture(self, window: MainWindow) -> bool: return False def do_activate(self) -> None: + icon_name = select_icon_name() + Gtk.Window.set_default_icon_name(icon_name) window = self.props.active_window if window is None: window = MainWindow( self, + icon_name=icon_name, resolve_save_dir=resolve_save_dir, load_config=load_config, save_config=save_config, build_output_path=build_output_path, format_status_saved=format_status_saved, ) + else: + window.set_icon_name(icon_name) window.present() if self._auto_capture_pending: self._auto_capture_pending = False diff --git a/src/screenux_window.py b/src/screenux_window.py index c6b43a6..3efdf09 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -63,6 +63,7 @@ class MainWindow(Gtk.ApplicationWindow): # type: ignore[misc] def __init__( self, app: Gtk.Application, + icon_name: str, resolve_save_dir: Callable[[], Path], load_config: Callable[[], dict[str, Any]], save_config: Callable[[dict[str, Any]], None], @@ -70,7 +71,7 @@ def __init__( format_status_saved: Callable[[Path], str], ): super().__init__(application=app, title="Screenux Screenshot") - self.set_icon_name(app.get_application_id() or "application-default-icon") + self.set_icon_name(icon_name) self.set_default_size(360, 180) self._resolve_save_dir = resolve_save_dir diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py index 760ebde..b554935 100644 --- a/tests/test_install_uninstall_scripts.py +++ b/tests/test_install_uninstall_scripts.py @@ -168,6 +168,8 @@ def test_installer_installs_bundle_and_creates_local_entries(self): wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" + icon_file_light = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-light.svg" + icon_file_dark = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-dark.svg" self.assertTrue(wrapper_path.exists()) self.assertTrue(os.access(wrapper_path, os.X_OK)) @@ -179,6 +181,8 @@ def test_installer_installs_bundle_and_creates_local_entries(self): desktop_file.read_text(encoding="utf-8"), ) self.assertTrue(icon_file.exists()) + self.assertTrue(icon_file_light.exists()) + self.assertTrue(icon_file_dark.exists()) def test_installer_skips_bundle_install_when_app_already_installed(self): _, env, log_file = _setup_mock_environment(installed=True) @@ -218,6 +222,8 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): wrapper_path = Path(env["HOME"]) / ".local/bin/screenux-screenshot" desktop_file = Path(env["HOME"]) / f".local/share/applications/{APP_ID}.desktop" icon_file = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}.svg" + icon_file_light = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-light.svg" + icon_file_dark = Path(env["HOME"]) / f".local/share/icons/hicolor/scalable/apps/{APP_ID}-dark.svg" data_dir = Path(env["HOME"]) / f".var/app/{APP_ID}" data_dir.mkdir(parents=True, exist_ok=True) (data_dir / "config.json").write_text("{}", encoding="utf-8") @@ -227,6 +233,8 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): desktop_file.write_text("[Desktop Entry]\n", encoding="utf-8") icon_file.parent.mkdir(parents=True, exist_ok=True) icon_file.write_text("", encoding="utf-8") + icon_file_light.write_text("", encoding="utf-8") + icon_file_dark.write_text("", encoding="utf-8") result = _run_command([str(UNINSTALLER)], env) log = log_file.read_text(encoding="utf-8") @@ -236,6 +244,8 @@ def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): self.assertFalse(wrapper_path.exists()) self.assertFalse(desktop_file.exists()) self.assertFalse(icon_file.exists()) + self.assertFalse(icon_file_light.exists()) + self.assertFalse(icon_file_dark.exists()) self.assertFalse(data_dir.exists()) self.assertIn( "gsettings reset org.gnome.shell.keybindings show-screenshot", diff --git a/tests/test_packaging_icon_metadata.py b/tests/test_packaging_icon_metadata.py index 6da35cf..6cfa577 100644 --- a/tests/test_packaging_icon_metadata.py +++ b/tests/test_packaging_icon_metadata.py @@ -18,17 +18,21 @@ def test_flatpak_manifest_installs_app_icon_asset(self) -> None: manifest = json.loads(manifest_file.read_text(encoding="utf-8")) build_commands = manifest["modules"][0]["build-commands"] - self.assertTrue( - any( - command.endswith( - f" /app/share/icons/hicolor/scalable/apps/{APP_ID}.svg" - ) - for command in build_commands - ) + expected_targets = ( + f"/app/share/icons/hicolor/scalable/apps/{APP_ID}.svg", + f"/app/share/icons/hicolor/scalable/apps/{APP_ID}-light.svg", + f"/app/share/icons/hicolor/scalable/apps/{APP_ID}-dark.svg", ) + for target in expected_targets: + self.assertTrue(any(command.endswith(f" {target}") for command in build_commands)) - icon_file = ROOT / "assets" / "icons" / f"{APP_ID}.svg" - self.assertTrue(icon_file.exists()) + icon_files = ( + ROOT / "assets" / "icons" / f"{APP_ID}.svg", + ROOT / "assets" / "icons" / f"{APP_ID}-light.svg", + ROOT / "assets" / "icons" / f"{APP_ID}-dark.svg", + ) + for icon_file in icon_files: + self.assertTrue(icon_file.exists()) if __name__ == "__main__": diff --git a/tests/test_theme_icon_selection.py b/tests/test_theme_icon_selection.py new file mode 100644 index 0000000..fb89895 --- /dev/null +++ b/tests/test_theme_icon_selection.py @@ -0,0 +1,46 @@ +import unittest + +import src.screenux_screenshot as screenshot + + +class _FakeSettings: + def __init__(self, prefer_dark: bool): + self._prefer_dark = prefer_dark + + def get_property(self, name: str): + if name == "gtk-application-prefer-dark-theme": + return self._prefer_dark + return None + + +class _FakeGtk: + class Settings: + _settings = None + + @staticmethod + def get_default(): + return _FakeGtk.Settings._settings + + +class ThemeIconSelectionTests(unittest.TestCase): + def test_select_icon_name_uses_dark_icon_when_dark_theme_is_preferred(self): + original_gtk = screenshot.Gtk + try: + _FakeGtk.Settings._settings = _FakeSettings(prefer_dark=True) + screenshot.Gtk = _FakeGtk + self.assertEqual(screenshot.select_icon_name(), f"{screenshot.APP_ID}-dark") + finally: + screenshot.Gtk = original_gtk + + def test_select_icon_name_uses_light_icon_when_dark_theme_is_not_preferred(self): + original_gtk = screenshot.Gtk + try: + _FakeGtk.Settings._settings = _FakeSettings(prefer_dark=False) + screenshot.Gtk = _FakeGtk + self.assertEqual(screenshot.select_icon_name(), f"{screenshot.APP_ID}-light") + finally: + screenshot.Gtk = original_gtk + + +if __name__ == "__main__": + unittest.main() From a5fc8ee3f55242ecd8af086b2375b41bdb4ba96f Mon Sep 17 00:00:00 2001 From: rafa Date: Fri, 27 Feb 2026 16:41:54 -0300 Subject: [PATCH 15/15] feat: Add CONTRIBUTING.md and LICENSE files; update README for project guidelines and licensing --- CONTRIBUTING.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 ++++++++++++++++++++ README.md | 8 ++++++++ 3 files changed, 82 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..53f77b0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to Screenux Screenshot + +Thanks for contributing. Keep changes focused, secure, and aligned with the +project's offline-first behavior. + +## Ground rules + +- Keep PRs small and scoped to one concern. +- Preserve existing style and structure. +- Do not add network-dependent behavior. +- Prefer secure defaults and minimal privileges. + +## Development workflow (TDD first) + +1. Add or update a failing test for the behavior change. +2. Implement the smallest code change to make it pass. +3. Refactor while keeping tests green. + +## Local setup + +```bash +python3 -m pip install -r requirements-dev.txt +``` + +Run app locally: + +```bash +./screenux-screenshot +``` + +## Validation before opening a PR + +Run the project checks relevant to your change: + +```bash +python3 -m py_compile src/screenux_screenshot.py +pytest -q +``` + +If your change touches shell scripts, also run shell checks used in CI. + +## Commit and PR guidance + +- Use clear commit messages in imperative mood. +- Include test updates with behavior changes. +- Describe user-visible impact in the PR description. +- Keep documentation in sync when behavior or workflow changes. + +## Security and privacy expectations + +- Keep runtime behavior offline-only. +- Do not broaden Flatpak/runtime permissions unless strictly required. +- Maintain safe file handling and local URI validation behavior. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..826824d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rafa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5c9212f..efc34d0 100644 --- a/README.md +++ b/README.md @@ -216,3 +216,11 @@ Flatpak permissions stay intentionally narrow (portal access + Desktop filesyste - Screenshot sources are validated as local, readable `file://` URIs. - Config parsing is defensive (invalid/non-object/oversized files are ignored). - Save operations use exclusive file creation to avoid accidental overwrite. + +## 🤝 Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow and PR guidance. + +## 📄 License + +This project is licensed under the MIT License. See [LICENSE](LICENSE).