refactor(pet): extract PetBase, deduplicate _start_server/safe wrappers, add singleton guard
This commit is contained in:
@@ -207,6 +207,77 @@ def build_bubble_image(message, max_width=220):
|
|||||||
'tail_tip': (tail_x, bottom_y),
|
'tail_tip': (tail_x, bottom_y),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Shared Base Class
|
||||||
|
# ============================================================================
|
||||||
|
class PetBase:
|
||||||
|
"""Shared logic for Mac and Windows pet implementations."""
|
||||||
|
|
||||||
|
def _schedule_main(self, fn):
|
||||||
|
"""Schedule fn on the GUI main thread. Subclasses must override."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def set_state_safe(self, state):
|
||||||
|
"""Thread-safe wrapper for set_state."""
|
||||||
|
self._schedule_main(lambda: self.set_state(state))
|
||||||
|
|
||||||
|
def show_toast_safe(self, message):
|
||||||
|
"""Thread-safe wrapper for show_toast."""
|
||||||
|
self._schedule_main(lambda m=message: self.show_toast(m))
|
||||||
|
|
||||||
|
def _start_server(self):
|
||||||
|
"""Start HTTP control server."""
|
||||||
|
pet = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if 'state' in params:
|
||||||
|
state = params['state'][0]
|
||||||
|
pet.set_state_safe(state)
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'ok')
|
||||||
|
elif 'msg' in params:
|
||||||
|
msg = params['msg'][0]
|
||||||
|
pet.show_toast_safe(msg)
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'ok')
|
||||||
|
else:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'?state=idle/walk/run/sprint or ?msg=hello')
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
|
||||||
|
if body:
|
||||||
|
pet.show_toast_safe(body)
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'ok')
|
||||||
|
else:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'empty body')
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
HTTPServer.allow_reuse_address = True
|
||||||
|
srv = HTTPServer(('127.0.0.1', PORT), Handler)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk')
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == 48:
|
||||||
|
print(f'⚠ Port {PORT} already in use')
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# macOS Implementation - Pure Cocoa with True Transparency
|
# macOS Implementation - Pure Cocoa with True Transparency
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -221,7 +292,7 @@ if sys.platform == 'darwin':
|
|||||||
from PyObjCTools import AppHelper
|
from PyObjCTools import AppHelper
|
||||||
import objc
|
import objc
|
||||||
|
|
||||||
class MacPet:
|
class MacPet(PetBase):
|
||||||
def __init__(self, skin_name=None):
|
def __init__(self, skin_name=None):
|
||||||
self.app = NSApplication.sharedApplication()
|
self.app = NSApplication.sharedApplication()
|
||||||
self.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
self.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
||||||
@@ -427,9 +498,8 @@ if sys.platform == 'darwin':
|
|||||||
)
|
)
|
||||||
print(f"→ State: {state}")
|
print(f"→ State: {state}")
|
||||||
|
|
||||||
def set_state_safe(self, state):
|
def _schedule_main(self, fn):
|
||||||
"""Thread-safe wrapper for set_state"""
|
AppHelper.callAfter(fn)
|
||||||
AppHelper.callAfter(lambda: self.set_state(state))
|
|
||||||
|
|
||||||
def show_toast(self, message):
|
def show_toast(self, message):
|
||||||
"""Show toast message above pet"""
|
"""Show toast message above pet"""
|
||||||
@@ -497,25 +567,6 @@ if sys.platform == 'darwin':
|
|||||||
)
|
)
|
||||||
print(f"Toast: {message}")
|
print(f"Toast: {message}")
|
||||||
|
|
||||||
def show_toast_safe(self, message):
|
|
||||||
"""Thread-safe wrapper for show_toast"""
|
|
||||||
from Foundation import NSRunLoop
|
|
||||||
from PyObjCTools import AppHelper
|
|
||||||
|
|
||||||
# Write to log file for debugging
|
|
||||||
with open('/tmp/pet_toast_debug.log', 'a') as f:
|
|
||||||
f.write(f"[DEBUG] show_toast_safe called with: {message}\n")
|
|
||||||
f.flush()
|
|
||||||
|
|
||||||
# Schedule on main thread
|
|
||||||
def show_on_main():
|
|
||||||
with open('/tmp/pet_toast_debug.log', 'a') as f:
|
|
||||||
f.write(f"[DEBUG] show_on_main executing\n")
|
|
||||||
f.flush()
|
|
||||||
self.show_toast(message)
|
|
||||||
|
|
||||||
AppHelper.callAfter(show_on_main)
|
|
||||||
|
|
||||||
def hideToast_(self, timer):
|
def hideToast_(self, timer):
|
||||||
"""Hide toast message"""
|
"""Hide toast message"""
|
||||||
if self.toast_window:
|
if self.toast_window:
|
||||||
@@ -525,59 +576,6 @@ if sys.platform == 'darwin':
|
|||||||
self.toast_image = None
|
self.toast_image = None
|
||||||
self.toast_timer = None
|
self.toast_timer = None
|
||||||
|
|
||||||
def _start_server(self):
|
|
||||||
"""Start HTTP control server"""
|
|
||||||
pet = self
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
params = parse_qs(parsed.query)
|
|
||||||
|
|
||||||
if 'state' in params:
|
|
||||||
state = params['state'][0]
|
|
||||||
pet.set_state_safe(state)
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'ok')
|
|
||||||
elif 'msg' in params:
|
|
||||||
msg = params['msg'][0]
|
|
||||||
pet.show_toast_safe(msg)
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'ok')
|
|
||||||
else:
|
|
||||||
self.send_response(400)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'?state=idle/walk/run/sprint or ?msg=hello')
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
|
|
||||||
if body:
|
|
||||||
pet.show_toast_safe(body)
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'ok')
|
|
||||||
else:
|
|
||||||
self.send_response(400)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'empty body')
|
|
||||||
|
|
||||||
def log_message(self, *a):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
HTTPServer.allow_reuse_address = True
|
|
||||||
srv = HTTPServer(('127.0.0.1', PORT), Handler)
|
|
||||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
||||||
t.start()
|
|
||||||
print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk')
|
|
||||||
except OSError as e:
|
|
||||||
if e.errno == 48:
|
|
||||||
print(f'⚠ Port {PORT} already in use')
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the application"""
|
"""Run the application"""
|
||||||
AppHelper.runEventLoop()
|
AppHelper.runEventLoop()
|
||||||
@@ -589,7 +587,7 @@ else:
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from PIL import ImageTk
|
from PIL import ImageTk
|
||||||
|
|
||||||
class WinPet:
|
class WinPet(PetBase):
|
||||||
def __init__(self, skin_name=None):
|
def __init__(self, skin_name=None):
|
||||||
self.root = tk.Tk()
|
self.root = tk.Tk()
|
||||||
self.root.wm_attributes('-topmost', True)
|
self.root.wm_attributes('-topmost', True)
|
||||||
@@ -753,64 +751,25 @@ else:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _start_server(self):
|
def _schedule_main(self, fn):
|
||||||
"""Start HTTP control server"""
|
self.root.after(0, fn)
|
||||||
pet = self
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
params = parse_qs(parsed.query)
|
|
||||||
|
|
||||||
if 'state' in params:
|
|
||||||
state = params['state'][0]
|
|
||||||
pet.root.after(0, pet.set_state, state)
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'ok')
|
|
||||||
elif 'msg' in params:
|
|
||||||
msg = params['msg'][0]
|
|
||||||
pet.root.after(0, pet.show_toast, msg)
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'ok')
|
|
||||||
else:
|
|
||||||
self.send_response(400)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'?state=idle/walk/run/sprint or ?msg=hello')
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
|
|
||||||
if body:
|
|
||||||
pet.root.after(0, pet.show_toast, body)
|
|
||||||
self.send_response(200)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'ok')
|
|
||||||
else:
|
|
||||||
self.send_response(400)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b'empty body')
|
|
||||||
|
|
||||||
def log_message(self, *a):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
HTTPServer.allow_reuse_address = True
|
|
||||||
srv = HTTPServer(('127.0.0.1', PORT), Handler)
|
|
||||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
||||||
t.start()
|
|
||||||
print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk')
|
|
||||||
except OSError as e:
|
|
||||||
if e.errno == 48:
|
|
||||||
print(f'⚠ Port {PORT} already in use')
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the application (already in mainloop)"""
|
"""Run the application (already in mainloop)"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
# Singleton: if port already in use, another instance is running
|
||||||
|
import socket
|
||||||
|
_s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
_s.connect(('127.0.0.1', PORT))
|
||||||
|
_s.close()
|
||||||
|
print(f'⚠ Pet already running on port {PORT}, exiting.')
|
||||||
|
sys.exit(0)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
pass
|
||||||
|
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
pet = MacPet()
|
pet = MacPet()
|
||||||
pet.run()
|
pet.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user