Files
thor-watcher/thor_settings_dialog.py
T

679 lines
28 KiB
Python

"""
Thor Watcher — Settings Dialog v0.3.0
Provides a Tkinter settings dialog that doubles as a first-run wizard.
Public API:
show_dialog(config_path, wizard=False) -> bool
Returns True if the user saved, False if they cancelled.
"""
import os
import json
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from socket import gethostname
import series4_ingest as watcher
# ── Defaults (mirror config.example.json) ────────────────────────────────────
DEFAULTS = {
"thordata_path": r"C:\THORDATA",
"scan_interval": 60,
"api_url": "",
"api_timeout": 5,
"api_interval": 300,
"source_id": "",
"source_type": "series4_watcher",
"local_timezone": "America/New_York",
"enable_logging": True,
"log_file": os.path.join(
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\",
"ThorWatcher", "agent_logs", "thor_watcher.log"
),
"log_retention_days": 30,
"update_source": "gitea",
"update_url": "",
# SFM forwarder defaults — mirror series4_ingest.load_config
"sfm_forward_enabled": False,
"sfm_url": "",
"sfm_forward_interval": 60,
"sfm_quiescence_seconds": 5,
"sfm_missing_report_grace_seconds": 60,
"sfm_http_timeout": 60,
"sfm_state_file": "",
"sfm_max_forwards_per_pass": 500,
"sfm_max_event_age_days": 365,
}
# ── Config I/O ────────────────────────────────────────────────────────────────
def _load_config(config_path):
"""Load existing config.json, merged with DEFAULTS for any missing key."""
values = dict(DEFAULTS)
if not os.path.exists(config_path):
return values
try:
with open(config_path, "r", encoding="utf-8") as f:
raw = json.load(f)
values.update(raw)
except Exception:
pass
return values
def _save_config(config_path, values):
"""Write values dict to config_path as JSON."""
config_dir = os.path.dirname(config_path)
if config_dir and not os.path.exists(config_dir):
os.makedirs(config_dir)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(values, f, indent=2)
# ── Widget helpers ────────────────────────────────────────────────────────────
def _make_spinbox(parent, from_, to, width=8):
try:
sb = ttk.Spinbox(parent, from_=from_, to=to, width=width)
except AttributeError:
sb = tk.Spinbox(parent, from_=from_, to=to, width=width)
return sb
def _add_label_entry(frame, row, label_text, var, hint=None, readonly=False):
tk.Label(frame, text=label_text, anchor="w").grid(
row=row, column=0, sticky="w", padx=(8, 4), pady=4
)
state = "readonly" if readonly else "normal"
entry = ttk.Entry(frame, textvariable=var, width=42, state=state)
entry.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4)
if hint and not var.get():
entry.config(foreground="grey")
entry.insert(0, hint)
def _on_focus_in(event, e=entry, h=hint, v=var):
if e.get() == h:
e.delete(0, tk.END)
e.config(foreground="black")
def _on_focus_out(event, e=entry, h=hint, v=var):
if not e.get():
e.config(foreground="grey")
e.insert(0, h)
v.set("")
entry.bind("<FocusIn>", _on_focus_in)
entry.bind("<FocusOut>", _on_focus_out)
return entry
def _add_label_spinbox(frame, row, label_text, var, from_, to):
tk.Label(frame, text=label_text, anchor="w").grid(
row=row, column=0, sticky="w", padx=(8, 4), pady=4
)
sb = _make_spinbox(frame, from_=from_, to=to, width=8)
sb.grid(row=row, column=1, sticky="w", padx=(0, 8), pady=4)
sb.delete(0, tk.END)
sb.insert(0, str(var.get()))
def _on_change(*args):
var.set(sb.get())
sb.config(command=_on_change)
sb.bind("<KeyRelease>", _on_change)
return sb
def _add_label_check(frame, row, label_text, var):
cb = ttk.Checkbutton(frame, text=label_text, variable=var)
cb.grid(row=row, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=4)
return cb
def _add_label_browse_entry(frame, row, label_text, var, browse_fn):
tk.Label(frame, text=label_text, anchor="w").grid(
row=row, column=0, sticky="w", padx=(8, 4), pady=4
)
inner = tk.Frame(frame)
inner.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4)
inner.columnconfigure(0, weight=1)
entry = ttk.Entry(inner, textvariable=var, width=36)
entry.grid(row=0, column=0, sticky="ew")
btn = ttk.Button(inner, text="Browse...", command=browse_fn, width=9)
btn.grid(row=0, column=1, padx=(4, 0))
return entry
# ── Main dialog class ─────────────────────────────────────────────────────────
class SettingsDialog:
def __init__(self, parent, config_path, wizard=False):
self.config_path = config_path
self.wizard = wizard
self.saved = False
self.root = parent
kind = "Setup" if wizard else "Settings"
title = "Thor Watcher v{}{}".format(watcher.VERSION, kind)
self.root.title(title)
self.root.resizable(False, False)
self.root.update_idletasks()
self._values = _load_config(config_path)
self._build_vars()
self._build_ui()
self.root.grab_set()
self.root.protocol("WM_DELETE_WINDOW", self._on_cancel)
# ── Variable setup ────────────────────────────────────────────────────────
def _build_vars(self):
v = self._values
# Connection
raw_url = str(v.get("api_url", ""))
_suffix = "/api/series4/heartbeat"
if raw_url.endswith(_suffix):
raw_url = raw_url[:-len(_suffix)]
self.var_api_url = tk.StringVar(value=raw_url)
self.var_api_interval = tk.StringVar(value=str(v.get("api_interval", 300)))
self.var_source_id = tk.StringVar(value=str(v.get("source_id", "")))
self.var_source_type = tk.StringVar(value=str(v.get("source_type", "series4_watcher")))
# Paths
self.var_thordata_path = tk.StringVar(value=str(v.get("thordata_path", r"C:\THORDATA")))
self.var_log_file = tk.StringVar(value=str(v.get("log_file", DEFAULTS["log_file"])))
# Scanning
self.var_scan_interval = tk.StringVar(value=str(v.get("scan_interval", 60)))
# Logging
en = v.get("enable_logging", True)
self.var_enable_logging = tk.BooleanVar(value=bool(en) if isinstance(en, bool) else str(en).lower() in ("true", "1", "yes"))
self.var_log_retention_days = tk.StringVar(value=str(v.get("log_retention_days", 30)))
# Updates
src = str(v.get("update_source", "gitea")).lower()
if src not in ("gitea", "url", "disabled"):
src = "gitea"
self.var_local_timezone = tk.StringVar(value=str(v.get("local_timezone", "America/New_York")))
self.var_update_source = tk.StringVar(value=src)
self.var_update_url = tk.StringVar(value=str(v.get("update_url", "")))
# SFM Forwarder
sfm_en = v.get("sfm_forward_enabled", False)
self.var_sfm_enabled = tk.BooleanVar(
value=bool(sfm_en) if isinstance(sfm_en, bool) else str(sfm_en).lower() in ("true", "1", "yes")
)
self.var_sfm_url = tk.StringVar(value=str(v.get("sfm_url", "")))
self.var_sfm_forward_interval = tk.StringVar(value=str(v.get("sfm_forward_interval", 60)))
self.var_sfm_quiescence = tk.StringVar(value=str(v.get("sfm_quiescence_seconds", 5)))
self.var_sfm_grace = tk.StringVar(value=str(v.get("sfm_missing_report_grace_seconds", 60)))
self.var_sfm_http_timeout = tk.StringVar(value=str(v.get("sfm_http_timeout", 60)))
self.var_sfm_max_per_pass = tk.StringVar(value=str(v.get("sfm_max_forwards_per_pass", 500)))
self.var_sfm_max_age_days = tk.StringVar(value=str(v.get("sfm_max_event_age_days", 365)))
self.var_sfm_state_file = tk.StringVar(value=str(v.get("sfm_state_file", "")))
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
outer = tk.Frame(self.root, padx=10, pady=8)
outer.pack(fill="both", expand=True)
if self.wizard:
welcome = (
"Welcome to Thor Watcher!\n\n"
"No configuration file was found. Please review the settings below\n"
"and click \"Save & Start\" when you are ready."
)
tk.Label(
outer, text=welcome, justify="left",
wraplength=460, fg="#1a5276", font=("TkDefaultFont", 9, "bold"),
).pack(fill="x", pady=(0, 8))
nb = ttk.Notebook(outer)
nb.pack(fill="both", expand=True)
self._build_tab_connection(nb)
self._build_tab_paths(nb)
self._build_tab_scanning(nb)
self._build_tab_logging(nb)
self._build_tab_forwarding(nb)
self._build_tab_updates(nb)
btn_frame = tk.Frame(outer)
btn_frame.pack(fill="x", pady=(10, 0))
save_label = "Save & Start" if self.wizard else "Save"
ttk.Button(btn_frame, text=save_label, command=self._on_save, width=14).pack(side="right", padx=(4, 0))
ttk.Button(btn_frame, text="Cancel", command=self._on_cancel, width=10).pack(side="right")
def _tab_frame(self, nb, title):
outer = tk.Frame(nb, padx=4, pady=4)
nb.add(outer, text=title)
outer.columnconfigure(1, weight=1)
return outer
def _build_tab_connection(self, nb):
f = self._tab_frame(nb, "Connection")
# URL row with Test button
tk.Label(f, text="Terra-View URL", anchor="w").grid(
row=0, column=0, sticky="w", padx=(8, 4), pady=4
)
url_frame = tk.Frame(f)
url_frame.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=4)
url_frame.columnconfigure(0, weight=1)
url_entry = ttk.Entry(url_frame, textvariable=self.var_api_url, width=32)
url_entry.grid(row=0, column=0, sticky="ew")
_hint = "http://192.168.x.x:8000"
if not self.var_api_url.get():
url_entry.config(foreground="grey")
url_entry.insert(0, _hint)
def _on_focus_in(e):
if url_entry.get() == _hint:
url_entry.delete(0, tk.END)
url_entry.config(foreground="black")
def _on_focus_out(e):
if not url_entry.get():
url_entry.config(foreground="grey")
url_entry.insert(0, _hint)
self.var_api_url.set("")
url_entry.bind("<FocusIn>", _on_focus_in)
url_entry.bind("<FocusOut>", _on_focus_out)
self._test_btn = ttk.Button(url_frame, text="Test", width=6,
command=self._test_connection)
self._test_btn.grid(row=0, column=1, padx=(4, 0))
self._test_status = tk.Label(url_frame, text="", anchor="w", width=20)
self._test_status.grid(row=0, column=2, padx=(6, 0))
_add_label_spinbox(f, 1, "API Interval (sec)", self.var_api_interval, 30, 3600)
source_id_hint = "Defaults to hostname ({})".format(gethostname())
_add_label_entry(f, 2, "Source ID", self.var_source_id, hint=source_id_hint)
_add_label_entry(f, 3, "Source Type", self.var_source_type, readonly=True)
_add_label_entry(f, 4, "Local Timezone", self.var_local_timezone,
hint="e.g. America/New_York, America/Chicago")
tk.Label(
f,
text="Used to convert MLG file timestamps (local time) to UTC for terra-view.",
justify="left", fg="#555555", wraplength=340,
).grid(row=5, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(0, 4))
def _test_connection(self):
import urllib.request
import urllib.error
self._test_status.config(text="Testing...", foreground="grey")
self._test_btn.config(state="disabled")
self.root.update_idletasks()
raw = self.var_api_url.get().strip()
if not raw or raw == "http://192.168.x.x:8000":
self._test_status.config(text="Enter a URL first", foreground="orange")
self._test_btn.config(state="normal")
return
url = raw.rstrip("/") + "/health"
try:
with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
if resp.status == 200:
self._test_status.config(text="Connected!", foreground="green")
else:
self._test_status.config(text="HTTP {}".format(resp.status), foreground="orange")
except urllib.error.URLError as e:
reason = str(e.reason) if hasattr(e, "reason") else str(e)
self._test_status.config(text="Failed: {}".format(reason[:30]), foreground="red")
except Exception as e:
self._test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red")
finally:
self._test_btn.config(state="normal")
def _build_tab_paths(self, nb):
f = self._tab_frame(nb, "Paths")
def browse_thordata():
d = filedialog.askdirectory(
title="Select THORDATA Folder",
initialdir=self.var_thordata_path.get() or "C:\\",
)
if d:
self.var_thordata_path.set(d.replace("/", "\\"))
_add_label_browse_entry(f, 0, "THORDATA Path", self.var_thordata_path, browse_thordata)
def browse_log():
p = filedialog.asksaveasfilename(
title="Select Log File",
defaultextension=".log",
filetypes=[("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")],
initialfile=os.path.basename(self.var_log_file.get() or "thor_watcher.log"),
initialdir=os.path.dirname(self.var_log_file.get() or "C:\\"),
)
if p:
self.var_log_file.set(p.replace("/", "\\"))
_add_label_browse_entry(f, 1, "Log File", self.var_log_file, browse_log)
def _build_tab_scanning(self, nb):
f = self._tab_frame(nb, "Scanning")
_add_label_spinbox(f, 0, "Scan Interval (sec)", self.var_scan_interval, 10, 3600)
def _build_tab_logging(self, nb):
f = self._tab_frame(nb, "Logging")
_add_label_check(f, 0, "Enable Logging", self.var_enable_logging)
_add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365)
def _build_tab_forwarding(self, nb):
f = self._tab_frame(nb, "SFM Forward")
# Row 0: enable checkbox
_add_label_check(f, 0, "Enable SFM Forwarding", self.var_sfm_enabled)
# Row 1: SFM URL + Test button
tk.Label(f, text="SFM URL", anchor="w").grid(
row=1, column=0, sticky="w", padx=(8, 4), pady=4
)
url_frame = tk.Frame(f)
url_frame.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4)
url_frame.columnconfigure(0, weight=1)
sfm_entry = ttk.Entry(url_frame, textvariable=self.var_sfm_url, width=32)
sfm_entry.grid(row=0, column=0, sticky="ew")
_hint = "http://10.0.0.44:8200"
if not self.var_sfm_url.get():
sfm_entry.config(foreground="grey")
sfm_entry.insert(0, _hint)
def _on_focus_in(e, ent=sfm_entry, h=_hint):
if ent.get() == h:
ent.delete(0, tk.END)
ent.config(foreground="black")
def _on_focus_out(e, ent=sfm_entry, h=_hint, v=self.var_sfm_url):
if not ent.get():
ent.config(foreground="grey")
ent.insert(0, h)
v.set("")
sfm_entry.bind("<FocusIn>", _on_focus_in)
sfm_entry.bind("<FocusOut>", _on_focus_out)
self._sfm_test_btn = ttk.Button(url_frame, text="Test", width=6,
command=self._test_sfm_connection)
self._sfm_test_btn.grid(row=0, column=1, padx=(4, 0))
self._sfm_test_status = tk.Label(url_frame, text="", anchor="w", width=20)
self._sfm_test_status.grid(row=0, column=2, padx=(6, 0))
# Rows 2-7: timing/limits spinboxes
_add_label_spinbox(f, 2, "Forward Interval (sec)", self.var_sfm_forward_interval, 30, 3600)
_add_label_spinbox(f, 3, "Quiescence (sec)", self.var_sfm_quiescence, 1, 60)
_add_label_spinbox(f, 4, "Missing-Report Grace (sec)", self.var_sfm_grace, 0, 3600)
_add_label_spinbox(f, 5, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 300)
_add_label_spinbox(f, 6, "Max Forwards Per Pass", self.var_sfm_max_per_pass, 1, 5000)
_add_label_spinbox(f, 7, "Max Event Age (days)", self.var_sfm_max_age_days, 1, 3650)
# Row 8: state file browse
def browse_state():
p = filedialog.asksaveasfilename(
title="Select SFM State File",
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialfile=os.path.basename(self.var_sfm_state_file.get() or "thor_forwarded.json"),
initialdir=os.path.dirname(self.var_sfm_state_file.get() or "C:\\"),
)
if p:
self.var_sfm_state_file.set(p.replace("/", "\\"))
_add_label_browse_entry(f, 8, "State File", self.var_sfm_state_file, browse_state)
# Row 9: help text
help_text = (
"Forwards .IDFH (histogram) and .IDFW (waveform) event files plus their\n"
"TXT/<basename>.txt sidecars to a seismo-relay SFM server.\n"
"Idempotent: each file is tracked by sha256, so re-scans never re-POST.\n"
"If the TXT sidecar appears AFTER the binary was forwarded alone, the\n"
"next pass will re-forward so the relay can refresh the DB row with\n"
"device-authoritative PPV/ZCFreq/peak values.\n"
"State file blank → defaults to <log_dir>\\thor_forwarded.json."
)
tk.Label(
f, text=help_text, justify="left", fg="#555555", wraplength=420,
).grid(row=9, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4))
def _test_sfm_connection(self):
import urllib.request
import urllib.error
self._sfm_test_status.config(text="Testing...", foreground="grey")
self._sfm_test_btn.config(state="disabled")
self.root.update_idletasks()
raw = self.var_sfm_url.get().strip()
if not raw or raw == "http://10.0.0.44:8200":
self._sfm_test_status.config(text="Enter a URL first", foreground="orange")
self._sfm_test_btn.config(state="normal")
return
url = raw.rstrip("/") + "/health"
try:
with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
if resp.status == 200:
self._sfm_test_status.config(text="Connected!", foreground="green")
else:
self._sfm_test_status.config(text="HTTP {}".format(resp.status), foreground="orange")
except urllib.error.URLError as e:
reason = str(e.reason) if hasattr(e, "reason") else str(e)
self._sfm_test_status.config(text="Failed: {}".format(reason[:30]), foreground="red")
except Exception as e:
self._sfm_test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red")
finally:
self._sfm_test_btn.config(state="normal")
def _build_tab_updates(self, nb):
f = self._tab_frame(nb, "Updates")
# Current version display
tk.Label(f, text="Current Version", anchor="w").grid(
row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 2)
)
tk.Label(
f, text="v{}".format(watcher.VERSION), anchor="w",
font=("TkDefaultFont", 9, "bold"),
).grid(row=0, column=1, sticky="w", padx=(0, 8), pady=(8, 2))
tk.Label(f, text="Auto-Update Source", anchor="w").grid(
row=1, column=0, sticky="w", padx=(8, 4), pady=(8, 2)
)
radio_frame = tk.Frame(f)
radio_frame.grid(row=1, column=1, sticky="w", padx=(0, 8), pady=(8, 2))
ttk.Radiobutton(
radio_frame, text="Gitea (default)",
variable=self.var_update_source, value="gitea",
command=self._on_update_source_change,
).grid(row=0, column=0, sticky="w", padx=(0, 12))
ttk.Radiobutton(
radio_frame, text="Custom URL",
variable=self.var_update_source, value="url",
command=self._on_update_source_change,
).grid(row=0, column=1, sticky="w", padx=(0, 12))
ttk.Radiobutton(
radio_frame, text="Disabled",
variable=self.var_update_source, value="disabled",
command=self._on_update_source_change,
).grid(row=0, column=2, sticky="w")
tk.Label(f, text="Update Server URL", anchor="w").grid(
row=2, column=0, sticky="w", padx=(8, 4), pady=4
)
self._update_url_entry = ttk.Entry(f, textvariable=self.var_update_url, width=42)
self._update_url_entry.grid(row=2, column=1, sticky="ew", padx=(0, 8), pady=4)
tk.Label(
f,
text=(
"Gitea: checks the Gitea release page automatically every 5 minutes.\n"
"Custom URL: fetches version.txt and thor-watcher.exe from a web\n"
"server — use when Gitea is not reachable (e.g. terra-view URL).\n"
"Disabled: no automatic update checks. Remote push from terra-view\n"
"still works when disabled."
),
justify="left", fg="#555555", wraplength=380,
).grid(row=3, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(4, 8))
self._on_update_source_change()
def _on_update_source_change(self):
if self.var_update_source.get() == "url":
self._update_url_entry.config(state="normal")
else:
self._update_url_entry.config(state="disabled")
# ── Validation ────────────────────────────────────────────────────────────
def _get_int_var(self, var, name, min_val, max_val):
raw = str(var.get()).strip()
try:
val = int(raw)
except ValueError:
messagebox.showerror("Validation Error",
"{} must be an integer (got: {!r}).".format(name, raw))
return None
if val < min_val or val > max_val:
messagebox.showerror("Validation Error",
"{} must be between {} and {} (got {}).".format(name, min_val, max_val, val))
return None
return val
# ── Save / Cancel ─────────────────────────────────────────────────────────
def _on_save(self):
checks = [
(self.var_api_interval, "API Interval", 30, 3600),
(self.var_scan_interval, "Scan Interval", 10, 3600),
(self.var_log_retention_days, "Log Retention Days", 1, 365),
(self.var_sfm_forward_interval, "Forward Interval", 30, 3600),
(self.var_sfm_quiescence, "Quiescence", 1, 60),
(self.var_sfm_grace, "Missing-Report Grace", 0, 3600),
(self.var_sfm_http_timeout, "HTTP Timeout", 5, 300),
(self.var_sfm_max_per_pass, "Max Forwards Per Pass", 1, 5000),
(self.var_sfm_max_age_days, "Max Event Age (days)", 1, 3650),
]
int_values = {}
for var, name, mn, mx in checks:
result = self._get_int_var(var, name, mn, mx)
if result is None:
return
int_values[name] = result
source_id = self.var_source_id.get().strip()
if source_id.startswith("Defaults to hostname"):
source_id = ""
api_url = self.var_api_url.get().strip()
if api_url == "http://192.168.x.x:8000" or not api_url:
api_url = ""
else:
api_url = api_url.rstrip("/") + "/api/series4/heartbeat"
sfm_url = self.var_sfm_url.get().strip()
if sfm_url == "http://10.0.0.44:8200":
sfm_url = ""
sfm_url = sfm_url.rstrip("/") # event_forwarder adds the endpoint path
values = {
"thordata_path": self.var_thordata_path.get().strip(),
"scan_interval": int_values["Scan Interval"],
"api_url": api_url,
"api_timeout": 5,
"api_interval": int_values["API Interval"],
"source_id": source_id,
"source_type": self.var_source_type.get().strip() or "series4_watcher",
"local_timezone": self.var_local_timezone.get().strip() or "America/New_York",
"enable_logging": self.var_enable_logging.get(),
"log_file": self.var_log_file.get().strip(),
"log_retention_days": int_values["Log Retention Days"],
"update_source": self.var_update_source.get().strip() or "gitea",
"update_url": self.var_update_url.get().strip(),
"sfm_forward_enabled": self.var_sfm_enabled.get(),
"sfm_url": sfm_url,
"sfm_forward_interval": int_values["Forward Interval"],
"sfm_quiescence_seconds": int_values["Quiescence"],
"sfm_missing_report_grace_seconds": int_values["Missing-Report Grace"],
"sfm_http_timeout": int_values["HTTP Timeout"],
"sfm_max_forwards_per_pass": int_values["Max Forwards Per Pass"],
"sfm_max_event_age_days": int_values["Max Event Age (days)"],
"sfm_state_file": self.var_sfm_state_file.get().strip(),
}
try:
_save_config(self.config_path, values)
except Exception as e:
messagebox.showerror("Save Error", "Could not write config.json:\n{}".format(e))
return
self.saved = True
self.root.destroy()
def _on_cancel(self):
self.saved = False
self.root.destroy()
# ── Public API ────────────────────────────────────────────────────────────────
def show_dialog(config_path, wizard=False):
"""
Open the settings dialog.
Parameters
----------
config_path : str
Absolute path to config.json (read if exists, written on Save).
wizard : bool
If True, shows first-run welcome message and "Save & Start" button.
Returns
-------
bool
True if the user saved, False if they cancelled.
"""
root = tk.Tk()
root.withdraw()
top = tk.Toplevel(root)
top.deiconify()
dlg = SettingsDialog(top, config_path, wizard=wizard)
top.update_idletasks()
w = top.winfo_reqwidth()
h = top.winfo_reqheight()
sw = top.winfo_screenwidth()
sh = top.winfo_screenheight()
top.geometry("{}x{}+{}+{}".format(w, h, (sw - w) // 2, (sh - h) // 2))
root.wait_window(top)
root.destroy()
return dlg.saved