- Add network interface selection per server (VPN/multi-NIC support) - Fix "Install Everything" button hanging on error - Add interactive SSH terminal with PTY (pyte + xterm-256color) - Add release.py for automated versioning and changelog generation - Add CLAUDE.md with project instructions - Add screenshots and release binaries for v1.1–v1.4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""
|
|
Setup tab — one-click installation for Claude Code integration.
|
|
Includes configuration path management and backup/restore.
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
from tkinter import filedialog, messagebox
|
|
import customtkinter as ctk
|
|
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
|
|
from core.i18n import t
|
|
from core.logger import log
|
|
|
|
|
|
class SetupTab(ctk.CTkFrame):
|
|
def __init__(self, master, store):
|
|
super().__init__(master, fg_color="transparent")
|
|
self.store = store
|
|
|
|
# Header
|
|
self.header_label = ctk.CTkLabel(
|
|
self, text=t("claude_integration"),
|
|
font=ctk.CTkFont(size=20, weight="bold")
|
|
)
|
|
self.header_label.pack(padx=20, pady=(20, 5))
|
|
|
|
self.desc_label = ctk.CTkLabel(
|
|
self, text=t("claude_desc"),
|
|
text_color="#9ca3af", justify="center"
|
|
)
|
|
self.desc_label.pack(padx=20, pady=(0, 15))
|
|
|
|
# Status card
|
|
self.status_frame = ctk.CTkFrame(self)
|
|
self.status_frame.pack(fill="x", padx=20, pady=10)
|
|
|
|
self.status_title = ctk.CTkLabel(
|
|
self.status_frame, text=t("status"),
|
|
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
|
|
)
|
|
self.status_title.pack(fill="x", padx=15, pady=(10, 5))
|
|
|
|
self._status_labels: dict[str, ctk.CTkLabel] = {}
|
|
self._status_text_labels: dict[str, ctk.CTkLabel] = {}
|
|
status_items = [
|
|
("shared_dir", "status_shared_dir"),
|
|
("servers_json", "status_servers_json"),
|
|
("ssh_script", "status_ssh_script"),
|
|
("encryption", "status_encryption"),
|
|
("skill_installed", "status_skill"),
|
|
("ssh_key_exists", "status_ssh_key"),
|
|
]
|
|
for key, i18n_key in status_items:
|
|
row = ctk.CTkFrame(self.status_frame, fg_color="transparent")
|
|
row.pack(fill="x", padx=15, pady=2)
|
|
indicator = ctk.CTkLabel(row, text="\u25cf", width=20, text_color="#6b7280")
|
|
indicator.pack(side="left")
|
|
text_label = ctk.CTkLabel(row, text=t(i18n_key), anchor="w")
|
|
text_label.pack(side="left", fill="x", expand=True)
|
|
self._status_labels[key] = indicator
|
|
self._status_text_labels[key] = (text_label, i18n_key)
|
|
|
|
# Buttons
|
|
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
btn_frame.pack(fill="x", padx=20, pady=15)
|
|
|
|
self.install_all_btn = ctk.CTkButton(
|
|
btn_frame, text=t("install_everything"),
|
|
font=ctk.CTkFont(size=14, weight="bold"),
|
|
height=40, fg_color="#22c55e", hover_color="#16a34a",
|
|
command=self._install_all
|
|
)
|
|
self.install_all_btn.pack(fill="x", pady=(0, 10))
|
|
|
|
# Individual buttons row
|
|
ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
|
|
ind_frame.pack(fill="x")
|
|
|
|
self.ssh_py_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_py"), width=100, fg_color="#6b7280",
|
|
command=self._install_script)
|
|
self.ssh_py_btn.pack(side="left", padx=(0, 5))
|
|
self.skill_btn = ctk.CTkButton(ind_frame, text=t("install_skill"), width=100, fg_color="#6b7280",
|
|
command=self._install_skill)
|
|
self.skill_btn.pack(side="left", padx=5)
|
|
self.ssh_key_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_key"), width=100, fg_color="#6b7280",
|
|
command=self._gen_key)
|
|
self.ssh_key_btn.pack(side="left", padx=5)
|
|
self.refresh_btn = ctk.CTkButton(ind_frame, text=t("refresh"), width=80, fg_color="#3b82f6",
|
|
command=self._refresh_status)
|
|
self.refresh_btn.pack(side="right")
|
|
|
|
# ── Monitoring section ─────────────────────────
|
|
monitor_frame = ctk.CTkFrame(self)
|
|
monitor_frame.pack(fill="x", padx=20, pady=(5, 5))
|
|
|
|
self.monitor_title = ctk.CTkLabel(
|
|
monitor_frame, text=t("monitoring"),
|
|
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
|
|
)
|
|
self.monitor_title.pack(fill="x", padx=15, pady=(10, 5))
|
|
|
|
interval_row = ctk.CTkFrame(monitor_frame, fg_color="transparent")
|
|
interval_row.pack(fill="x", padx=15, pady=(0, 10))
|
|
|
|
self.interval_label = ctk.CTkLabel(interval_row, text=t("check_interval"), anchor="w")
|
|
self.interval_label.pack(side="left", padx=(0, 10))
|
|
|
|
self._interval_buttons: dict[int, ctk.CTkButton] = {}
|
|
current_interval = store.get_check_interval()
|
|
for seconds, key in [(30, "interval_30s"), (60, "interval_60s"), (120, "interval_120s"), (300, "interval_300s")]:
|
|
is_active = (seconds == current_interval)
|
|
btn = ctk.CTkButton(
|
|
interval_row, text=t(key), width=60, height=28,
|
|
fg_color="#3b82f6" if is_active else "#6b7280",
|
|
hover_color="#2563eb" if is_active else "#4b5563",
|
|
command=lambda s=seconds: self._set_interval(s)
|
|
)
|
|
btn.pack(side="left", padx=2)
|
|
self._interval_buttons[seconds] = btn
|
|
|
|
# ── Configuration section ─────────────────────
|
|
config_frame = ctk.CTkFrame(self)
|
|
config_frame.pack(fill="x", padx=20, pady=(5, 5))
|
|
|
|
self.config_title = ctk.CTkLabel(
|
|
config_frame, text=t("configuration"),
|
|
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
|
|
)
|
|
self.config_title.pack(fill="x", padx=15, pady=(10, 5))
|
|
|
|
# Config path row
|
|
path_row = ctk.CTkFrame(config_frame, fg_color="transparent")
|
|
path_row.pack(fill="x", padx=15, pady=5)
|
|
|
|
self.config_label = ctk.CTkLabel(path_row, text=t("config_label"), anchor="w", width=60)
|
|
self.config_label.pack(side="left")
|
|
self._path_label = ctk.CTkLabel(
|
|
path_row, text=store.get_config_path(),
|
|
anchor="w", text_color="#9ca3af",
|
|
font=ctk.CTkFont(family="Consolas", size=11)
|
|
)
|
|
self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10))
|
|
self.change_path_btn = ctk.CTkButton(
|
|
path_row, text=t("change_path"), width=100, fg_color="#6b7280",
|
|
command=self._change_config_path
|
|
)
|
|
self.change_path_btn.pack(side="right")
|
|
|
|
# Backup row
|
|
backup_row = ctk.CTkFrame(config_frame, fg_color="transparent")
|
|
backup_row.pack(fill="x", padx=15, pady=(5, 10))
|
|
|
|
self.backup_btn = ctk.CTkButton(
|
|
backup_row, text=t("backup_now"), width=100, fg_color="#3b82f6",
|
|
command=self._backup_now
|
|
)
|
|
self.backup_btn.pack(side="left", padx=(0, 10))
|
|
|
|
self._backup_var = ctk.StringVar(value=t("select_backup"))
|
|
backups = store.list_backups()
|
|
values = backups if backups else [t("no_backups")]
|
|
self._backup_menu = ctk.CTkOptionMenu(
|
|
backup_row, variable=self._backup_var,
|
|
values=values, width=250
|
|
)
|
|
self._backup_menu.pack(side="left", padx=(0, 10))
|
|
|
|
self.restore_btn = ctk.CTkButton(
|
|
backup_row, text=t("restore"), width=80, fg_color="#ef4444", hover_color="#dc2626",
|
|
command=self._restore_backup
|
|
)
|
|
self.restore_btn.pack(side="left")
|
|
|
|
# Log
|
|
self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled")
|
|
self.log.pack(fill="both", expand=True, padx=20, pady=(5, 20))
|
|
|
|
# Initial status check
|
|
self._refresh_status()
|
|
|
|
def _set_interval(self, seconds: int):
|
|
self.store.set_check_interval(seconds)
|
|
for s, btn in self._interval_buttons.items():
|
|
if s == seconds:
|
|
btn.configure(fg_color="#3b82f6", hover_color="#2563eb")
|
|
else:
|
|
btn.configure(fg_color="#6b7280", hover_color="#4b5563")
|
|
|
|
def _log(self, text: str):
|
|
self.log.configure(state="normal")
|
|
self.log.insert("end", text + "\n")
|
|
self.log.configure(state="disabled")
|
|
self.log.see("end")
|
|
|
|
def _refresh_status(self):
|
|
status = check_status()
|
|
for key, label in self._status_labels.items():
|
|
if status.get(key, False):
|
|
label.configure(text="\u25cf", text_color="#22c55e") # green
|
|
else:
|
|
label.configure(text="\u25cf", text_color="#ef4444") # red
|
|
|
|
def _install_all(self):
|
|
self.install_all_btn.configure(state="disabled", text=t("installing_all"))
|
|
|
|
def _do():
|
|
try:
|
|
results = install_all()
|
|
for msg in results:
|
|
self.after(0, lambda m=msg: self._log(m))
|
|
self.after(0, self._refresh_status)
|
|
self.after(0, lambda: self._log("\n" + t("install_done")))
|
|
except Exception as e:
|
|
log.error(f"install_all failed: {e}")
|
|
self.after(0, lambda: self._log(f"ERROR: {e}"))
|
|
finally:
|
|
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything")))
|
|
|
|
threading.Thread(target=_do, daemon=True).start()
|
|
|
|
def _install_script(self):
|
|
msg = install_ssh_script()
|
|
self._log(msg)
|
|
self._refresh_status()
|
|
|
|
def _install_skill(self):
|
|
msg = install_skill()
|
|
self._log(msg)
|
|
self._refresh_status()
|
|
|
|
def _gen_key(self):
|
|
try:
|
|
msg = generate_ssh_key()
|
|
self._log(msg)
|
|
except Exception as e:
|
|
log.error(f"generate_ssh_key failed: {e}")
|
|
self._log(f"ERROR: {e}")
|
|
self._refresh_status()
|
|
|
|
# ── Configuration methods ─────────────────────────
|
|
|
|
def _change_config_path(self):
|
|
path = filedialog.askopenfilename(
|
|
title=t("select_servers_json"),
|
|
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
|
|
initialdir=os.path.dirname(self.store.get_config_path())
|
|
)
|
|
if path:
|
|
self.store.set_config_path(path)
|
|
self._path_label.configure(text=path)
|
|
self._log(t("config_changed").format(path=path))
|
|
|
|
def _backup_now(self):
|
|
try:
|
|
name = self.store.create_backup()
|
|
self._log(t("backup_created").format(name=name))
|
|
self._refresh_backups()
|
|
except Exception as e:
|
|
self._log(t("backup_failed").format(e=e))
|
|
|
|
def _restore_backup(self):
|
|
selected = self._backup_var.get()
|
|
if not selected or selected in (t("select_backup"), t("no_backups")):
|
|
self._log(t("no_backup_selected"))
|
|
return
|
|
if not messagebox.askyesno(t("restore_backup_title"), t("restore_confirm").format(name=selected)):
|
|
return
|
|
try:
|
|
self.store.restore_backup(selected)
|
|
self._log(t("restored").format(name=selected))
|
|
except Exception as e:
|
|
self._log(t("restore_failed").format(e=e))
|
|
|
|
def _refresh_backups(self):
|
|
backups = self.store.list_backups()
|
|
if backups:
|
|
self._backup_menu.configure(values=backups)
|
|
self._backup_var.set(backups[0])
|
|
else:
|
|
self._backup_menu.configure(values=[t("no_backups")])
|
|
self._backup_var.set(t("no_backups"))
|