From 0d02959f86da95203f797407c101e488602da90b Mon Sep 17 00:00:00 2001 From: Game_Time <108236317+RayBytes@users.noreply.github.com> Date: Sun, 17 Aug 2025 21:50:19 +0500 Subject: [PATCH] Add files via upload --- app_qt.py | 471 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ build.py | 214 +++++++++++++++++++++++++ icon.png | Bin 0 -> 16898 bytes 3 files changed, 685 insertions(+) create mode 100644 app_qt.py create mode 100644 build.py create mode 100644 icon.png diff --git a/app_qt.py b/app_qt.py new file mode 100644 index 0000000..209a833 --- /dev/null +++ b/app_qt.py @@ -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 "" + 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() diff --git a/build.py b/build.py new file mode 100644 index 0000000..e4f50f1 --- /dev/null +++ b/build.py @@ -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() diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ca5e086ca8a0d0669a6db7ac44e7547994c3f159 GIT binary patch literal 16898 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

a~BdpHjlx0_vBy)Ah%5gUbodzb_0n`GI$>L_$z> zw{Ii)svStlr{O*@+qWkN9jFC!m85i%^X6P4ivC>Mm78p^F zGB92oW}mQHIjAf*DFTF(3K_ZPST8BYE}WDXml8m7;~ts(d$I#ZotY=40%M0bJ>Xe9L#;`=bPJ38m36_O>IrN%EN=2&a`17l@1) z$BB_9sn4cT-Wku~!V*|=JwdGd>0;kp3KO(@lxoScah|L&+vfB9wXjifGUdYPi)TQb z$%0*%Kb2K%BTESGVv<^pp40O9@YizMQw7NY*gGgirKe?vWA;-q6KVT#nCmmb;S%DG ziH|gW=Wm&L?g+S`FH|z;&^k!*40x)RfXc0|q>~79NZV>S<+Bb#(N3&a%xA?oj@im$ zE$ftp`!$Q`?_I|ndvEK~j@byt%y}d>(=CUpIpBrtE=W2;Q%Wp3+9F-#X;fR&M=yhw zYDv1Myoq6dLQ}bujvCqRs?Kgcjp&j!N@H>UsEar7U%->j9I*OXr4V4DzWYg4JC@la(yY@9EpN}u~XJJe7jIdiJn+=Oblmx)5m2>+4`JH zBxN*%i=TvnoN`s&BDXQ7=U@84PAIQaxhwT@e8ZWroP1A(>%N;e(>0<{Vm&uJ&axoy z;b{XxadC0t0hbl`Ex3>DugoiBrz4InSpp{~VD!H4gy3mZ%qMZ9F@nm1?aAK<$4ns4EzC z6t4G~zG64}RprZ`iBaJ#Dvx=Ju8xqQeHwyueC@fb&!K8-^zmBRa!4jGfR+HqH$LYP z{5?-*cf*jW`=5Gl1vUaFK8%~4cX9%>CsG3~8NS!w^8CGRNgzonzp!bUJ^U4aDO11& zh-8+WA=#JW`wLto4fNtbWu*8d+_Wb1yP5Y+QC}9YZlH&|8*2(3IQIfxqr_Y1YvO9i z+m$HJEf2~W4TVAT#TkDfN;-62`IYrUEnGbMvSDsfBKpC5WalaCG~=1Jqc_L#*?=wM z2ad>gzUwo&W&6`&57pU0je!d6vm-!+^o)TL+#;_f=c1Fv>y){+=fR^`RDVm)f-MrI zNP!hHp_-=fV^~Cs|3H-nnX^v&rx<(W7+v&cQg*=%-gMsE5v-pS^eN$`si~)ng}fS@ z-moXeF;Cc>-OL+DwCFm42edt1xK=HUyGX@NHho{?XOU9svQ@!n zp$QhneT-4EMi)I32n=!GBzV;bx_{S?ZizHqP;p2m2ci^z0$8Uta&B#l&}a=mn|VE?KpRPXjZ~bSM{