feat: add standalone GUI launcher (run_forge.bat + run_forge_launcher.py)

Double-click run_forge.bat to launch a dark-themed tkinter GUI that
manages Fooocus (port 7865) and Prompt Forge Bridge (port 8080).
Includes server status dots, Start/Stop/Restart controls, launch
options (preset, --in-browser, --listen), real-time log streaming,
and quick-open browser links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kyyuu 2026-03-28 14:24:18 +09:00
parent 99d956be78
commit f950594e8a
2 changed files with 466 additions and 0 deletions

33
run_forge.bat Normal file
View File

@ -0,0 +1,33 @@
@echo off
chcp 65001 > nul
title FOOOCUS FORGE LAUNCHER
cd /d "%~dp0"
REM ── Python を探す (embedded > PATH) ──────────────────────────────
if exist "python_embedded\python.exe" (
set "PYTHON=python_embedded\python.exe"
echo [INFO] Embedded Python を使用します
) else (
set "PYTHON=python"
)
REM ── tkinter が使えるか確認 ───────────────────────────────────────
%PYTHON% -c "import tkinter" 2>nul
if errorlevel 1 (
echo.
echo [ERROR] tkinter が見つかりません。
echo Python を公式サイト (python.org) からインストールしてください。
echo インストール時に "tcl/tk and IDLE" にチェックを入れてください。
pause
exit /b 1
)
REM ── ランチャー起動 ───────────────────────────────────────────────
%PYTHON% run_forge_launcher.py
if errorlevel 1 (
echo.
echo [ERROR] 起動中にエラーが発生しました。
pause
)

433
run_forge_launcher.py Normal file
View File

@ -0,0 +1,433 @@
"""
FOOOCUS FORGE LAUNCHER
tkinter dark-themed GUI launcher for Fooocus + Prompt Forge Bridge
"""
import sys
import os
import threading
import subprocess
import socket
import time
import webbrowser
import queue
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
# ── カラーテーマ ──────────────────────────────────────────────────
BG = "#0a0a0f"
BG2 = "#12121a"
BG3 = "#1a1a28"
BORDER = "#2a2a3d"
FG = "#e8e8f0"
FG2 = "#8888aa"
ACCENT = "#7c4dff"
ACCENT2 = "#b388ff"
ACCENT3 = "#00e5ff"
GREEN = "#00e676"
RED = "#ff1744"
YELLOW = "#ffea00"
ORANGE = "#ff6d00"
X_BLUE = "#1da1f2"
# ── ポート定義 ─────────────────────────────────────────────────────
PORT_FOOOCUS = 7865
PORT_BRIDGE = 8080
# ── 起動コマンド ───────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
def _py():
emb = os.path.join(BASE_DIR, "python_embedded", "python.exe")
return emb if os.path.exists(emb) else sys.executable
def build_fooocus_cmd(preset="", in_browser=False, listen=False):
cmd = [_py(), "launch.py"]
if preset:
cmd += ["--preset", preset]
if in_browser:
cmd.append("--in-browser")
if listen:
cmd.append("--listen")
return cmd
def is_port_open(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return True
except OSError:
return False
# ══════════════════════════════════════════════════════════════════
class LauncherApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("FOOOCUS FORGE LAUNCHER")
self.configure(bg=BG)
self.resizable(True, True)
self.minsize(680, 520)
# プロセス管理
self._proc_fooocus: subprocess.Popen | None = None
self._log_queue: queue.Queue = queue.Queue()
# ── ウィジェット構築 ──
self._build_header()
self._build_status_bar()
self._build_options()
self._build_controls()
self._build_log()
self._build_links()
# ── イベント ──
self.bind("<Escape>", lambda _: self._on_close())
self.protocol("WM_DELETE_WINDOW", self._on_close)
# ── ポーリング開始 ──
self._poll_status()
self._drain_log_queue()
# ── ヘッダー ──────────────────────────────────────────────────
def _build_header(self):
f = tk.Frame(self, bg=BG, pady=12)
f.pack(fill="x", padx=20)
tk.Label(f, text="⚡ FOOOCUS FORGE LAUNCHER",
bg=BG, fg=ACCENT2,
font=("Consolas", 16, "bold")).pack(side="left")
tk.Label(f, text="v1.0",
bg=BG, fg=FG2,
font=("Consolas", 10)).pack(side="left", padx=(8, 0), pady=4)
# ── ステータスバー ────────────────────────────────────────────
def _build_status_bar(self):
outer = tk.Frame(self, bg=BG3, bd=0, highlightbackground=BORDER,
highlightthickness=1)
outer.pack(fill="x", padx=20, pady=(0, 8))
inner = tk.Frame(outer, bg=BG3, pady=8)
inner.pack(fill="x", padx=12)
# Fooocus
fc = tk.Frame(inner, bg=BG3)
fc.pack(side="left", padx=(0, 24))
self._dot_fooocus = tk.Label(fc, text="", fg=RED, bg=BG3,
font=("Consolas", 14))
self._dot_fooocus.pack(side="left")
tk.Label(fc, text=f" Fooocus :{PORT_FOOOCUS}",
bg=BG3, fg=FG, font=("Consolas", 11)).pack(side="left")
self._lbl_fooocus = tk.Label(fc, text="停止中",
bg=BG3, fg=RED,
font=("Consolas", 10))
self._lbl_fooocus.pack(side="left", padx=(6, 0))
sep = tk.Frame(inner, bg=BORDER, width=1, height=24)
sep.pack(side="left", padx=12)
# Bridge
bc = tk.Frame(inner, bg=BG3)
bc.pack(side="left")
self._dot_bridge = tk.Label(bc, text="", fg=RED, bg=BG3,
font=("Consolas", 14))
self._dot_bridge.pack(side="left")
tk.Label(bc, text=f" Bridge :{PORT_BRIDGE}",
bg=BG3, fg=FG, font=("Consolas", 11)).pack(side="left")
self._lbl_bridge = tk.Label(bc, text="停止中",
bg=BG3, fg=RED,
font=("Consolas", 10))
self._lbl_bridge.pack(side="left", padx=(6, 0))
# ── 起動オプション ────────────────────────────────────────────
def _build_options(self):
outer = tk.Frame(self, bg=BG2, bd=0, highlightbackground=BORDER,
highlightthickness=1)
outer.pack(fill="x", padx=20, pady=(0, 8))
inner = tk.Frame(outer, bg=BG2, pady=8, padx=12)
inner.pack(fill="x")
tk.Label(inner, text="起動オプション", bg=BG2, fg=FG2,
font=("Consolas", 9, "bold")).grid(
row=0, column=0, columnspan=6, sticky="w", pady=(0, 6))
# プリセット
tk.Label(inner, text="プリセット:", bg=BG2, fg=FG,
font=("Consolas", 10)).grid(row=1, column=0, sticky="w", padx=(0, 6))
self._preset_var = tk.StringVar(value="なし")
preset_cb = ttk.Combobox(inner, textvariable=self._preset_var,
values=["なし", "anime", "realistic", "lcm"],
state="readonly", width=12,
font=("Consolas", 10))
preset_cb.grid(row=1, column=1, sticky="w", padx=(0, 20))
self._style_combobox(preset_cb)
# ブラウザ自動起動
self._browser_var = tk.BooleanVar(value=False)
chk_browser = tk.Checkbutton(inner, text="ブラウザ自動起動",
variable=self._browser_var,
bg=BG2, fg=FG, selectcolor=BG3,
activebackground=BG2, activeforeground=ACCENT2,
font=("Consolas", 10))
chk_browser.grid(row=1, column=2, sticky="w", padx=(0, 20))
# --listen
self._listen_var = tk.BooleanVar(value=False)
chk_listen = tk.Checkbutton(inner, text="--listen (LAN公開)",
variable=self._listen_var,
bg=BG2, fg=FG, selectcolor=BG3,
activebackground=BG2, activeforeground=ACCENT2,
font=("Consolas", 10))
chk_listen.grid(row=1, column=3, sticky="w")
# ── コントロールボタン ────────────────────────────────────────
def _build_controls(self):
f = tk.Frame(self, bg=BG, pady=4)
f.pack(fill="x", padx=20)
btn_cfg = dict(font=("Consolas", 11, "bold"), cursor="hand2",
relief="flat", bd=0, padx=18, pady=7)
self._btn_start = tk.Button(
f, text="▶ 起動", bg=ACCENT, fg="white",
activebackground=ACCENT2, activeforeground="white",
command=self._start_all, **btn_cfg)
self._btn_start.pack(side="left", padx=(0, 8))
self._btn_stop = tk.Button(
f, text="■ 停止", bg=BG3, fg=RED,
activebackground=BORDER, activeforeground=RED,
command=self._stop_all, state="disabled", **btn_cfg)
self._btn_stop.pack(side="left", padx=(0, 8))
self._btn_restart = tk.Button(
f, text="↺ 再起動", bg=BG3, fg=YELLOW,
activebackground=BORDER, activeforeground=YELLOW,
command=self._restart_all, state="disabled", **btn_cfg)
self._btn_restart.pack(side="left", padx=(0, 8))
tk.Button(f, text="ログクリア", bg=BG3, fg=FG2,
activebackground=BORDER, activeforeground=FG,
command=self._clear_log, **btn_cfg).pack(side="right")
# ── ログ ──────────────────────────────────────────────────────
def _build_log(self):
f = tk.Frame(self, bg=BG)
f.pack(fill="both", expand=True, padx=20, pady=(8, 4))
tk.Label(f, text="ログ", bg=BG, fg=FG2,
font=("Consolas", 9, "bold")).pack(anchor="w", pady=(0, 3))
self._log = scrolledtext.ScrolledText(
f, bg=BG2, fg=FG, insertbackground=FG,
font=("Consolas", 9), relief="flat",
bd=0, highlightbackground=BORDER, highlightthickness=1,
state="disabled", wrap="word")
self._log.pack(fill="both", expand=True)
# タグ色
self._log.tag_config("info", foreground=FG2)
self._log.tag_config("ok", foreground=GREEN)
self._log.tag_config("warn", foreground=YELLOW)
self._log.tag_config("err", foreground=RED)
self._log.tag_config("accent", foreground=ACCENT3)
# ── クイックリンク ────────────────────────────────────────────
def _build_links(self):
f = tk.Frame(self, bg=BG, pady=8)
f.pack(fill="x", padx=20)
link_cfg = dict(font=("Consolas", 10), cursor="hand2",
relief="flat", bd=0, padx=12, pady=5)
tk.Button(f, text="🌐 Prompt Forge http://127.0.0.1:8080",
bg=BG3, fg=ACCENT3,
activebackground=BORDER, activeforeground=ACCENT3,
command=lambda: webbrowser.open("http://127.0.0.1:8080"),
**link_cfg).pack(side="left", padx=(0, 8))
tk.Button(f, text="🌐 Fooocus UI http://127.0.0.1:7865",
bg=BG3, fg=ACCENT2,
activebackground=BORDER, activeforeground=ACCENT2,
command=lambda: webbrowser.open("http://127.0.0.1:7865"),
**link_cfg).pack(side="left")
# ── Combobox スタイル ─────────────────────────────────────────
def _style_combobox(self, cb):
style = ttk.Style(self)
style.theme_use("default")
style.configure("TCombobox",
fieldbackground=BG3,
background=BG3,
foreground=FG,
selectbackground=ACCENT,
selectforeground="white",
arrowcolor=FG2)
style.map("TCombobox",
fieldbackground=[("readonly", BG3)],
foreground=[("readonly", FG)],
background=[("readonly", BG3)])
self.option_add("*TCombobox*Listbox.background", BG3)
self.option_add("*TCombobox*Listbox.foreground", FG)
self.option_add("*TCombobox*Listbox.selectBackground", ACCENT)
self.option_add("*TCombobox*Listbox.font", ("Consolas", 10))
# ── ログ書き込み ──────────────────────────────────────────────
def _log_write(self, text: str, tag: str = "info"):
self._log.configure(state="normal")
self._log.insert("end", text + "\n", tag)
self._log.see("end")
self._log.configure(state="disabled")
def _clear_log(self):
self._log.configure(state="normal")
self._log.delete("1.0", "end")
self._log.configure(state="disabled")
# ── ログキューをUIスレッドで消費 ──────────────────────────────
def _drain_log_queue(self):
try:
while True:
text, tag = self._log_queue.get_nowait()
self._log_write(text, tag)
except queue.Empty:
pass
self.after(100, self._drain_log_queue)
def _queue_log(self, text: str, tag: str = "info"):
self._log_queue.put((text, tag))
# ── ステータスポーリング ──────────────────────────────────────
def _poll_status(self):
fooocus_up = is_port_open(PORT_FOOOCUS)
bridge_up = is_port_open(PORT_BRIDGE)
# Fooocus
if fooocus_up:
self._dot_fooocus.config(fg=GREEN)
self._lbl_fooocus.config(text="起動中", fg=GREEN)
else:
if self._proc_fooocus and self._proc_fooocus.poll() is None:
self._dot_fooocus.config(fg=YELLOW)
self._lbl_fooocus.config(text="起動中...", fg=YELLOW)
else:
self._dot_fooocus.config(fg=RED)
self._lbl_fooocus.config(text="停止中", fg=RED)
# Bridge (Fooocusの中で起動されるためプロセスを別管理しない)
if bridge_up:
self._dot_bridge.config(fg=GREEN)
self._lbl_bridge.config(text="起動中", fg=GREEN)
else:
self._dot_bridge.config(fg=RED)
self._lbl_bridge.config(text="停止中", fg=RED)
# ボタン状態
running = (self._proc_fooocus is not None and
self._proc_fooocus.poll() is None)
self._btn_start.config(state="disabled" if running else "normal")
self._btn_stop.config(state="normal" if running else "disabled")
self._btn_restart.config(state="normal" if running else "disabled")
self.after(2000, self._poll_status)
# ── 起動 ──────────────────────────────────────────────────────
def _start_all(self):
preset = self._preset_var.get()
browser = self._browser_var.get()
listen = self._listen_var.get()
cmd = build_fooocus_cmd(
preset = "" if preset == "なし" else preset,
in_browser = browser,
listen = listen
)
self._log_write(f"▶ 起動コマンド: {' '.join(cmd)}", "accent")
try:
self._proc_fooocus = subprocess.Popen(
cmd,
cwd=BASE_DIR,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1
)
except FileNotFoundError as e:
self._log_write(f"[ERROR] Python が見つかりません: {e}", "err")
return
self._log_write(f"PID: {self._proc_fooocus.pid}", "info")
threading.Thread(target=self._stream_output,
args=(self._proc_fooocus,), daemon=True).start()
def _stream_output(self, proc: subprocess.Popen):
try:
for line in proc.stdout:
line = line.rstrip("\n")
if not line:
continue
low = line.lower()
if any(k in low for k in ("error", "exception", "traceback", "failed")):
tag = "err"
elif any(k in low for k in ("warning", "warn")):
tag = "warn"
elif any(k in low for k in ("running on", "http://", "startup", "ready", "started")):
tag = "ok"
else:
tag = "info"
self._queue_log(line, tag)
except Exception:
pass
self._queue_log("── プロセス終了 ──", "warn")
# ── 停止 ──────────────────────────────────────────────────────
def _stop_all(self):
if self._proc_fooocus and self._proc_fooocus.poll() is None:
self._log_write("■ 停止中...", "warn")
try:
self._proc_fooocus.terminate()
try:
self._proc_fooocus.wait(timeout=8)
except subprocess.TimeoutExpired:
self._proc_fooocus.kill()
self._log_write("停止しました。", "ok")
except Exception as e:
self._log_write(f"[ERROR] 停止失敗: {e}", "err")
self._proc_fooocus = None
# ── 再起動 ────────────────────────────────────────────────────
def _restart_all(self):
self._log_write("↺ 再起動中...", "accent")
self._stop_all()
time.sleep(1)
self._start_all()
# ── 終了 ──────────────────────────────────────────────────────
def _on_close(self):
if (self._proc_fooocus and self._proc_fooocus.poll() is None):
if messagebox.askyesno(
"確認",
"Fooocus が起動中です。終了しますか?\n(プロセスも停止されます)",
parent=self
):
self._stop_all()
else:
return
self.destroy()
# ── エントリーポイント ─────────────────────────────────────────────
if __name__ == "__main__":
app = LauncherApp()
# ウィンドウを画面中央に
app.update_idletasks()
w, h = 700, 580
sw = app.winfo_screenwidth()
sh = app.winfo_screenheight()
x = (sw - w) // 2
y = (sh - h) // 2
app.geometry(f"{w}x{h}+{x}+{y}")
app.mainloop()