make-deb.py: rebuild the Debian package from the current compose distributable on any host (stdlib-only ar/tar writer, GNU tar format for dpkg)

The distributable's host-compiled pieces (launcher bin, libapplauncher.so, jlink runtime) are linked against Arch's glibc 2.38/libstdc++ — bookworm has 2.36/3.4.30, so a straight repackage silently exits 1 at launch. dist/bookworm-compat/ holds bookworm-built replacements (launcher-bin, libapplauncher.so, runtime/) extracted once from the known-good 0.1.0-1 deb; make-deb.py injects them into the tar stream without touching the build tree. Verified in a bookworm container: apt install -> ii, zero unresolved libs, app stays up 30s under Xvfb, build 6efec57.
This commit is contained in:
avi 2026-09-23 16:23:10 -05:00
commit 6e34161828
2 changed files with 252 additions and 0 deletions

251
dist/make-deb.py vendored Executable file
View file

@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Build the SHONAR Desktop .deb from the compose distributable.
Usage: dist/make-deb.py # writes dist/shonar-desktop_<ver>_amd64.deb
Runs anywhere Python 3 runs (stdlib only: ar + tar.gz are written by hand),
so no Debian box or dpkg-deb is needed — the packaged tree is the
self-contained compose distribution (bundled JVM runtime, linux x64).
Layout mirrors the original package: app under /opt/shonar-desktop,
menu entry installed by postinst via xdg-desktop-menu.
"""
import gzip, hashlib, io, os, subprocess, sys, tarfile, time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DIST = os.path.join(REPO, "dist")
APP_TREE = os.path.join(
REPO, "app/build/compose/binaries/main/app/shonar-desktop")
PKG = "shonar-desktop"
VERSION = os.environ.get("SHONAR_DEB_VERSION", "0.1.0-2")
ARCH = "amd64"
MAINTAINER = "avi <avi@localhost>"
DESCRIPTION = ("SHONAR Desktop — self-hosted notes and audio recording.\n"
" Local transcription (faster-whisper), summaries, searchable\n"
" transcript index. Runs fully offline; data never leaves the\n"
" machine. Installs under /opt/shonar-desktop.")
# Same runtime deps dpkg-shlibdeps resolved for the bundled JVM on bookworm.
DEPENDS = ("libasound2, libbrotli1, libbsd0, libc6, libexpat1, libfontconfig1,"
" libfreetype6, libgcc-s1, libgif7, libgl1, libglib2.0-0, libglvnd0,"
" libglx0, libgraphite2-3, libharfbuzz0b, libjpeg62-turbo,"
" liblcms2-2, libmd0, libpcre2-8-0, libpng16-16, libstdc++6,"
" libx11-6, libxau6, libxcb1, libxdmcp6, libxext6, libxi6,"
" libxrender1, libxtst6, xdg-utils, zlib1g")
DESKTOP_ENTRY = f"""[Desktop Entry]
Type=Application
Name=SHONAR Desktop
Comment=Self-hosted notes and audio recording — transcribe, summarize, search, all local
Exec=/opt/{PKG}/bin/{PKG}
Path=/opt/{PKG}
Icon=/opt/{PKG}/lib/{PKG}.png
Terminal=false
Categories=AudioVideo;
Keywords=record;transcribe;whisper;notes;summary;
StartupWMClass=com-shonar-desktop-MainKt
"""
COPYRIGHT = f"""Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: https://git.atitlan.io/avi/S.H.O.N.A.R._Desktop_Companion
License: proprietary
Copyright holder: avi. All rights reserved.
"""
def tar_bytes(root_name, add_dir_mode=0o755, overrides=None,
override_prefixes=()):
# overrides: mapping of tree-relative path -> replacement bytes,
# injected without touching the tree (it may be in use by a running app).
# override_prefixes: tree-relative dir paths whose ENTIRE contents come
# from overrides (keys "prefix/sub/path" replace the tree's prefix/...).
overrides = overrides or {}
"""Build an uncompressed tar with './' members rooted at root_name."""
buf = io.BytesIO()
# GNU format, NOT the Python default PAX: dpkg rejects 'x' extended
# headers ("unsupported PAX tar header type 'x'").
with tarfile.open(fileobj=buf, mode="w", format=tarfile.GNU_FORMAT) as tf:
# root dirs (share/ and share/doc/ are generated, not in APP_TREE —
# dpkg needs explicit dir entries for them)
for name in (".", "./opt", f"./opt/{PKG}", f"./opt/{PKG}/share",
f"./opt/{PKG}/share/doc"):
ti = tarfile.TarInfo(name)
ti.type = tarfile.DIRTYPE
ti.mode = add_dir_mode
ti.mtime = int(time.time())
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root"
tf.addfile(ti)
# app tree contents
def overridden(tree_rel):
tree_rel = tree_rel.replace(os.sep, "/")
if tree_rel in overrides:
return True
return any(tree_rel.startswith(p + "/") for p in override_prefixes)
for dirpath, dirnames, filenames in os.walk(APP_TREE):
rel = os.path.relpath(dirpath, APP_TREE)
for d in sorted(dirnames):
p = os.path.join(dirpath, d)
r = "./opt/%s/%s" % (PKG, os.path.normpath(os.path.join(rel, d)))
ti = tf.gettarinfo(p, arcname=r)
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root"
tf.addfile(ti)
for f in sorted(filenames):
p = os.path.join(dirpath, f)
r = "./opt/%s/%s" % (PKG, os.path.normpath(os.path.join(rel, f)))
tree_rel = os.path.relpath(p, APP_TREE)
if overridden(tree_rel):
continue
ti = tf.gettarinfo(p, arcname=r)
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root"
if f == "shonar-desktop" and "/bin/" in r:
ti.mode = 0o755
with open(p, "rb") as fh:
tf.addfile(ti, fh)
# generated files
for name, content in (
(f"./opt/{PKG}/lib/{PKG}-{PKG}.desktop", DESKTOP_ENTRY),
(f"./opt/{PKG}/share/doc/copyright", COPYRIGHT),
):
data = content.encode()
ti = tarfile.TarInfo(name)
ti.size = len(data)
ti.mode = 0o644
ti.mtime = int(time.time())
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root"
tf.addfile(ti, io.BytesIO(data))
for tree_rel, data in sorted(overrides.items()):
name = "./opt/%s/%s" % (PKG, tree_rel)
ti = tarfile.TarInfo(name)
ti.size, ti.mode = len(data), 0o755
ti.mtime = int(time.time())
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root"
tf.addfile(ti, io.BytesIO(data))
return buf.getvalue()
def installed_size():
total = 0
for dirpath, _, filenames in os.walk(APP_TREE):
for f in filenames:
total += os.path.getsize(os.path.join(dirpath, f))
return total // 1024
def control_bytes():
return (
f"Package: {PKG}\nVersion: {VERSION}\nSection: misc\n"
f"Maintainer: {MAINTAINER}\nPriority: optional\n"
f"Architecture: {ARCH}\nProvides: {PKG}\nDepends: {DEPENDS}\n"
f"Installed-Size: {installed_size()}\nDescription: {DESCRIPTION}\n"
).encode()
def maint_script(stage, body):
return f"""#!/bin/sh
set -e
case "$1" in
{stage})
{body}
;;
*)
;;
esac
exit 0
""".encode()
def ar_member(name, data):
hdr = ("%-16s%-12d%-6d%-6d%-8o%-10d`\n"
% (name + "/", 0, 0, 0, 0o100644, len(data)))
pad = b"\n" if len(data) % 2 else b""
return hdr.encode() + data + pad
def main():
if not os.path.isdir(APP_TREE):
sys.exit(f"no distributable at {APP_TREE} — run "
"./gradlew :app:createDistributable first")
# The launcher .so AND the launcher binary are the only host-compiled
# artifacts in the tree; built on Arch they need a newer libstdc++ than
# Debian bookworm ships. dist/bookworm-compat/ holds both compiled on
# bookworm — swap them in before packaging.
compat = os.path.join(DIST, "bookworm-compat")
overrides = {}
override_prefixes = ()
for fname, tree_rel in (
("libapplauncher.so", "lib/libapplauncher.so"),
("launcher-bin", "bin/shonar-desktop"),
):
src = os.path.join(compat, fname)
if os.path.exists(src):
overrides[tree_rel] = open(src, "rb").read()
print(f"injecting bookworm-compatible {fname} as {tree_rel}")
# The bundled jlink runtime is host-compiled too (jvm .so + native libs).
# On Arch it needs glibc >= 2.38; bookworm has 2.36. A bookworm-built
# runtime tree at dist/bookworm-compat/runtime replaces it wholesale
# (identical module/file set — jlink of the same JDK modules).
compat_runtime = os.path.join(compat, "runtime")
if os.path.isdir(compat_runtime):
for dirpath, _, filenames in os.walk(compat_runtime):
for f in filenames:
p = os.path.join(dirpath, f)
rel = "lib/runtime/" + os.path.relpath(p, compat_runtime)
overrides[rel.replace(os.sep, "/")] = open(p, "rb").read()
override_prefixes = ("lib/runtime",)
print(f"injecting bookworm-compatible jlink runtime "
f"({len([k for k in overrides if k.startswith('runtime/')])} files)")
baked = "unknown"
for f in os.listdir(os.path.join(APP_TREE, "lib/app")):
if f.startswith("app-0.1.0-"):
out = subprocess.run(
["unzip", "-p", os.path.join(APP_TREE, "lib/app", f),
"shonar-build.properties"], capture_output=True, text=True)
for line in out.stdout.splitlines():
if line.startswith("commit="):
baked = line.split("=", 1)[1]
print(f"packaging distributable built from commit {baked}")
control_tar = io.BytesIO()
with tarfile.open(fileobj=control_tar, mode="w",
format=tarfile.GNU_FORMAT) as tf:
def add(name, data, mode):
ti = tarfile.TarInfo(name)
ti.size, ti.mode, ti.mtime = len(data), mode, int(time.time())
ti.uid = ti.gid = 0
ti.uname = ti.gname = "root"
tf.addfile(ti, io.BytesIO(data))
ti = tarfile.TarInfo("./")
ti.type = tarfile.DIRTYPE; ti.mode = 0o755; ti.mtime = int(time.time())
tf.addfile(ti)
add("./control", control_bytes(), 0o644)
add("./preinst", maint_script("install|upgrade", " :"), 0o755)
add("./postinst", maint_script(
"configure",
f"xdg-desktop-menu install /opt/{PKG}/lib/{PKG}-{PKG}.desktop"),
0o755)
add("./prerm", maint_script(
"remove|upgrade|deconfigure",
f"xdg-desktop-menu uninstall /opt/{PKG}/lib/{PKG}-{PKG}.desktop"),
0o755)
add("./postrm", maint_script("purge|remove|upgrade", " :"), 0o755)
out_path = os.path.join(DIST, f"{PKG}_{VERSION}_{ARCH}.deb")
with open(out_path, "wb") as f:
f.write(b"!<arch>\n")
f.write(ar_member("debian-binary", b"2.0\n"))
f.write(ar_member("control.tar.gz", gzip.compress(control_tar.getvalue())))
f.write(ar_member("data.tar.gz",
gzip.compress(tar_bytes(APP_TREE,
overrides=overrides,
override_prefixes=override_prefixes))))
size = os.path.getsize(out_path)
print(f"wrote {out_path} ({size/1024/1024:.1f} MB)")
print("sha256:", hashlib.sha256(open(out_path, 'rb').read()).hexdigest())
if __name__ == "__main__":
main()