Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down Expand Up @@ -43,3 +45,4 @@ htmlcov/
# Project-specific build artifacts
build-dir/
.flatpak-builder/
.ci-shim/
39 changes: 39 additions & 0 deletions Dockerfile.deb
Original file line number Diff line number Diff line change
@@ -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"]
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>`, `Cancelled`, `Failed: <reason>`
- Built-in editor for quick annotations (shapes/text)
- Editor zoom controls with `Best fit` and quick presets (`33%` to `2000%`)
Expand All @@ -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
```
Expand Down Expand Up @@ -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

<img width="730" height="660" alt="image" src="https://github.com/user-attachments/assets/e7bf2baa-afe9-48a8-a84d-4f7126fde637" />
Expand All @@ -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

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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_<version>_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:
Expand Down
7 changes: 7 additions & 0 deletions packaging/linux/screenux-screenshot.desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Screenux Screenshot
Exec=screenux-screenshot
Icon=screenux-screenshot
Terminal=false
Categories=Utility;Graphics;
Binary file added packaging/linux/screenux-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packaging/pyinstaller_hooks/hook-gi.repository.Gdk.py
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 46 additions & 0 deletions packaging/pyinstaller_hooks/hook-gi.repository.Gtk.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading