From 12c168bbfe17adfc3856f393ff9bdddd0ea25c40 Mon Sep 17 00:00:00 2001 From: Pat Teruel Date: Sun, 1 Mar 2026 10:57:26 +0800 Subject: [PATCH] Add PyObjC Cocoa UI (mac_cocoa.py) and update build to install pyobjc Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build_mac.sh | 2 +- mac_cocoa.py | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 4 +- 3 files changed, 184 insertions(+), 3 deletions(-) mode change 100644 => 100755 build_mac.sh create mode 100644 mac_cocoa.py diff --git a/build_mac.sh b/build_mac.sh old mode 100644 new mode 100755 index 3b008fb..666ce3f --- a/build_mac.sh +++ b/build_mac.sh @@ -11,7 +11,7 @@ source .venv/bin/activate pip install --upgrade pip setuptools wheel pip install -r requirements.txt -pip install py2app +pip install py2app pyobjc-framework-Cocoa # Build the macOS .app python3 setup.py py2app diff --git a/mac_cocoa.py b/mac_cocoa.py new file mode 100644 index 0000000..d2c0410 --- /dev/null +++ b/mac_cocoa.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Cocoa-native GUI using PyObjC to generate the One Piece progress report without a web server. +This is a minimal native UI: Generate Report, Copy to Clipboard, and Debug output. +""" + +import os +import threading +from datetime import datetime +from app import current_week, format_date_range, get_latest_sunday, get_previous_sunday +from clients import jellyfin + +import objc +from AppKit import * + + +class AppDelegate(NSObject): + def applicationDidFinishLaunching_(self, notification): + # Create main window + rect = NSMakeRect(0.0, 0.0, 900.0, 650.0) + style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + NSWindowStyleMaskResizable | NSWindowStyleMaskMiniaturizable) + self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(rect, style, NSBackingStoreBuffered, False) + self.window.center() + self.window.setTitle_("One Piece Progress Reporter") + + content = self.window.contentView() + + # Title + title = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 610, 600, 24)) + title.setStringValue_("🏴‍☠️ One Piece Progress Reporter") + title.setBezeled_(False) + title.setDrawsBackground_(False) + title.setEditable_(False) + title.setSelectable_(False) + content.addSubview_(title) + + # Generate button + gen_btn = NSButton.alloc().initWithFrame_(NSMakeRect(760, 610, 120, 30)) + gen_btn.setTitle_("Generate Report") + gen_btn.setTarget_(self) + gen_btn.setAction_("generateReport:") + content.addSubview_(gen_btn) + + # Report label and box + report_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 580, 200, 18)) + report_label.setStringValue_("Report:") + report_label.setBezeled_(False) + report_label.setDrawsBackground_(False) + report_label.setEditable_(False) + report_label.setSelectable_(False) + content.addSubview_(report_label) + + report_scroll = NSScrollView.alloc().initWithFrame_(NSMakeRect(20, 420, 860, 150)) + report_scroll.setHasVerticalScroller_(True) + report_view = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, 860, 150)) + report_view.setEditable_(False) + report_scroll.setDocumentView_(report_view) + content.addSubview_(report_scroll) + self.report_view = report_view + + # Copy button + copy_btn = NSButton.alloc().initWithFrame_(NSMakeRect(760, 360, 120, 30)) + copy_btn.setTitle_("Copy to Clipboard") + copy_btn.setTarget_(self) + copy_btn.setAction_("copyReport:") + content.addSubview_(copy_btn) + + # Debug label and box + debug_label = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 390, 200, 18)) + debug_label.setStringValue_("Debug:") + debug_label.setBezeled_(False) + debug_label.setDrawsBackground_(False) + debug_label.setEditable_(False) + debug_label.setSelectable_(False) + content.addSubview_(debug_label) + + debug_scroll = NSScrollView.alloc().initWithFrame_(NSMakeRect(20, 40, 860, 340)) + debug_scroll.setHasVerticalScroller_(True) + debug_view = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, 860, 340)) + debug_view.setEditable_(False) + debug_scroll.setDocumentView_(debug_view) + content.addSubview_(debug_scroll) + self.debug_view = debug_view + + # Initial debug text + try: + self.debug_view.setString_(f"LATEST_SUNDAY={get_latest_sunday()}\nPREVIOUS_SUNDAY={get_previous_sunday()}\n") + except Exception: + self.debug_view.setString_("(Could not compute initial debug values)\n") + + self.window.makeKeyAndOrderFront_(None) + NSApp.activateIgnoringOtherApps_(True) + + # Button actions + def generateReport_(self, sender): + # Run the heavy work in a background thread and marshal UI updates back to the main thread + threading.Thread(target=self._generate_worker, daemon=True).start() + + def _generate_worker(self): + try: + jellyfin.clear_debug_logs() + + latest = get_latest_sunday() + previous = get_previous_sunday() + latest_dt = datetime.fromisoformat(latest.replace("Z", "+00:00")) + previous_dt = datetime.fromisoformat(previous.replace("Z", "+00:00")) + user_id = os.environ.get("JELLYFIN_USER_ID") + if not user_id: + raise RuntimeError("JELLYFIN_USER_ID not set in environment") + + ep_info = jellyfin.find_last_one_piece_episode(latest_dt, user_id) + prev_ep_info = jellyfin.find_last_one_piece_before_previous_sunday(previous_dt, user_id) + previous_episode = prev_ep_info["episode_number"] + total_episodes = int(os.environ.get("TOTAL_EPISODES", 1155)) + watched_this_week = ep_info["episode_number"] - previous_episode + week_start, week_end, _ = current_week() + date_range = format_date_range(week_start, week_end) + + report_text = ( + f"ONE PIECE PROGRESS!!! ({date_range})\n" + f"Episode {ep_info['episode_number']}/{total_episodes}\n" + f"Around {watched_this_week} episodes watched this week!" + ) + + debug_output = jellyfin.get_debug_logs() + payload = {"report": report_text, "debug": debug_output} + # Update UI on main thread + self.performSelectorOnMainThread_withObject_waitUntilDone_("updateUI:", payload, False) + except Exception as e: + payload = {"error": str(e), "debug": jellyfin.get_debug_logs()} + self.performSelectorOnMainThread_withObject_waitUntilDone_("showError:", payload, False) + + def updateUI_(self, payload): + report = payload.get("report", "") + debug = payload.get("debug", "") + try: + self.report_view.setString_(report) + self.debug_view.setString_(debug) + except Exception: + pass + + def showError_(self, payload): + error = payload.get("error", "Unknown error") + debug = payload.get("debug", "") + alert = NSAlert.alloc().init() + alert.setMessageText_("Error") + alert.setInformativeText_(error) + alert.runModal() + try: + self.debug_view.setString_(debug) + except Exception: + pass + + def copyReport_(self, sender): + try: + s = self.report_view.string() or "" + pb = NSPasteboard.generalPasteboard() + pb.clearContents() + # Use public.utf8-plain-text which is commonly accepted + pb.setString_forType_(s, "public.utf8-plain-text") + alert = NSAlert.alloc().init() + alert.setMessageText_("Copied") + alert.setInformativeText_("Report copied to clipboard.") + alert.runModal() + except Exception as e: + alert = NSAlert.alloc().init() + alert.setMessageText_("Copy failed") + alert.setInformativeText_(str(e)) + alert.runModal() + + +def main(): + app = NSApplication.sharedApplication() + delegate = AppDelegate.alloc().init() + app.setDelegate_(delegate) + app.run() + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index 32d4241..945505b 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,11 @@ from setuptools import setup -APP = ['mac_native.py'] +APP = ['mac_cocoa.py'] DATA_FILES = [] OPTIONS = { 'argv_emulation': True, 'packages': ['clients'], - 'includes': ['tkinter','requests', 'dotenv'], + 'includes': ['objc','AppKit','requests', 'dotenv'], 'resources': ['templates'], 'plist': { 'CFBundleName': 'MediaEpisodesViewer',