diff --git a/__pycache__/client_agent_v2.cpython-314.pyc b/__pycache__/client_agent_v2.cpython-314.pyc index 94dfedc..24a4075 100644 Binary files a/__pycache__/client_agent_v2.cpython-314.pyc and b/__pycache__/client_agent_v2.cpython-314.pyc differ diff --git a/client_agent_v2.py b/client_agent_v2.py index ccc3feb..8e0e187 100644 --- a/client_agent_v2.py +++ b/client_agent_v2.py @@ -2,6 +2,7 @@ import argparse import json import os import platform +import queue import re import socket import subprocess @@ -78,13 +79,13 @@ def resource_path(relative_path): def windows_autostart_command(): if getattr(sys, "frozen", False): - arguments = [str(Path(sys.executable).resolve())] + arguments = [str(Path(sys.executable).resolve()), "--startup"] else: executable = Path(sys.executable).resolve() pythonw = executable.with_name("pythonw.exe") if executable.name.lower() == "python.exe" and pythonw.exists(): executable = pythonw - arguments = [str(executable), str(Path(__file__).resolve())] + arguments = [str(executable), str(Path(__file__).resolve()), "--startup"] return subprocess.list2cmdline(arguments) @@ -183,8 +184,22 @@ def normalize_server_url(value): def run_command(args, timeout=8): + subprocess_options = {} + if is_windows(): + subprocess_options["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = 0 + subprocess_options["startupinfo"] = startupinfo try: - completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) + completed = subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + check=False, + **subprocess_options, + ) except Exception: return "" if completed.returncode != 0: @@ -195,7 +210,10 @@ def run_command(args, timeout=8): def run_powershell(command, timeout=10): if not is_windows(): return "" - return run_command(["powershell", "-NoProfile", "-Command", command], timeout=timeout) + return run_command( + ["powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", command], + timeout=timeout, + ) def detect_hostname(): @@ -767,20 +785,26 @@ class ClientV2App: self.heartbeat_stop_event = None self.heartbeat_thread = None self.heartbeat_context = None + self.tray_icon = None + self.tray_thread = None + self.tray_available = False + self.tray_actions = queue.Queue() + self.is_shutting_down = False self.last_update_var = tk.StringVar(value=f"Последнее обновление: {time.strftime('%H:%M')}") self.inventory_var.trace_add("write", self.on_inventory_number_changed) self.root.title(CLIENT_DISPLAY_NAME) self.window_icon_image = None self.apply_window_icon() - self.root.geometry("920x330") - self.root.minsize(860, 320) + self.root.geometry("1100x390") + self.root.minsize(1000, 370) self.root.configure(bg=BG) self.content_window = None self.root.update_idletasks() self.build_ui() self.root.protocol("WM_DELETE_WINDOW", self.on_close) self.root.after(1000, self.start_heartbeat) + self.root.after(100, self.process_tray_actions) def build_ui(self): self.root.overrideredirect(False) @@ -1353,6 +1377,73 @@ class ClientV2App: self.heartbeat_thread = None self.heartbeat_context = None + def start_tray(self): + try: + import pystray + from PIL import Image + except ImportError: + self.tray_available = False + return False + + icon_path = resource_path(CLIENT_ICON_RELATIVE) + if not icon_path.exists(): + self.tray_available = False + return False + try: + image = Image.open(icon_path).convert("RGBA") + menu = pystray.Menu( + pystray.MenuItem( + "Открыть", + lambda _icon, _item: self.tray_actions.put("show"), + default=True, + ), + pystray.MenuItem( + "Скрыть", + lambda _icon, _item: self.tray_actions.put("hide"), + ), + pystray.Menu.SEPARATOR, + pystray.MenuItem( + "Выход", + lambda _icon, _item: self.tray_actions.put("exit"), + ), + ) + self.tray_icon = pystray.Icon(CLIENT_DISPLAY_NAME, image, CLIENT_DISPLAY_NAME, menu) + self.tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True) + self.tray_thread.start() + except Exception: + self.tray_icon = None + self.tray_thread = None + self.tray_available = False + return False + self.tray_available = True + return True + + def process_tray_actions(self): + if self.is_shutting_down: + return + try: + while True: + action = self.tray_actions.get_nowait() + if action == "show": + self.show_window() + elif action == "hide": + self.hide_window() + elif action == "exit": + self.shutdown_application() + return + except queue.Empty: + pass + self.root.after(100, self.process_tray_actions) + + def show_window(self): + self.root.deiconify() + self.root.state("normal") + self.root.lift() + self.root.focus_force() + + def hide_window(self): + self.root.withdraw() + def _heartbeat_loop(self, server, token, inventory_number, stop_event): while not stop_event.is_set(): try: @@ -1362,6 +1453,15 @@ class ClientV2App: stop_event.wait(HEARTBEAT_INTERVAL_SECONDS) def on_close(self): + if self.tray_available: + self.hide_window() + return + self.shutdown_application() + + def shutdown_application(self): + if self.is_shutting_down: + return + self.is_shutting_down = True context = self.heartbeat_context stop_event = self.heartbeat_stop_event heartbeat_thread = self.heartbeat_thread @@ -1377,6 +1477,12 @@ class ClientV2App: self.heartbeat_stop_event = None self.heartbeat_thread = None self.heartbeat_context = None + if self.tray_icon is not None: + try: + self.tray_icon.stop() + except Exception: + pass + self.tray_icon = None self.root.destroy() @@ -1387,7 +1493,20 @@ def run_gui(args): if args.token: config["token"] = args.token.strip() root = tk.Tk() - ClientV2App(root, config) + if args.startup: + root.withdraw() + app = ClientV2App(root, config) + if app.is_bound_to_server: + try: + enable_windows_autostart() + except OSError as error: + app.status_var.set(f"Не удалось обновить автозагрузку: {error}") + tray_started = app.start_tray() + if args.startup and tray_started: + app.hide_window() + elif args.startup: + app.status_var.set("Не удалось запустить значок в трее") + app.show_window() root.mainloop() @@ -1424,6 +1543,7 @@ def main(): parser.add_argument("--server", help="Base URL of inventory server") parser.add_argument("--token", help="Shared client agent token") parser.add_argument("--once", action="store_true", help="Send one update without GUI") + parser.add_argument("--startup", action="store_true", help="Start hidden in the Windows tray") parser.add_argument("--inventory-number") parser.add_argument("--device-type") parser.add_argument("--model") diff --git a/client_agent_v2_windows_onefile.spec b/client_agent_v2_windows_onefile.spec index 538ab3f..4501c5a 100644 --- a/client_agent_v2_windows_onefile.spec +++ b/client_agent_v2_windows_onefile.spec @@ -15,7 +15,7 @@ a = Analysis( datas=[ (os.path.join(project_root, "picture", "ICON", "icon_client.png"), "picture/ICON"), ], - hiddenimports=[], + hiddenimports=["pystray", "pystray._win32", "PIL.Image"], hookspath=[], hooksconfig={}, runtime_hooks=[],