diff --git a/dist/Dockerfile.debian b/dist/Dockerfile.debian index f181819..a22246b 100644 --- a/dist/Dockerfile.debian +++ b/dist/Dockerfile.debian @@ -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/* \ diff --git a/dist/make-deb.py b/dist/make-deb.py new file mode 100755 index 0000000..bc697d0 --- /dev/null +++ b/dist/make-deb.py @@ -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__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 " +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"!\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()