diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml new file mode 100644 index 0000000..dba7e10 --- /dev/null +++ b/.github/workflows/snap.yml @@ -0,0 +1,66 @@ +name: snap + +# Snap is its own pipeline, separate from the wheel-derived artifacts: it doesn't +# consume the shared wheel, it ships its own runtime (base: core24), and it +# publishes to the Snap Store rather than PyPI / the GitHub Release. It's also a +# heavy build, so it runs only on a tag or on demand — not on every push/PR. +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + # Build and smoke-test the snap, then hand it off as an artifact. This runs + # freely; only the store upload below is gated. + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # action-build installs LXD + snapcraft and builds inside a core24 + # container, so the runner OS is irrelevant. The version comes from the + # AppStream via adopt-info in snapcraft.yaml. + - name: Build the snap + uses: canonical/action-build@v1.3.0 + id: snapcraft + + # Catch "builds but won't run" before it can reach the store. + - name: Smoke-test the snap + run: | + sudo snap install --dangerous ${{ steps.snapcraft.outputs.snap }} + npyquick --version + + - name: Upload the snap + uses: actions/upload-artifact@v4 + with: + name: snap + path: ${{ steps.snapcraft.outputs.snap }} + + # Upload to the Snap Store's edge channel. Gated on the `snap-store` + # environment (like publish-pypi is on `pypi`): the job waits for a manual + # approval before anything is published. Promotion edge -> stable stays a + # separate manual step. Needs the SNAPCRAFT_STORE_CREDENTIALS secret (from + # `snapcraft export-login`); scope it to this environment. + publish: + needs: build + runs-on: ubuntu-latest + environment: snap-store + steps: + - name: Download the snap + uses: actions/download-artifact@v4 + with: + name: snap + path: snap-artifact + + - name: Locate the snap + id: loc + run: echo "path=$(ls snap-artifact/*.snap)" >> "$GITHUB_OUTPUT" + + - name: Publish to the edge channel + uses: canonical/action-publish@v1.2.0 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.loc.outputs.path }} + release: edge diff --git a/README.md b/README.md index f8217cf..d9324d1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Python versions Windows EXE Linux AppImage + Snap Store License

@@ -31,7 +32,7 @@ npyquick is designed as a small, practical viewer for this job: **Linux (AppImage):** -On x86-64 Linux, AppImage is available for one-click installation. Download `npyquick-x86_64.AppImage` from the [latest release](https://github.com/LiukDiihMieu/npyquick/releases/latest), then: +On x86-64 Linux, AppImage is available for one-click installation. Download `npyquick--x86_64.AppImage` from the [latest release](https://github.com/LiukDiihMieu/npyquick/releases/latest), then: ```bash chmod +x npyquick-x86_64.AppImage @@ -40,6 +41,19 @@ chmod +x npyquick-x86_64.AppImage To open `.npy` / `.npz` by double-clicking in your file manager, register the default handler once with `./npyquick-x86_64.AppImage --install-desktop` (details in [Linux desktop integration](#linux-desktop-integration)). If you integrate the AppImage with a tool like [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher), this association is set up for you automatically. +**Linux (Snap):** + +On Ubuntu and other distributions with snap support, install from the Snap Store: + +```bash +sudo snap install npyquick +``` + +The Snap has two limitations the AppImage and pip builds don't: + +- **File access:** it's sandboxed to your home folder, plus removable media after you run `sudo snap connect npyquick:removable-media`. Files on other drives won't open — including home folders that are symlinks to another disk — so use the AppImage or pip install for those. +- **No double-click:** the Snap can't register `.npy` / `.npz` file associations, so double-clicking won't launch it. Open npyquick first, then drag a file onto the window, or use File › Open. + **Windows:** Download the `npyquick--setup.exe` installer from the [latest release](https://github.com/LiukDiihMieu/npyquick/releases/latest) and run it. It installs per-user (no administrator rights needed) and adds a Start Menu shortcut. To open `.npy` / `.npz` by double-clicking, tick *Associate .npy and .npz files with npyquick* during setup. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index fa0230a..748a97f 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -30,6 +30,20 @@ verification of what CI produced (and the recipe if you ever build locally). - [ ] `--install-desktop` writes a correct desktop entry. - [ ] AppImageLauncher integration still works. +## Linux Snap + +Built and published manually (not by the tag CI), on an Ubuntu 22.04 host with LXD. + +- [ ] `snapcraft pack --use-lxd` builds the `.snap` (version is adopted from the AppStream ``). +- [ ] Installs with `sudo snap install --dangerous`. +- [ ] `.npy` and `.npz` sample files open across the image / table / cross-section / histogram views. +- [ ] Launches from the application menu with the correct icon. +- [ ] Works on both X11 and Wayland sessions (the Qt xcb path needs the staged xcb/xkb libs). +- [ ] A file outside the sandbox (e.g. on another drive) shows the sandbox hint, not a raw error. +- [ ] Uploaded to the edge channel: `snapcraft upload --release=edge ...`. +- [ ] After edge verification, promoted to stable: `snapcraft release npyquick stable`. +- [ ] Store text/icon updated with `snapcraft upload-metadata --force`; links and screenshots set in the web dashboard. + ## Windows installer - [ ] PyInstaller onedir build succeeds on Windows. diff --git a/snap/README.md b/snap/README.md new file mode 100644 index 0000000..2729a09 --- /dev/null +++ b/snap/README.md @@ -0,0 +1,26 @@ +# npyquick (Snap) + +Snap packaging for npyquick. Build locally with: + +```sh +snapcraft pack --use-lxd +``` + +## Opening files + +The Snap is strictly confined, so it reads files in your home directory (and +removable media once that interface is connected). To open an array: + +- **Drag and drop** a `.npy` or `.npz` file onto the window — the quickest way. +- Or use **File ▸ Open**. + +### Why double-click doesn't open npyquick + +Double-clicking a `.npy` / `.npz` in the file manager will not launch npyquick. +A Snap cannot register new file types with the host system, so the desktop +doesn't recognise these extensions as belonging to npyquick. This is a +limitation of how Snaps integrate with the system, not a setting we can flip +in the package (it is unrelated to the sandbox — the same gap exists without +strict confinement). + +Open npyquick from the application menu, then drag a file in or use File ▸ Open. diff --git a/snap/gui/npyquick.desktop b/snap/gui/npyquick.desktop new file mode 100644 index 0000000..c3349c5 --- /dev/null +++ b/snap/gui/npyquick.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=npyquick +GenericName=NumPy Array Viewer +Comment=Quick viewer for NumPy .npy and .npz files +Exec=npyquick %f +Icon=${SNAP}/meta/gui/npyquick.png +Terminal=false +Categories=Science;Utility; +MimeType=application/x-npy;application/x-npz; +Keywords=numpy;npy;npz;array;viewer; +StartupNotify=true diff --git a/snap/gui/npyquick.png b/snap/gui/npyquick.png new file mode 100644 index 0000000..4ab275d Binary files /dev/null and b/snap/gui/npyquick.png differ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..8e2a6fe --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,68 @@ +name: npyquick +base: core24 +# title, version, summary and description are adopted from the AppStream +# metainfo file (see the `metadata` part), keeping them in one place shared +# with the AppImage build instead of hand-maintaining them here. +adopt-info: metadata +# Store/launcher icon. AppStream has no element, so set it explicitly. +# Store icon limits: <=512x512, 1:1, <=256 KB. +icon: snap/gui/npyquick.png +# Store listing links (AppStream URLs are not adopted, so keep these). +contact: https://github.com/LiukDiihMieu/npyquick/issues +issues: https://github.com/LiukDiihMieu/npyquick/issues +source-code: https://github.com/LiukDiihMieu/npyquick + +license: GPL-3.0-or-later +grade: stable +confinement: strict + +apps: + npyquick: + command: bin/npyquick + # The gnome extension provides common desktop integration pieces + # such as fonts, themes, icons, Wayland/X11, and related runtime setup. + # PySide6/Qt itself still comes from the Python dependencies. + extensions: [gnome] + common-id: io.github.liukdiihmieu.npyquick + # The .desktop entry and icon come from snap/gui/ (copied to meta/gui/ at + # build; snapd auto-detects them). The desktop file's Icon= points at + # ${SNAP}/meta/gui/npyquick.png — an absolute in-snap path, which is what + # the launcher needs to resolve a bundled icon. + plugs: + # Allow opening user data from the home directory. + # Files under /mnt, /media, or /run/media require the removable-media + # interface to be connected manually. + - home + - removable-media + +parts: + npyquick: + plugin: python + source: . + stage-packages: + # System libraries the Qt6 xcb (X11) platform plugin links against. The + # gnome platform supplies some xcb libs but not all of these; without them + # the app runs on Wayland but crashes on X11 with "Could not load the Qt + # platform plugin xcb". The missing set was confirmed via `ldd libqxcb.so` + # on an X11 host. (Qt's error singles out xcb-cursor, but the real gaps are + # the xkb/icccm/keysyms/shape ones below.) + - libxcb-cursor0 + - libxcb-icccm4 + - libxcb-keysyms1 + - libxcb-shape0 + - libxcb-xkb1 + - libxkbcommon-x11-0 + + # Adopt store metadata (title/version/summary/description) from the AppStream + # metainfo file, reusing the one written for the AppImage. Also ships it at + # the standard usr/share/metainfo/ path so the installed snap carries proper + # AppStream metadata. + metadata: + plugin: dump + source: packaging/appimage + organize: + io.github.liukdiihmieu.npyquick.appdata.xml: usr/share/metainfo/io.github.liukdiihmieu.npyquick.appdata.xml + stage: + - usr/share/metainfo + parse-info: + - usr/share/metainfo/io.github.liukdiihmieu.npyquick.appdata.xml diff --git a/src/npyquick/app.py b/src/npyquick/app.py index 7a444f0..d40bfd2 100644 --- a/src/npyquick/app.py +++ b/src/npyquick/app.py @@ -406,11 +406,37 @@ def open_file(self) -> None: if path: self.load_file(path) + @staticmethod + def _snap_sandbox_hint(path: str) -> str | None: + """When running as a confined Snap, explain why a file the sandbox + can't reach failed to open. Returns None unless we are a Snap *and* + the path is genuinely unreachable from inside confinement — so a + visible-but-failing file (e.g. corrupt) keeps its real error. + """ + if not os.environ.get("SNAP"): + return None + # Reachable from inside the Snap -> the failure has another cause. + if os.path.exists(path): + return None + real = os.path.realpath(path) + home = os.path.realpath(os.environ.get("SNAP_REAL_HOME") or os.path.expanduser("~")) + if real == home or real.startswith(home + os.sep): + return None # a missing file under home is just a normal "not found" + if real.startswith(("/media/", "/run/media/", "/mnt/")): + return ( + "Can't open this file — if it's on a USB/external drive, run: " + "sudo snap connect npyquick:removable-media" + ) + return ( + "Can't open this file — Snap can only access your home folder " + "and connected removable-media locations." + ) + def load_file(self, path: str) -> bool: try: self._model.load(path) except Exception as exc: - self._sb.showMessage(f"Error loading {path}: {exc}") + self._sb.showMessage(self._snap_sandbox_hint(path) or f"Error loading {path}: {exc}") return False self._current_path = path diff --git a/tests/test_app_workflow.py b/tests/test_app_workflow.py index e6e6f20..30684ea 100644 --- a/tests/test_app_workflow.py +++ b/tests/test_app_workflow.py @@ -7,6 +7,9 @@ from __future__ import annotations import numpy as np +import pytest + +from npyquick.app import MainWindow # --------------------------------------------------------------------------- @@ -42,6 +45,68 @@ def test_corrupt_npy_load_preserves_previously_loaded_array( np.testing.assert_array_equal(main_window._model.array, prior) +# --------------------------------------------------------------------------- +# load_file() Snap-sandbox hint: when confined as a Snap, a path the sandbox +# can't reach gets a clear explanation instead of a raw "No such file" error. +# --------------------------------------------------------------------------- + +def test_sandbox_hint_absent_outside_snap(monkeypatch): + monkeypatch.delenv("SNAP", raising=False) + assert MainWindow._snap_sandbox_hint("/nonexistent_drive/x.npy") is None + + +def test_sandbox_hint_absent_for_paths_under_home(monkeypatch): + monkeypatch.setenv("SNAP", "/snap/npyquick/x1") + monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice") + assert MainWindow._snap_sandbox_hint("/home/alice/lab/x.npy") is None + + +def test_sandbox_hint_for_other_drive(monkeypatch): + monkeypatch.setenv("SNAP", "/snap/npyquick/x1") + monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice") + msg = MainWindow._snap_sandbox_hint("/nonexistent_drive/experiments/x.npy") + assert msg is not None and "home folder" in msg and "snap connect" not in msg + + +@pytest.mark.parametrize("path", [ + "/media/nonexistent/usb/x.npy", + "/run/media/alice/USB/x.npy", + "/mnt/nonexistent/x.npy", +]) +def test_sandbox_hint_for_removable_media(path, monkeypatch): + monkeypatch.setenv("SNAP", "/snap/npyquick/x1") + monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice") + msg = MainWindow._snap_sandbox_hint(path) + assert msg is not None and "sudo snap connect npyquick:removable-media" in msg + + +def test_sandbox_hint_follows_symlink_to_other_drive(tmp_path, monkeypatch): + """A symlink under home pointing to another drive resolves out of reach.""" + monkeypatch.setenv("SNAP", "/snap/npyquick/x1") + monkeypatch.setenv("SNAP_REAL_HOME", str(tmp_path)) # treat tmp_path as home + link = tmp_path / "link.npy" + link.symlink_to("/nonexistent_drive/experiments/x.npy") + msg = MainWindow._snap_sandbox_hint(str(link)) + assert msg is not None and "home folder" in msg + + +def test_sandbox_hint_absent_when_path_is_visible(tmp_path, monkeypatch): + """A reachable file that fails for another reason keeps its real error.""" + monkeypatch.setenv("SNAP", "/snap/npyquick/x1") + monkeypatch.setenv("SNAP_REAL_HOME", str(tmp_path / "home")) # home elsewhere + f = tmp_path / "x.npy" + f.write_bytes(b"x") # exists / visible, but outside the (fake) home + assert MainWindow._snap_sandbox_hint(str(f)) is None + + +def test_load_file_uses_sandbox_hint_when_confined(main_window, monkeypatch): + """A failed load of an out-of-sandbox path reports the hint, not the raw error.""" + monkeypatch.setenv("SNAP", "/snap/npyquick/x1") + monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice") + main_window.load_file("/nonexistent_drive/experiments/missing.npy") + assert "home folder" in main_window._sb.currentMessage() + + # --------------------------------------------------------------------------- # Export Plot menu shape per view type, plus correct rebuild on transitions. # ---------------------------------------------------------------------------