"""Blueforce GUI surfaces. The installer and the remote portal deliberately have separate view models. They share only the backend contract; no selection, navigation, or execution state crosses the boundary. """ from __future__ import annotations import os import queue import subprocess import threading import tkinter as tk from tkinter import messagebox, ttk from typing import Any from bfos import ui_theme as theme from bfos.ui_gui import ( CommandError, GuiContext, Operation, Target, dealer_error, operation_by_id, parse_inventory, ) from bfos.preferences import load_preferences, save_dealer class SurfaceFrame(ttk.Frame): """Common chrome only; each product surface owns its own state below.""" def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: self.root = root self.backend = backend self.ctx = ctx self._alive = True self._after_id: str | None = None self.styles = theme.apply_theme(root) super().__init__(root, style=self.styles["frame"]) self.pack(fill="both", expand=True) def destroy(self) -> None: """Cancel pending after() callbacks before tearing down widgets.""" self._alive = False after_id, self._after_id = self._after_id, None if after_id is not None: try: self.after_cancel(after_id) except Exception: pass super().destroy() def clear(self) -> None: for child in self.winfo_children(): child.destroy() def title_row(self, title: str, subtitle: str) -> ttk.Frame: row = ttk.Frame(self, style=self.styles["frame"]) row.pack(fill="x", padx=theme.SP_6, pady=(theme.SP_5, theme.SP_3)) ttk.Label(row, text=title, style=self.styles["section"]).pack(anchor="w") ttk.Label(row, text=subtitle, style=self.styles["panel_muted"]).pack(anchor="w") return row class ModePicker(SurfaceFrame): """Neutral launcher: choosing a mode is the only shared interaction.""" def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: super().__init__(root, backend, ctx) root.title("Blueforce Field OS — çalışma alanı") root.geometry("980x620") root.minsize(760, 520) self._render() def _active_identity(self) -> str: return f"Aktif kullanıcı: {self.ctx.active_user}" if self.ctx.active_user else "Aktif kullanıcı ayarlanmadı" def _render(self) -> None: self.title_row( "Blueforce Field OS", self._active_identity() + " · Çalışacağınız yüzeyi seçin.", ) body = ttk.Frame(self, style=self.styles["frame"]) body.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_3) body.columnconfigure(0, weight=1) body.columnconfigure(1, weight=1) self._mode_card( body, 0, "Kurulum konsolu", "Yeni veya yeniden kurulacak tek cihaz için kontrollü, adım adım akış.", ("Bayi numarası", "Ön kontrol", "Kurulum ve kayıt", "Canlı çıktı"), "Kurulum alanına gir", lambda: self._open(InstallerSurface), ) self._mode_card( body, 1, "Uzaktan yönetim portalı", "Envanterdeki bilgisayarları izleyin, seçin ve yetkili operasyonları çalıştırın.", ("Filo görünümü", "Cihaz durumu", "Operasyon", "Denetim izi"), "Portala gir", lambda: self._open(RemotePortalSurface), ) ttk.Label( self, text="Modu sonradan üst çubuktan değiştirerek yeni bir çalışma alanı açabilirsiniz.", style=self.styles["faint"], ).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) def _mode_card(self, parent: ttk.Frame, column: int, title: str, description: str, steps: tuple[str, ...], action: str, command) -> None: card = ttk.Frame(parent, style=self.styles["panel"]) card.grid(row=0, column=column, sticky="nsew", padx=(0, theme.SP_3) if column == 0 else (theme.SP_3, 0)) inner = ttk.Frame(card, style=self.styles["panel"]) inner.pack(fill="both", expand=True, padx=theme.SP_5, pady=theme.SP_5) ttk.Label(inner, text=title, style=self.styles["panel_title"]).pack(anchor="w") ttk.Label(inner, text=description, style=self.styles["panel_label"], wraplength=330, justify="left").pack(anchor="w", pady=(theme.SP_2, theme.SP_5)) for step in steps: ttk.Label(inner, text="— " + step, style=self.styles["panel_muted"]).pack(anchor="w", pady=1) ttk.Button(inner, text=action, style=self.styles["accent_button"], command=command).pack(anchor="w", pady=(theme.SP_6, 0)) def _open(self, surface_cls) -> None: self.destroy() surface_cls(self.root, self.backend, self.ctx) class InstallerSurface(SurfaceFrame): """Single-device installation surface; no fleet navigation exists here.""" def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: super().__init__(root, backend, ctx) root.title("Blueforce Field OS — Kurulum") root.geometry("980x680") root.minsize(760, 560) self.dealer_var = tk.StringVar() self.preferences = load_preferences() self.dealer_var.set(self.preferences.get("dealer_id", "")) self.dealer_locked = bool(self.preferences.get("dealer_id")) self.has_root = getattr(os, "geteuid", lambda: 0)() == 0 self.identity_var = tk.StringVar(value=self._identity_text()) self.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed()) initial_status = ( "Hazır — bayi numarası bekleniyor" if self.has_root else "Yönetici yetkisi yok — kurulum için uygulamayı 'sudo bf' ile açın" ) self.status_var = tk.StringVar(value=initial_status) self.output_queue: queue.Queue[str] = queue.Queue() self._render() self._drain_output() def _render(self) -> None: self.title_row("Kurulum konsolu", "Tek cihazı kurun, kaydedin ve doğrulayın.") content = ttk.Frame(self, style=self.styles["frame"]) content.pack(fill="both", expand=True, padx=theme.SP_6) content.columnconfigure(1, weight=1) steps = ttk.Frame(content, style=self.styles["panel"]) steps.grid(row=0, column=0, sticky="ns", padx=(0, theme.SP_4)) ttk.Label(steps, text="KURULUM AKIŞI", style=self.styles["panel_title"]).pack( anchor="w", padx=theme.SP_4, pady=theme.SP_4) for i, label in enumerate(("Cihazı tanımla", "Ön kontrol", "Kur ve kaydet", "Doğrula"), 1): ttk.Label(steps, text=f"{i:02d} {label}", style=self.styles["panel_label"] if i == 1 else self.styles["panel_muted"]).pack( anchor="w", padx=theme.SP_4, pady=(0, theme.SP_3)) main = ttk.Frame(content, style=self.styles["panel"]) main.grid(row=0, column=1, sticky="nsew") main.columnconfigure(0, weight=1) ttk.Label(main, text="Cihaz kimliği", style=self.styles["panel_title"]).grid( row=0, column=0, sticky="w", padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2)) ttk.Label(main, text="8 haneli bayi numarasını girin. Bu akış yalnızca bu cihazı etkiler.", style=self.styles["panel_muted"]).grid(row=1, column=0, sticky="w", padx=theme.SP_5) ttk.Label(main, textvariable=self.identity_var, style=self.styles["panel_label"]).grid( row=1, column=1, sticky="e", padx=theme.SP_5) entry = ttk.Entry(main, textvariable=self.dealer_var, style=self.styles["entry"], width=22, state="readonly" if self.dealer_locked else "normal") self.dealer_entry = entry entry.grid(row=2, column=0, sticky="w", padx=theme.SP_5, pady=(theme.SP_4, theme.SP_2)) entry.focus_set() self.change_dealer_button = ttk.Button( main, text="Bayi no değiştir", style=self.styles["quiet_button"], command=self._unlock_dealer, state="normal" if self.dealer_locked else "disabled") self.change_dealer_button.grid(row=2, column=1, sticky="w", padx=theme.SP_3, pady=(theme.SP_4, theme.SP_2)) self.install_button = ttk.Button( main, text="Kurulumu başlat", style=self.styles["accent_button"], command=self._run_install, state="normal" if self.has_root else "disabled", ) self.install_button.grid(row=3, column=0, sticky="w", padx=theme.SP_5, pady=theme.SP_4) ttk.Label(main, textvariable=self.status_var, style=self.styles["panel_muted"]).grid( row=4, column=0, sticky="w", padx=theme.SP_5, pady=(0, theme.SP_4)) output = ttk.Frame(self, style=self.styles["panel"]) output.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_4) ttk.Label(output, text="Kurulum çıktısı", style=self.styles["panel_title"]).pack( anchor="w", padx=theme.SP_4, pady=(theme.SP_3, theme.SP_2)) self.output = tk.Text(output, height=8, wrap="none", state="disabled", font=("TkFixedFont", 10), relief="flat", padx=8, pady=8) self.output.pack(fill="both", expand=True, padx=theme.SP_3, pady=(0, theme.SP_3)) ttk.Button(self, text="Çalışma alanı seçimine dön", style=self.styles["quiet_button"], command=self._back).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) def _run_install(self) -> None: if not self.has_root: self.status_var.set("Kurulum engellendi — yönetici yetkisi için: sudo bf") self._append( "[ENGELLENDİ] Kurulum yönetici yetkisi ister.\n" "Bu uygulamayı terminalden 'sudo bf' ile yeniden başlatın.\n" ) return dealer = self.dealer_var.get().strip() problem = dealer_error(dealer) if problem: self.status_var.set(problem) return if self.preferences.get("dealer_id") and self.preferences["dealer_id"] != dealer: if not messagebox.askyesno( "Kimlik geçişini onaylayın", "Mevcut Linux hesabı, SSH anahtarı, RustDesk ve sudo sahiplikleri yeni kimliğe taşınacak. Devam edilsin mi?", default="no", ): self.status_var.set("Kimlik geçişi iptal edildi; mevcut kimlik korundu.") return script = self.ctx.repo_root / "scripts" / "identity" / "bf-reidentity" if not script.is_file(): self.status_var.set("Kimlik geçiş komutu bulunamadı.") return self.status_var.set("Kimlik geçişi çalışıyor…") result = subprocess.run([str(script), dealer], capture_output=True, text=True, check=False) self._append(result.stdout + result.stderr) if result.returncode != 0: self.status_var.set("Kimlik geçişi başarısız; eski kimlik korundu.") return saved = load_preferences() else: try: saved = save_dealer(dealer) except (OSError, PermissionError, ValueError) as exc: self.status_var.set(f"Ayar kaydedilemedi: {exc}") return self.ctx.active_dealer = saved["dealer_id"] self.ctx.active_user = saved["active_user"] self.dealer_locked = True self.dealer_entry.configure(state="readonly") self.change_dealer_button.configure(state="normal") operation = operation_by_id("install-local") if operation is None: self.status_var.set("Kurulum operasyonu katalogda bulunamadı.") return target = Target("dealer", dealer) try: plan = self.backend.build_command(operation, target, {}, check_mode=False) except Exception as exc: self.status_var.set(f"Plan üretilemedi: {exc}") return preview = self._preview_plan(plan, operation, target) if not messagebox.askyesno("Kurulumu onaylayın", "Bu cihaz için kurulum başlatılsın mı?\n\n" + preview, default="no"): self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.") return if plan.destructive and not messagebox.askyesno( "İKİNCİ ONAY — yıkıcı işlem", "Bu kurulum seçili cihazda DEĞİŞİKLİK YAPAR (dosya, servis ve sahiplikler).\n" "Bu onay yalnız bu kurulum için geçerlidir. Gerçekten başlatılsın mı?", default="no", ): self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.") return self.status_var.set("Kurulum çalışıyor…") self._append("\n" + "\n".join(plan.lines) + "\n") def worker() -> None: try: result = self.backend.run(plan, on_line=self.output_queue.put) self.output_queue.put(f"\n{'[OK]' if result.ok else '[HATA]'} Çıkış kodu: {result.exit_code}\n") except Exception as exc: self.output_queue.put(f"[HATA] {exc}\n") threading.Thread(target=worker, daemon=True).start() def _identity_text(self) -> str: dealer = self.dealer_var.get().strip() return f"Aktif kullanıcı: bf-{dealer}" if dealer else "Aktif kullanıcı ayarlanmadı" def _on_dealer_changed(self) -> None: self.identity_var.set(self._identity_text()) def _unlock_dealer(self) -> None: if not messagebox.askyesno( "Bayi numarasını değiştir", "Mevcut bayi kimliği kilidini açıp yeni bir numara girmek istiyor musunuz?", default="no", ): return self.dealer_locked = False self.dealer_entry.configure(state="normal") self.dealer_var.set("") self.change_dealer_button.configure(state="disabled") self.status_var.set("Yeni bayi numarası girin; kurulum onayında kaydedilecek.") self.dealer_entry.focus_set() def _append(self, text: str) -> None: try: self.output.configure(state="normal") self.output.insert("end", text) self.output.see("end") self.output.configure(state="disabled") except tk.TclError: pass def _preview_plan(self, plan, operation, target) -> str: """Yerel plan önizlemesi: komutlar + notlar + yıkıcı/yetki uyarısı.""" lines = ["Çalıştırılacak komut(lar):", ""] if plan.lines: for line in plan.lines: lines.append(f" {line}") else: lines.append(" (komut üretilemedi)") lines.append("") lines.append(f"İşlem : {operation.label} [{operation.id}]") lines.append(f"Hedef : {plan.target_label or target.label}") if plan.needs_root: lines.append("Yetki : root gerekir — konsolu sudo ile açın.") for note in plan.notes: lines.append(f"Not : {note}") if plan.destructive: lines.append("") lines.append("UYARI: Bu YIKICI bir işlemdir; seçili cihazda değişiklik yapar.") return "\n".join(lines) def _drain_output(self) -> None: if not self._alive: return drained: list[str] = [] try: while True: drained.append(self.output_queue.get_nowait()) except queue.Empty: pass for line in drained: try: self._append(line) except tk.TclError: return if drained: terminal = next( (line.strip() for line in reversed(drained) if line.strip().startswith("[OK]") or line.strip().startswith("[HATA]")), None, ) try: self.status_var.set(terminal if terminal is not None else "Kurulum çıktısı güncelleniyor…") except tk.TclError: return if self._alive: try: self._after_id = self.after(100, self._drain_output) except tk.TclError: pass def _back(self) -> None: self.destroy() ModePicker(self.root, self.backend, self.ctx) class RemotePortalSurface(SurfaceFrame): """Fleet portal surface; its state never reuses installer controls.""" def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: super().__init__(root, backend, ctx) root.title("Blueforce Field OS — Uzaktan Yönetim") root.geometry("1180x760") root.minsize(860, 620) self.search_var = tk.StringVar() self.selected_host: str | None = None self.preferences = load_preferences() self.status_var = tk.StringVar(value="Bir cihaz seçin") self.output_queue: queue.Queue[str] = queue.Queue() self._run_done = False self._render() root.bind("", self._on_resize) self.search_var.trace_add("write", lambda *_: self._render_devices()) self._render_devices() self._drain_output() def _render(self) -> None: self.title_row("Uzaktan yönetim portalı", "Envanter, sağlık ve kontrollü uzaktan operasyonlar · " + (f"Aktif kullanıcı: {self.ctx.active_user}" if self.ctx.active_user else "Aktif kullanıcı ayarlanmadı")) body = ttk.Frame(self, style=self.styles["frame"]) body.pack(fill="both", expand=True, padx=theme.SP_6) body.columnconfigure(1, weight=1) body.rowconfigure(1, weight=1) nav = ttk.Frame(body, style=self.styles["panel"]) nav.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, theme.SP_4)) ttk.Label(nav, text="PORTAL", style=self.styles["panel_title"]).pack(anchor="w", padx=theme.SP_4, pady=theme.SP_4) for label in ("Genel bakış", "Cihazlar", "Operasyonlar", "Denetim kaydı"): ttk.Button(nav, text=label, style=self.styles["quiet_button"], state="disabled" if label != "Cihazlar" else "normal").pack(fill="x", padx=theme.SP_3, pady=2) ttk.Button(nav, text="Kurulum alanına geç", style=self.styles["quiet_button"], command=self._back).pack(side="bottom", fill="x", padx=theme.SP_3, pady=theme.SP_3) ttk.Button(nav, text="Ayarlar · bayi no değiştir", style=self.styles["quiet_button"], command=self._change_dealer).pack(side="bottom", fill="x", padx=theme.SP_3, pady=(0, theme.SP_3)) metrics = ttk.Frame(body, style=self.styles["frame"]) metrics.grid(row=0, column=1, sticky="ew", pady=(0, theme.SP_4)) cards = self._metrics() for col, (label, value) in enumerate(cards): metrics.columnconfigure(col, weight=1) card = ttk.Frame(metrics, style=self.styles["panel"]) card.grid(row=0, column=col, sticky="ew", padx=(0, theme.SP_3) if col < len(cards) - 1 else (0, 0)) ttk.Label(card, text=label, style=self.styles["panel_title"]).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_3, 0)) ttk.Label(card, text=value, style=self.styles["panel_label"]).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_1, theme.SP_3)) devices = ttk.Frame(body, style=self.styles["panel"]) devices.grid(row=1, column=1, sticky="nsew") devices.columnconfigure(0, weight=1) devices.columnconfigure(1, weight=1) devices.rowconfigure(2, weight=1) ttk.Label(devices, text="Cihaz envanteri", style=self.styles["panel_title"]).grid(row=0, column=0, sticky="w", padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2)) ttk.Entry(devices, textvariable=self.search_var, style=self.styles["entry"]).grid(row=1, column=0, sticky="ew", padx=theme.SP_4, pady=(0, theme.SP_3)) columns = ("host", "wave", "address") self.tree = ttk.Treeview(devices, columns=columns, show="headings", selectmode="browse") for column, heading in zip(columns, ("CİHAZ", "DALGA", "ADRES")): self.tree.heading(column, text=heading) self.tree.column(column, width=180, anchor="w") self.tree.grid(row=2, column=0, sticky="nsew", padx=(theme.SP_4, theme.SP_2), pady=(0, theme.SP_3)) self.tree.bind("<>", self._select_device) self.detail = ttk.Frame(devices, style=self.styles["panel"]) self.detail.grid(row=2, column=1, sticky="nsew", padx=(theme.SP_2, theme.SP_4), pady=(0, theme.SP_3)) ttk.Label(self.detail, text="Cihaz detayları", style=self.styles["panel_title"]).pack( anchor="w", padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2)) self.detail_value = ttk.Label(self.detail, text="Bir cihaz seçin", style=self.styles["panel_label"], justify="left") self.detail_value.pack(anchor="w", padx=theme.SP_4, pady=(0, theme.SP_3)) self.detail_hint = ttk.Label(self.detail, text="Envanterden bir satır seçtiğinizde kimlik ve hedef bilgisi burada görünür.", style=self.styles["panel_muted"], wraplength=260, justify="left") self.detail_hint.pack(anchor="w", padx=theme.SP_4) action = ttk.Frame(self, style=self.styles["panel"]) action.pack(fill="x", padx=theme.SP_6, pady=(theme.SP_4, theme.SP_3)) ttk.Label(action, textvariable=self.status_var, style=self.styles["panel_muted"]).pack(side="left", padx=theme.SP_4, pady=theme.SP_3) self.operation = ttk.Combobox(action, state="readonly", width=32, values=tuple(op.label for op in self.backend.list_operations() if not op.destructive)) self.operation.pack(side="right", padx=(theme.SP_2, theme.SP_4), pady=theme.SP_3) ttk.Button(action, text="Salt-okunur çalıştır", style=self.styles["accent_button"], command=self._run_read_only).pack(side="right", pady=theme.SP_3) log = ttk.Frame(self, style=self.styles["panel"]) log.pack(fill="x", padx=theme.SP_6, pady=(0, theme.SP_4)) ttk.Label(log, text="Çalışma çıktısı", style=self.styles["panel_title"]).pack( anchor="w", padx=theme.SP_4, pady=(theme.SP_3, theme.SP_2)) self.output = tk.Text(log, height=4, wrap="none", state="disabled", font=("TkFixedFont", 10), relief="flat", padx=8, pady=8) self.output.pack(fill="x", padx=theme.SP_3, pady=(0, theme.SP_3)) def _metrics(self) -> tuple[tuple[str, str], ...]: groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} hosts = sorted({host for values in groups.values() for host in values}) # parse_inventory yalnız host adlarını verir; çevrimiçi/işlem durumu # için ajan-izleme entegrasyonu gerekir — sahte veri gösterilmez. return ( ("TOPLAM CİHAZ", str(len(hosts))), ("CANLI DURUM", "İzleme entegrasyonu gerekir — canlı veri yok"), ) def _on_resize(self, event) -> None: if event.widget is not self.root or not hasattr(self, "detail"): return if event.width < 1050: self.detail.grid_remove() else: self.detail.grid() def _render_devices(self) -> None: if not hasattr(self, "tree"): return for item in self.tree.get_children(): self.tree.delete(item) groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} query = self.search_var.get().strip().lower() hosts = sorted({host for values in groups.values() for host in values}) for host in hosts: if query and query not in host.lower(): continue wave = next((name for name, values in groups.items() if host in values), "—") # parse_inventory host adından öte adres vermez; bilinmeyen adres "—" kalır. self.tree.insert("", "end", iid=host, values=(host, wave, "—")) def _select_device(self, _event=None) -> None: selected = self.tree.selection() self.selected_host = selected[0] if selected else None self.status_var.set(f"Seçili cihaz: {self.selected_host}" if self.selected_host else "Bir cihaz seçin") if self.selected_host: item = self.tree.item(self.selected_host) values = item.get("values", ()) self.detail_value.configure(text=( f"{self.selected_host}\n\n" f"Durum: bağlantı bekleniyor\n" f"Dalga: {values[1] if len(values) > 1 else '—'}\n" f"Adres: {values[2] if len(values) > 2 else '—'}" )) self.detail_hint.configure(text="Salt-okunur operasyonlar seçili cihaza uygulanır. Yıkıcı işlemler ayrıca onay ve yetki kapısından geçer.") def _run_read_only(self) -> None: if not self.selected_host: messagebox.showinfo("Cihaz seçin", "Önce envanterden bir cihaz seçin.") return label = self.operation.get() operation = next((op for op in self.backend.list_operations() if op.label == label), None) if operation is None: messagebox.showinfo("Operasyon seçin", "Salt-okunur bir operasyon seçin.") return host = self.selected_host dealer = host[3:] if host.startswith("bf-") else host problem = dealer_error(dealer) if problem: messagebox.showerror("Geçersiz cihaz kimliği", problem) self.status_var.set(problem) return target = Target("dealer", dealer) self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}") # The existing backend remains the only command authority. try: plan = self.backend.build_command(operation, target, {}, check_mode=True) except Exception as exc: self.status_var.set(f"Hata: {exc}") return grants = self._collect_grants(operation, target) if grants is None: self.status_var.set("İptal edildi") return if grants: try: plan = self.backend.build_command(operation, target, {}, grants=grants, check_mode=True) except Exception as exc: self.status_var.set(f"Hata: {exc}") return if plan.destructive and not hasattr(self.backend, "gate_steps"): self.status_var.set("Yıkıcı işlem engellendi — ana konsoldan (sudo bf) iki onay ile çalıştırın.") messagebox.showerror( "Yıkıcı işlem engellendi", "Bu plan değişiklik yapar; portal yalnız salt-okunur çalıştırır.\n" "Yıkıcı işlem için ana konsolu kullanın (sudo bf).", ) return preview = "\n".join(plan.lines) if plan.lines else "(komut üretilemedi)" for note in plan.notes: preview += f"\nNot: {note}" if not messagebox.askyesno("Salt-okunur operasyon", "Komut çalıştırılsın mı?\n\n" + preview, default="no"): self.status_var.set("İptal edildi") return self._run_done = False self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}") self._append("\n" + "\n".join(plan.lines) + "\n" if plan.lines else "\n") def worker() -> None: try: result = self.backend.run(plan, on_line=self.output_queue.put) self.output_queue.put(f"\n{'[OK]' if result.ok else '[HATA]'} Çıkış kodu: {result.exit_code}\n") except TypeError: try: result = self.backend.run(plan) self.output_queue.put(f"\n{'[OK]' if result.ok else '[HATA]'} Çıkış kodu: {result.exit_code}\n") except Exception as exc: self.output_queue.put(f"[HATA] {exc}\n") except Exception as exc: self.output_queue.put(f"[HATA] {exc}\n") threading.Thread(target=worker, daemon=True).start() def _collect_grants(self, operation, target) -> dict | None: """Backend kapılarını sorar; iptalde None döner (ana konsol ile aynı sıra).""" gate_steps = getattr(self.backend, "gate_steps", None) if not callable(gate_steps): return {} try: steps = gate_steps(operation, target) except CommandError as exc: messagebox.showerror("Kapı onayları alınamadı", str(exc)) return None grants: dict = {} for step in steps: kind = getattr(step, "kind", "approval") prompt = getattr(step, "prompt", None) or "Onay" message = getattr(step, "message", None) or "Devam edilsin mi?" if kind == "token": token = getattr(step, "token", "") or "" answer = self._token_dialog(prompt, message) if answer is None or answer.strip() != token: self.status_var.set("Kapı onayı verilmedi; işlem iptal edildi.") return None elif not messagebox.askyesno(prompt, message, default="no"): self.status_var.set("Kapı onayı verilmedi; işlem iptal edildi.") return None for name in (getattr(step, "unlocks", ()) or ()): grants[name] = True return grants def _token_dialog(self, title: str, message: str) -> str | None: dialog = tk.Toplevel(self.root) dialog.title(title) dialog.transient(self.root) dialog.grab_set() value = tk.StringVar() ttk.Label(dialog, text=message, style=self.styles["panel_title"], wraplength=320, justify="left").pack( padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2), anchor="w") entry = ttk.Entry(dialog, textvariable=value, width=28, style=self.styles["entry"]) entry.pack(padx=theme.SP_5, pady=(0, theme.SP_4), anchor="w") result: dict = {"value": None} def confirm() -> None: result["value"] = value.get() dialog.destroy() def cancel() -> None: dialog.destroy() ttk.Button(dialog, text="Onayla", style=self.styles["accent_button"], command=confirm).pack(padx=theme.SP_5, pady=(0, theme.SP_2), anchor="w") ttk.Button(dialog, text="Vazgeç", style=self.styles["quiet_button"], command=cancel).pack(padx=theme.SP_5, pady=(0, theme.SP_5), anchor="w") entry.focus_set() dialog.wait_window() return result["value"] def _append(self, text: str) -> None: try: self.output.configure(state="normal") except tk.TclError: return try: self.output.insert("end", text) self.output.see("end") self.output.configure(state="disabled") except tk.TclError: pass def _drain_output(self) -> None: if not self._alive: return drained: list[str] = [] try: while True: drained.append(self.output_queue.get_nowait()) except queue.Empty: pass for line in drained: try: self._append(line) except tk.TclError: return if drained: terminal = next( (line.strip() for line in reversed(drained) if line.strip().startswith("[OK]") or line.strip().startswith("[HATA]")), None, ) try: if terminal is not None: self.status_var.set(terminal) self._run_done = True elif not self._run_done: self.status_var.set("Çalışma çıktısı güncelleniyor…") except tk.TclError: return if self._alive: try: self._after_id = self.after(100, self._drain_output) except tk.TclError: pass def _back(self) -> None: self.destroy() ModePicker(self.root, self.backend, self.ctx) def _change_dealer(self) -> None: dialog = tk.Toplevel(self.root) dialog.title("Bayi numarasını değiştir") dialog.transient(self.root) dialog.grab_set() value = tk.StringVar(value=self.preferences.get("dealer_id", "")) ttk.Label(dialog, text="Yeni bayi no (8 hane)", style=self.styles["panel_title"]).pack( padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2), anchor="w") entry = ttk.Entry(dialog, textvariable=value, width=20, style=self.styles["entry"]) entry.pack(padx=theme.SP_5, pady=(0, theme.SP_4), anchor="w") def apply() -> None: problem = dealer_error(value.get().strip()) if problem: messagebox.showerror("Geçersiz bayi numarası", problem, parent=dialog) return if not messagebox.askyesno("Kimliği değiştir", "Aktif kullanıcı bf-" + value.get().strip() + " olarak değiştirilsin mi?", parent=dialog, default="no"): return try: saved = save_dealer(value.get().strip()) except (OSError, PermissionError, ValueError) as exc: messagebox.showerror("Kayıt başarısız", f"Bayi numarası kaydedilemedi: {exc}", parent=dialog) self.status_var.set(f"Ayar kaydedilemedi: {exc}") return self.ctx.active_dealer = saved["dealer_id"] self.ctx.active_user = saved["active_user"] dialog.destroy() self._back() ttk.Button(dialog, text="Kaydet ve portalı yenile", style=self.styles["accent_button"], command=apply).pack( padx=theme.SP_5, pady=(0, theme.SP_5), anchor="w") entry.focus_set() def launch_surface(root: tk.Tk, backend: Any, ctx: GuiContext, mode: str | None = None) -> SurfaceFrame: """Launch one independent surface, or the neutral mode picker.""" surface = {"install": InstallerSurface, "portal": RemotePortalSurface}.get(mode, ModePicker) return surface(root, backend, ctx)