"""SSH 隧道管理器 — Windows 桌面入口(pywebview + paramiko)。""" from __future__ import annotations import ctypes import json import os import subprocess from pathlib import Path import webview from engine import TunnelManager, probe_host from storage import Store, data_root APP_DIR_NAME = "SshTunnelManager" APP_TITLE = "SSH 隧道管理器" UI_PATH = Path(__file__).parent / "ui" / "index.html" ICON_PATH = Path(__file__).parent / "ui" / "app.ico" TRAY_ICON_PATH = Path(__file__).parent / "ui" / "icon-256.png" _SENSITIVE = ("password", "key_passphrase") def data_dir() -> Path: d = data_root() d.mkdir(parents=True, exist_ok=True) return d def _port_ok(v) -> bool: try: return 1 <= int(v) <= 65535 except (TypeError, ValueError): return False class TrayController: """系统托盘:窗口最小化/关闭后驻留托盘,可恢复或真正退出。""" def __init__(self, api: "Api"): self._api = api self._icon = None self._hinted = False def start(self) -> None: try: from PIL import Image from pystray import Icon, Menu, MenuItem image = Image.open(TRAY_ICON_PATH) menu = Menu( MenuItem("显示主窗口", self._on_show, default=True), Menu.SEPARATOR, MenuItem("退出", self._on_exit), ) self._icon = Icon("ssh-tunnel-manager", image, APP_TITLE, menu) self._icon.run_detached() self._icon.visible = True except Exception: # noqa: BLE001 托盘失败不影响主窗口 self._icon = None def _on_show(self, *_args) -> None: self._api.show_main_window() def _on_exit(self, *_args) -> None: self._api.exit_application() def notify_minimized_once(self) -> None: if self._hinted or not self._icon: return self._hinted = True try: self._icon.notify("已最小化到系统托盘,隧道在后台保持运行", APP_TITLE) except Exception: pass def stop(self) -> None: icon, self._icon = self._icon, None if icon: try: icon.stop() except Exception: pass class Api: """暴露给前端 JS 的接口。所有方法返回 {"ok": bool, ...}。""" def __init__(self, store: Store, manager: TunnelManager): self._store = store self._manager = manager self._win: webview.Window | None = None self._maximized = False self._really_exiting = False self.tray = TrayController(self) # ---------- 事件推送 ---------- def _emit(self, ev: dict) -> None: if not self._win: return try: self._win.evaluate_js("window.__bus && window.__bus(%s)" % json.dumps(ev, ensure_ascii=False)) except Exception: pass def _push_state(self) -> None: self._emit({"type": "state", "state": self._state()}) # ---------- 序列化 ---------- def _state(self) -> dict: hosts_by_id = {h["id"]: h for h in self._store.data["hosts"]} hosts = [self._host_out(h) for h in self._store.data["hosts"]] tunnels = [] for t in self._store.data["tunnels"]: out = {k: t.get(k) for k in ("id", "name", "host_id", "local_port", "remote_host", "remote_port", "autostart", "autoreconnect")} host = hosts_by_id.get(t.get("host_id")) out["host_label"] = (f"{host['username']}@{host['hostname']}:{host.get('port', 22)}" if host else "(主机不存在)") out["host_auth"] = host.get("auth") if host else None tun = self._manager.tunnels.get(t["id"]) out["runtime"] = tun.snapshot() if tun else { "id": t["id"], "status": "stopped", "error": "", "started_at": None, "conn_count": 0} out["logs"] = list(tun.logs)[-60:] if tun else [] tunnels.append(out) return { "hosts": hosts, "tunnels": tunnels, "feed": self._feed(), "config_dir": str(self._store.data_dir), } @staticmethod def _host_out(h: dict) -> dict: return { "id": h["id"], "name": h.get("name"), "hostname": h.get("hostname"), "port": h.get("port", 22), "username": h.get("username"), "auth": h.get("auth", "password"), "key_path": h.get("key_path", ""), "note": h.get("note", ""), "has_password": bool(h.get("password")), "has_passphrase": bool(h.get("key_passphrase")), } def _feed(self) -> list: rows = [] for tun in self._manager.tunnels.values(): for e in tun.logs: rows.append({"tunnel_id": tun.cfg["id"], "tunnel_name": tun.cfg.get("name", tun.cfg["id"]), **e}) rows.sort(key=lambda r: r["t"]) return rows[-300:] def _apply(self) -> None: self._manager.apply_config( self._store.data["tunnels"], {h["id"]: h for h in self._store.data["hosts"]}) @staticmethod def _fail(error: str) -> dict: return {"ok": False, "error": error} # ---------- JS 接口 ---------- def get_state(self) -> dict: return {"ok": True, "state": self._state()} def save_host(self, p: dict) -> dict: try: hostname = (p.get("hostname") or "").strip() username = (p.get("username") or "").strip() port = p.get("port") or 22 if not hostname: return self._fail("请填写主机地址") if not username: return self._fail("请填写用户名") if not _port_ok(port): return self._fail("SSH 端口须为 1-65535") auth = p.get("auth") or "password" if auth == "key" and not (p.get("key_path") or "").strip(): return self._fail("请选择私钥文件") hosts = self._store.data["hosts"] old = next((h for h in hosts if h["id"] == p.get("id")), None) rec = { "id": old["id"] if old else self._store.new_id(), "name": (p.get("name") or "").strip() or hostname, "hostname": hostname, "port": int(port), "username": username, "auth": auth, "password": (p.get("password") or "").strip(), "key_path": (p.get("key_path") or "").strip(), "key_passphrase": (p.get("key_passphrase") or ""), "note": (p.get("note") or "").strip(), } if old: # 留空 = 保持原值;切换认证方式时清掉另一侧的机密 for k in _SENSITIVE: if rec[k] == "": rec[k] = old.get(k, "") if auth == "key": rec["password"] = "" else: rec["key_passphrase"] = "" hosts[hosts.index(old)] = rec else: hosts.append(rec) self._store.save() self._apply() return {"ok": True, "id": rec["id"], "state": self._state()} except Exception as e: # noqa: BLE001 return self._fail(str(e)) def delete_host(self, host_id: str) -> dict: used = [t["name"] for t in self._store.data["tunnels"] if t.get("host_id") == host_id] if used: return self._fail("该主机正被隧道使用:" + "、".join(used)) self._store.data["hosts"] = [h for h in self._store.data["hosts"] if h["id"] != host_id] self._store.save() self._apply() return {"ok": True, "state": self._state()} def save_tunnel(self, p: dict) -> dict: try: host_id = p.get("host_id") if not host_id or host_id not in {h["id"] for h in self._store.data["hosts"]}: return self._fail("请选择主机(可在「主机」页先添加)") local_port = p.get("local_port") remote_port = p.get("remote_port") if not _port_ok(local_port): return self._fail("本地端口须为 1-65535") if not _port_ok(remote_port): return self._fail("远程端口须为 1-65535") remote_host = (p.get("remote_host") or "").strip() or "127.0.0.1" tid = p.get("id") for t in self._store.data["tunnels"]: if t["id"] != tid and int(t["local_port"]) == int(local_port): return self._fail(f"本地端口 {local_port} 已被隧道「{t['name']}」使用") tunnels = self._store.data["tunnels"] old = next((t for t in tunnels if t["id"] == tid), None) rec = { "id": old["id"] if old else self._store.new_id(), "name": (p.get("name") or "").strip() or f"{remote_host}:{remote_port}", "host_id": host_id, "local_port": int(local_port), "remote_host": remote_host, "remote_port": int(remote_port), "autostart": bool(p.get("autostart")), "autoreconnect": bool(p.get("autoreconnect", True)), } if old: tunnels[tunnels.index(old)] = rec else: tunnels.append(rec) self._store.save() self._apply() return {"ok": True, "id": rec["id"], "state": self._state()} except Exception as e: # noqa: BLE001 return self._fail(str(e)) def delete_tunnel(self, tunnel_id: str) -> dict: tun = self._manager.tunnels.pop(tunnel_id, None) if tun: tun.stop() self._store.data["tunnels"] = [t for t in self._store.data["tunnels"] if t["id"] != tunnel_id] self._store.save() return {"ok": True, "state": self._state()} def start_tunnel(self, tunnel_id: str) -> dict: self._apply() tun = self._manager.tunnels.get(tunnel_id) if not tun: return self._fail("隧道不存在") tun.start() return {"ok": True} def stop_tunnel(self, tunnel_id: str) -> dict: tun = self._manager.tunnels.get(tunnel_id) if not tun: return {"ok": True} tun.stop() return {"ok": True} def probe_host(self, p: dict) -> dict: """用表单当前值(空密码沿用已存值)测试连接。""" try: old = next((h for h in self._store.data["hosts"] if h["id"] == p.get("id")), None) host = { "hostname": (p.get("hostname") or "").strip(), "port": int(p.get("port") or 22), "username": (p.get("username") or "").strip(), "auth": p.get("auth") or "password", "password": (p.get("password") or ""), "key_path": (p.get("key_path") or "").strip(), "key_passphrase": (p.get("key_passphrase") or ""), } if old: for k in _SENSITIVE: if host[k] == "": host[k] = old.get(k, "") if not host["hostname"]: return self._fail("请先填写主机地址") ok, msg = probe_host(host, self._manager.known_hosts_path) return {"ok": ok, "message": msg} except Exception as e: # noqa: BLE001 return self._fail(str(e)) def open_url(self, url: str) -> dict: if not (url.startswith("http://") or url.startswith("https://")): return self._fail("仅允许打开 http(s) 链接") try: os.startfile(url) # noqa: S606 return {"ok": True} except Exception as e: # noqa: BLE001 return self._fail(str(e)) def copy_text(self, text: str) -> dict: try: CF_UNICODETEXT = 13 GMEM_MOVEABLE = 0x0002 u32 = ctypes.WinDLL("user32") k32 = ctypes.WinDLL("kernel32") if not u32.OpenClipboard(None): return self._fail("剪贴板被占用") try: u32.EmptyClipboard() buf = ctypes.create_unicode_buffer(text) h = k32.GlobalAlloc(GMEM_MOVEABLE, ctypes.sizeof(buf)) ptr = k32.GlobalLock(h) ctypes.memmove(ptr, buf, ctypes.sizeof(buf)) k32.GlobalUnlock(h) u32.SetClipboardData(CF_UNICODETEXT, h) finally: u32.CloseClipboard() return {"ok": True} except Exception as e: # noqa: BLE001 return self._fail(str(e)) def pick_key_file(self) -> dict: try: import tkinter as tk from tkinter import filedialog root = tk.Tk() root.withdraw() root.attributes("-topmost", True) path = filedialog.askopenfilename( parent=root, title="选择私钥文件", filetypes=[("私钥 / 所有文件", "*.*")]) root.destroy() return {"ok": True, "path": path} except Exception as e: # noqa: BLE001 return self._fail(str(e)) # ---------- 窗口控制 ---------- def win_minimize(self) -> dict: """标题栏「最小化」:隐藏到系统托盘。""" self.hide_main_window() return {"ok": True} def win_maximize(self) -> dict: if self._win: if self._maximized: self._win.restore() else: self._win.maximize() self._maximized = not self._maximized return {"ok": True} def win_close(self) -> dict: """标题栏「关闭」:隐藏到系统托盘,隧道保持运行;退出走托盘菜单。""" self.hide_main_window() return {"ok": True} # ---------- 托盘联动(供 TrayController / 事件调用) ---------- def hide_main_window(self) -> None: if self._win: try: self._win.hide() except Exception: pass self.tray.notify_minimized_once() def show_main_window(self) -> None: if self._win: try: self._win.show() except Exception: pass def exit_application(self) -> None: self._really_exiting = True self.tray.stop() try: self._manager.stop_all() except Exception: pass if self._win: try: self._win.destroy() except Exception: pass def main() -> None: dd = data_dir() store = Store(dd) holder: dict = {} manager = TunnelManager(lambda ev: holder["api"]._emit(ev), dd) api = Api(store, manager) holder["api"] = api # 随应用启动的隧道在窗口出现前就拉起 api._apply() win = webview.create_window( "SSH 隧道管理器", str(UI_PATH), js_api=api, width=1120, height=740, min_size=(900, 580), frameless=True, easy_drag=False, background_color="#111418", ) api._win = win win.events.closed += api._manager.stop_all def on_closing(*_args): """Alt+F4 / 系统关窗请求也转为隐藏到托盘。""" if api._really_exiting: return None api.hide_main_window() return False try: win.events.closing += on_closing except Exception: pass def after_start(): # 任务栏 / Alt+Tab / 窗口图标 try: import clr clr.AddReference("System.Drawing") from System.Drawing import Icon win.native.Icon = Icon(str(ICON_PATH)) except Exception: pass api.tray.start() try: webview.start(after_start) except Exception as e: # noqa: BLE001 msg = ("启动失败:未检测到 WebView2 运行时。\n\n" "请安装 Microsoft Edge WebView2 Runtime 后重试。\n" "下载: https://developer.microsoft.com/microsoft-edge/webview2/\n\n" f"详细错误: {e}") ctypes.windll.user32.MessageBoxW(None, msg, "SSH 隧道管理器", 0x10) raise SystemExit(1) from e if __name__ == "__main__": try: main() except SystemExit: raise except Exception: # noqa: BLE001 # pythonw 无控制台,任何未捕获异常都必须可见,否则表现为"闪退" import traceback tb = traceback.format_exc() log_path = None try: log_path = data_dir() / "crash.log" log_path.write_text(tb, encoding="utf-8") except Exception: pass msg = "程序遇到错误,已退出。" if log_path: msg += f"\n\n详细日志:{log_path}" try: ctypes.windll.user32.MessageBoxW( None, msg + "\n\n" + tb[-900:], "SSH 隧道管理器", 0x10) except Exception: pass raise SystemExit(1)