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:
parent
6efec5702c
commit
6e34161828
2 changed files with 252 additions and 0 deletions
1
dist/Dockerfile.debian
vendored
1
dist/Dockerfile.debian
vendored
|
|
@ -3,6 +3,7 @@ FROM debian:bookworm
|
|||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-17-jdk dpkg-dev curl ca-certificates unzip git fakeroot \
|
||||
xdg-utils \
|
||||
python3 python3-venv python3-pip \
|
||||
xvfb libasound2 libfreetype6 libfontconfig1 libgl1 libxrender1 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|
|
|
|||
251
dist/make-deb.py
vendored
Executable file
251
dist/make-deb.py
vendored
Executable 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue