Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ jobs:
steps:
- uses: actions/checkout@v4

# PySide6 imports need the Qt runtime libraries even offscreen.
- name: Install Qt runtime libraries (headless)
run: >
sudo apt-get update && sudo apt-get install -y --no-install-recommends
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libfontconfig1

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
Expand Down
7 changes: 3 additions & 4 deletions launchers/macos/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>LSMinimumSystemVersion</key><string>12.0</string>
<key>NSHighResolutionCapable</key><true/>
<!-- The launcher only execs `uv run scripto`; the visible Dock icon is
the branded viewer the GUI opens. Hiding the launcher from the Dock
leaves exactly one Scripto icon. -->
<key>LSUIElement</key><true/>
<!-- The launcher execs `uv run scripto`, and `uv run` execs python, so
the Qt GUI process *is* this bundle's process: the Dock entry below
carries Scripto's name and icon with no runtime branding. -->
</dict>
</plist>
PLIST
Expand Down
6 changes: 1 addition & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ dependencies = [
"tqdm>=4.66",
"faster-whisper>=1.1",
"mlx-whisper>=0.4.1; sys_platform == 'darwin' and platform_machine == 'arm64'",
"flet[desktop]>=0.86.0", # [desktop] pins flet-desktop in the lock — a bare
# "flet" leaves the viewer as an orphan that any exact `uv sync` removes
# Close-to-Dock: hides the viewer app via NSRunningApplication.
"pyobjc-framework-cocoa>=10; sys_platform == 'darwin'",
"pyside6>=6.8",
]

[project.urls]
Expand All @@ -29,7 +26,6 @@ scripto-cli = "scripto.cli:main"

[dependency-groups]
dev = [
"flet-web>=0.86.0",
"pytest>=8.0",
]

Expand Down
62 changes: 30 additions & 32 deletions src/scripto/app.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,44 @@
"""`scripto` entry point — the desktop GUI.
"""`scripto` entry point — the desktop GUI (PySide6).

`SCRIPTO_GUI_WEB=1` serves the same UI over HTTP without opening a window
(used for headless UI testing); `SCRIPTO_GUI_PORT` picks the port.
The GUI is a plain in-process Qt app: the Dock/taskbar entry *is* this
process, so the macOS launcher bundle provides the icon and name with no
runtime branding. Window icon falls back to the repo asset when present.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

from .core.logs import setup_logging


def _window_icon():
from PySide6.QtGui import QIcon

from .core.update import repo_root

root = repo_root()
icon_path = (root / "assets/icon.png") if root else None
if icon_path is not None and icon_path.is_file():
return QIcon(str(icon_path))
return None


def main() -> int:
setup_logging()
import flet as ft

from .gui.app_gui import gui_main

if os.environ.get("SCRIPTO_GUI_WEB"):
# Headless web serving for UI testing (dev-only deps: flet-web, uvicorn).
# flet 0.86's ft.app(view=None) opens the desktop socket, not HTTP —
# the fastapi integration is the supported web path.
import uvicorn
import flet_web.fastapi as flet_fastapi

port = int(os.environ.get("SCRIPTO_GUI_PORT", "8551"))
uvicorn.run(
flet_fastapi.app(gui_main),
host="127.0.0.1",
port=port,
log_level="warning",
)
else:
# macOS: open the window with the Scripto-branded viewer copy so the
# Dock shows our icon, not the stock Flet one (no-op elsewhere).
from .core.viewer import ensure_branded_viewer

viewer_dir = ensure_branded_viewer()
if viewer_dir is not None:
os.environ["FLET_VIEW_PATH"] = str(viewer_dir)
ft.app(target=gui_main)
return 0

from .gui_qt.main_window import MainWindow, ScriptoApp

app = ScriptoApp(sys.argv)
icon = _window_icon()
if icon is not None:
app.setWindowIcon(icon)

window = MainWindow()
app.main_window = window
window.show()
return app.exec()


if __name__ == "__main__":
Expand Down
161 changes: 0 additions & 161 deletions src/scripto/core/viewer.py

This file was deleted.

23 changes: 20 additions & 3 deletions src/scripto/engines/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,29 @@ def repo_for(spec: WhisperModelSpec, engine_name: str) -> str:
)


def _hf_cache_dir() -> str | None:
"""HF cache path from the environment, resolved at call time.

huggingface_hub freezes HF_HOME/HF_HUB_CACHE into constants on first
import, so relying on its default would make the effective cache depend
on import order (and ignore env changes — which is also what lets tests
point at an empty cache). None = the library default.
"""
hub = os.environ.get("HF_HUB_CACHE")
if hub:
return hub
home = os.environ.get("HF_HOME")
return str(Path(home) / "hub") if home else None


def _cached_repo_ids() -> set[str]:
"""One scan of the HF cache; empty set when the cache does not exist."""
try:
from huggingface_hub import scan_cache_dir
from huggingface_hub.errors import CacheNotFound

try:
info = scan_cache_dir()
info = scan_cache_dir(_hf_cache_dir())
except CacheNotFound:
return set()
return {repo.repo_id for repo in info.repos}
Expand Down Expand Up @@ -126,7 +141,9 @@ def update(self, n=1):
logger.info("downloading %s (%s)", repo, spec.size_hint)
# token=False: anonymous access for public repos; a stale HF_TOKEN env var
# otherwise surfaces as a misleading 401 (lesson from my-transcriptor).
snapshot_download(repo_id=repo, token=False, tqdm_class=_Progress)
snapshot_download(
repo_id=repo, token=False, tqdm_class=_Progress, cache_dir=_hf_cache_dir()
)


def delete_model(spec: WhisperModelSpec, engine_name: str) -> bool:
Expand All @@ -136,7 +153,7 @@ def delete_model(spec: WhisperModelSpec, engine_name: str) -> bool:

repo = repo_for(spec, engine_name)
try:
info = scan_cache_dir()
info = scan_cache_dir(_hf_cache_dir())
except CacheNotFound:
return False
hashes = [
Expand Down
7 changes: 4 additions & 3 deletions src/scripto/gui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Desktop GUI (Flet).
"""GUI state layer.

Split by rule: ``viewmodel.py`` owns all state and logic and never imports
flet (fully unit-tested); the flet layer is a thin renderer that drains the
viewmodel on a throttled timer and only touches changed rows.
a UI toolkit (fully unit-tested); the Qt view layer in ``scripto.gui_qt``
is a thin renderer that drains the viewmodel on a throttled timer and only
touches changed rows.
"""
Loading
Loading