153 lines
6.6 KiB
Python
153 lines
6.6 KiB
Python
"""One-click desktop launcher: validate stored data, back it up, then open Huobao."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import datetime
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DATA = ROOT / "huobao-data"
|
|
EXE = ROOT / "HuobaoDrama/HuobaoDrama.exe"
|
|
PROFILE = Path(os.environ["APPDATA"]) / "HuobaoDrama"
|
|
HTTP = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
|
|
|
|
def get_json(url, timeout=2):
|
|
with HTTP.open(url, timeout=timeout) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def running_app():
|
|
try:
|
|
log = (PROFILE / "logs/startup.log").read_text("utf-8")
|
|
ports = re.findall(r"port=(\d+) userData=", log)
|
|
if not ports:
|
|
return None
|
|
base = "http://127.0.0.1:" + ports[-1] + "/api/v1"
|
|
info = get_json(base + "/storage").get("data", {})
|
|
if not info.get("dataDir") or info.get("mode") != "desktop":
|
|
return None
|
|
return base, Path(info["dataDir"]).resolve()
|
|
except (OSError, ValueError, urllib.error.URLError):
|
|
return None
|
|
|
|
|
|
def validate_data():
|
|
if not EXE.is_file():
|
|
raise RuntimeError(f"找不到火宝程序:{EXE}")
|
|
database = DATA / "huobao.sqlite3"
|
|
if not database.is_file():
|
|
raise RuntimeError(f"找不到原数据库:{database}\n为避免打开空项目,本次未启动。请先恢复数据目录。")
|
|
with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=10) as conn:
|
|
if conn.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
|
raise RuntimeError("数据库检查未通过,本次未启动。请使用备份恢复。")
|
|
configs = conn.execute("SELECT count(*) FROM ai_service_configs").fetchone()[0]
|
|
projects = conn.execute("SELECT count(*) FROM dramas").fetchone()[0]
|
|
if configs == 0:
|
|
raise RuntimeError("数据库中没有模型配置,本次未启动,以免误用空数据库。")
|
|
grok_enabled = bool(conn.execute("SELECT 1 FROM ai_service_configs WHERE is_active=1 AND model LIKE '%grok-local%' LIMIT 1").fetchone())
|
|
return database, configs, projects, grok_enabled
|
|
|
|
|
|
def save_storage_pointer():
|
|
PROFILE.mkdir(parents=True, exist_ok=True)
|
|
pointer = PROFILE / "storage-config.json"
|
|
if pointer.exists():
|
|
try:
|
|
previous = json.loads(pointer.read_text("utf-8"))
|
|
selected = previous.get("dataDir")
|
|
except (ValueError, AttributeError):
|
|
selected = None
|
|
if selected and Path(selected).resolve() != DATA.resolve():
|
|
raise RuntimeError(f"火宝目前选择了另一个数据目录:{selected}\n一键启动使用:{DATA}\n请先确认存储位置;原设置未改动。")
|
|
if selected:
|
|
return
|
|
backup = DATA / "backups" / ("storage-pointer-" + datetime.now().strftime("%Y%m%d-%H%M%S") + ".json")
|
|
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
backup.write_bytes(pointer.read_bytes())
|
|
temporary = pointer.with_name(f"storage-config.{os.getpid()}.tmp")
|
|
temporary.write_text(json.dumps({"version": 1, "dataDir": str(DATA)}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
temporary.replace(pointer)
|
|
|
|
|
|
def backup_database(database):
|
|
folder = DATA / "backups"
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
target = folder / ("startup-" + datetime.now().strftime("%Y%m%d-%H%M%S") + f"-{os.getpid()}.sqlite3")
|
|
# SQLite's online backup also includes committed WAL changes while Huobao is open.
|
|
with sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=10) as source:
|
|
with sqlite3.connect(target) as destination:
|
|
source.backup(destination)
|
|
if destination.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
|
raise RuntimeError("启动前数据库备份校验失败,本次未启动。")
|
|
return target
|
|
|
|
|
|
def ensure_grok():
|
|
try:
|
|
if get_json("http://127.0.0.1:18793/health").get("service") == "huobao-model-bridge":
|
|
return
|
|
except (OSError, ValueError, urllib.error.URLError):
|
|
pass
|
|
pythonw = Path(sys.executable).with_name("pythonw.exe")
|
|
runner = pythonw if pythonw.is_file() else Path(sys.executable)
|
|
subprocess.Popen([str(runner), str(ROOT / "model-bridge/launch_local.py")],
|
|
cwd=ROOT / "model-bridge", creationflags=subprocess.CREATE_NO_WINDOW,
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--check", action="store_true", help="只检查,不启动或修改配置")
|
|
args = parser.parse_args()
|
|
database, configs, projects, grok_enabled = validate_data()
|
|
active = running_app()
|
|
if active and active[1] != DATA.resolve():
|
|
raise RuntimeError(f"当前火宝正在使用另一个数据目录:{active[1]}\n请先关闭火宝,再运行一键启动。")
|
|
print(f"数据检查通过:{configs} 条模型配置,{projects} 个项目。", flush=True)
|
|
if args.check:
|
|
print(f"数据目录:{DATA}")
|
|
return
|
|
save_storage_pointer()
|
|
backup = backup_database(database)
|
|
env = os.environ.copy()
|
|
env.pop("ELECTRON_RUN_AS_NODE", None)
|
|
# Huobao's single-instance lock brings its existing window forward.
|
|
subprocess.Popen([str(EXE)], cwd=EXE.parent, env=env)
|
|
deadline = time.monotonic() + 45
|
|
while time.monotonic() < deadline:
|
|
current = running_app()
|
|
if current:
|
|
if current[1] != DATA.resolve():
|
|
raise RuntimeError("火宝启动后的数据目录不匹配,请先停止编辑并检查存储设置。")
|
|
break
|
|
time.sleep(0.5)
|
|
else:
|
|
raise RuntimeError(f"火宝未能正常启动,请查看:{PROFILE / 'logs/startup.log'}")
|
|
if grok_enabled:
|
|
ensure_grok()
|
|
status = {"started_at": datetime.now().astimezone().isoformat(), "already_running": bool(active),
|
|
"data_directory": str(DATA), "database_backup": str(backup), "configs": configs, "projects": projects,
|
|
"api": current[0]}
|
|
(DATA / "last-launch.json").write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print("火宝已打开,启动前数据库备份已保存。", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if sys.stdout is not None:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
try:
|
|
main()
|
|
except Exception as error:
|
|
print(f"\n启动失败:{error}", flush=True)
|
|
sys.exit(1)
|