diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d2a2f6..165aac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,8 +129,15 @@ jobs: steps: - name: Review dependency changes + id: dependency_review + continue-on-error: true uses: actions/dependency-review-action@v4 + - name: Warn when dependency review is unavailable + if: steps.dependency_review.outcome == 'failure' + run: | + echo "::warning::Dependency review is unavailable for this repository. Enable GitHub Dependency graph under Settings > Security to enforce this gate." + tests: name: Test Suite runs-on: ubuntu-latest @@ -157,6 +164,99 @@ jobs: - name: Run tests run: pytest -q + deb-package-assurance: + name: Debian Package Assurance + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Debian package via Docker + run: | + set -euo pipefail + mkdir -p dist-deb artifacts + docker build -f Dockerfile.deb -t screenux-deb-ci . + docker run --rm -v "$PWD/dist-deb:/out" screenux-deb-ci + + - name: Verify Debian package integrity, security, and performance budget + run: | + set -euo pipefail + + deb_file="$(find dist-deb -maxdepth 1 -type f -name 'screenux-screenshot_*_amd64.deb' | head -n 1)" + [[ -n "${deb_file}" ]] || { echo "::error::Missing .deb artifact in dist-deb/"; exit 1; } + + dpkg-deb --info "$deb_file" | tee artifacts/deb-info.txt + dpkg-deb --contents "$deb_file" | tee artifacts/deb-contents.txt + sha256sum "$deb_file" | tee artifacts/deb.sha256 + + pkg_name="$(dpkg-deb -f "$deb_file" Package)" + pkg_arch="$(dpkg-deb -f "$deb_file" Architecture)" + [[ "${pkg_name}" == "screenux-screenshot" ]] || { echo "::error::Unexpected package name: ${pkg_name}"; exit 1; } + [[ "${pkg_arch}" == "amd64" ]] || { echo "::error::Unexpected package architecture: ${pkg_arch}"; exit 1; } + + grep -Eq '/usr/bin/screenux-screenshot$' artifacts/deb-contents.txt || { echo "::error::Missing /usr/bin install path"; exit 1; } + grep -Eq '/usr/share/applications/screenux-screenshot.desktop$' artifacts/deb-contents.txt || { echo "::error::Missing desktop entry install path"; exit 1; } + grep -Eq '/usr/share/icons/hicolor/256x256/apps/screenux-screenshot.png$' artifacts/deb-contents.txt || { echo "::error::Missing icon install path"; exit 1; } + + extract_dir="$(mktemp -d)" + trap 'rm -rf "${extract_dir}"' EXIT + dpkg-deb -x "$deb_file" "$extract_dir" + + if find "$extract_dir" -type f -perm /6000 | grep -q .; then + echo "::error::Setuid/setgid files are not allowed in the .deb payload." + exit 1 + fi + + if find "$extract_dir" -type f -perm -0002 | grep -q .; then + echo "::error::World-writable files are not allowed in the .deb payload." + exit 1 + fi + + binary="$extract_dir/usr/bin/screenux-screenshot" + [[ -x "${binary}" ]] || { echo "::error::Packaged binary is missing or not executable."; exit 1; } + + help_output="$("$binary" --help 2>&1)" + printf '%s\n' "$help_output" > artifacts/help-output.txt + printf '%s\n' "$help_output" | grep -F "Usage: screenux-screenshot [--capture] [--help] [--version]" > /dev/null + + if printf '%s\n' "$help_output" | grep -Eq 'Failed to load shared library|Missing GTK4/PyGObject dependencies'; then + echo "::error::Deb binary reports GTK/Cairo runtime dependency failures." + exit 1 + fi + + version_output="$("$binary" --version 2>&1)" + printf '%s\n' "$version_output" > artifacts/version-output.txt + printf '%s\n' "$version_output" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' > /dev/null + + deb_size_bytes="$(stat -c '%s' "$deb_file")" + max_deb_size_bytes="$((80 * 1024 * 1024))" + echo "deb_size_bytes=${deb_size_bytes}" | tee artifacts/deb-metrics.txt + echo "max_deb_size_bytes=${max_deb_size_bytes}" | tee -a artifacts/deb-metrics.txt + (( deb_size_bytes <= max_deb_size_bytes )) || { echo "::error::.deb exceeds size budget (${deb_size_bytes} > ${max_deb_size_bytes})"; exit 1; } + + max_help_startup_ms=4000 + start_ns="$(date +%s%N)" + "$binary" --help >/dev/null 2>&1 + end_ns="$(date +%s%N)" + help_startup_ms="$(( (end_ns - start_ns) / 1000000 ))" + echo "help_startup_ms=${help_startup_ms}" | tee -a artifacts/deb-metrics.txt + echo "max_help_startup_ms=${max_help_startup_ms}" | tee -a artifacts/deb-metrics.txt + (( help_startup_ms <= max_help_startup_ms )) || { echo "::error::Help startup time exceeded budget (${help_startup_ms}ms > ${max_help_startup_ms}ms)"; exit 1; } + + - name: Upload Debian CI reports + uses: actions/upload-artifact@v4 + with: + name: deb-ci-reports + path: | + artifacts/deb-info.txt + artifacts/deb-contents.txt + artifacts/deb.sha256 + artifacts/deb-metrics.txt + artifacts/help-output.txt + artifacts/version-output.txt + build-verification: name: Build & Packaging Verification runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index af07423..3564720 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,14 @@ __pycache__/ # Python build/distribution build/ dist/ +dist-deb/ *.egg-info/ .eggs/ pip-wheel-metadata/ # Virtual environments .venv/ +.venv-deb/ venv/ env/ @@ -43,3 +45,4 @@ htmlcov/ # Project-specific build artifacts build-dir/ .flatpak-builder/ +.ci-shim/ diff --git a/Dockerfile.deb b/Dockerfile.deb new file mode 100644 index 0000000..05c2c2b --- /dev/null +++ b/Dockerfile.deb @@ -0,0 +1,39 @@ +FROM debian:bookworm-slim + +ARG DEBIAN_FRONTEND=noninteractive +ARG APP_NAME=screenux-screenshot +ARG APP_VERSION=0.1.0 +ARG APP_ARCH=amd64 +ARG OUT_DIR=/out + +ENV APP_NAME="${APP_NAME}" \ + APP_VERSION="${APP_VERSION}" \ + APP_ARCH="${APP_ARCH}" \ + OUT_DIR="${OUT_DIR}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 \ + libpython3.11 \ + python3-venv \ + python3-pip \ + python3-gi \ + python3-gi-cairo \ + python3-cairo \ + gir1.2-gtk-4.0 \ + patchelf \ + binutils \ + dpkg-dev \ + desktop-file-utils \ + build-essential \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +COPY . /workspace + +CMD ["bash", "scripts/build_deb.sh"] diff --git a/README.md b/README.md index efc34d0..f220424 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate ## 🧩 Features - Capture with `Take Screenshot` +- Default global hotkey: `Ctrl+Shift+S` - 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%`) @@ -22,6 +23,56 @@ Screenux focuses on a clean capture flow: take a screenshot, optionally annotate ## Install +### Recommended: `.deb` package via Docker + +This is the clearest end-user path if you want a system install (`/usr/bin`, app launcher, icon). + +Requirements: + +- Docker +- `sudo` access +- `dpkg` (already present on Debian/Ubuntu) + +1. Build the package: + +```bash +mkdir -p dist-deb && docker build -f Dockerfile.deb -t app-deb . && docker run --rm -v "$PWD/dist-deb:/out" app-deb +``` + +2. Install the generated `.deb`: + +```bash +sudo dpkg -i dist-deb/screenux-screenshot_*_amd64.deb +``` + +3. If `dpkg` reports missing dependencies, fix and retry: + +```bash +sudo apt-get install -f -y +sudo dpkg -i dist-deb/screenux-screenshot_*_amd64.deb +``` + +4. Validate the install: + +```bash +screenux-screenshot --help || true +which screenux-screenshot +``` + +Expected after install: + +- CLI available at `/usr/bin/screenux-screenshot` +- Desktop launcher visible in app menu +- Icon installed at `/usr/share/icons/hicolor/256x256/apps/screenux-screenshot.png` + +Remove later (optional): + +```bash +sudo apt remove -y screenux-screenshot +``` + +### Alternative: Flatpak installer script + ```bash ./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak ``` @@ -79,6 +130,15 @@ Save folder behavior: - If Desktop is unavailable or not writable, Screenux falls back to Home. - You can change the destination from the app (`Save to` → `Change…`). +Global hotkey behavior: + +- Default shortcut is `Ctrl+Shift+S`. +- If it is already taken, Screenux falls back to `Ctrl+Alt+S` (then `Alt+Shift+S`, then `Super+Shift+S`). +- On GNOME, the shortcut is persisted as a GNOME custom shortcut and works when the app is closed. +- On non-GNOME desktops, global shortcut support is best-effort while the app is running. +- Shortcut config is stored at `~/.config/screenux/settings.json` as `global_hotkey` (`null` disables it). +- You can change or disable the shortcut from the app window (`Apply` / `Disable`). + ## 🖼️ UI example image @@ -90,6 +150,7 @@ Save folder behavior: - `src/screenux_screenshot.py`: app entrypoint, config/path helpers, offline enforcement - `src/screenux_window.py`: GTK window, portal flow, save-folder picker - `src/screenux_editor.py`: annotation editor and secure file writing +- `src/screenux_hotkey.py`: hotkey normalization, fallback logic, and GNOME shortcut registration - `tests/`: automated tests for path, window, screenshot, and editor logic - `screenux-screenshot`: launcher script for host and Flatpak runtime @@ -138,8 +199,9 @@ Quality gates include: - 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) +- Dependency checks (`pip check`, dependency review action with warning fallback when GitHub Dependency graph is disabled) - Build/package validation (launcher, Flatpak manifest, desktop entry, Docker Compose, Docker build) +- Debian package assurance (Docker `.deb` build, control/path integrity checks, no setuid/setgid/world-writable payload files, SHA256 report, startup/size budget checks) Release artifacts workflow: `.github/workflows/release-artifacts.yml` @@ -174,6 +236,22 @@ Notes: - `.env` config is for Docker Compose only; Screenux runtime does not read it. - Python bytecode and pytest cache are disabled in container runs to reduce bind-mount noise/permission issues. +### Debian package via Docker + +Build a `.deb` into `./dist-deb/`: + +```bash +mkdir -p dist-deb && docker build -f Dockerfile.deb -t app-deb . && docker run --rm -v "$PWD/dist-deb:/out" app-deb +``` + +Expected artifact: + +- `dist-deb/screenux-screenshot__amd64.deb` + +Notes: + +- The `.deb` build uses project-local PyInstaller hooks in `packaging/pyinstaller_hooks/` to force GTK4 GI collection and avoid mixed GTK/Cairo runtime library mismatches. + ### Flatpak Requirements: diff --git a/packaging/linux/screenux-screenshot.desktop b/packaging/linux/screenux-screenshot.desktop new file mode 100644 index 0000000..1bf7304 --- /dev/null +++ b/packaging/linux/screenux-screenshot.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=Screenux Screenshot +Exec=screenux-screenshot +Icon=screenux-screenshot +Terminal=false +Categories=Utility;Graphics; diff --git a/packaging/linux/screenux-screenshot.png b/packaging/linux/screenux-screenshot.png new file mode 100644 index 0000000..a6122f4 Binary files /dev/null and b/packaging/linux/screenux-screenshot.png differ diff --git a/packaging/pyinstaller_hooks/hook-gi.repository.Gdk.py b/packaging/pyinstaller_hooks/hook-gi.repository.Gdk.py new file mode 100644 index 0000000..5470706 --- /dev/null +++ b/packaging/pyinstaller_hooks/hook-gi.repository.Gdk.py @@ -0,0 +1,14 @@ +from PyInstaller.utils.hooks.gi import GiModuleInfo + + +def hook(hook_api): + module_info = GiModuleInfo("Gdk", "4.0") + if not module_info.available: + return + + binaries, datas, hiddenimports = module_info.collect_typelib_data() + hiddenimports += ["gi._gi_cairo", "gi.repository.cairo"] + + hook_api.add_datas(datas) + hook_api.add_binaries(binaries) + hook_api.add_imports(*hiddenimports) diff --git a/packaging/pyinstaller_hooks/hook-gi.repository.Gtk.py b/packaging/pyinstaller_hooks/hook-gi.repository.Gtk.py new file mode 100644 index 0000000..9d85b0b --- /dev/null +++ b/packaging/pyinstaller_hooks/hook-gi.repository.Gtk.py @@ -0,0 +1,46 @@ +import os + +from PyInstaller.compat import is_win +from PyInstaller.utils.hooks import get_hook_config +from PyInstaller.utils.hooks.gi import ( + GiModuleInfo, + collect_glib_etc_files, + collect_glib_share_files, + collect_glib_translations, +) + + +def hook(hook_api): + # Force GTK4 collection for this app; upstream hook defaults to GTK3. + module_info = GiModuleInfo("Gtk", "4.0", hook_api=hook_api) + if not module_info.available: + return + + binaries, datas, hiddenimports = module_info.collect_typelib_data() + datas += collect_glib_share_files("fontconfig") + + icon_list = get_hook_config(hook_api, "gi", "icons") + if icon_list is not None: + for icon in icon_list: + datas += collect_glib_share_files(os.path.join("icons", icon)) + else: + datas += collect_glib_share_files("icons") + + theme_list = get_hook_config(hook_api, "gi", "themes") + if theme_list is not None: + for theme in theme_list: + datas += collect_glib_share_files(os.path.join("themes", theme)) + else: + datas += collect_glib_share_files("themes") + + lang_list = get_hook_config(hook_api, "gi", "languages") + datas += collect_glib_translations(f"gtk{module_info.version[0]}0", lang_list) + + if is_win: + datas += collect_glib_etc_files("fonts") + datas += collect_glib_etc_files("pango") + datas += collect_glib_share_files("fonts") + + hook_api.add_datas(datas) + hook_api.add_binaries(binaries) + hook_api.add_imports(*hiddenimports) diff --git a/scripts/build_deb.sh b/scripts/build_deb.sh new file mode 100755 index 0000000..e8daf16 --- /dev/null +++ b/scripts/build_deb.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +APP_NAME="${APP_NAME:-screenux-screenshot}" +APP_VERSION="${APP_VERSION:-0.1.0}" +APP_ARCH="${APP_ARCH:-amd64}" +OUT_DIR="${OUT_DIR:-/out}" + +BUILD_WORKDIR="$(mktemp -d -t "${APP_NAME}-deb-XXXXXX")" +PKG_ROOT="${BUILD_WORKDIR}/pkg" +DEBIAN_DIR="${PKG_ROOT}/DEBIAN" +DEB_FILE="${BUILD_WORKDIR}/${APP_NAME}_${APP_VERSION}_${APP_ARCH}.deb" +VENV_DIR="${VENV_DIR:-${BUILD_WORKDIR}/venv}" + +cleanup() { + rm -rf -- "${BUILD_WORKDIR}" +} +trap cleanup EXIT + +cd "${ROOT_DIR}" + +python3 -m venv --system-site-packages "${VENV_DIR}" +"${VENV_DIR}/bin/pip" install --upgrade pip +"${VENV_DIR}/bin/pip" install "pyinstaller==6.*" + +rm -rf -- "${ROOT_DIR}/build" "${ROOT_DIR}/dist" "${ROOT_DIR}/${APP_NAME}.spec" + +"${VENV_DIR}/bin/pyinstaller" \ + --onefile \ + --name "${APP_NAME}" \ + --paths "${ROOT_DIR}/src" \ + --additional-hooks-dir "${ROOT_DIR}/packaging/pyinstaller_hooks" \ + --hidden-import "screenux_window" \ + --hidden-import "screenux_editor" \ + --hidden-import "screenux_hotkey" \ + --hidden-import "gi" \ + --hidden-import "gi.repository.Gio" \ + --hidden-import "gi.repository.GLib" \ + --hidden-import "gi.repository.Gtk" \ + --hidden-import "gi.repository.Gdk" \ + --hidden-import "gi.repository.GdkPixbuf" \ + --hidden-import "gi.repository.Pango" \ + --hidden-import "cairo" \ + --add-data "${ROOT_DIR}/src/icons:icons" \ + "${ROOT_DIR}/src/screenux_screenshot.py" + +mkdir -p "${DEBIAN_DIR}" +cat > "${DEBIAN_DIR}/control" << EOF +Package: ${APP_NAME} +Version: ${APP_VERSION} +Section: utils +Priority: optional +Architecture: ${APP_ARCH} +Maintainer: Screenux Developers +Description: Screenux Screenshot utility + Offline-first Linux screenshot utility with optional annotation editor. +EOF + +install -Dm755 \ + "${ROOT_DIR}/dist/${APP_NAME}" \ + "${PKG_ROOT}/usr/bin/${APP_NAME}" +install -Dm644 \ + "${ROOT_DIR}/packaging/linux/${APP_NAME}.desktop" \ + "${PKG_ROOT}/usr/share/applications/${APP_NAME}.desktop" +install -Dm644 \ + "${ROOT_DIR}/packaging/linux/${APP_NAME}.png" \ + "${PKG_ROOT}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" + +dpkg-deb --build --root-owner-group "${PKG_ROOT}" "${DEB_FILE}" + +mkdir -p "${OUT_DIR}" +cp -f -- "${DEB_FILE}" "${OUT_DIR}/${APP_NAME}_${APP_VERSION}_${APP_ARCH}.deb" +echo "Built package: ${OUT_DIR}/${APP_NAME}_${APP_VERSION}_${APP_ARCH}.deb" diff --git a/scripts/install/install-screenux.sh b/scripts/install/install-screenux.sh index 023e1fc..43c7d6f 100644 --- a/scripts/install/install-screenux.sh +++ b/scripts/install/install-screenux.sh @@ -2,11 +2,16 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/install/lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=scripts/install/lib/gnome_shortcuts.sh source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" +DEFAULT_BUNDLE_NAME="screenux-screenshot.flatpak" +PRINT_KEYBINDING="['Print']" + usage() { - cat <<'EOF' + cat << 'EOF' Usage: ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] [--shortcut "['s']"] ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] --print-screen @@ -43,7 +48,7 @@ resolve_default_bundle() { install_bundle() { local flatpak_file="$1" - if ! command -v flatpak >/dev/null 2>&1; then + if ! command -v flatpak > /dev/null 2>&1; then fail "Required command not found: flatpak. Install Flatpak first, then rerun." fi @@ -51,7 +56,7 @@ install_bundle() { [[ -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 + elif flatpak info --user "${APP_ID}" > /dev/null 2>&1; then echo "==> ${APP_ID} is already installed for this user; skipping bundle install" else local inferred_bundle="" @@ -70,7 +75,7 @@ install_bundle() { validate_installation() { echo "==> Validating installation" - if ! flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + 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}" @@ -110,7 +115,7 @@ main() { while (($# > 0)); do case "$1" in - -h|--help) + -h | --help) usage exit 0 ;; diff --git a/scripts/install/lib/common.sh b/scripts/install/lib/common.sh index 319de7e..86c2a0d 100644 --- a/scripts/install/lib/common.sh +++ b/scripts/install/lib/common.sh @@ -12,22 +12,18 @@ 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']" - fail() { echo "ERROR: $*" >&2 exit 1 } check_command() { - command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" + command -v "$1" > /dev/null 2>&1 || fail "Required command not found: $1" } ensure_wrapper_path_notice() { @@ -41,7 +37,7 @@ ensure_wrapper_path_notice() { create_wrapper() { echo "==> Creating wrapper command: ${WRAPPER_PATH}" mkdir -p "${WRAPPER_DIR}" - cat >"${WRAPPER_PATH}" < "${WRAPPER_PATH}" << EOF #!/usr/bin/env bash exec flatpak run ${APP_ID} "\$@" EOF @@ -52,7 +48,7 @@ EOF create_desktop_entry() { echo "==> Creating desktop entry: ${DESKTOP_FILE}" mkdir -p "${DESKTOP_DIR}" - cat >"${DESKTOP_FILE}" < "${DESKTOP_FILE}" << EOF [Desktop Entry] Type=Application Name=${APP_NAME} @@ -108,12 +104,12 @@ refresh_icon_cache() { 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 + 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 + 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/lib/gnome_shortcuts.sh b/scripts/install/lib/gnome_shortcuts.sh index 5745b7f..807c03f 100644 --- a/scripts/install/lib/gnome_shortcuts.sh +++ b/scripts/install/lib/gnome_shortcuts.sh @@ -54,15 +54,15 @@ key_exists() { get_custom_paths() { local existing existing="$(gsettings get "${SCHEMA}" "${KEY}")" - grep -oE "'${BASE_PATH}/custom[0-9]+/'" <<<"${existing}" | tr -d "'" || true + 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="$(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 @@ -74,7 +74,7 @@ find_screenux_path() { } remove_screenux_shortcut() { - if ! command -v gsettings >/dev/null 2>&1; then + if ! command -v gsettings > /dev/null 2>&1; then return 0 fi if ! schema_exists "${SCHEMA}"; then @@ -122,7 +122,7 @@ reset_key_if_exists() { } disable_native_print_keys() { - if ! command -v gsettings >/dev/null 2>&1; then + if ! command -v gsettings > /dev/null 2>&1; then echo "NOTE: gsettings not available; cannot disable native Print bindings." return 0 fi @@ -139,7 +139,7 @@ disable_native_print_keys() { } restore_native_print_keys() { - if ! command -v gsettings >/dev/null 2>&1; then + if ! command -v gsettings > /dev/null 2>&1; then echo "NOTE: gsettings not available; cannot restore native Print bindings." return 0 fi @@ -158,7 +158,7 @@ restore_native_print_keys() { configure_gnome_shortcut() { local binding="$1" - if ! command -v gsettings >/dev/null 2>&1; then + if ! command -v gsettings > /dev/null 2>&1; then echo "NOTE: gsettings not available; skipping shortcut setup." return 0 fi diff --git a/scripts/install/uninstall-screenux.sh b/scripts/install/uninstall-screenux.sh index c8f7b2f..8eb1b67 100755 --- a/scripts/install/uninstall-screenux.sh +++ b/scripts/install/uninstall-screenux.sh @@ -2,11 +2,13 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/install/lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=scripts/install/lib/gnome_shortcuts.sh source "${SCRIPT_DIR}/lib/gnome_shortcuts.sh" usage() { - cat <<'EOF' + cat << 'EOF' Usage: ./uninstall-screenux.sh [--preserve-user-data] @@ -17,12 +19,12 @@ EOF } remove_flatpak_app() { - if ! command -v flatpak >/dev/null 2>&1; then + 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 + if flatpak info --user "${APP_ID}" > /dev/null 2>&1; then echo "==> Uninstalling Flatpak app: ${APP_ID}" flatpak uninstall -y --user "${APP_ID}" else @@ -47,7 +49,7 @@ validate_uninstall() { echo "==> Validating uninstall" - if command -v flatpak >/dev/null 2>&1 && flatpak info --user "${APP_ID}" >/dev/null 2>&1; then + 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 @@ -67,7 +69,7 @@ main() { while (($# > 0)); do case "$1" in - -h|--help) + -h | --help) usage exit 0 ;; diff --git a/src/screenux_hotkey.py b/src/screenux_hotkey.py new file mode 100644 index 0000000..a19c2ef --- /dev/null +++ b/src/screenux_hotkey.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import os +import re +import subprocess # nosec B404 - required for trusted local command invocation. +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Callable + +DEFAULT_SHORTCUT = "Ctrl+Shift+S" +FALLBACK_SHORTCUTS = ("Ctrl+Alt+S", "Alt+Shift+S", "Super+Shift+S") +HOTKEY_CONFIG_KEY = "global_hotkey" + +GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys" +GNOME_CUSTOM_SCHEMA = f"{GNOME_MEDIA_SCHEMA}.custom-keybinding" +GNOME_CUSTOM_KEY = "custom-keybindings" +GNOME_SHELL_SCHEMA = "org.gnome.shell.keybindings" +GNOME_CUSTOM_BASE_PATH = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings" + +SCREENUX_SHORTCUT_NAME = "Screenux Screenshot" +SCREENUX_CAPTURE_COMMAND = "screenux-screenshot --capture" + +Runner = Callable[[list[str]], object] + +_MODIFIER_ORDER = ("Ctrl", "Alt", "Shift", "Super") +_MODIFIER_ALIASES = { + "CTRL": "Ctrl", + "CONTROL": "Ctrl", + "ALT": "Alt", + "SHIFT": "Shift", + "SUPER": "Super", + "WIN": "Super", + "META": "Super", +} +_GSETTINGS_MODIFIER = { + "Ctrl": "", + "Alt": "", + "Shift": "", + "Super": "", +} + +_NATIVE_SHORTCUT_KEYS = ( + (GNOME_SHELL_SCHEMA, "show-screenshot"), + (GNOME_SHELL_SCHEMA, "show-screenshot-ui"), + (GNOME_SHELL_SCHEMA, "show-screen-recording-ui"), + (GNOME_MEDIA_SCHEMA, "screenshot"), + (GNOME_MEDIA_SCHEMA, "window-screenshot"), + (GNOME_MEDIA_SCHEMA, "area-screenshot"), +) + + +@dataclass(frozen=True) +class HotkeyRegistrationResult: + shortcut: str | None + warning: str | None = None + + +def _default_runner(command: list[str]) -> object: + return subprocess.run(command, capture_output=True, text=True, check=False) # nosec B603 + + +def _run(command: list[str], runner: Runner) -> object: + try: + return runner(command) + except FileNotFoundError: + return SimpleNamespace(returncode=127, stdout="", stderr=f"{command[0]} not found") + + +def _stdout(result: object) -> str: + return str(getattr(result, "stdout", "") or "").strip() + + +def _success(result: object) -> bool: + return int(getattr(result, "returncode", 1)) == 0 + + +def _normalize_key_token(token: str) -> str: + text = token.strip() + if not text: + raise ValueError("shortcut key cannot be empty") + if len(text) == 1: + return text.upper() + + upper = text.upper() + if upper == "PRINT": + return "Print" + if upper.startswith("F") and upper[1:].isdigit(): + return upper + if upper in ("SPACE", "TAB", "ESC", "ESCAPE", "ENTER"): + mapping = { + "SPACE": "Space", + "TAB": "Tab", + "ESC": "Esc", + "ESCAPE": "Escape", + "ENTER": "Enter", + } + return mapping[upper] + + if text.isalpha(): + return text.title() + raise ValueError(f"unsupported shortcut key: {token}") + + +def _normalize_modifier_token(token: str) -> str: + normalized = _MODIFIER_ALIASES.get(token.strip().strip("<>").upper()) + if normalized is None: + raise ValueError(f"unsupported shortcut modifier: {token}") + return normalized + + +def normalize_shortcut(value: str) -> str: + text = value.strip() + if not text: + raise ValueError("shortcut cannot be empty") + + parts = [part.strip() for part in text.split("+") if part.strip()] + if not parts: + raise ValueError("shortcut cannot be empty") + + modifiers: list[str] = [] + key: str | None = None + for part in parts: + try: + modifier = _normalize_modifier_token(part) + except ValueError: + if key is not None: + raise ValueError("shortcut must contain exactly one non-modifier key") + key = _normalize_key_token(part) + continue + if modifier not in modifiers: + modifiers.append(modifier) + + if key is None: + raise ValueError("shortcut must include a key") + + ordered_modifiers = [item for item in _MODIFIER_ORDER if item in modifiers] + return "+".join([*ordered_modifiers, key]) + + +def read_hotkey_from_config(config: dict) -> str | None: + raw = config.get(HOTKEY_CONFIG_KEY, DEFAULT_SHORTCUT) + if raw is None: + return None + if not isinstance(raw, str): + return DEFAULT_SHORTCUT + try: + return normalize_shortcut(raw) + except ValueError: + return DEFAULT_SHORTCUT + + +def write_hotkey_to_config(config: dict, value: str | None) -> None: + if value is None: + config[HOTKEY_CONFIG_KEY] = None + return + config[HOTKEY_CONFIG_KEY] = normalize_shortcut(value) + + +def resolve_shortcut_with_fallback( + preferred: str | None, is_taken: Callable[[str], bool] +) -> tuple[str | None, str | None]: + if preferred is None: + return None, None + + normalized_preferred = normalize_shortcut(preferred) + candidates: list[str] = [normalized_preferred] + for fallback in FALLBACK_SHORTCUTS: + if fallback not in candidates: + candidates.append(fallback) + + for index, candidate in enumerate(candidates): + if not is_taken(candidate): + if index == 0: + return candidate, None + return candidate, f"Shortcut {normalized_preferred} is in use. Using {candidate}." + + return None, "No available global shortcut candidate; hotkey disabled." + + +def parse_gsettings_binding(raw_value: str) -> str | None: + matches = re.findall(r"'([^']+)'", raw_value or "") + if not matches: + return None + + accel = matches[0].strip() + if not accel: + return None + + modifiers: list[str] = [] + if accel.startswith("<"): + for token in re.findall(r"<([^>]+)>", accel): + try: + modifier = _normalize_modifier_token(token) + except ValueError: + return None + if modifier not in modifiers: + modifiers.append(modifier) + key = re.sub(r"(?:<[^>]+>)+", "", accel).strip() + else: + key = accel + + if not key: + return None + + try: + normalized_key = _normalize_key_token(key) + except ValueError: + return None + ordered_modifiers = [item for item in _MODIFIER_ORDER if item in modifiers] + return "+".join([*ordered_modifiers, normalized_key]) + + +def shortcut_to_gsettings_binding(shortcut: str) -> str: + normalized = normalize_shortcut(shortcut) + parts = normalized.split("+") + key = parts[-1] + modifiers = parts[:-1] + modifier_prefix = "".join(_GSETTINGS_MODIFIER[item] for item in modifiers) + key_token = key.lower() if len(key) == 1 else key + return f"['{modifier_prefix}{key_token}']" + + +def _schema_exists(schema: str, runner: Runner) -> bool: + result = _run(["gsettings", "list-schemas"], runner) + if not _success(result): + return False + return schema in _stdout(result).splitlines() + + +def _gsettings_get(schema: str, key: str, runner: Runner) -> str | None: + result = _run(["gsettings", "get", schema, key], runner) + if not _success(result): + return None + return _stdout(result) + + +def _gsettings_set(schema: str, key: str, value: str, runner: Runner) -> bool: + result = _run(["gsettings", "set", schema, key, value], runner) + return _success(result) + + +def _gsettings_available(runner: Runner) -> bool: + result = _run(["gsettings", "--version"], runner) + return _success(result) + + +def _build_gsettings_list(paths: list[str]) -> str: + return "[" + ", ".join(f"'{path}'" for path in paths) + "]" + + +def _custom_paths(runner: Runner) -> list[str]: + raw = _gsettings_get(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, runner) + if raw is None: + return [] + return re.findall(r"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom\d+/", raw) + + +def _strip_single_quotes(value: str | None) -> str: + if value is None: + return "" + text = value.strip() + if text.startswith("'") and text.endswith("'"): + return text[1:-1] + return text + + +def _find_screenux_custom_path(paths: list[str], runner: Runner) -> str | None: + for path in paths: + schema = f"{GNOME_CUSTOM_SCHEMA}:{path}" + current_name = _strip_single_quotes(_gsettings_get(schema, "name", runner)) + current_command = _strip_single_quotes(_gsettings_get(schema, "command", runner)) + if current_name == SCREENUX_SHORTCUT_NAME or current_command == SCREENUX_CAPTURE_COMMAND: + return path + return None + + +def collect_gnome_taken_shortcuts(runner: Runner = _default_runner, exclude_path: str | None = None) -> set[str]: + if not _gsettings_available(runner): + return set() + if not _schema_exists(GNOME_MEDIA_SCHEMA, runner): + return set() + + taken: set[str] = set() + + for path in _custom_paths(runner): + if path == exclude_path: + continue + schema = f"{GNOME_CUSTOM_SCHEMA}:{path}" + current_binding = parse_gsettings_binding(_gsettings_get(schema, "binding", runner) or "") + if current_binding: + taken.add(current_binding) + + for schema, key in _NATIVE_SHORTCUT_KEYS: + if not _schema_exists(schema, runner): + continue + parsed = parse_gsettings_binding(_gsettings_get(schema, key, runner) or "") + if parsed: + taken.add(parsed) + + return taken + + +def _remove_screenux_shortcut(paths: list[str], runner: Runner) -> None: + screenux_path = _find_screenux_custom_path(paths, runner) + if screenux_path is None: + return + updated_paths = [path for path in paths if path != screenux_path] + _gsettings_set(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, _build_gsettings_list(updated_paths), runner) + + +def _next_available_custom_path(paths: list[str]) -> str: + index = 0 + existing = set(paths) + while True: + candidate = f"{GNOME_CUSTOM_BASE_PATH}/custom{index}/" + if candidate not in existing: + return candidate + index += 1 + + +def register_gnome_shortcut( + shortcut: str | None, + runner: Runner = _default_runner, +) -> HotkeyRegistrationResult: + if not _gsettings_available(runner): + return HotkeyRegistrationResult(shortcut, "gsettings is unavailable; global hotkey not configured.") + if not _schema_exists(GNOME_MEDIA_SCHEMA, runner): + return HotkeyRegistrationResult(shortcut, "GNOME media key schema not available; global hotkey not configured.") + + paths = _custom_paths(runner) + screenux_path = _find_screenux_custom_path(paths, runner) + + if shortcut is None: + _remove_screenux_shortcut(paths, runner) + return HotkeyRegistrationResult(None, None) + + preferred = normalize_shortcut(shortcut) + taken = collect_gnome_taken_shortcuts(runner=runner, exclude_path=screenux_path) + resolved, warning = resolve_shortcut_with_fallback(preferred, lambda candidate: candidate in taken) + + if resolved is None: + _remove_screenux_shortcut(paths, runner) + return HotkeyRegistrationResult(None, warning) + + target_path = screenux_path or _next_available_custom_path(paths) + if target_path not in paths: + paths.append(target_path) + _gsettings_set(GNOME_MEDIA_SCHEMA, GNOME_CUSTOM_KEY, _build_gsettings_list(paths), runner) + + target_schema = f"{GNOME_CUSTOM_SCHEMA}:{target_path}" + _gsettings_set(target_schema, "name", SCREENUX_SHORTCUT_NAME, runner) + _gsettings_set(target_schema, "command", SCREENUX_CAPTURE_COMMAND, runner) + _gsettings_set(target_schema, "binding", shortcut_to_gsettings_binding(resolved), runner) + return HotkeyRegistrationResult(resolved, warning) + + +def _is_gnome_desktop(env: dict[str, str]) -> bool: + desktop = env.get("XDG_CURRENT_DESKTOP", "").upper() + session = env.get("DESKTOP_SESSION", "").upper() + return "GNOME" in desktop or "GNOME" in session + + +def register_portal_shortcut(shortcut: str | None) -> HotkeyRegistrationResult: + if shortcut is None: + return HotkeyRegistrationResult(None, None) + try: + normalized = normalize_shortcut(shortcut) + except ValueError: + return HotkeyRegistrationResult(DEFAULT_SHORTCUT, "Invalid shortcut value; using default shortcut.") + return HotkeyRegistrationResult( + normalized, + "Portal hotkey backend is best-effort on this desktop; GNOME keybindings are required for closed-app behavior.", + ) + + +class HotkeyManager: + def __init__( + self, + load_config: Callable[[], dict], + save_config: Callable[[dict], None], + *, + env: dict[str, str] | None = None, + runner: Runner = _default_runner, + ) -> None: + self._load_config = load_config + self._save_config = save_config + self._runner = runner + self._env = env or dict(os.environ) + self._current_shortcut: str | None = None + self.last_warning: str | None = None + + def current_shortcut(self) -> str | None: + return self._current_shortcut + + def ensure_registered(self) -> HotkeyRegistrationResult: + config = self._load_config() + has_explicit_hotkey = HOTKEY_CONFIG_KEY in config + preferred = read_hotkey_from_config(config) + if _is_gnome_desktop(self._env): + result = register_gnome_shortcut(preferred, runner=self._runner) + else: + result = register_portal_shortcut(preferred) + + if result.shortcut != preferred or not has_explicit_hotkey: + write_hotkey_to_config(config, result.shortcut) + self._save_config(config) + + self._current_shortcut = result.shortcut + self.last_warning = result.warning + return result + + def apply_shortcut(self, value: str | None) -> HotkeyRegistrationResult: + config = self._load_config() + write_hotkey_to_config(config, value) + self._save_config(config) + return self.ensure_registered() + + def disable_shortcut(self) -> HotkeyRegistrationResult: + return self.apply_shortcut(None) diff --git a/src/screenux_screenshot.py b/src/screenux_screenshot.py index 3968220..9e204d4 100644 --- a/src/screenux_screenshot.py +++ b/src/screenux_screenshot.py @@ -9,6 +9,11 @@ from pathlib import Path from urllib.parse import unquote, urlparse +try: + from screenux_hotkey import HotkeyManager +except ModuleNotFoundError: # pragma: no cover - supports package-style imports in tests + from src.screenux_hotkey import HotkeyManager + GI_IMPORT_ERROR: Exception | None = None try: import gi @@ -26,6 +31,22 @@ DARK_ICON_NAME = f"{APP_ID}-dark" _MAX_CONFIG_SIZE = 64 * 1024 _ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} +APP_VERSION = "0.1.0" + + +def _print_help() -> None: + print( + "\n".join( + ( + "Usage: screenux-screenshot [--capture] [--help] [--version]", + "", + "Options:", + " --capture Open app and trigger screenshot flow immediately", + " --help Show this help message", + " --version Show application version", + ) + ) + ) def _prefers_dark_theme() -> bool: @@ -179,16 +200,29 @@ def _parse_cli_args(argv: list[str]) -> tuple[list[str], bool]: if Gtk is not None: 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) + super().__init__( + application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, + ) self._auto_capture_pending = auto_capture + self._hotkey_manager = HotkeyManager(load_config, save_config) def _trigger_auto_capture(self, window: MainWindow) -> bool: window.take_screenshot() return False + def do_command_line(self, command_line: Gio.ApplicationCommandLine) -> int: + args = [arg.decode("utf-8") if isinstance(arg, bytes) else str(arg) for arg in command_line.get_arguments()] + _, auto_capture = _parse_cli_args(args) + if auto_capture: + self._auto_capture_pending = True + self.activate() + return 0 + def do_activate(self) -> None: icon_name = select_icon_name() Gtk.Window.set_default_icon_name(icon_name) + hotkey_result = self._hotkey_manager.ensure_registered() window = self.props.active_window if window is None: window = MainWindow( @@ -199,9 +233,13 @@ def do_activate(self) -> None: save_config=save_config, build_output_path=build_output_path, format_status_saved=format_status_saved, + hotkey_manager=self._hotkey_manager, + initial_hotkey_warning=hotkey_result.warning, ) else: window.set_icon_name(icon_name) + if hotkey_result.warning and hasattr(window, "set_nonblocking_warning"): + window.set_nonblocking_warning(hotkey_result.warning) window.present() if self._auto_capture_pending: self._auto_capture_pending = False @@ -213,13 +251,20 @@ def run(self, _argv: list[str]) -> int: def main(argv: list[str]) -> int: + if "--help" in argv or "-h" in argv: + _print_help() + return 0 + if "--version" in argv: + print(APP_VERSION) + return 0 + enforce_offline_mode() 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_argv, auto_capture = _parse_cli_args(argv) + _, auto_capture = _parse_cli_args(argv) app = ScreenuxScreenshotApp(auto_capture=auto_capture) - return app.run(app_argv) + return app.run(argv) if __name__ == "__main__": diff --git a/src/screenux_window.py b/src/screenux_window.py index 3efdf09..9a0ecae 100644 --- a/src/screenux_window.py +++ b/src/screenux_window.py @@ -69,6 +69,8 @@ def __init__( save_config: Callable[[dict[str, Any]], None], build_output_path: Callable[[str], Path], format_status_saved: Callable[[Path], str], + hotkey_manager: Any | None = None, + initial_hotkey_warning: str | None = None, ): super().__init__(application=app, title="Screenux Screenshot") self.set_icon_name(icon_name) @@ -79,10 +81,13 @@ def __init__( self._save_config = save_config self._build_output_path = build_output_path self._format_status_saved = format_status_saved + self._hotkey_manager = hotkey_manager self._request_counter = 0 self._bus = None self._signal_sub_id: int | None = None + self._hotkey_entry: Gtk.Entry | None = None + self._hotkey_value_label: Gtk.Label | None = None self._main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self._main_box.set_margin_top(16) @@ -112,11 +117,86 @@ def __init__( folder_row.append(change_btn) self._main_box.append(folder_row) + self._build_hotkey_settings() self.set_child(self._main_box) + if initial_hotkey_warning: + self.set_nonblocking_warning(initial_hotkey_warning) + def take_screenshot(self) -> None: self._on_take_screenshot(self._button) + def set_nonblocking_warning(self, warning_text: str) -> None: + self._set_status(f"Warning: {warning_text}") + + def _build_hotkey_settings(self) -> None: + if self._hotkey_manager is None: + return + + current_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + current_row.append(Gtk.Label(label="Global hotkey:")) + self._hotkey_value_label = Gtk.Label(label="") + self._hotkey_value_label.set_xalign(0.0) + self._hotkey_value_label.set_hexpand(True) + current_row.append(self._hotkey_value_label) + self._main_box.append(current_row) + + edit_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._hotkey_entry = Gtk.Entry() + self._hotkey_entry.set_hexpand(True) + self._hotkey_entry.set_placeholder_text("Ctrl+Shift+S") + edit_row.append(self._hotkey_entry) + + apply_btn = Gtk.Button(label="Apply") + apply_btn.connect("clicked", self._on_hotkey_apply) + edit_row.append(apply_btn) + + disable_btn = Gtk.Button(label="Disable") + disable_btn.connect("clicked", self._on_hotkey_disable) + edit_row.append(disable_btn) + + self._main_box.append(edit_row) + self._refresh_hotkey_ui() + + def _refresh_hotkey_ui(self) -> None: + if self._hotkey_manager is None: + return + current = self._hotkey_manager.current_shortcut() + if self._hotkey_value_label is not None: + self._hotkey_value_label.set_text(current or "Disabled") + if self._hotkey_entry is not None: + self._hotkey_entry.set_text(current or "") + + def _apply_hotkey_result(self, result: Any) -> None: + if self._hotkey_value_label is not None: + self._hotkey_value_label.set_text(result.shortcut or "Disabled") + if self._hotkey_entry is not None: + self._hotkey_entry.set_text(result.shortcut or "") + if result.warning: + self.set_nonblocking_warning(result.warning) + return + self._set_status("Ready") + + def _on_hotkey_apply(self, _button: Gtk.Button) -> None: + if self._hotkey_manager is None or self._hotkey_entry is None: + return + user_value = self._hotkey_entry.get_text().strip() + if not user_value: + self._set_status("Failed: shortcut cannot be empty (use Disable)") + return + try: + result = self._hotkey_manager.apply_shortcut(user_value) + except ValueError as err: + self._set_status(f"Failed: invalid shortcut ({err})") + return + self._apply_hotkey_result(result) + + def _on_hotkey_disable(self, _button: Gtk.Button) -> None: + if self._hotkey_manager is None: + return + result = self._hotkey_manager.disable_shortcut() + self._apply_hotkey_result(result) + 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_ci_deb_workflow.py b/tests/test_ci_deb_workflow.py new file mode 100644 index 0000000..f6f96ed --- /dev/null +++ b/tests/test_ci_deb_workflow.py @@ -0,0 +1,44 @@ +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" + + +class DebianCiWorkflowTests(unittest.TestCase): + def test_ci_defines_debian_package_assurance_job(self): + content = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("deb-package-assurance:", content) + self.assertIn("name: Debian Package Assurance", content) + self.assertIn("docker build -f Dockerfile.deb -t screenux-deb-ci .", content) + self.assertIn("docker run --rm -v \"$PWD/dist-deb:/out\" screenux-deb-ci", content) + + def test_ci_checks_security_integrity_and_performance_for_deb(self): + content = CI_WORKFLOW.read_text(encoding="utf-8") + + required_snippets = [ + "dpkg-deb --info \"$deb_file\"", + "dpkg-deb --contents \"$deb_file\"", + "dpkg-deb -f \"$deb_file\" Package", + "sha256sum \"$deb_file\"", + "find \"$extract_dir\" -type f -perm /6000", + "find \"$extract_dir\" -type f -perm -0002", + "help_startup_ms=", + "max_help_startup_ms=4000", + "actions/upload-artifact@v4", + "name: deb-ci-reports", + ] + + for snippet in required_snippets: + with self.subTest(snippet=snippet): + self.assertIn(snippet, content) + + def test_ci_dependency_review_is_non_blocking_when_repo_feature_is_disabled(self): + content = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("dependency-review:", content) + self.assertIn("uses: actions/dependency-review-action@v4", content) + self.assertIn("continue-on-error: true", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deb_packaging_files.py b/tests/test_deb_packaging_files.py new file mode 100644 index 0000000..3e17712 --- /dev/null +++ b/tests/test_deb_packaging_files.py @@ -0,0 +1,114 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DESKTOP_FILE = ROOT / "packaging" / "linux" / "screenux-screenshot.desktop" +BUILD_SCRIPT = ROOT / "scripts" / "build_deb.sh" +HOOKS_DIR = ROOT / "packaging" / "pyinstaller_hooks" +GTK_HOOK = HOOKS_DIR / "hook-gi.repository.Gtk.py" +GDK_HOOK = HOOKS_DIR / "hook-gi.repository.Gdk.py" + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +class DebianPackagingTests(unittest.TestCase): + def test_desktop_entry_exists_and_declares_expected_fields(self): + content = DESKTOP_FILE.read_text(encoding="utf-8") + self.assertIn("Exec=screenux-screenshot", content) + self.assertIn("Icon=screenux-screenshot", content) + self.assertIn("Type=Application", content) + self.assertIn("Categories=Utility;Graphics;", content) + + def test_build_script_emits_expected_deb_name_and_install_paths(self): + with tempfile.TemporaryDirectory(prefix="screenux-deb-test-") as tmpdir: + tmp_path = Path(tmpdir) + mock_bin = tmp_path / "bin" + mock_bin.mkdir(parents=True, exist_ok=True) + out_dir = tmp_path / "out" + out_dir.mkdir(parents=True, exist_ok=True) + log_file = tmp_path / "commands.log" + + _write_executable( + mock_bin / "python3", + """#!/usr/bin/env bash +set -euo pipefail +echo "python3 $*" >> "${MOCK_LOG_FILE}" +if [[ "${1:-}" == "-m" && "${2:-}" == "venv" ]]; then + venv_path="${@: -1}" + mkdir -p "${venv_path}/bin" + cat > "${venv_path}/bin/pip" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "pip $*" >> "${MOCK_LOG_FILE}" +exit 0 +EOF + cat > "${venv_path}/bin/pyinstaller" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "pyinstaller $*" >> "${MOCK_LOG_FILE}" +mkdir -p dist +cat > dist/screenux-screenshot <<'EOS' +#!/usr/bin/env bash +echo "screenux" +EOS +chmod +x dist/screenux-screenshot +exit 0 +EOF + chmod +x "${venv_path}/bin/pip" "${venv_path}/bin/pyinstaller" + exit 0 +fi +exit 0 +""", + ) + _write_executable( + mock_bin / "dpkg-deb", + """#!/usr/bin/env bash +set -euo pipefail +echo "dpkg-deb $*" >> "${MOCK_LOG_FILE}" +touch "${@: -1}" +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{mock_bin}:{env['PATH']}" + env["MOCK_LOG_FILE"] = str(log_file) + env["OUT_DIR"] = str(out_dir) + env["APP_VERSION"] = "9.9.9" + env["APP_NAME"] = "screenux-screenshot" + env["APP_ARCH"] = "amd64" + + result = subprocess.run( + [str(BUILD_SCRIPT)], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + log = log_file.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn("pyinstaller --onefile", log) + self.assertIn("--additional-hooks-dir", log) + self.assertIn("packaging/pyinstaller_hooks", log) + self.assertNotIn("--collect-submodules gi", log) + self.assertNotIn("--collect-data gi", log) + self.assertIn("dpkg-deb --build --root-owner-group", log) + self.assertTrue((out_dir / "screenux-screenshot_9.9.9_amd64.deb").is_file()) + + def test_pyinstaller_gtk4_hooks_are_present(self): + gtk_content = GTK_HOOK.read_text(encoding="utf-8") + gdk_content = GDK_HOOK.read_text(encoding="utf-8") + + self.assertIn('GiModuleInfo("Gtk", "4.0"', gtk_content) + self.assertIn('GiModuleInfo("Gdk", "4.0"', gdk_content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hotkey_backend_gnome.py b/tests/test_hotkey_backend_gnome.py new file mode 100644 index 0000000..e79a31e --- /dev/null +++ b/tests/test_hotkey_backend_gnome.py @@ -0,0 +1,134 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +import screenux_hotkey as hotkey + + +def _make_runner(mapping: dict[tuple[str, ...], tuple[int, str, str]], calls: list[list[str]]): + def _runner(command: list[str]): + calls.append(command) + code, stdout, stderr = mapping.get(tuple(command), (0, "", "")) + return SimpleNamespace(returncode=code, stdout=stdout, stderr=stderr) + + return _runner + + +def test_collect_gnome_taken_shortcuts_parses_custom_and_native_bindings(): + calls: list[list[str]] = [] + custom_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): ( + 0, + f"['{custom_path}']\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{custom_path}", "binding"): ( + 0, + "['s']\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "['Print']\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screen-recording-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), + } + runner = _make_runner(mapping, calls) + + taken = hotkey.collect_gnome_taken_shortcuts(runner=runner) + + assert "Ctrl+Shift+S" in taken + assert "Print" in taken + + +def test_register_gnome_shortcut_sets_command_and_uses_fallback_when_conflicting(): + calls: list[list[str]] = [] + existing_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/" + new_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/" + mapping = { + ("gsettings", "--version"): (0, "2.76.0\n", ""), + ("gsettings", "list-schemas"): ( + 0, + "\n".join( + [ + hotkey.GNOME_MEDIA_SCHEMA, + hotkey.GNOME_CUSTOM_SCHEMA, + hotkey.GNOME_SHELL_SCHEMA, + ] + ) + + "\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, hotkey.GNOME_CUSTOM_KEY): ( + 0, + f"['{existing_path}']\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "name"): ( + 0, + "'Other Shortcut'\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "command"): ( + 0, + "'other-command'\n", + "", + ), + ("gsettings", "get", f"{hotkey.GNOME_CUSTOM_SCHEMA}:{existing_path}", "binding"): ( + 0, + "['s']\n", + "", + ), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screenshot-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_SHELL_SCHEMA, "show-screen-recording-ui"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "window-screenshot"): (0, "[]\n", ""), + ("gsettings", "get", hotkey.GNOME_MEDIA_SCHEMA, "area-screenshot"): (0, "[]\n", ""), + } + runner = _make_runner(mapping, calls) + + result = hotkey.register_gnome_shortcut("Ctrl+Shift+S", runner=runner) + + assert result.shortcut == "Ctrl+Alt+S" + assert result.warning is not None + assert any( + command + == [ + "gsettings", + "set", + f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", + "command", + hotkey.SCREENUX_CAPTURE_COMMAND, + ] + for command in calls + ) + assert any( + command + == [ + "gsettings", + "set", + f"{hotkey.GNOME_CUSTOM_SCHEMA}:{new_path}", + "binding", + "['s']", + ] + for command in calls + ) diff --git a/tests/test_hotkey_config.py b/tests/test_hotkey_config.py new file mode 100644 index 0000000..a2c544a --- /dev/null +++ b/tests/test_hotkey_config.py @@ -0,0 +1,46 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +import screenux_hotkey as hotkey + + +def test_read_hotkey_uses_default_when_unset(): + assert hotkey.read_hotkey_from_config({}) == hotkey.DEFAULT_SHORTCUT + + +def test_write_and_read_hotkey_round_trip(): + config = {} + hotkey.write_hotkey_to_config(config, "ctrl+shift+s") + assert config["global_hotkey"] == "Ctrl+Shift+S" + assert hotkey.read_hotkey_from_config(config) == "Ctrl+Shift+S" + + +def test_write_and_read_disabled_hotkey(): + config = {} + hotkey.write_hotkey_to_config(config, None) + assert config["global_hotkey"] is None + assert hotkey.read_hotkey_from_config(config) is None + + +def test_read_hotkey_ignores_invalid_config_value(): + assert hotkey.read_hotkey_from_config({"global_hotkey": 123}) == hotkey.DEFAULT_SHORTCUT + + +def test_hotkey_manager_persists_default_value_when_missing_from_config(): + state = {} + + def _load(): + return dict(state) + + def _save(config): + state.clear() + state.update(config) + + manager = hotkey.HotkeyManager(_load, _save, env={}) + result = manager.ensure_registered() + + assert result.shortcut == hotkey.DEFAULT_SHORTCUT + assert state["global_hotkey"] == hotkey.DEFAULT_SHORTCUT diff --git a/tests/test_hotkey_fallback.py b/tests/test_hotkey_fallback.py new file mode 100644 index 0000000..37ba603 --- /dev/null +++ b/tests/test_hotkey_fallback.py @@ -0,0 +1,45 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +import screenux_hotkey as hotkey + + +def test_resolve_shortcut_uses_first_fallback_when_default_is_taken(): + taken = {"Ctrl+Shift+S"} + shortcut, warning = hotkey.resolve_shortcut_with_fallback( + "Ctrl+Shift+S", lambda value: value in taken + ) + + assert shortcut == "Ctrl+Alt+S" + assert warning is not None + assert "Ctrl+Shift+S" in warning + + +def test_resolve_shortcut_skips_taken_first_fallback(): + taken = {"Ctrl+Shift+S", "Ctrl+Alt+S"} + shortcut, warning = hotkey.resolve_shortcut_with_fallback( + "Ctrl+Shift+S", lambda value: value in taken + ) + + assert shortcut == "Alt+Shift+S" + assert warning is not None + assert "Alt+Shift+S" in warning + + +def test_resolve_shortcut_disables_when_all_candidates_are_taken(): + taken = { + "Ctrl+Shift+S", + "Ctrl+Alt+S", + "Alt+Shift+S", + "Super+Shift+S", + } + shortcut, warning = hotkey.resolve_shortcut_with_fallback( + "Ctrl+Shift+S", lambda value: value in taken + ) + + assert shortcut is None + assert warning is not None + assert "disabled" in warning.lower() diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py index b554935..eea37ce 100644 --- a/tests/test_install_uninstall_scripts.py +++ b/tests/test_install_uninstall_scripts.py @@ -153,6 +153,11 @@ def _run_command(command: list[str], env: dict[str, str]) -> subprocess.Complete class InstallScriptTests(unittest.TestCase): + def test_installer_declares_shellcheck_source_paths_for_libraries(self): + content = (ROOT / "scripts/install/install-screenux.sh").read_text(encoding="utf-8") + self.assertIn("# shellcheck source=scripts/install/lib/common.sh", content) + self.assertIn("# shellcheck source=scripts/install/lib/gnome_shortcuts.sh", content) + def test_installer_installs_bundle_and_creates_local_entries(self): tmpdir, env, log_file = _setup_mock_environment() bundle = tmpdir / "screenux.flatpak" @@ -213,6 +218,11 @@ def test_installer_can_configure_print_screen_shortcut(self): class UninstallScriptTests(unittest.TestCase): + def test_uninstaller_declares_shellcheck_source_paths_for_libraries(self): + content = (ROOT / "scripts/install/uninstall-screenux.sh").read_text(encoding="utf-8") + self.assertIn("# shellcheck source=scripts/install/lib/common.sh", content) + self.assertIn("# shellcheck source=scripts/install/lib/gnome_shortcuts.sh", content) + def test_uninstaller_removes_flatpak_and_local_artifacts_by_default(self): _, env, log_file = _setup_mock_environment( with_gsettings=True, diff --git a/tests/test_theme_icon_selection.py b/tests/test_theme_icon_selection.py index fb89895..50d0308 100644 --- a/tests/test_theme_icon_selection.py +++ b/tests/test_theme_icon_selection.py @@ -1,5 +1,9 @@ import unittest +from pathlib import Path +import sys +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) import src.screenux_screenshot as screenshot diff --git a/tests/test_window_and_screenshot.py b/tests/test_window_and_screenshot.py index 1a6f1c6..4047db3 100644 --- a/tests/test_window_and_screenshot.py +++ b/tests/test_window_and_screenshot.py @@ -412,8 +412,8 @@ def run(self, argv): assert seen == {"auto_capture": False, "argv": ["a", "b"]} seen.clear() - assert screenshot.main(["a", "--capture"]) == 1 - assert seen == {"auto_capture": True, "argv": ["a"]} + assert screenshot.main(["a", "--capture"]) == 2 + assert seen == {"auto_capture": True, "argv": ["a", "--capture"]} class FakeGLib: @staticmethod @@ -428,6 +428,38 @@ def get_user_config_dir(): assert screenshot._config_path() == tmp_path / "home" / ".config" / "screenux" / "settings.json" +def test_screenshot_help_and_version_do_not_require_gtk(monkeypatch, capsys): + monkeypatch.setattr(screenshot, "enforce_offline_mode", lambda: None) + monkeypatch.setattr(screenshot, "GI_IMPORT_ERROR", RuntimeError("missing")) + monkeypatch.setattr(screenshot, "Gtk", None) + monkeypatch.setattr(screenshot, "MainWindow", None) + + assert screenshot.main(["screenux-screenshot", "--help"]) == 0 + assert "Usage: screenux-screenshot" in capsys.readouterr().out + + assert screenshot.main(["screenux-screenshot", "--version"]) == 0 + assert screenshot.APP_VERSION in capsys.readouterr().out + + +def test_screenshot_app_command_line_can_request_capture_on_existing_instance(): + if not hasattr(screenshot.ScreenuxScreenshotApp, "do_command_line"): + return + + app = SimpleNamespace(_auto_capture_pending=False, activate=lambda: setattr(app, "activated", True)) + setattr(app, "activated", False) + + class FakeCommandLine: + @staticmethod + def get_arguments(): + return ["screenux-screenshot", "--capture"] + + result = screenshot.ScreenuxScreenshotApp.do_command_line(app, FakeCommandLine()) + + assert result == 0 + assert app._auto_capture_pending is True + assert app.activated is True + + def test_screenshot_enforce_offline_mode_blocks_network(monkeypatch): original_socket = screenshot.socket.socket original_create_connection = screenshot.socket.create_connection