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/
dist/
build/
flatpak-build/
flatpak-repo/
*.flatpak
# Test caches
.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
- [requirements.txt](requirements.txt) — Python dependencies
- [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/models.py](app/models.py) — SQLAlchemy model
- [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
Current environment variables used by the API container:
@ -257,7 +307,7 @@ Use a custom Bottles directory:
Run:
`python gtk_client.py`
`python cellar-gtk.py`
The GUI lets you:

145
cellar-gtk.py

@ -3,12 +3,23 @@
from __future__ import annotations
import json
import os
import sys
import threading
import urllib.error
from dataclasses import asdict, dataclass
from pathlib import Path
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:
import gi # type: ignore[import-not-found]
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]
# ── Constants ───────────────────────────────────────────────────
APP_ID = "net.enne2.Cellar"
CONFIG_PATH = Path.home() / ".config" / "cellar" / "gtk_client.json"
APP_CSS = """\
@ -134,7 +146,10 @@ def normalize_name(value: str | None) -> str:
def _load_css() -> None:
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(
Gdk.Display.get_default(),
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 ─────────────────────────────────────────────
class SettingsWindow(Adw.Window):
"""Preferences dialog using libadwaita widgets."""
@ -159,7 +282,7 @@ class SettingsWindow(Adw.Window):
self.set_modal(True)
self.set_default_size(480, -1)
toolbar_view = Adw.ToolbarView()
toolbar_view = make_toolbar_view()
header = Adw.HeaderBar()
cancel_btn = Gtk.Button(label="Annulla")
@ -180,11 +303,11 @@ class SettingsWindow(Adw.Window):
"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)
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)
group.add(self.bottles_row)
@ -326,6 +449,7 @@ class CellarWindow(Adw.ApplicationWindow):
def __init__(self, app: Adw.Application):
super().__init__(application=app, title="Cellar")
self.set_icon_name(APP_ID)
self.config = load_config()
self.settings_window: SettingsWindow | None = None
self.rows: list[ArchiveRow] = []
@ -363,7 +487,10 @@ class CellarWindow(Adw.ApplicationWindow):
# ── Outer box (banner + progress + scrolled content) ──
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)
outer_box.append(self.banner)
@ -399,9 +526,9 @@ class CellarWindow(Adw.ApplicationWindow):
self.bottles_info_row.add_css_class("property")
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.connect("notify::active", self._store_replace_preference)
connect_switch_row_active(self.replace_row, self._store_replace_preference)
info_group.add(self.replace_row)
content_box.append(info_group)
@ -447,7 +574,7 @@ class CellarWindow(Adw.ApplicationWindow):
self.toast_overlay.set_child(outer_box)
# ── ToolbarView ──
toolbar_view = Adw.ToolbarView()
toolbar_view = make_toolbar_view()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(self.toast_overlay)
@ -699,7 +826,7 @@ class CellarWindow(Adw.ApplicationWindow):
# ── Application ─────────────────────────────────────────────────
class CellarApplication(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id="com.cellar.gtkclient")
super().__init__(application_id=APP_ID)
def do_activate(self) -> None:
_load_css()

4
cellar.spec

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