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
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ dependencies = [
"tqdm>=4.66",
"faster-whisper>=1.1",
"mlx-whisper>=0.4.1; sys_platform == 'darwin' and platform_machine == 'arm64'",
"flet>=0.86.0",
"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'",
]

[project.urls]
Expand Down
59 changes: 54 additions & 5 deletions src/scripto/core/viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
logger = logging.getLogger(__name__)

VIEWER_APP_NAME = "Scripto.app"
VIEWER_BUNDLE_ID = "local.scripto.viewer"
STOCK_BUNDLE_ID = "com.appveyor.flet"
# Bump to force a rebuild of existing branded copies (a new bundle path
# also sidesteps macOS icon caches).
BRAND_REVISION = 2


def ensure_branded_viewer() -> Path | None:
Expand All @@ -47,13 +52,56 @@ def rebrand_bundle(app: Path, icon: Path | None) -> None:
info = plistlib.loads(info_path.read_bytes())
info["CFBundleName"] = "Scripto"
info["CFBundleDisplayName"] = "Scripto"
info["CFBundleIdentifier"] = "local.scripto.viewer"
info_path.write_bytes(plistlib.dumps(info))
info["CFBundleIdentifier"] = VIEWER_BUNDLE_ID
if icon is not None:
icon_name = str(info.get("CFBundleIconFile", "AppIcon"))
if not icon_name.endswith(".icns"):
icon_name += ".icns"
shutil.copyfile(icon, app / "Contents/Resources" / icon_name)
# With CFBundleIconName present, macOS takes the icon from the
# compiled Assets.car (stock Flet art) and ignores the .icns —
# drop the key so CFBundleIconFile wins.
info.pop("CFBundleIconName", None)
info_path.write_bytes(plistlib.dumps(info))


def hide_viewer_app(
bundle_ids: tuple[str, ...] = (VIEWER_BUNDLE_ID, STOCK_BUNDLE_ID),
) -> bool:
"""Hides the whole viewer app, ⌘H-style: the window disappears (no
minimized thumbnail) while the Dock icon stays, and clicking the Dock
icon makes macOS unhide it — no permissions involved. Unlike hiding
the *window* (``page.window.visible = False``), which macOS treats as
closing it and thereby ends the flet session, hiding the *app* leaves
the window open underneath, so jobs keep running.

Returns False when unavailable (non-macOS, viewer not found) — the
caller falls back to minimizing.
"""
if sys.platform != "darwin":
return False
try:
from AppKit import NSRunningApplication

# Hide every matching instance: after crashes or dev restarts a
# stale (possibly already-hidden) copy can linger, and picking just
# the first match could target the wrong one and fail spuriously.
hidden_any = False
found_any = False
for bundle_id in bundle_ids:
for app in NSRunningApplication.runningApplicationsWithBundleIdentifier_(
bundle_id
):
found_any = True
if app.isHidden() or app.hide():
hidden_any = True
if not found_any:
logger.warning("no viewer app found to hide (bundle ids: %s)",
", ".join(bundle_ids))
return hidden_any
except Exception:
logger.warning("hiding the viewer app failed", exc_info=True)
return False


def _build_if_needed() -> Path | None:
Expand All @@ -68,10 +116,11 @@ def _build_if_needed() -> Path | None:
if source_app is None:
return None

# Keyed by the cache-dir name (e.g. flet-desktop-full-0.86.0): a flet
# upgrade via `uv sync` gets a fresh copy, old versions are pruned.
# Keyed by the cache-dir name (e.g. flet-desktop-full-0.86.0) plus the
# branding revision: a flet upgrade via `uv sync` or a branding change
# gets a fresh copy, old versions are pruned.
viewers = data_dir() / "viewer"
dest = viewers / source_dir.name
dest = viewers / f"{source_dir.name}-r{BRAND_REVISION}"
if (dest / VIEWER_APP_NAME / "Contents/MacOS").is_dir():
return dest

Expand Down
15 changes: 10 additions & 5 deletions src/scripto/gui/app_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1121,12 +1121,17 @@ def _on_resized(self, _e) -> None:
pass

def _on_window_event(self, e: ft.WindowEvent) -> None:
# The close button minimizes to the Dock instead of quitting, so a
# running batch survives a casual window close (⌘Q still quits, and
# the in-app updater's destroy() bypasses prevent_close).
# The close button hides the whole app: window gone (no minimized
# thumbnail), Dock icon stays, clicking it brings the window back —
# so a running batch survives a casual close. ⌘Q still quits, and
# the in-app updater's destroy() bypasses prevent_close. Where
# app-hiding is unavailable (Windows/Linux) fall back to minimize.
if e.type == ft.WindowEventType.CLOSE:
self.page.window.minimized = True
self.page.update()
from ..core.viewer import hide_viewer_app

if not hide_viewer_app():
self.page.window.minimized = True
self.page.update()

def _toast(self, message: str, ok: bool = True) -> None:
self.page.show_dialog(ft.SnackBar(
Expand Down
12 changes: 12 additions & 0 deletions tests/test_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def _fake_bundle(tmp_path: Path) -> Path:
"CFBundleName": "Flet",
"CFBundleIdentifier": "com.appveyor.flet",
"CFBundleIconFile": "AppIcon",
# Present in the real client; must be dropped when the icon is
# swapped or macOS keeps loading the Flet art from Assets.car.
"CFBundleIconName": "AppIcon",
}))
(app / "Contents/Resources/AppIcon.icns").write_bytes(b"old")
return app
Expand All @@ -33,12 +36,15 @@ def test_rebrand_renames_and_swaps_icon(tmp_path):
assert info["CFBundleName"] == "Scripto"
assert info["CFBundleDisplayName"] == "Scripto"
assert info["CFBundleIdentifier"] == "local.scripto.viewer"
assert "CFBundleIconName" not in info
assert (app / "Contents/Resources/AppIcon.icns").read_bytes() == b"new"


def test_rebrand_keeps_stock_icon_without_replacement(tmp_path):
app = _fake_bundle(tmp_path)
viewer.rebrand_bundle(app, None)
info = plistlib.loads((app / "Contents/Info.plist").read_bytes())
assert info["CFBundleIconName"] == "AppIcon", "stock icon path must stay intact"
assert (app / "Contents/Resources/AppIcon.icns").read_bytes() == b"old"


Expand All @@ -57,3 +63,9 @@ def test_build_produces_a_signed_scripto_bundle(tmp_path, monkeypatch):
assert info["CFBundleName"] == "Scripto"
import subprocess
subprocess.run(["codesign", "--verify", str(app)], check=True)


def test_hide_viewer_app_reports_unavailable_safely():
# Non-existent bundle ids: False on macOS (nothing to hide) and False
# everywhere else (not darwin) — never raises, caller falls back.
assert viewer.hide_viewer_app(bundle_ids=("test.scripto.nonexistent",)) is False
91 changes: 89 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading