#!/usr/bin/env python """ Desktop launcher: run school meal planner inside a native window. Starts Django backend (background thread), then opens a pywebview window. """ import os import sys import socket import threading import time import traceback # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- _app_dir = None def _get_app_dir(): global _app_dir if _app_dir is None: if getattr(sys, "frozen", False): _app_dir = os.path.dirname(sys.executable) else: _app_dir = os.path.dirname(os.path.abspath(__file__)) return _app_dir def _get_log_dir(): """Return a writable directory for log files. In frozen mode, prefer %LOCALAPPDATA% to avoid write errors in Program Files.""" if getattr(sys, "frozen", False): local = os.environ.get("LOCALAPPDATA", "") if local: d = os.path.join(local, "学校订餐菜单生成器") try: os.makedirs(d, exist_ok=True) # quick write test t = os.path.join(d, ".write_test") with open(t, "w") as f: f.write("") os.remove(t) return d except Exception: pass return _get_app_dir() def _log(msg): try: path = os.path.join(_get_log_dir(), "desktop.log") with open(path, "a", encoding="utf-8") as f: f.write(msg + "\n") except Exception: pass def _excepthook(exc_type, exc_value, exc_tb): _log("FATAL: " + "".join(traceback.format_exception(exc_type, exc_value, exc_tb))) sys.excepthook = _excepthook # --------------------------------------------------------------------------- # Fix for console=False mode: ensure stdout/stderr are never None # --------------------------------------------------------------------------- def _ensure_stdio(): """Redirect stdout/stderr to log file if they are None (console=False).""" if sys.stdout is None or sys.stderr is None: log_path = os.path.join(_get_log_dir(), "django.log") try: log_file = open(log_path, "a", encoding="utf-8", buffering=1) except Exception: log_file = open(os.path.join(_get_app_dir(), "django.log"), "a", encoding="utf-8", buffering=1) if sys.stdout is None: sys.stdout = log_file if sys.stderr is None: sys.stderr = log_file _ensure_stdio() # --------------------------------------------------------------------------- # Environment # --------------------------------------------------------------------------- def _frozen_dir(): if getattr(sys, "frozen", False): return sys._MEIPASS return os.path.dirname(os.path.abspath(__file__)) def setup_environment(): base = _frozen_dir() data_dir = _get_log_dir() sys.path.insert(0, base) backend_dir = os.path.join(base, "backend") if os.path.isdir(backend_dir) and backend_dir not in sys.path: sys.path.insert(0, backend_dir) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ["DJANGO_DEBUG"] = "False" os.environ["DJANGO_SECRET_KEY"] = "exe-local-secret-key-do-not-use-in-production" os.environ["DJANGO_ALLOWED_HOSTS"] = "127.0.0.1,localhost" os.environ["DJANGO_DB_PATH"] = os.path.join(data_dir, "db.sqlite3") os.environ["DJANGO_STATIC_URL"] = "/static/" os.environ["EXE_MODE"] = "1" static_fe = os.path.join(base, "backend", "static", "frontend") if not os.path.isdir(static_fe): static_fe = os.path.join(base, "static", "frontend") os.environ["FRONTEND_DIR"] = static_fe def _ensure_database(): """Copy the bundled db.sqlite3 to the data directory if missing or outdated. In the frozen EXE, migrations cannot be discovered from the PyInstaller data directory, so we ship a pre-migrated db.sqlite3 and copy it on first run. If the user's existing database has the wrong schema (e.g. upgraded from an older EXE), replace it with the bundled one. """ if not getattr(sys, "frozen", False): return data_dir = _get_log_dir() target_db = os.path.join(data_dir, "db.sqlite3") bundled_db = os.path.join(_get_app_dir(), "db.sqlite3") if not os.path.isfile(bundled_db): return need_copy = False if not os.path.isfile(target_db): need_copy = True else: import sqlite3 try: conn = sqlite3.connect(target_db) cols = [r[1] for r in conn.execute("PRAGMA table_info(meals_dish)").fetchall()] conn.close() if "cuisine_id" not in cols: need_copy = True except Exception: need_copy = True if need_copy: os.makedirs(data_dir, exist_ok=True) import shutil shutil.copy2(bundled_db, target_db) _log(f"Copied bundled db.sqlite3 to {target_db}") def init_database(): _ensure_database() import django django.setup() from django.core.management import call_command call_command("migrate", "--run-syncdb", verbosity=0) from meals.models import Dish if Dish.objects.count() == 0: try: call_command("seed_dishes", verbosity=0) except Exception: pass from django.contrib.auth import get_user_model User = get_user_model() if not User.objects.filter(username="admin").exists(): User.objects.create_superuser("admin", "admin@example.com", "admin123") # --------------------------------------------------------------------------- # Django server # --------------------------------------------------------------------------- PORT = 8000 def _port_in_use(port): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: return s.connect_ex(("127.0.0.1", port)) == 0 def start_django_server(): try: _log("start_django_server: calling django.setup()") import django django.setup() _log("start_django_server: calling runserver") from django.core.management import call_command call_command("runserver", f"127.0.0.1:{PORT}", "--noreload", verbosity=0) _log("start_django_server: runserver returned") except Exception: _log("start_django_server CRASHED:\n" + traceback.format_exc()) def wait_for_server(timeout=30): deadline = time.time() + timeout while time.time() < deadline: if _port_in_use(PORT): return True time.sleep(0.3) return False # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- WINDOW_TITLE = "学校订餐菜单生成器" WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 800 def _get_icon_path(): """Return the path to the application icon file.""" return os.path.join(_get_app_dir(), "app.ico") def main(): _log("=== desktop.py main() starting ===") setup_environment() _log("Environment setup done") init_database() _log("Database init done") server_thread = threading.Thread(target=start_django_server, daemon=True) server_thread.start() _log("Django thread started, waiting for port 8000...") if not wait_for_server(): _log("ERROR: Django server startup timed out!") sys.exit(1) _log("Django server ready, opening webview...") import webview url = f"http://127.0.0.1:{PORT}" # --------------------------------------------------------------------------- # JS API: opens new native windows from the index page # --------------------------------------------------------------------------- _child_windows = [] class JsApi: """Exposed to JavaScript as window.pywebview.api.""" def open_page(self, path, title=None, width=1280, height=800): """Open a new pywebview window for the given path.""" full_url = f"http://127.0.0.1:{PORT}{path}" win_title = title or WINDOW_TITLE w = webview.create_window( win_title, full_url, width=int(width), height=int(height), min_size=(900, 600), resizable=True, text_select=True, ) _child_windows.append(w) return True js_api = JsApi() window = webview.create_window( WINDOW_TITLE, url, width=WINDOW_WIDTH, height=WINDOW_HEIGHT, min_size=(900, 600), resizable=True, text_select=True, js_api=js_api, ) _log("Starting webview event loop...") webview.start(debug=False) _log("Webview closed.") if __name__ == "__main__": try: main() except Exception: _log("UNHANDLED: " + traceback.format_exc()) raise