feat: the Linux app as a Snap
All checks were successful
CI / check (push) Successful in 9m31s
CI / deploy (push) Successful in 11s

snap/snapcraft.yaml builds the Linux app as a strictly confined Snap on
core24 with the GNOME extension (GTK, WebKitGTK, GStreamer and libsecret
come from its runtime; files and the browser go through the desktop's
portals), connecting to learn.halkyon.eu, the project's instance. It
builds what is committed, with Flutter pinned as in CI.

- The session: libsecret's keyring over D-Bus needs the
  password-manager-service plug, which the Store never connects by
  itself, so unless it is connected the app keeps the session in an
  encrypted file whose key comes from the Secret portal
  (snap/local/secret-backend).
- network-manager-observe lets Serverpod hear that the network went and
  end the sync's connection at once; until the Store grants it, it too
  is connected by hand.
- The listing points to the project, not the instance: the website
  learn.halkyon.app, contact and issues support@halkyon.app.
- The icon is the app's with rounded corners (tool/snap_icon.py), for
  the Store and the dock alike.
- The app's version is 0.1.0, build 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-05 14:27:54 +02:00
commit 6da776e6ba
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 190 additions and 1 deletions

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ pubspec_overrides.yaml
.scloud/
# Deployment secrets (see deploy/.env.example)
deploy/.env
# Snaps built by `snapcraft pack` in the repository root.
/*.snap

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+1
version: 0.1.0+1
environment:
sdk: '^3.13.0'

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Halkyon Learn
Comment=Learn with flashcards, studied offline and synced between your devices
Exec=halkyon-learn
Icon=${SNAP}/meta/gui/halkyon-learn.png
Terminal=false
Categories=Education;
StartupWMClass=app.halkyon.learn

BIN
snap/gui/halkyon-learn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

12
snap/local/secret-backend Executable file
View file

@ -0,0 +1,12 @@
#!/bin/sh
# Starts the app with a way to keep the session that a Snap can use.
#
# Serverpod keeps the session with libsecret, whose usual way, the desktop's
# keyring over D-Bus, needs the password-manager-service plug, which the Snap
# Store never connects by itself. So unless someone connected it by hand,
# libsecret keeps an encrypted file in the Snap's own folder, whose key comes
# from the desktop's Secret portal.
if ! snapctl is-connected password-manager-service; then
export SECRET_BACKEND=file
fi
exec "$@"

118
snap/snapcraft.yaml Normal file
View file

@ -0,0 +1,118 @@
# The Linux app as a Snap, built in LXD on a developer's machine, in the repository
# root:
#
# snapcraft pack
#
# It builds what is committed (the source is this repository's HEAD), never the
# working tree with its build caches, and always for learn.halkyon.eu. Flutter is
# pinned like the local and CI one (FLUTTER_VERSION in .forgejo/workflows/ci.yaml):
# snapcraft's own Flutter plugin can only take a channel and can't pass
# --dart-define, so the build is spelled out below.
name: halkyon-learn
title: Halkyon Learn
base: core24
adopt-info: halkyon-learn # the version, from the app's pubspec.yaml
summary: Learn with flashcards, studied offline and synced between your devices
description: |
Halkyon Learn is a spaced-repetition learning app: studysets of flashcards with
rich text, notes, learning paths and PDFs, reviewed on an FSRS schedule. It works
offline and syncs between your devices.
It starts with an anonymous account; signing in with GitHub later keeps
everything. It connects to learn.halkyon.eu, the project's instance.
license: AGPL-3.0-or-later
source-code: https://code.halkyon.app/project-halkyon/halkyon_learn
# The project's, not an instance's: the app works with any instance.
website: https://learn.halkyon.app
contact: support@halkyon.app
# Reports by mail until people can open an account on the issue tracker.
issues: support@halkyon.app
# The app's icon with rounded corners, made by tool/snap_icon.py; also the desktop file's.
icon: snap/gui/halkyon-learn.png
grade: stable
confinement: strict
platforms:
amd64:
lint:
ignore:
# PDFium is loaded by name while the app runs, which the linter can't see.
- library:
- bin/lib/libpdfium.so
apps:
halkyon-learn:
command: bin/halkyon_learn_flutter
# How the session is kept: see snap/local/secret-backend.
command-chain: [bin/secret-backend]
# GTK, WebKitGTK, GStreamer and libsecret come from the GNOME runtime; files
# and the browser go through the desktop's portals.
extensions: [gnome]
plugs:
- network
# GitHub sign-in answers on a loopback address the app listens on.
- network-bind
- audio-playback
# Never connected by itself; whoever connects it by hand gets the keyring.
- password-manager-service
# Lets Serverpod hear from NetworkManager that the network went, and end
# the sync's connection at once rather than after 40 s of silence. Not
# connected by itself until the Store grants it on a forum request;
# without it the app logs the refusal once and works on.
- network-manager-observe
parts:
flutter:
plugin: nil
source: https://github.com/flutter/flutter.git
source-tag: '3.47.5'
source-depth: 1
build-packages:
- clang
- cmake
- curl
- git
- libgtk-3-dev
- lld
- llvm-18
- ninja-build
- pkg-config
- unzip
- xz-utils
- zip
override-build: |
mkdir -p "$CRAFT_PART_INSTALL/usr/libexec" "$CRAFT_PART_INSTALL/usr/bin"
cp -r "$CRAFT_PART_SRC" "$CRAFT_PART_INSTALL/usr/libexec/flutter"
ln -s ../libexec/flutter/bin/flutter "$CRAFT_PART_INSTALL/usr/bin/flutter"
ln -s ../libexec/flutter/bin/dart "$CRAFT_PART_INSTALL/usr/bin/dart"
"$CRAFT_PART_INSTALL/usr/bin/flutter" --disable-analytics
"$CRAFT_PART_INSTALL/usr/bin/flutter" precache --linux
# Needed to build, not in the Snap.
override-prime: ''
halkyon-learn:
after: [flutter]
plugin: nil
source: .
source-type: git
# The sound player links GStreamer, which the GNOME SDK lacks; the GNOME
# runtime brings the same GStreamer (1.24) to run.
build-packages:
- libgstreamer1.0-dev
- libgstreamer-plugins-base1.0-dev
override-build: |
set -eu
export PATH="$CRAFT_STAGE/usr/bin:$PATH"
cd halkyon_learn_flutter
craftctl set version="$(sed -n 's/^version: *\([^+]*\).*/\1/p' pubspec.yaml)"
flutter pub get --enforce-lockfile
flutter build linux --release \
--dart-define=SERVER_URL=https://learn.halkyon.eu/
mkdir -p "$CRAFT_PART_INSTALL/bin"
cp -r build/linux/x64/release/bundle/. "$CRAFT_PART_INSTALL/bin/"
secret-backend:
plugin: dump
source: snap/local
organize:
secret-backend: bin/secret-backend

47
tool/snap_icon.py Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Makes the snap's icon from the app's: the same picture with rounded corners.
The app's icon is a white square, which the Snap Store and the desktop's dock
show as it is, so the snap's icon rounds its corners off to transparency. The
radius, 142.222 of 512 pixels (about 28 %), is the one other desktop apps'
white rounded tiles use. The mask is drawn at four times the size and scaled
down, so the corners' edges are smooth.
Run from the repository root with Python 3 and Pillow (python3-pil on Ubuntu):
python3 tool/snap_icon.py
It rewrites snap/gui/halkyon-learn.png (512 x 512 pixels, the Store's size),
used by both the Store listing and the desktop file.
"""
from pathlib import Path
from PIL import Image, ImageDraw
SOURCE = Path("halkyon_learn_flutter/assets/icon/halkyon_icon.png")
TARGET = Path("snap/gui/halkyon-learn.png")
SIZE = 512
RADIUS = 142.222
SUPERSAMPLING = 4
def main() -> None:
icon = Image.open(SOURCE).convert("RGBA").resize((SIZE, SIZE), Image.LANCZOS)
large = SIZE * SUPERSAMPLING
mask = Image.new("L", (large, large), 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, large - 1, large - 1), radius=RADIUS * SUPERSAMPLING, fill=255
)
mask = mask.resize((SIZE, SIZE), Image.LANCZOS)
# Keeps what the icon itself makes transparent, and takes the corners away.
transparent = Image.new("L", (SIZE, SIZE), 0)
icon.putalpha(Image.composite(icon.getchannel("A"), transparent, mask))
icon.save(TARGET, optimize=True)
print(f"Wrote {TARGET} ({TARGET.stat().st_size} bytes).")
if __name__ == "__main__":
main()