Compare commits

...

6 Commits

  1. 3
      .gitignore
  2. 32
      Dockerfile.build
  3. 54
      README.md
  4. 145
      cellar-gtk.py
  5. 4
      cellar.spec
  6. 4
      flatpak/cellar-launcher.sh
  7. 11
      flatpak/net.enne2.Cellar.desktop
  8. 39
      flatpak/net.enne2.Cellar.metainfo.xml
  9. BIN
      flatpak/net.enne2.Cellar.png
  10. 27
      net.enne2.Cellar.yaml
  11. 44
      scripts/build-binaries.sh
  12. 45
      scripts/build-flatpak-repo.sh
  13. 38
      scripts/publish-flatpak-repo.sh

3
.gitignore vendored

@ -21,6 +21,9 @@ env/
*.egg-info/ *.egg-info/
dist/ dist/
build/ build/
flatpak-build/
flatpak-repo/
*.flatpak
# Test caches # Test caches
.pytest_cache/ .pytest_cache/

32
Dockerfile.build

@ -0,0 +1,32 @@
# Build image for Cellar binaries, targeting glibc 2.35 (Ubuntu 22.04).
# This keeps the binaries compatible with older systems than the host Fedora
# build, while still providing GTK4/libadwaita packages.
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
# ── System deps ──────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-dev python3-pip python3-venv \
# GTK4 / Adwaita
libgtk-4-dev libadwaita-1-dev python3-gi python3-gi-cairo \
gir1.2-gtk-4.0 gir1.2-adw-1 libglib2.0-dev \
# Misc build deps
build-essential git libffi-dev libssl-dev \
# UPX
upx-ucl \
# binutils for strip
binutils \
&& rm -rf /var/lib/apt/lists/*
# ── Python environment ───────────────────────────────────────────────────────
RUN python3 -m venv --system-site-packages /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel \
&& python -m pip install --no-cache-dir pyinstaller requests
# ── Build ─────────────────────────────────────────────────────────────────────
WORKDIR /src
COPY . .
CMD ["/bin/bash", "/src/scripts/build-binaries.sh"]

54
README.md

@ -57,7 +57,7 @@ Not implemented yet:
- [docker-compose.yml](docker-compose.yml) — local deployment - [docker-compose.yml](docker-compose.yml) — local deployment
- [requirements.txt](requirements.txt) — Python dependencies - [requirements.txt](requirements.txt) — Python dependencies
- [client.py](client.py) — minimal CLI client - [client.py](client.py) — minimal CLI client
- [gtk_client.py](gtk_client.py) — GTK4 desktop client - [cellar-gtk.py](cellar-gtk.py) — GTK4 desktop client
- [app/main.py](app/main.py) — API routes - [app/main.py](app/main.py) — API routes
- [app/models.py](app/models.py) — SQLAlchemy model - [app/models.py](app/models.py) — SQLAlchemy model
- [app/schemas.py](app/schemas.py) — API response schemas - [app/schemas.py](app/schemas.py) — API response schemas
@ -163,6 +163,56 @@ Interactive API docs are available at:
--- ---
## Flatpak package
The repository now includes a Flatpak manifest for the GTK client:
- [net.enne2.Cellar.yaml](net.enne2.Cellar.yaml)
Flatpak packaging assets are stored in [flatpak/](flatpak/):
- desktop entry
- AppStream metadata
- launcher script
- icon generated from [cellar.jpg](cellar.jpg)
Typical local build flow:
1. install `flatpak-builder`
2. install the required runtimes:
- `org.gnome.Platform//47`
- `org.gnome.Sdk//47`
3. run:
`flatpak-builder --user --install --force-clean flatpak-build net.enne2.Cellar.yaml`
Then launch it with:
`flatpak run net.enne2.Cellar`
To export a private Flatpak repository and a single-file bundle, use:
`./scripts/build-flatpak-repo.sh`
This creates:
- `flatpak-repo/` — OSTree/Flatpak repository ready to publish
- `dist/net.enne2.Cellar.flatpak` — installable Flatpak bundle
### Publish on `brain.local`
If `brain.local` serves static files, copy the generated repository there, for example:
`./scripts/publish-flatpak-repo.sh brain.local /var/www/html/flatpak/cellar`
Then clients can add the remote and install the app with:
`flatpak remote-add --if-not-exists --user --no-gpg-verify brain-local http://brain.local/flatpak/cellar`
`flatpak install --user brain-local net.enne2.Cellar`
---
## Environment configuration ## Environment configuration
Current environment variables used by the API container: Current environment variables used by the API container:
@ -257,7 +307,7 @@ Use a custom Bottles directory:
Run: Run:
`python gtk_client.py` `python cellar-gtk.py`
The GUI lets you: The GUI lets you:

145
cellar-gtk.py

@ -3,12 +3,23 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys
import threading import threading
import urllib.error import urllib.error
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
# When running as a frozen binary (PyInstaller) on non-GNOME desktops (e.g. KDE),
# no GTK theme is configured by default. Force Adwaita so the bundled theme is
# used and the UI renders correctly.
if getattr(sys, "frozen", False):
os.environ.setdefault("GTK_THEME", "Adwaita")
# Allow GTK4 to fall back from Wayland to X11 if the Wayland compositor
# does not support the required protocols (common on some KDE setups).
os.environ.setdefault("GDK_BACKEND", "wayland,x11")
try: try:
import gi # type: ignore[import-not-found] import gi # type: ignore[import-not-found]
except ImportError as exc: except ImportError as exc:
@ -31,6 +42,7 @@ gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore[import-not-found] from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore[import-not-found]
# ── Constants ─────────────────────────────────────────────────── # ── Constants ───────────────────────────────────────────────────
APP_ID = "net.enne2.Cellar"
CONFIG_PATH = Path.home() / ".config" / "cellar" / "gtk_client.json" CONFIG_PATH = Path.home() / ".config" / "cellar" / "gtk_client.json"
APP_CSS = """\ APP_CSS = """\
@ -134,7 +146,10 @@ def normalize_name(value: str | None) -> str:
def _load_css() -> None: def _load_css() -> None:
provider = Gtk.CssProvider() provider = Gtk.CssProvider()
provider.load_from_string(APP_CSS) if hasattr(provider, "load_from_string"):
provider.load_from_string(APP_CSS)
else:
provider.load_from_data(APP_CSS.encode("utf-8"))
Gtk.StyleContext.add_provider_for_display( Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), Gdk.Display.get_default(),
provider, provider,
@ -142,6 +157,114 @@ def _load_css() -> None:
) )
class CompatBanner(Gtk.Revealer):
"""Fallback banner for libadwaita versions that don't provide `Adw.Banner`."""
def __init__(self) -> None:
super().__init__()
self.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
box.set_margin_top(8)
box.set_margin_bottom(8)
box.set_margin_start(12)
box.set_margin_end(12)
box.add_css_class("toolbar")
self._label = Gtk.Label(xalign=0)
self._label.set_wrap(True)
self._label.set_hexpand(True)
box.append(self._label)
self.set_child(box)
def set_title(self, title: str) -> None:
self._label.set_text(title)
def set_revealed(self, revealed: bool) -> None:
self.set_reveal_child(revealed)
class CompatToolbarView(Gtk.Box):
"""Fallback for `Adw.ToolbarView` (available since libadwaita 1.4)."""
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._content: Gtk.Widget | None = None
def add_top_bar(self, widget: Gtk.Widget) -> None:
self.append(widget)
def set_content(self, widget: Gtk.Widget) -> None:
if self._content is not None:
self.remove(self._content)
self._content = widget
widget.set_vexpand(True)
self.append(widget)
class CompatEntryRow(Adw.ActionRow):
"""Fallback for `Adw.EntryRow` (available since libadwaita 1.2)."""
def __init__(self, title: str = ""):
super().__init__(title=title)
self._entry = Gtk.Entry()
self._entry.set_hexpand(True)
self.add_suffix(self._entry)
self.set_activatable_widget(self._entry)
def get_text(self) -> str:
return self._entry.get_text()
def set_text(self, text: str) -> None:
self._entry.set_text(text)
class CompatSwitchRow(Adw.ActionRow):
"""Fallback for `Adw.SwitchRow` (available since libadwaita 1.4)."""
def __init__(self, title: str = ""):
super().__init__(title=title)
self._switch = Gtk.Switch()
self._switch.set_valign(Gtk.Align.CENTER)
self.add_suffix(self._switch)
self.set_activatable_widget(self._switch)
def get_active(self) -> bool:
return self._switch.get_active()
def set_active(self, active: bool) -> None:
self._switch.set_active(active)
def connect_active_notify(self, callback: Callable[..., None]) -> None:
self._switch.connect("notify::active", callback)
def make_toolbar_view() -> Gtk.Widget:
if hasattr(Adw, "ToolbarView"):
return Adw.ToolbarView()
return CompatToolbarView()
def make_entry_row(title: str) -> Gtk.Widget:
if hasattr(Adw, "EntryRow"):
return Adw.EntryRow(title=title)
return CompatEntryRow(title=title)
def make_switch_row(title: str) -> Gtk.Widget:
if hasattr(Adw, "SwitchRow"):
return Adw.SwitchRow(title=title)
return CompatSwitchRow(title=title)
def connect_switch_row_active(row: Gtk.Widget, callback: Callable[..., None]) -> None:
if hasattr(row, "connect_active_notify"):
row.connect_active_notify(callback) # type: ignore[attr-defined]
else:
row.connect("notify::active", callback)
# ── Settings Dialog ───────────────────────────────────────────── # ── Settings Dialog ─────────────────────────────────────────────
class SettingsWindow(Adw.Window): class SettingsWindow(Adw.Window):
"""Preferences dialog using libadwaita widgets.""" """Preferences dialog using libadwaita widgets."""
@ -159,7 +282,7 @@ class SettingsWindow(Adw.Window):
self.set_modal(True) self.set_modal(True)
self.set_default_size(480, -1) self.set_default_size(480, -1)
toolbar_view = Adw.ToolbarView() toolbar_view = make_toolbar_view()
header = Adw.HeaderBar() header = Adw.HeaderBar()
cancel_btn = Gtk.Button(label="Annulla") cancel_btn = Gtk.Button(label="Annulla")
@ -180,11 +303,11 @@ class SettingsWindow(Adw.Window):
"locale di Bottles usata per l'installazione." "locale di Bottles usata per l'installazione."
) )
self.server_row = Adw.EntryRow(title="Indirizzo server") self.server_row = make_entry_row("Indirizzo server")
self.server_row.set_text(config.server_url) self.server_row.set_text(config.server_url)
group.add(self.server_row) group.add(self.server_row)
self.bottles_row = Adw.EntryRow(title="Cartella Bottles") self.bottles_row = make_entry_row("Cartella Bottles")
self.bottles_row.set_text(config.bottles_dir) self.bottles_row.set_text(config.bottles_dir)
group.add(self.bottles_row) group.add(self.bottles_row)
@ -326,6 +449,7 @@ class CellarWindow(Adw.ApplicationWindow):
def __init__(self, app: Adw.Application): def __init__(self, app: Adw.Application):
super().__init__(application=app, title="Cellar") super().__init__(application=app, title="Cellar")
self.set_icon_name(APP_ID)
self.config = load_config() self.config = load_config()
self.settings_window: SettingsWindow | None = None self.settings_window: SettingsWindow | None = None
self.rows: list[ArchiveRow] = [] self.rows: list[ArchiveRow] = []
@ -363,7 +487,10 @@ class CellarWindow(Adw.ApplicationWindow):
# ── Outer box (banner + progress + scrolled content) ── # ── Outer box (banner + progress + scrolled content) ──
outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.banner = Adw.Banner() if hasattr(Adw, "Banner"):
self.banner = Adw.Banner()
else:
self.banner = CompatBanner()
self.banner.set_revealed(False) self.banner.set_revealed(False)
outer_box.append(self.banner) outer_box.append(self.banner)
@ -399,9 +526,9 @@ class CellarWindow(Adw.ApplicationWindow):
self.bottles_info_row.add_css_class("property") self.bottles_info_row.add_css_class("property")
info_group.add(self.bottles_info_row) info_group.add(self.bottles_info_row)
self.replace_row = Adw.SwitchRow(title="Sostituisci installazioni esistenti") self.replace_row = make_switch_row("Sostituisci installazioni esistenti")
self.replace_row.set_active(self.config.replace_existing) self.replace_row.set_active(self.config.replace_existing)
self.replace_row.connect("notify::active", self._store_replace_preference) connect_switch_row_active(self.replace_row, self._store_replace_preference)
info_group.add(self.replace_row) info_group.add(self.replace_row)
content_box.append(info_group) content_box.append(info_group)
@ -447,7 +574,7 @@ class CellarWindow(Adw.ApplicationWindow):
self.toast_overlay.set_child(outer_box) self.toast_overlay.set_child(outer_box)
# ── ToolbarView ── # ── ToolbarView ──
toolbar_view = Adw.ToolbarView() toolbar_view = make_toolbar_view()
toolbar_view.add_top_bar(header) toolbar_view.add_top_bar(header)
toolbar_view.set_content(self.toast_overlay) toolbar_view.set_content(self.toast_overlay)
@ -699,7 +826,7 @@ class CellarWindow(Adw.ApplicationWindow):
# ── Application ───────────────────────────────────────────────── # ── Application ─────────────────────────────────────────────────
class CellarApplication(Adw.Application): class CellarApplication(Adw.Application):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(application_id="com.cellar.gtkclient") super().__init__(application_id=APP_ID)
def do_activate(self) -> None: def do_activate(self) -> None:
_load_css() _load_css()

4
cellar.spec

@ -2,7 +2,7 @@
"""PyInstaller spec for Cellar GTK – portable all-in-one executable.""" """PyInstaller spec for Cellar GTK – portable all-in-one executable."""
a = Analysis( a = Analysis(
["gtk_client.py"], ["cellar-gtk.py"],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[],
@ -32,7 +32,7 @@ a = Analysis(
"Gdk": "4.0", "Gdk": "4.0",
"Gsk": "4.0", "Gsk": "4.0",
}, },
"icons": ["Adwaita"], "icons": ["Adwaita", "hicolor"],
"themes": ["Adwaita"], "themes": ["Adwaita"],
"languages": [], "languages": [],
}, },

4
flatpak/cellar-launcher.sh

@ -0,0 +1,4 @@
#!/bin/sh
set -eu
export PYTHONPATH="/app/share/cellar${PYTHONPATH:+:$PYTHONPATH}"
exec python3 /app/share/cellar/cellar-gtk.py "$@"

11
flatpak/net.enne2.Cellar.desktop

@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=Cellar
Comment=Browse and restore Bottle archives from a Cellar server
Exec=cellar
Icon=net.enne2.Cellar
Terminal=false
Categories=Utility;Archiving;GTK;
Keywords=bottles;backup;archive;restore;wine;
StartupNotify=true
StartupWMClass=net.enne2.Cellar

39
flatpak/net.enne2.Cellar.metainfo.xml

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>net.enne2.Cellar</id>
<name>Cellar</name>
<summary>Browse and restore Bottle archives from a self-hosted server</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<developer id="net.enne2">
<name>Matteo Benedetto</name>
</developer>
<launchable type="desktop-id">net.enne2.Cellar.desktop</launchable>
<url type="homepage">https://git.enne2.net/enne2/cellar</url>
<url type="bugtracker">https://git.enne2.net/enne2/cellar/issues</url>
<provides>
<binary>cellar</binary>
</provides>
<description>
<p>Cellar is a small GTK application for browsing and restoring archives stored on a self-hosted Bottle archive server.</p>
<p>It can refresh the remote catalog, search archives locally, and reinstall archived Bottles environments with progress feedback.</p>
</description>
<categories>
<category>Utility</category>
<category>Archiving</category>
</categories>
<screenshots>
<screenshot type="default">
<caption>Cellar application artwork</caption>
<image type="source" width="1024" height="1024">https://git.enne2.net/enne2/cellar/raw/branch/master/cellar.jpg</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#5b0f11</color>
<color type="primary" scheme_preference="dark">#d8b27d</color>
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="1.0.0" date="2026-03-08" />
</releases>
</component>

BIN
flatpak/net.enne2.Cellar.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

27
net.enne2.Cellar.yaml

@ -0,0 +1,27 @@
app-id: net.enne2.Cellar
branch: stable
runtime: org.gnome.Platform
runtime-version: '47'
sdk: org.gnome.Sdk
command: cellar
finish-args:
- --share=ipc
- --share=network
- --socket=wayland
- --socket=fallback-x11
- --device=dri
- --filesystem=home
modules:
- name: cellar
buildsystem: simple
build-commands:
- install -Dm755 flatpak/cellar-launcher.sh /app/bin/cellar
- install -Dm644 cellar-gtk.py /app/share/cellar/cellar-gtk.py
- install -Dm644 client.py /app/share/cellar/client.py
- install -Dm644 cellar.jpg /app/share/cellar/cellar.jpg
- install -Dm644 flatpak/net.enne2.Cellar.desktop /app/share/applications/net.enne2.Cellar.desktop
- install -Dm644 flatpak/net.enne2.Cellar.metainfo.xml /app/share/metainfo/net.enne2.Cellar.metainfo.xml
- install -Dm644 flatpak/net.enne2.Cellar.png /app/share/icons/hicolor/512x512/apps/net.enne2.Cellar.png
sources:
- type: dir
path: .

44
scripts/build-binaries.sh

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME="${HOME:-/tmp}"
export PYINSTALLER_CONFIG_DIR="${PYINSTALLER_CONFIG_DIR:-/tmp/pyinstaller-cache}"
cd /src
echo "==> Python"
python --version
echo
echo "==> Cleaning previous artifacts"
rm -rf build dist
mkdir -p dist
echo
echo "==> Building cellar-cli"
python -m PyInstaller cellar-cli.spec --clean --noconfirm
echo
echo "==> Building cellar GTK"
python -m PyInstaller cellar.spec --clean --noconfirm
echo
echo "==> Built files"
ls -lh dist
echo
show_glibc_requirement() {
local binary="$1"
echo "-- $(basename "$binary")"
objdump -p "$binary" 2>/dev/null \
| grep -oE 'GLIBC_[0-9]+\.[0-9]+' \
| sort -Vu \
| tail -1
}
echo "==> GLIBC requirements"
show_glibc_requirement dist/cellar-cli
show_glibc_requirement dist/cellar
echo
echo "==> Done"

45
scripts/build-flatpak-repo.sh

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_ID="net.enne2.Cellar"
BRANCH="stable"
MANIFEST="${ROOT_DIR}/net.enne2.Cellar.yaml"
BUILD_DIR="${ROOT_DIR}/flatpak-build"
REPO_DIR="${ROOT_DIR}/flatpak-repo"
BUNDLE_PATH="${ROOT_DIR}/dist/${APP_ID}.flatpak"
if ! command -v flatpak-builder >/dev/null 2>&1; then
echo "flatpak-builder not found. Install it first." >&2
exit 1
fi
if ! command -v flatpak >/dev/null 2>&1; then
echo "flatpak not found. Install it first." >&2
exit 1
fi
mkdir -p "${ROOT_DIR}/dist"
rm -rf "${BUILD_DIR}"
mkdir -p "${REPO_DIR}"
flatpak-builder \
--force-clean \
--repo="${REPO_DIR}" \
"${BUILD_DIR}" \
"${MANIFEST}"
flatpak build-bundle "${REPO_DIR}" "${BUNDLE_PATH}" "${APP_ID}" "${BRANCH}"
cat <<EOF
Flatpak repository ready:
${REPO_DIR}
Flatpak bundle ready:
${BUNDLE_PATH}
Example private remote setup:
flatpak remote-add --if-not-exists --user --no-gpg-verify brain-local http://brain.local/flatpak/cellar
flatpak install --user brain-local ${APP_ID}
EOF

38
scripts/publish-flatpak-repo.sh

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 || $# -gt 3 ]]; then
echo "Usage: $0 <host> <remote-path> [public-url]" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_DIR="${ROOT_DIR}/flatpak-repo"
HOST="$1"
REMOTE_PATH="$2"
PUBLIC_URL="${3:-}"
if [[ ! -d "${REPO_DIR}" ]]; then
echo "Repository not found at ${REPO_DIR}. Run ./scripts/build-flatpak-repo.sh first." >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
echo "rsync not found. Install it first." >&2
exit 1
fi
rsync -av --delete "${REPO_DIR}/" "${HOST}:${REMOTE_PATH%/}/"
if [[ -n "${PUBLIC_URL}" ]]; then
cat <<EOF
Published to ${HOST}:${REMOTE_PATH}
Clients can add the remote with:
flatpak remote-add --if-not-exists --user --no-gpg-verify brain-local ${PUBLIC_URL}
EOF
else
echo
echo "Published to ${HOST}:${REMOTE_PATH}"
fi
Loading…
Cancel
Save