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/.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 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/Makefile b/Makefile new file mode 100644 index 0000000..7469cb5 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ +SHELL := /usr/bin/env bash + +INSTALLER := ./install-screenux.sh +UNINSTALLER := ./uninstall-screenux.sh +APP_ID := io.github.rafa.ScreenuxScreenshot +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) +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 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]" + @echo " make install-print-screen [BUNDLE=./screenux-screenshot.flatpak]" + @echo " make uninstall" + @echo " make uninstall-preserve-data" + @echo " make check-install-scripts" + +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:"; \ + 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)" + +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: + @if [[ -f "$(BUNDLE)" ]]; then \ + $(INSTALLER) --bundle "$(BUNDLE)"; \ + else \ + $(INSTALLER); \ + fi + +install-print-screen: + @if [[ -f "$(BUNDLE)" ]]; then \ + $(INSTALLER) --bundle "$(BUNDLE)" --print-screen; \ + else \ + $(INSTALLER) --print-screen; \ + fi + +install-flatpak: install + +uninstall: + @$(UNINSTALLER) + +uninstall-preserve-data: + @$(UNINSTALLER) --preserve-user-data + +check-install-scripts: + @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 e388227..efc34d0 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,53 @@ 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 +- Packaged app icon for desktop launcher integration -## 🚀 Quick start +## Install -### 1) Install system dependencies +```bash +./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak +``` -- `python3` -- `python3-gi` -- GTK4 introspection (`gir1.2-gtk-4.0` on Debian/Ubuntu) -- `xdg-desktop-portal` plus a desktop backend (GNOME/KDE/etc.) +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. -### 2) Clone the project +Optional GNOME Print Screen shortcut: ```bash -git clone https://github.com/rafaself/Screenux.git -cd Screenux +./install-screenux.sh --bundle /path/to/screenux-screenshot.flatpak --print-screen ``` -### 3) Run Screenux +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 -./screenux-screenshot +./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 +./uninstall-screenux.sh +``` + +Preserve app data in `~/.var/app/io.github.rafa.ScreenuxScreenshot`: + +```bash +./uninstall-screenux.sh --preserve-user-data ``` ## 🖱️ Usage @@ -112,6 +137,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) @@ -150,6 +176,33 @@ 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 +``` + +`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 @@ -163,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). 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/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..4588aff 100644 --- a/flatpak/io.github.rafa.ScreenuxScreenshot.json +++ b/flatpak/io.github.rafa.ScreenuxScreenshot.json @@ -20,7 +20,10 @@ "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", + "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/install-screenux.sh b/install-screenux.sh new file mode 100755 index 0000000..5aafb57 --- /dev/null +++ b/install-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/install-screenux.sh" "$@" 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 new file mode 100644 index 0000000..023e1fc --- /dev/null +++ b/scripts/install/install-screenux.sh @@ -0,0 +1,177 @@ +#!/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 [--bundle /path/to/screenux-screenshot.flatpak] [--shortcut "['s']"] + ./install-screenux.sh [--bundle /path/to/screenux-screenshot.flatpak] --print-screen + +Options: + --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 + ./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 Flatpak first, then rerun." + fi + + 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 + 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 + create_icon_asset + refresh_icon_cache +} + +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}" + [[ -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() { + 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 bundle_path="" + local shortcut_mode="none" + local keybinding="" + local shortcut_option_seen="false" + local positional=() + + while (($# > 0)); do + case "$1" in + -h|--help) + 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) + [[ "${shortcut_option_seen}" == "false" ]] || fail "Only one of --shortcut, --print-screen, or --no-shortcut can be used." + shortcut_option_seen="true" + shortcut_mode="print" + ;; + --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 + while (($# > 0)); do + positional+=("$1") + shift + done + break + ;; + -*) + fail "Unknown option: $1" + ;; + *) + positional+=("$1") + ;; + esac + shift + done + + if ((${#positional[@]} > 1)); then + fail "Unexpected positional arguments. Use --bundle PATH and optional shortcut flags." + fi + + if [[ -z "${bundle_path}" && ${#positional[@]} -eq 1 ]]; then + bundle_path="${positional[0]}" + fi + + install_bundle "${bundle_path}" + configure_shortcut "${shortcut_mode}" "${keybinding}" + validate_installation + + echo "==> Installation complete" + 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..319de7e --- /dev/null +++ b/scripts/install/lib/common.sh @@ -0,0 +1,119 @@ +#!/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" +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']" + +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}" < 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() { + 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_icon_asset() { + 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 +} + +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/lib/gnome_shortcuts.sh b/scripts/install/lib/gnome_shortcuts.sh new file mode 100644 index 0000000..5745b7f --- /dev/null +++ b/scripts/install/lib/gnome_shortcuts.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +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" + +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}" +} diff --git a/scripts/install/uninstall-screenux.sh b/scripts/install/uninstall-screenux.sh new file mode 100755 index 0000000..c8f7b2f --- /dev/null +++ b/scripts/install/uninstall-screenux.sh @@ -0,0 +1,101 @@ +#!/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 + remove_icon_asset + refresh_icon_cache +} + +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}" + [[ ! -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}" + 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/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/src/screenux_screenshot.py b/src/screenux_screenshot.py index db36223..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") @@ -139,6 +157,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,21 +178,34 @@ 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: + 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 + GLib.idle_add(self._trigger_auto_capture, window) else: class ScreenuxScreenshotApp: # pragma: no cover def run(self, _argv: list[str]) -> int: @@ -175,8 +217,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..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,6 +71,7 @@ def __init__( format_status_saved: Callable[[Path], str], ): super().__init__(application=app, title="Screenux Screenshot") + self.set_icon_name(icon_name) self.set_default_size(360, 180) self._resolve_save_dir = resolve_save_dir @@ -89,7 +91,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 +114,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_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%" diff --git a/tests/test_install_uninstall_scripts.py b/tests/test_install_uninstall_scripts.py new file mode 100644 index 0000000..b554935 --- /dev/null +++ b/tests/test_install_uninstall_scripts.py @@ -0,0 +1,277 @@ +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" + 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)) + 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"), + ) + 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) + + 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" + 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") + 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") + 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") + + 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(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", + 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/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() diff --git a/tests/test_packaging_icon_metadata.py b/tests/test_packaging_icon_metadata.py new file mode 100644 index 0000000..6cfa577 --- /dev/null +++ b/tests/test_packaging_icon_metadata.py @@ -0,0 +1,39 @@ +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"] + 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_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__": + unittest.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() 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 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" "$@"