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 @@
+
@@ -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.
# ---------------------------------------------------------------------------