Add files via upload
This commit is contained in:
471
app_qt.py
Normal file
471
app_qt.py
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import webbrowser
|
||||||
|
import multiprocessing as mp
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
from chatmock import create_app, cmd_login
|
||||||
|
from utils import load_chatgpt_tokens, parse_jwt_claims
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str, port: int, reasoning_effort: str = "medium", reasoning_summary: str = "auto") -> None:
|
||||||
|
app = create_app(reasoning_effort=reasoning_effort, reasoning_summary=reasoning_summary)
|
||||||
|
app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerProcess(QtCore.QObject):
|
||||||
|
state_changed = QtCore.Signal(bool)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._proc: QtCore.QProcess | None = None
|
||||||
|
self._host = "127.0.0.1"
|
||||||
|
self._port = 8000
|
||||||
|
self._effort = "medium"
|
||||||
|
self._summary = "auto"
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._proc is not None and self._proc.state() != QtCore.QProcess.NotRunning
|
||||||
|
|
||||||
|
def start(self, host: str, port: int, effort: str, summary: str) -> None:
|
||||||
|
if self.is_running():
|
||||||
|
return
|
||||||
|
self._host, self._port = host, port
|
||||||
|
self._effort, self._summary = effort, summary
|
||||||
|
self._proc = QtCore.QProcess()
|
||||||
|
self._proc.setProcessChannelMode(QtCore.QProcess.MergedChannels)
|
||||||
|
args = [
|
||||||
|
"--run-server",
|
||||||
|
"--host", host,
|
||||||
|
"--port", str(port),
|
||||||
|
"--effort", effort,
|
||||||
|
"--summary", summary,
|
||||||
|
]
|
||||||
|
self._proc.start(sys.executable, args)
|
||||||
|
self._proc.started.connect(lambda: self.state_changed.emit(True))
|
||||||
|
def _on_finished(code: int, status: QtCore.QProcess.ExitStatus) -> None:
|
||||||
|
self.state_changed.emit(False)
|
||||||
|
self._proc = None
|
||||||
|
self._proc.finished.connect(_on_finished)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self.is_running():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._proc.kill()
|
||||||
|
self._proc.waitForFinished(3000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
self.state_changed.emit(False)
|
||||||
|
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return f"http://{self._host}:{self._port}/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def resource_path(rel: str) -> str:
|
||||||
|
base = getattr(sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__)))
|
||||||
|
return os.path.join(base, rel)
|
||||||
|
|
||||||
|
|
||||||
|
def find_app_icon() -> QtGui.QIcon:
|
||||||
|
candidates = [
|
||||||
|
"appicon.icns",
|
||||||
|
"appicon.ico",
|
||||||
|
"appicon.png",
|
||||||
|
"icon.icns",
|
||||||
|
"icon.ico",
|
||||||
|
"icon.png",
|
||||||
|
"ChatMock.icns",
|
||||||
|
"ChatMock.png",
|
||||||
|
]
|
||||||
|
for name in candidates:
|
||||||
|
p = resource_path(name)
|
||||||
|
if os.path.exists(p):
|
||||||
|
icon = QtGui.QIcon(p)
|
||||||
|
if not icon.isNull():
|
||||||
|
return icon
|
||||||
|
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.SP_DesktopIcon)
|
||||||
|
|
||||||
|
|
||||||
|
def is_dark_mode() -> bool:
|
||||||
|
app = QtWidgets.QApplication.instance()
|
||||||
|
pal = app.palette() if app else QtGui.QPalette()
|
||||||
|
bg = pal.window().color()
|
||||||
|
return bg.lightness() < 128
|
||||||
|
|
||||||
|
|
||||||
|
def apply_theme() -> None:
|
||||||
|
dark = is_dark_mode()
|
||||||
|
if dark:
|
||||||
|
bg = "#111827" # slate-900
|
||||||
|
text = "#e5e7eb" # gray-200
|
||||||
|
subtext = "#9ca3af" # gray-400
|
||||||
|
border = "#374151" # slate-700
|
||||||
|
primary = "#3b82f6" # blue-500
|
||||||
|
primary_hover = "#2563eb"
|
||||||
|
danger = "#ef4444" # red-500
|
||||||
|
field_bg = "#0f172a" # slightly lighter (inputs)
|
||||||
|
else:
|
||||||
|
bg = "#ffffff"
|
||||||
|
text = "#0f172a"
|
||||||
|
subtext = "#64748b"
|
||||||
|
border = "#e5e7eb"
|
||||||
|
primary = "#2563eb"
|
||||||
|
primary_hover = "#1d4ed8"
|
||||||
|
danger = "#ef4444"
|
||||||
|
field_bg = "#ffffff"
|
||||||
|
|
||||||
|
css = f"""
|
||||||
|
QWidget {{ background: {bg}; color: {text}; }}
|
||||||
|
QGroupBox {{
|
||||||
|
background: {bg};
|
||||||
|
border: 1px solid {border};
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}}
|
||||||
|
QGroupBox::title {{
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
subcontrol-position: top left;
|
||||||
|
padding: 2px 6px;
|
||||||
|
color: {text};
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
}}
|
||||||
|
QLabel#subtitle {{ color: {subtext}; }}
|
||||||
|
QLabel {{ background: transparent; }}
|
||||||
|
QLineEdit, QComboBox {{
|
||||||
|
background: {field_bg};
|
||||||
|
border: 1px solid {border};
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}}
|
||||||
|
QPushButton {{
|
||||||
|
border: 1px solid {border};
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: {bg};
|
||||||
|
color: {text};
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
border-color: {primary};
|
||||||
|
}}
|
||||||
|
QPushButton[muted="true"] {{
|
||||||
|
background: transparent;
|
||||||
|
color: {subtext};
|
||||||
|
border-color: {border};
|
||||||
|
}}
|
||||||
|
QPushButton[muted="true"]:hover {{
|
||||||
|
border-color: {primary};
|
||||||
|
color: {text};
|
||||||
|
}}
|
||||||
|
QPushButton[primary="true"] {{
|
||||||
|
background: {primary};
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid {primary};
|
||||||
|
}}
|
||||||
|
QPushButton[primary="true"]:hover {{
|
||||||
|
background: {primary_hover};
|
||||||
|
border-color: {primary_hover};
|
||||||
|
}}
|
||||||
|
QPushButton[danger="true"] {{
|
||||||
|
background: transparent;
|
||||||
|
color: {danger};
|
||||||
|
border: 1px solid {danger};
|
||||||
|
}}
|
||||||
|
QPushButton[danger="true"]:hover {{
|
||||||
|
background: {danger};
|
||||||
|
color: #ffffff;
|
||||||
|
}}
|
||||||
|
QMenu {{
|
||||||
|
background: {bg};
|
||||||
|
border: 1px solid {border};
|
||||||
|
}}
|
||||||
|
QMenu::item:selected {{ background: {primary}; color: #ffffff; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication.instance()
|
||||||
|
if app:
|
||||||
|
app.setStyleSheet(css)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginWorker(QtCore.QThread):
|
||||||
|
finished_with_code = QtCore.Signal(int)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
code = cmd_login(no_browser=False, verbose=False)
|
||||||
|
except Exception:
|
||||||
|
code = 1
|
||||||
|
self.finished_with_code.emit(code)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QtWidgets.QMainWindow):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("ChatMock")
|
||||||
|
self.setMinimumSize(620, 420)
|
||||||
|
self._logged_in = False
|
||||||
|
self._server = ServerProcess()
|
||||||
|
self._server.state_changed.connect(self._on_server_state_changed)
|
||||||
|
|
||||||
|
# Central widget
|
||||||
|
cw = QtWidgets.QWidget()
|
||||||
|
self.setCentralWidget(cw)
|
||||||
|
root = QtWidgets.QVBoxLayout(cw)
|
||||||
|
root.setContentsMargins(16, 16, 16, 12)
|
||||||
|
root.setSpacing(12)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QtWidgets.QVBoxLayout()
|
||||||
|
self.title = QtWidgets.QLabel("ChatMock")
|
||||||
|
font = self.title.font()
|
||||||
|
font.setPointSize(20)
|
||||||
|
font.setBold(True)
|
||||||
|
self.title.setFont(font)
|
||||||
|
self.status = QtWidgets.QLabel("Welcome to ChatMock")
|
||||||
|
self.status.setObjectName("subtitle")
|
||||||
|
header.addWidget(self.title)
|
||||||
|
header.addWidget(self.status)
|
||||||
|
root.addLayout(header)
|
||||||
|
|
||||||
|
# Account card
|
||||||
|
acc_box = QtWidgets.QGroupBox("Account")
|
||||||
|
acc_box.setStyleSheet("QGroupBox{font-weight:600;}")
|
||||||
|
acc_layout = QtWidgets.QFormLayout(acc_box)
|
||||||
|
acc_layout.setLabelAlignment(QtCore.Qt.AlignLeft)
|
||||||
|
acc_layout.setFormAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
|
||||||
|
acc_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)
|
||||||
|
self.email_value = QtWidgets.QLabel("Not signed in")
|
||||||
|
self.email_value.setWordWrap(True)
|
||||||
|
self.plan_value = QtWidgets.QLabel("-")
|
||||||
|
self.accid_value = QtWidgets.QLabel("-")
|
||||||
|
self.accid_value.setWordWrap(True)
|
||||||
|
acc_layout.addRow("Email", self.email_value)
|
||||||
|
acc_layout.addRow("Plan", self.plan_value)
|
||||||
|
acc_layout.addRow("Account ID", self.accid_value)
|
||||||
|
acc_btns = QtWidgets.QHBoxLayout()
|
||||||
|
self.btn_login = QtWidgets.QPushButton("Log in")
|
||||||
|
self.btn_login.clicked.connect(self._on_login)
|
||||||
|
acc_btns.addWidget(self.btn_login)
|
||||||
|
acc_btns.addStretch(1)
|
||||||
|
acc_layout.addRow(acc_btns)
|
||||||
|
root.addWidget(acc_box)
|
||||||
|
|
||||||
|
# Server card
|
||||||
|
srv_box = QtWidgets.QGroupBox("Server")
|
||||||
|
srv_layout = QtWidgets.QVBoxLayout(srv_box)
|
||||||
|
form = QtWidgets.QGridLayout()
|
||||||
|
form.setHorizontalSpacing(12)
|
||||||
|
form.setVerticalSpacing(8)
|
||||||
|
form.addWidget(QtWidgets.QLabel("Host"), 0, 0)
|
||||||
|
self.host_edit = QtWidgets.QLineEdit("127.0.0.1")
|
||||||
|
self.host_edit.setClearButtonEnabled(True)
|
||||||
|
self.host_edit.setMinimumWidth(220)
|
||||||
|
self.host_edit.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
|
||||||
|
form.addWidget(self.host_edit, 0, 1)
|
||||||
|
form.addWidget(QtWidgets.QLabel("Port"), 0, 2)
|
||||||
|
self.port_edit = QtWidgets.QLineEdit("8000")
|
||||||
|
self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self))
|
||||||
|
self.port_edit.setMaximumWidth(100)
|
||||||
|
form.addWidget(self.port_edit, 0, 3)
|
||||||
|
form.setColumnStretch(1, 1)
|
||||||
|
srv_layout.addLayout(form)
|
||||||
|
|
||||||
|
actions = QtWidgets.QHBoxLayout()
|
||||||
|
self.btn_start = QtWidgets.QPushButton("Start in Background")
|
||||||
|
self.btn_start.setDefault(True)
|
||||||
|
self.btn_start.setProperty("primary", True)
|
||||||
|
self.btn_stop = QtWidgets.QPushButton("Stop")
|
||||||
|
self.btn_stop.setProperty("danger", True)
|
||||||
|
self.btn_open = QtWidgets.QPushButton("Open Base URL")
|
||||||
|
actions.addWidget(self.btn_start)
|
||||||
|
actions.addWidget(self.btn_stop)
|
||||||
|
actions.addWidget(self.btn_open)
|
||||||
|
actions.addStretch(1)
|
||||||
|
srv_layout.addLayout(actions)
|
||||||
|
|
||||||
|
# Reasoning controls
|
||||||
|
opts = QtWidgets.QGridLayout()
|
||||||
|
opts.setHorizontalSpacing(12)
|
||||||
|
opts.setVerticalSpacing(8)
|
||||||
|
opts.addWidget(QtWidgets.QLabel("Reasoning Effort"), 0, 0)
|
||||||
|
self.effort = QtWidgets.QComboBox()
|
||||||
|
self.effort.addItems(["low", "medium", "high", "none"]) # default medium
|
||||||
|
self.effort.setCurrentText("medium")
|
||||||
|
self.effort.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
|
||||||
|
self.effort.setMinimumContentsLength(7)
|
||||||
|
opts.addWidget(self.effort, 0, 1)
|
||||||
|
opts.addWidget(QtWidgets.QLabel("Reasoning Summary"), 0, 2)
|
||||||
|
self.summary = QtWidgets.QComboBox()
|
||||||
|
self.summary.addItems(["auto", "concise", "detailed", "none"]) # default auto
|
||||||
|
self.summary.setCurrentText("auto")
|
||||||
|
self.summary.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
|
||||||
|
self.summary.setMinimumContentsLength(8)
|
||||||
|
opts.addWidget(self.summary, 0, 3)
|
||||||
|
opts.setColumnStretch(1, 1)
|
||||||
|
opts.setColumnStretch(3, 1)
|
||||||
|
srv_layout.addLayout(opts)
|
||||||
|
|
||||||
|
url_row = QtWidgets.QHBoxLayout()
|
||||||
|
url_row.addWidget(QtWidgets.QLabel("Base URL:"))
|
||||||
|
self.baseurl = QtWidgets.QLabel("(server not running)")
|
||||||
|
self.baseurl.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse | QtCore.Qt.TextSelectableByKeyboard)
|
||||||
|
self.baseurl.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
|
||||||
|
url_row.addWidget(self.baseurl, 1)
|
||||||
|
self.btn_copy = QtWidgets.QPushButton("Copy")
|
||||||
|
url_row.addWidget(self.btn_copy)
|
||||||
|
srv_layout.addLayout(url_row)
|
||||||
|
root.addWidget(srv_box)
|
||||||
|
|
||||||
|
self.btn_start.clicked.connect(self._start_server)
|
||||||
|
self.btn_stop.clicked.connect(self._stop_server)
|
||||||
|
self.btn_copy.clicked.connect(self._copy_url)
|
||||||
|
self.btn_open.clicked.connect(self._open_base_url)
|
||||||
|
|
||||||
|
# Tray
|
||||||
|
self.tray = QtWidgets.QSystemTrayIcon(self)
|
||||||
|
icon = find_app_icon()
|
||||||
|
self.setWindowIcon(icon)
|
||||||
|
self.tray.setIcon(icon)
|
||||||
|
tray_menu = QtWidgets.QMenu()
|
||||||
|
act_show = tray_menu.addAction("Show Window")
|
||||||
|
tray_menu.addSeparator()
|
||||||
|
act_quit = tray_menu.addAction("Quit")
|
||||||
|
act_show.triggered.connect(self._show_window)
|
||||||
|
act_quit.triggered.connect(QtWidgets.QApplication.quit)
|
||||||
|
self.tray.setContextMenu(tray_menu)
|
||||||
|
self.tray.show()
|
||||||
|
|
||||||
|
self._refresh_login_state()
|
||||||
|
self._on_server_state_changed(False)
|
||||||
|
|
||||||
|
QtWidgets.QApplication.instance().aboutToQuit.connect(self._server.stop)
|
||||||
|
|
||||||
|
def _refresh_login_state(self) -> None:
|
||||||
|
access_token, account_id, id_token = load_chatgpt_tokens()
|
||||||
|
if access_token and id_token:
|
||||||
|
self.status.setText("Signed in • Ready to serve")
|
||||||
|
self._logged_in = True
|
||||||
|
self.btn_login.setEnabled(True)
|
||||||
|
self.btn_login.setProperty("muted", True)
|
||||||
|
try:
|
||||||
|
self.btn_login.style().unpolish(self.btn_login)
|
||||||
|
self.btn_login.style().polish(self.btn_login)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.btn_login.setToolTip("You are logged in. Click to re-authenticate.")
|
||||||
|
id_claims = parse_jwt_claims(id_token) or {}
|
||||||
|
access_claims = parse_jwt_claims(access_token) or {}
|
||||||
|
email = id_claims.get("email") or id_claims.get("preferred_username") or "<unknown>"
|
||||||
|
plan_raw = (access_claims.get("https://api.openai.com/auth") or {}).get("chatgpt_plan_type") or "unknown"
|
||||||
|
plan_map = {"plus": "Plus", "pro": "Pro", "free": "Free", "team": "Team", "enterprise": "Enterprise"}
|
||||||
|
plan = plan_map.get(str(plan_raw).lower(), str(plan_raw).title() if isinstance(plan_raw, str) else "Unknown")
|
||||||
|
self.email_value.setText(email)
|
||||||
|
self.plan_value.setText(plan)
|
||||||
|
self.accid_value.setText(account_id or "-")
|
||||||
|
else:
|
||||||
|
self.status.setText("Not signed in • Click Log in")
|
||||||
|
self._logged_in = False
|
||||||
|
self.btn_login.setEnabled(True)
|
||||||
|
self.btn_login.setProperty("muted", False)
|
||||||
|
try:
|
||||||
|
self.btn_login.style().unpolish(self.btn_login)
|
||||||
|
self.btn_login.style().polish(self.btn_login)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.btn_login.setToolTip("Log in to ChatGPT")
|
||||||
|
self.email_value.setText("Not signed in")
|
||||||
|
self.plan_value.setText("-")
|
||||||
|
self.accid_value.setText("-")
|
||||||
|
self.btn_start.setEnabled(not self._server.is_running() and self._logged_in)
|
||||||
|
|
||||||
|
def _on_login(self) -> None:
|
||||||
|
self.status.setText("Launching login flow…")
|
||||||
|
self.btn_login.setEnabled(False)
|
||||||
|
self._login_worker = LoginWorker()
|
||||||
|
self._login_worker.finished_with_code.connect(self._after_login)
|
||||||
|
self._login_worker.start()
|
||||||
|
|
||||||
|
def _after_login(self, code: int) -> None:
|
||||||
|
if code == 0:
|
||||||
|
QtWidgets.QMessageBox.information(self, "Login", "Login successful. You can now start the server.")
|
||||||
|
elif code == 13:
|
||||||
|
QtWidgets.QMessageBox.warning(self, "Login", "Login helper port is in use. Close other instances and try again.")
|
||||||
|
else:
|
||||||
|
QtWidgets.QMessageBox.critical(self, "Login", "Login failed. Please try again.")
|
||||||
|
self._refresh_login_state()
|
||||||
|
|
||||||
|
def _start_server(self) -> None:
|
||||||
|
try:
|
||||||
|
host = self.host_edit.text().strip() or "127.0.0.1"
|
||||||
|
port = int(self.port_edit.text().strip() or "8000")
|
||||||
|
except ValueError:
|
||||||
|
QtWidgets.QMessageBox.critical(self, "Port", "Invalid port number.")
|
||||||
|
return
|
||||||
|
effort = self.effort.currentText().strip()
|
||||||
|
summary = self.summary.currentText().strip()
|
||||||
|
self.status.setText(f"Starting server at http://{host}:{port} …")
|
||||||
|
self.btn_start.setEnabled(False)
|
||||||
|
self._server.start(host, port, effort, summary)
|
||||||
|
|
||||||
|
def _stop_server(self) -> None:
|
||||||
|
self._server.stop()
|
||||||
|
|
||||||
|
def _on_server_state_changed(self, running: bool) -> None:
|
||||||
|
self.btn_start.setEnabled((not running) and self._logged_in)
|
||||||
|
self.btn_stop.setEnabled(running)
|
||||||
|
self.btn_open.setEnabled(running)
|
||||||
|
self.btn_copy.setEnabled(running)
|
||||||
|
if running:
|
||||||
|
self.status.setText("Serving • Running in background")
|
||||||
|
self.baseurl.setText(self._server.base_url())
|
||||||
|
self.hide()
|
||||||
|
self.tray.showMessage("ChatMock", "Server is running in the background", QtWidgets.QSystemTrayIcon.Information, 1500)
|
||||||
|
else:
|
||||||
|
self.status.setText("Server stopped")
|
||||||
|
self.baseurl.setText("(server not running)")
|
||||||
|
|
||||||
|
def _copy_url(self) -> None:
|
||||||
|
url = self.baseurl.text().strip()
|
||||||
|
if url and not url.startswith("("):
|
||||||
|
QtWidgets.QApplication.clipboard().setText(url)
|
||||||
|
|
||||||
|
def _open_base_url(self) -> None:
|
||||||
|
url = self.baseurl.text().strip()
|
||||||
|
if url and not url.startswith("("):
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
def _show_window(self) -> None:
|
||||||
|
self.show()
|
||||||
|
self.raise_()
|
||||||
|
self.activateWindow()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
mp.freeze_support()
|
||||||
|
if "--run-server" in sys.argv:
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser(add_help=False)
|
||||||
|
p.add_argument("--run-server", action="store_true")
|
||||||
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
|
p.add_argument("--port", type=int, default=8000)
|
||||||
|
p.add_argument("--effort", default="medium")
|
||||||
|
p.add_argument("--summary", default="auto")
|
||||||
|
args, _ = p.parse_known_args()
|
||||||
|
run_server(args.host, args.port, args.effort, args.summary)
|
||||||
|
return
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication(sys.argv)
|
||||||
|
apply_theme()
|
||||||
|
w = MainWindow()
|
||||||
|
w.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
214
build.py
Normal file
214
build.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import plistlib
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent.resolve()
|
||||||
|
BUILD_DIR = ROOT / "build"
|
||||||
|
ICONS_DIR = BUILD_DIR / "icons"
|
||||||
|
|
||||||
|
|
||||||
|
def info(msg: str) -> None:
|
||||||
|
print(f"[build] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
ICONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_icon_png(path: Path) -> Image.Image:
|
||||||
|
if Image is None:
|
||||||
|
raise RuntimeError("Pillow is required to process icons.")
|
||||||
|
img = Image.open(path).convert("RGBA")
|
||||||
|
size = max(img.width, img.height)
|
||||||
|
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
x = (size - img.width) // 2
|
||||||
|
y = (size - img.height) // 2
|
||||||
|
canvas.paste(img, (x, y))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def rounded(img: Image.Image, radius_ratio: float = 0.22) -> Image.Image:
|
||||||
|
if Image is None:
|
||||||
|
return img
|
||||||
|
w, h = img.size
|
||||||
|
r = int(min(w, h) * max(0.0, min(radius_ratio, 0.5)))
|
||||||
|
if r <= 0:
|
||||||
|
return img
|
||||||
|
mask = Image.new("L", (w, h), 0)
|
||||||
|
from PIL import ImageDraw
|
||||||
|
d = ImageDraw.Draw(mask)
|
||||||
|
d.rounded_rectangle((0, 0, w, h), radius=r, fill=255)
|
||||||
|
out = img.copy()
|
||||||
|
out.putalpha(mask)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_windows_ico(src_png: Path, out_ico: Path, radius_ratio: float) -> Path:
|
||||||
|
info("Generating Windows .ico")
|
||||||
|
square = load_icon_png(src_png)
|
||||||
|
sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||||
|
images = [rounded(square.resize((s, s), Image.LANCZOS), radius_ratio) for s in sizes]
|
||||||
|
images[0].save(out_ico, format="ICO", sizes=[(s, s) for s in sizes])
|
||||||
|
return out_ico
|
||||||
|
|
||||||
|
|
||||||
|
def make_macos_icns(src_png: Path, out_icns: Path, radius_ratio: float) -> Path:
|
||||||
|
info("Generating macOS .icns")
|
||||||
|
iconset = BUILD_DIR / "icon.iconset"
|
||||||
|
if iconset.exists():
|
||||||
|
shutil.rmtree(iconset)
|
||||||
|
iconset.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
square = load_icon_png(src_png)
|
||||||
|
sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||||
|
mapping = {
|
||||||
|
16: ["icon_16x16.png", "icon_32x32.png"],
|
||||||
|
32: ["icon_16x16@2x.png"],
|
||||||
|
64: ["icon_32x32@2x.png"],
|
||||||
|
128: ["icon_128x128.png", "icon_256x256.png"],
|
||||||
|
256: ["icon_128x128@2x.png"],
|
||||||
|
512: ["icon_512x512.png"],
|
||||||
|
1024:["icon_512x512@2x.png"],
|
||||||
|
}
|
||||||
|
for s in sizes:
|
||||||
|
img = rounded(square.resize((s, s), Image.LANCZOS), radius_ratio)
|
||||||
|
for name in mapping.get(s, []):
|
||||||
|
img.save(iconset / name, format="PNG")
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(out_icns)], check=True)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("Failed to create .icns. Ensure Xcode command line tools are installed (iconutil).\n"
|
||||||
|
f"Details: {e}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(iconset, ignore_errors=True)
|
||||||
|
return out_icns
|
||||||
|
|
||||||
|
|
||||||
|
def pyinstaller_add_data_arg(src: Path, dest: str) -> str:
|
||||||
|
sep = ";" if os.name == "nt" else ":"
|
||||||
|
return f"{src}{sep}{dest}"
|
||||||
|
|
||||||
|
|
||||||
|
def run_pyinstaller(entry: Path, name: str, icon: Path | None, extra_data: list[tuple[Path, str]], bundle_id: str | None = None) -> None:
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "PyInstaller",
|
||||||
|
"--windowed", "--noconfirm",
|
||||||
|
"--name", name,
|
||||||
|
]
|
||||||
|
if bundle_id and platform.system().lower() == "darwin":
|
||||||
|
cmd += ["--osx-bundle-identifier", bundle_id]
|
||||||
|
if icon is not None:
|
||||||
|
cmd += ["--icon", str(icon)]
|
||||||
|
for (src, dest) in extra_data:
|
||||||
|
cmd += ["--add-data", pyinstaller_add_data_arg(src, dest)]
|
||||||
|
cmd.append(str(entry))
|
||||||
|
info("Running: " + " ".join(cmd))
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_macos_plist(app_path: Path, bundle_id: str, icon_base_name: str = "appicon") -> None:
|
||||||
|
info("Patching macOS Info.plist")
|
||||||
|
plist_path = app_path / "Contents" / "Info.plist"
|
||||||
|
if not plist_path.exists():
|
||||||
|
info(f"No Info.plist at {plist_path}, skipping patch")
|
||||||
|
return
|
||||||
|
with plist_path.open("rb") as f:
|
||||||
|
data = plistlib.load(f)
|
||||||
|
data["CFBundleIdentifier"] = bundle_id
|
||||||
|
data["CFBundleName"] = data.get("CFBundleName") or app_path.stem
|
||||||
|
data["CFBundleDisplayName"] = data.get("CFBundleDisplayName") or app_path.stem
|
||||||
|
data["CFBundleIconFile"] = icon_base_name
|
||||||
|
data["CFBundleIconName"] = icon_base_name
|
||||||
|
with plist_path.open("wb") as f:
|
||||||
|
plistlib.dump(data, f)
|
||||||
|
|
||||||
|
def make_dmg(app_path: Path, dmg_path: Path, volume_name: str) -> None:
|
||||||
|
info("Creating DMG")
|
||||||
|
staging = BUILD_DIR / "dmg_staging"
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
(staging).mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.rmtree(staging / app_path.name, ignore_errors=True)
|
||||||
|
shutil.copytree(app_path, staging / app_path.name, symlinks=True)
|
||||||
|
try:
|
||||||
|
os.symlink("/Applications", staging / "Applications")
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
dmg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run([
|
||||||
|
"hdiutil", "create", "-volname", volume_name,
|
||||||
|
"-srcfolder", str(staging),
|
||||||
|
"-format", "UDZO",
|
||||||
|
"-imagekey", "zlib-level=9",
|
||||||
|
str(dmg_path)
|
||||||
|
], check=True)
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--name", default="ChatMock")
|
||||||
|
parser.add_argument("--entry", default="app_qt.py")
|
||||||
|
parser.add_argument("--icon", default="icon.png")
|
||||||
|
parser.add_argument("--radius", type=float, default=0.22)
|
||||||
|
parser.add_argument("--square", action="store_true")
|
||||||
|
parser.add_argument("--dmg", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ensure_dirs()
|
||||||
|
entry = ROOT / args.entry
|
||||||
|
icon_src = ROOT / args.icon
|
||||||
|
if not entry.exists():
|
||||||
|
raise SystemExit(f"Entry not found: {entry}")
|
||||||
|
if not icon_src.exists():
|
||||||
|
raise SystemExit(f"Icon PNG not found: {icon_src}")
|
||||||
|
|
||||||
|
os_name = platform.system().lower()
|
||||||
|
extra_data: list[tuple[Path, str]] = [(ROOT / "prompt.md", ".")]
|
||||||
|
|
||||||
|
bundle_icon: Path | None = None
|
||||||
|
rr = 0.0 if args.square else float(args.radius)
|
||||||
|
if os_name == "windows":
|
||||||
|
ico = ICONS_DIR / "appicon.ico"
|
||||||
|
make_windows_ico(icon_src, ico, rr)
|
||||||
|
bundle_icon = ico
|
||||||
|
extra_data.append((ico, "."))
|
||||||
|
elif os_name == "darwin":
|
||||||
|
icns = ICONS_DIR / "appicon.icns"
|
||||||
|
make_macos_icns(icon_src, icns, rr)
|
||||||
|
bundle_icon = icns
|
||||||
|
extra_data.append((icns, "."))
|
||||||
|
else:
|
||||||
|
png_copy = ICONS_DIR / "appicon.png"
|
||||||
|
if Image is not None:
|
||||||
|
square = load_icon_png(icon_src).resize((512, 512), Image.LANCZOS)
|
||||||
|
square = rounded(square, rr) if rr > 0 else square
|
||||||
|
square.save(png_copy)
|
||||||
|
else:
|
||||||
|
shutil.copy2(icon_src, png_copy)
|
||||||
|
extra_data.append((png_copy, "."))
|
||||||
|
|
||||||
|
run_pyinstaller(entry, args.name, bundle_icon, extra_data)
|
||||||
|
if os_name == "darwin":
|
||||||
|
app_path = ROOT / "dist" / f"{args.name}.app"
|
||||||
|
if app_path.exists():
|
||||||
|
bid = "com.chatmock.app"
|
||||||
|
patch_macos_plist(app_path, bundle_id=bid, icon_base_name="appicon")
|
||||||
|
if args.dmg:
|
||||||
|
dmg = ROOT / "dist" / f"{args.name}.dmg"
|
||||||
|
make_dmg(app_path, dmg, args.name)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user