From ed4f33da97c46c9a3ce70e2d0e81461b348d7b2b Mon Sep 17 00:00:00 2001 From: TN019 Date: Sun, 26 Jul 2026 00:18:56 +1000 Subject: [PATCH] Hide the whole app on window close instead of minimizing: no window thumbnail lingers, the Dock icon brings it back, and jobs keep running. --- pyproject.toml | 5 ++- src/scripto/core/viewer.py | 59 +++++++++++++++++++++--- src/scripto/gui/app_gui.py | 15 ++++--- tests/test_viewer.py | 12 +++++ uv.lock | 91 +++++++++++++++++++++++++++++++++++++- 5 files changed, 169 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58ca562..93a8f09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/scripto/core/viewer.py b/src/scripto/core/viewer.py index 68fcb6f..a9d9006 100644 --- a/src/scripto/core/viewer.py +++ b/src/scripto/core/viewer.py @@ -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: @@ -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: @@ -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 diff --git a/src/scripto/gui/app_gui.py b/src/scripto/gui/app_gui.py index c7932bd..04e5b71 100644 --- a/src/scripto/gui/app_gui.py +++ b/src/scripto/gui/app_gui.py @@ -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( diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 02d6f43..5d9cdbb 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -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 @@ -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" @@ -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 diff --git a/uv.lock b/uv.lock index 56f77b4..a3ef34e 100644 --- a/uv.lock +++ b/uv.lock @@ -198,6 +198,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/a1/2cf56e96b794ed340b0e43ee69280d99090196748dce19a1198b2156a78f/flet-0.86.0-py3-none-any.whl", hash = "sha256:1651bdd1396022c823b0c267780f5aa5aff13ec61b79e15cd97986bf20d8b723", size = 620264, upload-time = "2026-07-14T21:50:30.946Z" }, ] +[package.optional-dependencies] +desktop = [ + { name = "flet-desktop" }, +] + +[[package]] +name = "flet-desktop" +version = "0.86.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flet" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4d/bf268920dcf0028f7db1d6e149072f4b750b5c1fb47534500657241d6a4a/flet_desktop-0.86.0.tar.gz", hash = "sha256:0e632070aff0c930a2721186bc9b4b3e7f8e374fd29b333731d772404974e8c5", size = 6326, upload-time = "2026-07-14T21:50:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/9c/4e8730dd312f0c850a4e64be8150981ab658742ef071b8a9609cb33287ed/flet_desktop-0.86.0-py3-none-any.whl", hash = "sha256:05fe38605bceca505e1c70700f090e0d9861ccf8a72a1b87e059ccc096ecedb4", size = 6418, upload-time = "2026-07-14T21:50:39.196Z" }, +] + [[package]] name = "flet-web" version = "0.86.0" @@ -388,6 +406,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/e3/7a93e09c9f94e637ca90209ceef0334a9a1d45b0bdb7c92ff922d25d6187/llvmlite-0.48.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a5c413317050a1d67c34708bde97707f9b2257ef1017f7532d21fe7d9a9ff30", size = 40480654, upload-time = "2026-07-01T18:42:25.076Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -401,6 +431,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mlx" version = "0.32.0" @@ -787,6 +826,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -903,6 +975,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "scipy" version = "1.18.0" @@ -928,10 +1013,11 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "faster-whisper" }, - { name = "flet" }, + { name = "flet", extra = ["desktop"] }, { name = "huggingface-hub" }, { name = "mlx-whisper", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "platformdirs" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, { name = "tqdm" }, ] @@ -944,10 +1030,11 @@ dev = [ [package.metadata] requires-dist = [ { name = "faster-whisper", specifier = ">=1.1" }, - { name = "flet", specifier = ">=0.86.0" }, + { name = "flet", extras = ["desktop"], specifier = ">=0.86.0" }, { name = "huggingface-hub", specifier = ">=0.23" }, { name = "mlx-whisper", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'", specifier = ">=0.4.1" }, { name = "platformdirs", specifier = ">=4.0" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'", specifier = ">=10" }, { name = "tqdm", specifier = ">=4.66" }, ]