diff --git a/linux/blitztext/__main__.py b/linux/blitztext/__main__.py index b749e75..9528353 100644 --- a/linux/blitztext/__main__.py +++ b/linux/blitztext/__main__.py @@ -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()) diff --git a/linux/blitztext/blitztext.py b/linux/blitztext/blitztext.py new file mode 100644 index 0000000..6835fe3 --- /dev/null +++ b/linux/blitztext/blitztext.py @@ -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()) diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 04164ee..e2415c1 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -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 = "++" 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 = "++" 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. diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 092f48d..7fa0e00 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -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 diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 72c6f40..6b5a908 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -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""" diff --git a/linux/blitztext/llm.py b/linux/blitztext/llm.py new file mode 100644 index 0000000..4dd54dc --- /dev/null +++ b/linux/blitztext/llm.py @@ -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 diff --git a/linux/blitztext/rewrite.py b/linux/blitztext/rewrite.py deleted file mode 100644 index 36e453e..0000000 --- a/linux/blitztext/rewrite.py +++ /dev/null @@ -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 diff --git a/linux/blitztext/stt.py b/linux/blitztext/stt.py new file mode 100644 index 0000000..681a50e --- /dev/null +++ b/linux/blitztext/stt.py @@ -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)) diff --git a/linux/packaging/blitztext.png b/linux/packaging/blitztext.png new file mode 100644 index 0000000..d4c30a9 Binary files /dev/null and b/linux/packaging/blitztext.png differ diff --git a/linux/packaging/blitztext.svg b/linux/packaging/blitztext.svg deleted file mode 100644 index 8a9f57f..0000000 --- a/linux/packaging/blitztext.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - diff --git a/linux/packaging/blitztext_128.png b/linux/packaging/blitztext_128.png new file mode 100644 index 0000000..7c36fbf Binary files /dev/null and b/linux/packaging/blitztext_128.png differ diff --git a/linux/packaging/blitztext_256.png b/linux/packaging/blitztext_256.png new file mode 100644 index 0000000..a40fe85 Binary files /dev/null and b/linux/packaging/blitztext_256.png differ diff --git a/linux/packaging/blitztext_32.png b/linux/packaging/blitztext_32.png new file mode 100644 index 0000000..a6d84c4 Binary files /dev/null and b/linux/packaging/blitztext_32.png differ diff --git a/linux/packaging/blitztext_48.png b/linux/packaging/blitztext_48.png new file mode 100644 index 0000000..21a3305 Binary files /dev/null and b/linux/packaging/blitztext_48.png differ diff --git a/linux/packaging/blitztext_64.png b/linux/packaging/blitztext_64.png new file mode 100644 index 0000000..af717e3 Binary files /dev/null and b/linux/packaging/blitztext_64.png differ diff --git a/linux/packaging/build-deb.sh b/linux/packaging/build-deb.sh index ef8c99f..56da440 100755 --- a/linux/packaging/build-deb.sh +++ b/linux/packaging/build-deb.sh @@ -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)"