Add STT + LLM engine presets, rename entry to blitztext.py, macOS app icon

- stt.py: local + remote (OpenAI-compatible) transcription, TCP reachability
  status, benchmark. Tested live against faster-whisper-server :8010.
- llm.py: LLM engine presets (url/model/key/temperature) + chat + status.
  Tested live against local Qwen :19001 and llama-swap :28080.
- config: [stt]/[[stt_engine]] and [llm]/[[llm_engine]] presets with active
  selectors; defaults synthesized from legacy [whisper]/[rewrite].
- daemon: transcribe via active STT engine, rewrite via active LLM engine;
  load local Whisper only when the active STT engine is local.
- Rename package entry __main__.py -> blitztext.py (thin __main__ shim keeps
  `python -m blitztext` working). Remove rewrite.py (folded into llm.py).
- App icon extracted from the macOS AppIcon.icns; installed at several hicolor
  sizes by the .deb and used as the window icon. Drop placeholder SVG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-04 20:21:03 +02:00
parent 34e4736080
commit 3026f1ff90
16 changed files with 449 additions and 160 deletions

View File

@ -1,58 +1,6 @@
"""CLI entrypoint: `python -m blitztext [run|transcribe|config-path]`."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import __version__
from .config import CONFIG_PATH, ensure_default, load
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.")
parser.add_argument("--version", action="version", version=f"blitztext {__version__}")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("tray", help="Run in the system tray with a workflow menu (default).")
sub.add_parser("gui", help="Open the control panel window.")
sub.add_parser("run", help="Start the headless hotkey daemon (no window/tray).")
sub.add_parser("config-path", help="Print the config file path and exit.")
p_tx = sub.add_parser("transcribe", help="Transcribe a WAV file and print the text (no hotkeys).")
p_tx.add_argument("audio", type=Path)
args = parser.parse_args(argv)
cmd = args.cmd or "tray"
if cmd == "config-path":
print(ensure_default(CONFIG_PATH))
return 0
if cmd in ("gui", "tray"):
ensure_default(CONFIG_PATH)
from .gtkui import run_gui
return run_gui(tray_mode=(cmd == "tray"))
cfg = load()
if cmd == "transcribe":
from .transcribe import Transcriber
tx = Transcriber(cfg.model, cfg.device, cfg.compute_type, cfg.beam_size)
print(tx.transcribe(args.audio, language=cfg.language))
return 0
# cmd == "run"
from .daemon import Daemon
try:
Daemon(cfg).run()
except KeyboardInterrupt:
print("\n[blitztext] stopped.", file=sys.stderr)
return 0
"""Module entry point so `python -m blitztext` runs the app in blitztext.py."""
from .blitztext import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,62 @@
"""Blitztext application entry point (CLI + GUI/tray launcher).
The thin ``__main__.py`` shim imports ``main()`` from here, so the app's code
lives in ``blitztext.py`` while ``python -m blitztext`` still works.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import __version__
from .config import CONFIG_PATH, ensure_default, load
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.")
parser.add_argument("--version", action="version", version=f"blitztext {__version__}")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("tray", help="Run in the system tray with a workflow menu (default).")
sub.add_parser("gui", help="Open the control panel window.")
sub.add_parser("run", help="Start the headless hotkey daemon (no window/tray).")
sub.add_parser("config-path", help="Print the config file path and exit.")
p_tx = sub.add_parser("transcribe", help="Transcribe a WAV file and print the text (no hotkeys).")
p_tx.add_argument("audio", type=Path)
args = parser.parse_args(argv)
cmd = args.cmd or "tray"
if cmd == "config-path":
print(ensure_default(CONFIG_PATH))
return 0
if cmd in ("gui", "tray"):
ensure_default(CONFIG_PATH)
from .gtkui import run_gui
return run_gui(tray_mode=(cmd == "tray"))
cfg = load()
if cmd == "transcribe":
from .transcribe import Transcriber
tx = Transcriber(cfg.model, cfg.device, cfg.compute_type, cfg.beam_size)
print(tx.transcribe(args.audio, language=cfg.language))
return 0
# cmd == "run"
from .daemon import Daemon
try:
Daemon(cfg).run()
except KeyboardInterrupt:
print("\n[blitztext] stopped.", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -7,6 +7,9 @@ import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from .llm import LLMEngine
from .stt import STTEngine
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "blitztext"
CONFIG_PATH = CONFIG_DIR / "config.toml"
@ -51,9 +54,31 @@ class Config:
routing_hotkey: str = "<ctrl>+<alt>+<space>"
routing_default: str = "" # preset name used when no keyword matches; "" = first
routing_threshold: float = 0.82
# speech-to-text engines (presets)
stt_engines: list[STTEngine] = field(default_factory=list)
stt_active: str = ""
# llm engines (presets) for the rewrite step
llm_engines: list[LLMEngine] = field(default_factory=list)
llm_active: str = ""
# workflows
workflows: list[Workflow] = field(default_factory=list)
@property
def active_stt(self) -> STTEngine:
e = next((x for x in self.stt_engines if x.name == self.stt_active), None)
if e:
return e
return self.stt_engines[0] if self.stt_engines else STTEngine("Local", "local", model=self.model)
@property
def active_llm(self) -> LLMEngine:
e = next((x for x in self.llm_engines if x.name == self.llm_active), None)
if e:
return e
if self.llm_engines:
return self.llm_engines[0]
return LLMEngine("Default", self.base_url, self.rewrite_model, self.api_key_env, self.temperature)
def preset_by_name(self, name: str | None) -> "Workflow | None":
if not name:
return None
@ -127,6 +152,32 @@ def load(path: Path = CONFIG_PATH) -> Config:
if not cfg.workflows:
raise ValueError(f"No [[workflow]] entries defined in {path}")
# STT engines (default: a single local faster-whisper engine).
cfg.stt_engines = [
STTEngine(
name=e["name"],
type=e.get("type", "local"),
url=e.get("url", "").rstrip("/"),
model=e.get("model", ""),
api_key_env=e.get("api_key_env", ""),
)
for e in data.get("stt_engine", [])
] or [STTEngine("Local faster-whisper", "local", model=cfg.model)]
cfg.stt_active = data.get("stt", {}).get("active", cfg.stt_engines[0].name)
# LLM engines (default: synthesized from the legacy [rewrite] block).
cfg.llm_engines = [
LLMEngine(
name=e["name"],
url=e.get("url", "https://api.openai.com/v1").rstrip("/"),
model=e.get("model", "gpt-4o-mini"),
api_key_env=e.get("api_key_env", ""),
temperature=float(e.get("temperature", cfg.temperature)),
)
for e in data.get("llm_engine", [])
] or [LLMEngine("Default", cfg.base_url, cfg.rewrite_model, cfg.api_key_env, cfg.temperature)]
cfg.llm_active = data.get("llm", {}).get("active", cfg.llm_engines[0].name)
return cfg
@ -164,6 +215,20 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
"default": cfg.routing_default,
"threshold": cfg.routing_threshold,
},
"stt": {"active": cfg.stt_active},
"stt_engine": [
{k: v for k, v in {
"name": e.name, "type": e.type, "url": e.url,
"model": e.model, "api_key_env": e.api_key_env,
}.items() if v or k in ("name", "type")}
for e in cfg.stt_engines
],
"llm": {"active": cfg.llm_active},
"llm_engine": [
{"name": e.name, "url": e.url, "model": e.model,
"api_key_env": e.api_key_env, "temperature": e.temperature}
for e in cfg.llm_engines
],
"workflow": [],
}
for wf in cfg.workflows:
@ -235,6 +300,47 @@ hotkey = "<ctrl>+<alt>+<space>"
default = "Transcribe" # preset used when no keyword is recognised
threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
# ----------------------------------------------------------------------------
# Speech-to-text engines (presets). The active one is used for transcription.
# type = "local" -> in-process faster-whisper (uses [whisper] above)
# type = "openai" -> remote OpenAI-compatible /audio/transcriptions server
# (faster-whisper-server, Groq, WhisperX, NIMs, ...)
# ----------------------------------------------------------------------------
[stt]
active = "Local faster-whisper"
[[stt_engine]]
name = "Local faster-whisper"
type = "local"
# [[stt_engine]]
# name = "faster-whisper-server"
# type = "openai"
# url = "http://localhost:8010/v1"
# model = "Systran/faster-whisper-base"
# api_key_env = "" # e.g. GROQ_API_KEY for a cloud endpoint
# ----------------------------------------------------------------------------
# LLM engines (presets) for the rewrite step. Any OpenAI-compatible chat API
# (OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, ...).
# ----------------------------------------------------------------------------
[llm]
active = "Default"
[[llm_engine]]
name = "Default"
url = "https://api.openai.com/v1"
model = "gpt-4o-mini"
api_key_env = "OPENAI_API_KEY"
temperature = 0.3
# [[llm_engine]]
# name = "Local llama-swap"
# url = "http://localhost:28080/v1"
# model = "Qwen3.5-4B"
# api_key_env = ""
# temperature = 0.3
# ----------------------------------------------------------------------------
# Workflows / presets. mode = "transcribe" types the raw transcript. mode =
# "rewrite" sends it through the LLM with `prompt` as the system prompt.

View File

@ -11,11 +11,12 @@ import sys
import threading
from typing import Callable
from . import llm, stt
from .config import Config, Workflow
from .llm import LLMError
from .notify import notify
from .paste import active_window_id, deliver
from .recorder import Recording, detect_recorder
from .rewrite import RewriteError, rewrite
from .routing import route
from .transcribe import Transcriber
@ -33,6 +34,7 @@ class Daemon:
self._active_workflow: Workflow | None = None
self._target_window: str | None = None
self._busy = False
self._prepared = False
self._listener = None
# Synthetic preset used by the voice-routing hotkey.
self._route_workflow = Workflow(name="Voice", hotkey=cfg.routing_hotkey, mode="route")
@ -53,19 +55,27 @@ class Daemon:
# -- model load (slow; call off the UI thread) ----------------------------
def prepare(self) -> None:
self._emit("loading", None, f"Loading Whisper '{self.cfg.model}'")
self._notify("Loading model…", f"Whisper '{self.cfg.model}' ({self.cfg.device})")
self.transcriber = Transcriber(
model=self.cfg.model,
device=self.cfg.device,
compute_type=self.cfg.compute_type,
beam_size=self.cfg.beam_size,
)
engine = self.cfg.active_stt
if engine.is_local:
model = engine.model or self.cfg.model
self._emit("loading", None, f"Loading Whisper '{model}'")
self._notify("Loading model…", f"Whisper '{model}' ({self.cfg.device})")
self.transcriber = Transcriber(
model=model,
device=self.cfg.device,
compute_type=self.cfg.compute_type,
beam_size=self.cfg.beam_size,
)
else:
# Remote STT engine — no local model to load.
self.transcriber = None
self._emit("loading", None, f"Using {engine.name}")
self._prepared = True
self._emit("idle", None, "Ready")
@property
def ready(self) -> bool:
return self.transcriber is not None
return getattr(self, "_prepared", False)
@property
def is_recording(self) -> bool:
@ -105,7 +115,14 @@ class Daemon:
self._emit("busy", label, "Transcribing…")
self._notify(f"{label}", "Transcribing…")
hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else ""
text = self.transcriber.transcribe(audio_path, language=self.cfg.language, hotwords=hotwords)
text = stt.transcribe(
self.cfg.active_stt,
audio_path,
language=self.cfg.language,
hotwords=hotwords,
local_transcriber=self.transcriber,
timeout=self.cfg.timeout,
)
if not text:
self._emit("idle", label, "No speech detected")
@ -132,16 +149,15 @@ class Daemon:
self._emit("busy", label, "Rewriting…")
self._notify(f"{label}", "Rewriting…")
try:
text = rewrite(
text,
text = llm.chat(
self.cfg.active_llm,
target.prompt,
base_url=self.cfg.base_url,
api_key=self.cfg.api_key,
model=target.model or self.cfg.rewrite_model,
temperature=target.temperature if target.temperature is not None else self.cfg.temperature,
text,
model=target.model or None,
temperature=target.temperature,
timeout=self.cfg.timeout,
)
except RewriteError as exc:
except LLMError as exc:
self._emit("error", label, str(exc))
self._notify("Rewrite failed", str(exc), "critical")
return

View File

@ -24,8 +24,8 @@ from .config import Config, load, save # noqa: E402
from .daemon import Daemon # noqa: E402
ICON_PATHS = [
"/usr/share/icons/hicolor/scalable/apps/blitztext.svg",
str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.svg"),
"/usr/share/icons/hicolor/256x256/apps/blitztext.png",
str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.png"),
]
CSS = b"""

87
linux/blitztext/llm.py Normal file
View File

@ -0,0 +1,87 @@
"""LLM engine abstraction for the rewrite step.
Engines are user-managed presets pointing at any OpenAI-compatible chat endpoint
(OpenAI, vLLM, llama-swap, Ollama /v1, LM Studio, Groq, OpenRouter, ). Provides
reachability status (online/offline) and a chat-completion call. Uses only the
standard library so the daemon needs no extra deps.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass
from .stt import reachable
@dataclass
class LLMEngine:
name: str
url: str = "https://api.openai.com/v1" # base URL (incl. /v1)
model: str = "gpt-4o-mini"
api_key_env: str = "OPENAI_API_KEY"
temperature: float = 0.3
@property
def api_key(self) -> str | None:
return os.environ.get(self.api_key_env) if self.api_key_env else None
class LLMError(RuntimeError):
pass
def status(engine: LLMEngine, timeout: float = 2.0) -> bool:
"""Reachable if the endpoint host:port accepts a TCP connection."""
return reachable(engine.url, timeout)
def chat(
engine: LLMEngine,
system_prompt: str,
user_text: str,
*,
model: str | None = None,
temperature: float | None = None,
timeout: int = 45,
) -> str:
api_key = engine.api_key
payload = json.dumps(
{
"model": model or engine.model,
"temperature": engine.temperature if temperature is None else temperature,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
}
).encode("utf-8")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
engine.url.rstrip("/") + "/chat/completions", data=payload, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
raise LLMError(f"HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise LLMError(f"Connection failed: {exc.reason}") from exc
try:
content = body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise LLMError(f"Unexpected response: {str(body)[:300]}") from exc
content = (content or "").strip()
if not content:
raise LLMError("Empty response from model.")
return content

View File

@ -1,73 +0,0 @@
"""Optional LLM rewrite via an OpenAI-compatible chat endpoint.
Uses only the standard library (urllib) so the daemon needs no extra deps for
the rewrite step. Works against OpenAI or any local server that implements the
/chat/completions API (e.g. vLLM, llama-swap).
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
class RewriteError(RuntimeError):
pass
def rewrite(
text: str,
system_prompt: str,
*,
base_url: str,
api_key: str | None,
model: str,
temperature: float = 0.3,
timeout: int = 45,
) -> str:
if not api_key:
raise RewriteError(
"No API key for rewrite. Set the env var named by rewrite.api_key_env, "
"or use a local endpoint that ignores auth."
)
payload = json.dumps(
{
"model": model,
"temperature": temperature,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text},
],
}
).encode("utf-8")
req = urllib.request.Request(
f"{base_url}/chat/completions",
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise RewriteError(f"HTTP {exc.code}: {detail[:300]}") from exc
except urllib.error.URLError as exc:
raise RewriteError(f"Connection failed: {exc.reason}") from exc
try:
content = body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise RewriteError(f"Unexpected response: {str(body)[:300]}") from exc
content = (content or "").strip()
if not content:
raise RewriteError("Empty response from model.")
return content

152
linux/blitztext/stt.py Normal file
View File

@ -0,0 +1,152 @@
"""Speech-to-text engine abstraction: local faster-whisper or remote endpoints.
Engines are user-managed presets. Each is either the in-process local
faster-whisper model, or a remote OpenAI-compatible server exposing
`/audio/transcriptions` (faster-whisper-server, Groq, WhisperX, whisper.cpp's
OpenAI shim, NVIDIA NIMs, ). Provides reachability checks (online/offline) and
a benchmark helper (transcript + elapsed seconds).
"""
from __future__ import annotations
import json
import socket
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlparse
@dataclass
class STTEngine:
name: str
type: str = "local" # "local" | "openai"
url: str = "" # base URL incl. /v1 for remote, e.g. http://localhost:8010/v1
model: str = "" # remote model id, or local whisper size override
api_key_env: str = "" # env var holding a bearer key (optional)
@property
def is_local(self) -> bool:
return self.type == "local"
class STTError(RuntimeError):
pass
# --- reachability ------------------------------------------------------------
def reachable(url: str, timeout: float = 2.0) -> bool:
"""True if a TCP connection to the URL's host:port succeeds."""
host, port = _host_port(url)
if not host:
return False
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def status(engine: STTEngine, timeout: float = 2.0) -> bool:
"""True if the engine is usable now (local always; remote = TCP reachable)."""
if engine.is_local:
return True
return reachable(engine.url, timeout)
def _host_port(url: str) -> tuple[str | None, int]:
try:
u = urlparse(url if "://" in url else "http://" + url)
port = u.port or (443 if u.scheme == "https" else 80)
return u.hostname, port
except ValueError:
return None, 0
# --- transcription -----------------------------------------------------------
def transcribe(
engine: STTEngine,
audio_path: Path,
*,
language: str = "",
hotwords: str = "",
local_transcriber=None,
timeout: int = 60,
) -> str:
if engine.is_local:
if local_transcriber is None:
raise STTError("Local engine selected but the model isn't loaded.")
return local_transcriber.transcribe(audio_path, language=language, hotwords=hotwords)
return _transcribe_remote(engine, audio_path, language=language, prompt=hotwords, timeout=timeout)
def _transcribe_remote(engine: STTEngine, audio_path: Path, *, language: str, prompt: str, timeout: int) -> str:
import os
base = engine.url.rstrip("/")
endpoint = base + "/audio/transcriptions"
fields = {"model": engine.model or "whisper-1", "response_format": "json"}
if language:
fields["language"] = language
if prompt:
fields["prompt"] = prompt
headers = {}
key = os.environ.get(engine.api_key_env) if engine.api_key_env else None
if key:
headers["Authorization"] = f"Bearer {key}"
body, content_type = _multipart(fields, audio_path)
headers["Content-Type"] = content_type
req = urllib.request.Request(endpoint, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
raise STTError(f"HTTP {exc.code} from {endpoint}: {detail}") from exc
except urllib.error.URLError as exc:
raise STTError(f"Cannot reach {endpoint}: {exc.reason}") from exc
try:
return (json.loads(raw).get("text") or "").strip()
except json.JSONDecodeError:
return raw.strip() # some servers return plain text
def _multipart(fields: dict, audio_path: Path) -> tuple[bytes, str]:
boundary = uuid.uuid4().hex
nl = b"\r\n"
out = bytearray()
for k, v in fields.items():
out += b"--" + boundary.encode() + nl
out += f'Content-Disposition: form-data; name="{k}"'.encode() + nl + nl
out += str(v).encode() + nl
out += b"--" + boundary.encode() + nl
out += f'Content-Disposition: form-data; name="file"; filename="{audio_path.name}"'.encode() + nl
out += b"Content-Type: audio/wav" + nl + nl
out += audio_path.read_bytes() + nl
out += b"--" + boundary.encode() + b"--" + nl
return bytes(out), f"multipart/form-data; boundary={boundary}"
# --- benchmark ---------------------------------------------------------------
@dataclass
class BenchResult:
engine: str
ok: bool
text: str = ""
seconds: float = 0.0
error: str = ""
def benchmark(engine: STTEngine, audio_path: Path, *, language: str = "", local_transcriber=None) -> BenchResult:
t0 = time.perf_counter()
try:
text = transcribe(engine, audio_path, language=language, local_transcriber=local_transcriber)
return BenchResult(engine.name, True, text, time.perf_counter() - t0)
except Exception as exc: # noqa: BLE001 - report any failure to the UI
return BenchResult(engine.name, False, "", time.perf_counter() - t0, str(exc))

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2b8cff"/>
<stop offset="1" stop-color="#0a5cf0"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)"/>
<path d="M70 26 L42 70 H60 L56 102 L86 56 H66 Z"
fill="#ffffff" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

View File

@ -52,8 +52,11 @@ exec /opt/blitztext/venv/bin/python -m blitztext "$@"
EOF
install -Dm644 "$HERE/$PKG.desktop" "$ROOT/usr/share/applications/$PKG.desktop"
install -Dm644 "$HERE/$PKG.svg" "$ROOT/usr/share/icons/hicolor/scalable/apps/$PKG.svg"
install -Dm644 "$HERE/copyright" "$ROOT/usr/share/doc/$PKG/copyright"
# App icon (extracted from the macOS AppIcon) at several hicolor sizes.
for s in 32 48 64 128 256; do
install -Dm644 "$HERE/${PKG}_${s}.png" "$ROOT/usr/share/icons/hicolor/${s}x${s}/apps/${PKG}.png"
done
# 3) DEBIAN control + maintainer scripts ------------------------------------
SIZE_KB="$(du -sk "$ROOT" | cut -f1)"