Files

576 lines
16 KiB
Python

from __future__ import annotations
import faulthandler
import subprocess
import sys
import threading
import time
import webbrowser
from typing import Any, Callable, Optional
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = ImageDraw = ImageFont = None
def render_app_icon(size: int):
scale = size / 1024
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
outer = tuple(round(value * scale) for value in (92, 92, 932, 932))
draw.ellipse(outer, fill=(0, 151, 221, 255))
try:
font = ImageFont.truetype(
"/System/Library/Fonts/Helvetica.ttc",
round(430 * scale),
)
except OSError:
font = ImageFont.load_default()
box = draw.textbbox((0, 0), "T", font=font)
width = box[2] - box[0]
height = box[3] - box[1]
draw.text(
(
(size - width) / 2 - box[0],
(size - height) / 2 - box[1] - round(10 * scale),
),
"T",
font=font,
fill=(255, 255, 255, 255),
)
return image
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon":
if Image is None:
raise SystemExit("Pillow is required to render the macOS app icon")
output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns"
render_app_icon(1024).save(output_path, format="ICNS")
raise SystemExit(0)
try:
import customtkinter as ctk
except ImportError:
ctk = None
try:
import pyperclip
except ImportError:
pyperclip = None
try:
import pystray
except ImportError:
pystray = None
try:
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
except ImportError:
NSApplication = None
NSApplicationActivationPolicyAccessory = None
from proxy import get_link_host
from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE,
apply_ctk_appearance,
create_ctk_toplevel,
ctk_theme_for_platform,
install_tkinter_variable_del_guard,
main_content_frame,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons,
install_tray_config_form,
populate_first_run_window,
tray_settings_scroll_and_footer,
validate_config_form,
)
from ui.i18n import set_language, t
from utils.tray_common import (
APP_DIR,
APP_NAME,
DEFAULT_CONFIG,
FIRST_RUN_MARKER,
LOG_FILE,
acquire_lock,
bootstrap,
check_ipv6_warning,
ensure_dirs,
load_config,
load_icon,
log,
maybe_notify_update,
release_lock,
restart_proxy,
save_config,
start_proxy,
stop_proxy,
tg_proxy_url,
)
_tray_icon: Optional[Any] = None
_ctk_root: Optional[Any] = None
_settings_window: Optional[Any] = None
_ns_app: Optional[Any] = None
_config: dict = {}
_exiting = False
_crash_log: Optional[Any] = None
def _activate_app() -> None:
if _ns_app is not None:
try:
_ns_app.activateIgnoringOtherApps_(True)
except Exception as exc:
log.warning("Failed to activate macOS app: %s", repr(exc))
def _hide_ctk_root() -> None:
if _ctk_root is None:
return
try:
_ctk_root.withdraw()
except Exception as exc:
log.warning("Failed to hide CTk root: %s", repr(exc))
def _dispatch(callback: Callable[[], None], delay_ms: int = 0) -> None:
if _ctk_root is None:
return
def invoke() -> None:
try:
callback()
except Exception as exc:
log.exception("UI callback failed")
try:
_show_error(str(exc))
except Exception as dialog_exc:
log.error("Failed to show UI error: %s", repr(dialog_exc))
try:
if delay_ms > 0:
_ctk_root.after(delay_ms, invoke)
else:
_ctk_root.after_idle(invoke)
except Exception as exc:
log.warning("Failed to dispatch UI callback: %s", repr(exc))
def _messagebox(kind: str, text: str, title: str) -> Any:
import tkinter as tk
from tkinter import messagebox
result: list[Any] = []
done = threading.Event()
def show() -> None:
parent = None
try:
_activate_app()
_hide_ctk_root()
parent = tk.Toplevel(_ctk_root)
parent.withdraw()
result.append(getattr(messagebox, kind)(title, text, parent=parent))
finally:
if parent is not None:
try:
parent.destroy()
except tk.TclError:
pass
_hide_ctk_root()
if _ctk_root is not None:
try:
_ctk_root.after_idle(_hide_ctk_root)
_ctk_root.after(50, _hide_ctk_root)
except tk.TclError:
pass
done.set()
if threading.current_thread() is threading.main_thread():
show()
elif _ctk_root is not None:
_dispatch(show)
done.wait()
return result[0] if result else False
def _standalone_info(text: str) -> None:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
messagebox.showinfo(t("app.name"), text, parent=root)
root.destroy()
def _show_error(text: str, title: Optional[str] = None) -> None:
_messagebox("showerror", text, title or t("app.error_title"))
def _show_info(text: str, title: Optional[str] = None) -> None:
_messagebox("showinfo", text, title or t("app.name"))
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
return bool(_messagebox("askyesno", text, title or t("app.name")))
def _refresh_tray_menu() -> None:
if _tray_icon is None:
return
_tray_icon.menu = _build_menu()
try:
_tray_icon.update_menu()
except Exception as exc:
log.warning("Failed to refresh tray menu: %s", repr(exc))
def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Opening %s", url)
try:
if subprocess.call(["open", url]) != 0:
raise OSError("open command failed")
except OSError:
try:
if not webbrowser.open(url):
raise OSError("webbrowser.open returned False")
except OSError:
try:
if pyperclip is not None:
pyperclip.copy(url)
else:
subprocess.run(["pbcopy"], input=url.encode(), check=True)
_show_info(t("dialog.open_tg_fail_clipboard", url=url))
except (OSError, subprocess.SubprocessError) as exc:
_show_error(t("dialog.copy_fail", error=exc))
def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
try:
if pyperclip is not None:
pyperclip.copy(url)
else:
subprocess.run(["pbcopy"], input=url.encode(), check=True)
except (OSError, subprocess.SubprocessError) as exc:
_show_error(t("dialog.copy_fail", error=exc))
def _on_restart(icon=None, item=None) -> None:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error),
daemon=True,
name="proxy-restart",
).start()
def _on_edit_config(icon=None, item=None) -> None:
log.info("Settings requested")
_dispatch(_edit_config_dialog, delay_ms=300)
def _on_open_logs(icon=None, item=None) -> None:
if LOG_FILE.exists():
try:
subprocess.run(["open", str(LOG_FILE)], check=True)
except (OSError, subprocess.SubprocessError) as exc:
_show_error(t("dialog.log_open_fail", error=exc))
else:
_show_info(t("dialog.log_not_found"))
def _finish_exit() -> None:
global _exiting
if _exiting:
return
_exiting = True
log.info("User requested exit")
if _tray_icon is not None:
try:
_tray_icon.stop()
except Exception as exc:
log.warning("Failed to stop tray icon: %s", repr(exc))
if _ctk_root is not None:
_ctk_root.quit()
def _on_exit(icon=None, item=None) -> None:
_dispatch(_finish_exit)
def _edit_config_dialog() -> None:
global _settings_window
if _settings_window is not None:
try:
if _settings_window.winfo_exists():
_activate_app()
_settings_window.lift()
_settings_window.focus_force()
return
except Exception as exc:
log.warning("Failed to reuse settings window: %s", repr(exc))
_settings_window = None
log.info("Creating settings window")
cfg = dict(_config)
theme = ctk_theme_for_platform()
width, height = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel(
ctk,
title=t("app.settings_title"),
width=width,
height=height,
theme=theme,
after_create=lambda window: _activate_app(),
)
_settings_window = root
log.info("Settings window created")
frame_pad_x, frame_pad_y = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(
ctk,
root,
theme,
padx=frame_pad_x,
pady=frame_pad_y,
)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
original_language = _config.get("language", DEFAULT_CONFIG["language"])
log.info("Building settings form")
widgets = install_tray_config_form(
ctk,
scroll,
theme,
cfg,
DEFAULT_CONFIG,
show_autostart=False,
on_language_change=_refresh_tray_menu,
)
log.info("Settings form built")
original_appearance = ctk.get_appearance_mode()
def restore_ui_locale() -> None:
set_language(original_language)
_refresh_tray_menu()
def finish() -> None:
global _settings_window
root.destroy()
_settings_window = None
_hide_ctk_root()
def cancel() -> None:
ctk.set_appearance_mode(original_appearance)
restore_ui_locale()
finish()
def save() -> None:
from tkinter import messagebox
merged = validate_config_form(
widgets,
DEFAULT_CONFIG,
include_autostart=False,
)
if isinstance(merged, str):
messagebox.showerror(t("app.error_title"), merged, parent=root)
return
merged["force_test_dc"] = _config.get(
"force_test_dc",
DEFAULT_CONFIG["force_test_dc"],
)
ui_only_keys = {"appearance", "check_updates", "language"}
config_changed = any(merged.get(key) != _config.get(key) for key in merged)
proxy_changed = any(
merged.get(key) != _config.get(key)
for key in merged
if key not in ui_only_keys
)
if not config_changed:
restore_ui_locale()
finish()
return
save_config(merged)
_config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged)
_refresh_tray_menu()
if not proxy_changed:
finish()
return
do_restart = messagebox.askyesno(
t("dialog.restart_title"),
t("dialog.restart_body"),
parent=root,
)
finish()
if do_restart:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error),
daemon=True,
name="proxy-restart",
).start()
root.protocol("WM_DELETE_WINDOW", cancel)
install_tray_config_buttons(
ctk,
footer,
theme,
on_save=save,
on_cancel=cancel,
)
_activate_app()
log.info("Settings window ready")
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
check_ipv6_warning(_show_info)
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
theme = ctk_theme_for_platform()
width, height = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk,
title=t("app.name"),
width=width,
height=height,
theme=theme,
after_create=lambda window: _activate_app(),
)
def done(open_telegram: bool) -> None:
FIRST_RUN_MARKER.touch()
root.destroy()
_hide_ctk_root()
if open_telegram:
_on_open_in_telegram()
check_ipv6_warning(_show_info)
root.protocol("WM_DELETE_WINDOW", lambda: done(False))
populate_first_run_window(
ctk,
root,
theme,
host=host,
port=port,
secret=secret,
on_done=done,
)
_activate_app()
def _build_menu():
if pystray is None:
return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host)
return pystray.Menu(
pystray.MenuItem(
t("tray.open_telegram", host=link_host, port=port),
_on_open_in_telegram,
default=True,
),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.restart"), _on_restart),
pystray.MenuItem(t("tray.settings"), _on_edit_config),
pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit),
)
def _initialize_gui() -> bool:
global _ctk_root, _ns_app
if ctk is None or pystray is None or Image is None or NSApplication is None:
return False
install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk, _config.get("appearance", "auto"))
_ctk_root = ctk.CTk()
_ctk_root.title(t("app.name"))
_ctk_root.geometry("1x1+0+0")
try:
_ctk_root.attributes("-alpha", 0.0)
except Exception as exc:
log.warning("Failed to make CTk root transparent: %s", repr(exc))
_ctk_root.withdraw()
_ns_app = NSApplication.sharedApplication()
if NSApplicationActivationPolicyAccessory is not None:
_ns_app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
return True
def _enable_crash_log() -> None:
global _crash_log
try:
ensure_dirs()
_crash_log = open(APP_DIR / "crash.log", "a", encoding="utf-8", buffering=1)
faulthandler.enable(file=_crash_log, all_threads=True)
except OSError as exc:
log.warning("Failed to enable crash log: %s", repr(exc))
def run_tray() -> None:
global _tray_icon, _config
_config = load_config()
bootstrap(_config)
_enable_crash_log()
if not _initialize_gui():
log.error("pystray, Pillow, customtkinter or AppKit not installed; running in console mode")
start_proxy(_config, _show_error)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
stop_proxy()
return
start_proxy(_config, _show_error)
_tray_icon = pystray.Icon(
APP_NAME,
load_icon(),
t("app.name"),
menu=_build_menu(),
darwin_nsapplication=_ns_app,
)
_tray_icon.run_detached()
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_ctk_root.after(0, _show_first_run)
log.info("Tray icon running")
_ctk_root.mainloop()
stop_proxy()
if _ctk_root is not None:
_ctk_root.destroy()
log.info("Tray app exited")
def main() -> None:
if not acquire_lock():
_standalone_info(t("dialog.already_running"))
return
try:
run_tray()
finally:
release_lock()
if __name__ == "__main__":
main()