965 lines
44 KiB
Python
965 lines
44 KiB
Python
"""抖音托管客服 · 桌面整合版
|
||
|
||
一个软件搞定一切:
|
||
1) 启动后先显示「站点选择」界面(内置 dev / dev1,可自行添加更多站点,
|
||
可勾选“记住选择”下次直接进入);
|
||
2) 进入站点后,页面右下角自动出现「一键本地登录」悬浮按钮,
|
||
以及「切换站点」按钮可随时换环境;
|
||
3) 点「一键本地登录」→ 弹出你的托管账号列表 → 选一个 → 本机直接打开
|
||
一个已登录该托管账号的浏览器,进入抖音。
|
||
|
||
原理:桌面壳(pywebview)把云端网页装进原生窗口,并注入一段脚本。该脚本
|
||
用网页里已有的登录令牌(localStorage.kefu_token)调用云端接口取账号与凭证,
|
||
再通过 pywebview 的 JS↔Python 桥把凭证交给本机 Playwright 打开可见浏览器。
|
||
因此浏览器开在你本机、看得见,与云服务器有没有图形界面无关。
|
||
|
||
依赖:
|
||
pip install pywebview playwright
|
||
python -m playwright install chromium
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import threading
|
||
from urllib.parse import urlparse
|
||
|
||
import webview
|
||
|
||
from browser_open import normalize_storage_state, open_logged_in_browser
|
||
from updater import check_update, download_installer
|
||
from version import __version__
|
||
|
||
WINDOW_TITLE = f"抖音托管客服 · 桌面版 v{__version__}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 多站点配置:内置站点 + 用户自定义站点(保存在用户目录,升级软件不丢失)。
|
||
# 以后要新增内置环境,往 BUILTIN_SITES 里加一行即可。
|
||
# ---------------------------------------------------------------------------
|
||
BUILTIN_SITES = [
|
||
{"name": "节点1", "url": "https://dev.zhenyangtang.com.cn/"},
|
||
{"name": "节点2", "url": "https://dev1.zhenyangtang.com.cn/"},
|
||
]
|
||
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".douyin_desktop_config.json")
|
||
|
||
|
||
def load_config() -> dict:
|
||
try:
|
||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
|
||
def save_config(cfg: dict) -> None:
|
||
try:
|
||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def normalize_site_url(url: str) -> str:
|
||
url = (url or "").strip()
|
||
if not url:
|
||
return ""
|
||
if not url.startswith(("http://", "https://")):
|
||
url = "https://" + url
|
||
if not url.endswith("/"):
|
||
url += "/"
|
||
return url
|
||
|
||
|
||
def get_all_sites() -> list[dict]:
|
||
"""内置站点 + 自定义站点(去重,按 URL)。"""
|
||
cfg = load_config()
|
||
sites: list[dict] = []
|
||
seen: set[str] = set()
|
||
for s in BUILTIN_SITES:
|
||
u = normalize_site_url(s["url"])
|
||
sites.append({"name": s["name"], "url": u, "builtin": True})
|
||
seen.add(u)
|
||
for s in cfg.get("custom_sites", []):
|
||
u = normalize_site_url(s.get("url", ""))
|
||
if u and u not in seen:
|
||
sites.append({"name": s.get("name") or urlparse(u).hostname, "url": u, "builtin": False})
|
||
seen.add(u)
|
||
return sites
|
||
|
||
|
||
def site_hosts() -> set[str]:
|
||
return {urlparse(s["url"]).hostname or "" for s in get_all_sites()} - {""}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 站点选择界面(软件启动首页)。与注入浮层同一套视觉语言:#fe2c55 主色 + 玻璃拟态。
|
||
# ---------------------------------------------------------------------------
|
||
PICKER_HTML = r"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>选择站点</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0;
|
||
font-family:'PingFang SC','Microsoft YaHei',system-ui,-apple-system,'Segoe UI',sans-serif;}
|
||
html,body{height:100%;}
|
||
body{display:flex;align-items:center;justify-content:center;padding:28px;
|
||
background:#0f1023;
|
||
background-image:radial-gradient(900px 500px at 15% -10%,rgba(254,44,85,.28),transparent 60%),
|
||
radial-gradient(800px 500px at 110% 110%,rgba(99,102,241,.25),transparent 60%);}
|
||
.card{width:520px;max-width:100%;max-height:92vh;display:flex;flex-direction:column;
|
||
background:rgba(255,255,255,.06);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);
|
||
border:1px solid rgba(255,255,255,.14);border-radius:20px;overflow:hidden;
|
||
box-shadow:0 30px 80px rgba(0,0,0,.45);}
|
||
.head{padding:26px 28px 18px;}
|
||
.brand{display:flex;align-items:center;gap:12px;}
|
||
.logo{width:42px;height:42px;border-radius:12px;flex:none;display:flex;align-items:center;justify-content:center;
|
||
background:linear-gradient(135deg,#fe2c55,#ff7a59);box-shadow:0 8px 20px rgba(254,44,85,.4);}
|
||
.logo svg{width:22px;height:22px;color:#fff;}
|
||
h1{font-size:18px;font-weight:600;color:#fff;line-height:1.3;}
|
||
.sub{margin-top:3px;font-size:12.5px;color:rgba(255,255,255,.55);}
|
||
.body{padding:4px 20px 8px;overflow:auto;}
|
||
.site{display:flex;align-items:center;gap:12px;width:100%;text-align:left;
|
||
padding:13px 14px;margin-bottom:10px;border:1px solid rgba(255,255,255,.12);
|
||
border-radius:14px;background:rgba(255,255,255,.04);cursor:pointer;
|
||
transition:border-color .18s ease,background .18s ease,transform .18s ease;}
|
||
.site:hover{border-color:#fe2c55;background:rgba(254,44,85,.12);transform:translateY(-1px);}
|
||
.site:focus-visible{outline:2px solid #fe2c55;outline-offset:2px;}
|
||
.dot{width:36px;height:36px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center;
|
||
background:rgba(255,255,255,.08);color:rgba(255,255,255,.85);font-weight:600;font-size:15px;}
|
||
.site:hover .dot{background:rgba(254,44,85,.9);color:#fff;}
|
||
.si{flex:1;min-width:0;}
|
||
.sn{font-size:14.5px;font-weight:600;color:#fff;display:flex;align-items:center;gap:8px;}
|
||
.tag{font-size:10.5px;font-weight:500;padding:1px 7px;border-radius:999px;
|
||
background:rgba(255,255,255,.1);color:rgba(255,255,255,.6);}
|
||
.su{margin-top:3px;font-size:12px;color:rgba(255,255,255,.5);
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.ping{flex:none;display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
|
||
border-radius:999px;font-size:11px;font-weight:500;
|
||
background:rgba(255,255,255,.08);color:rgba(255,255,255,.55);}
|
||
.ping i{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.35);flex:none;}
|
||
.ping.fast{background:rgba(34,197,94,.15);color:#4ade80;}
|
||
.ping.fast i{background:#22c55e;}
|
||
.ping.mid{background:rgba(245,158,11,.15);color:#fbbf24;}
|
||
.ping.mid i{background:#f59e0b;}
|
||
.ping.slow{background:rgba(239,68,68,.15);color:#f87171;}
|
||
.ping.slow i{background:#ef4444;}
|
||
.ping.fail{background:rgba(239,68,68,.15);color:#f87171;}
|
||
.ping.fail i{background:#ef4444;}
|
||
@keyframes blink{50%{opacity:.35;}}
|
||
.ping.wait i{animation:blink 1s ease infinite;}
|
||
.arrow{flex:none;color:rgba(255,255,255,.3);transition:color .18s ease,transform .18s ease;}
|
||
.site:hover .arrow{color:#fff;transform:translateX(2px);}
|
||
.arrow svg{width:18px;height:18px;display:block;}
|
||
.del{flex:none;border:none;background:transparent;color:rgba(255,255,255,.35);cursor:pointer;
|
||
width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
||
transition:background .18s ease,color .18s ease;}
|
||
.del:hover{background:rgba(239,68,68,.2);color:#f87171;}
|
||
.del svg{width:15px;height:15px;}
|
||
.foot{padding:6px 20px 22px;}
|
||
.remember{display:flex;align-items:center;gap:8px;padding:6px 2px 14px;
|
||
font-size:12.5px;color:rgba(255,255,255,.65);cursor:pointer;user-select:none;}
|
||
.remember input{width:15px;height:15px;accent-color:#fe2c55;cursor:pointer;}
|
||
.addbar{display:flex;gap:8px;}
|
||
.addbar input{flex:1;min-width:0;padding:10px 12px;font-size:13px;color:#fff;
|
||
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);border-radius:10px;outline:none;
|
||
transition:border-color .18s ease;}
|
||
.addbar input::placeholder{color:rgba(255,255,255,.35);}
|
||
.addbar input:focus{border-color:#fe2c55;}
|
||
.addbar input.name{flex:0 0 120px;}
|
||
.addbar button{flex:none;padding:10px 16px;font-size:13px;font-weight:600;color:#fff;
|
||
background:#fe2c55;border:none;border-radius:10px;cursor:pointer;
|
||
transition:background .18s ease,box-shadow .18s ease;}
|
||
.addbar button:hover{background:#e0214a;box-shadow:0 6px 18px rgba(254,44,85,.4);}
|
||
.err{margin-top:8px;font-size:12px;color:#f87171;min-height:16px;}
|
||
.empty{padding:20px;text-align:center;font-size:13px;color:rgba(255,255,255,.45);}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="head">
|
||
<div class="brand">
|
||
<div class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/>
|
||
<line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div>
|
||
<div>
|
||
<h1>抖音托管客服 · 桌面版</h1>
|
||
<div class="sub">请选择要进入的站点,也可以在下方添加新站点</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="body" id="list"><div class="empty">正在加载站点…</div></div>
|
||
<div class="foot">
|
||
<label class="remember"><input type="checkbox" id="remember">记住选择,下次启动直接进入(可在站点内切换)</label>
|
||
<div class="addbar">
|
||
<input class="name" id="addName" placeholder="名称(选填)" maxlength="20">
|
||
<input id="addUrl" placeholder="https://xxx.zhenyangtang.com.cn/" spellcheck="false">
|
||
<button id="addBtn">添加</button>
|
||
</div>
|
||
<div class="err" id="err"></div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
var CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
||
var X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||
});
|
||
}
|
||
function api() { return window.pywebview && window.pywebview.api; }
|
||
function setErr(msg) { document.getElementById('err').textContent = msg || ''; }
|
||
|
||
function pingSite(url, badge) {
|
||
if (!badge) return;
|
||
api().ping_site(url).then(function (r) {
|
||
if (!r || !r.ok) {
|
||
badge.className = 'ping fail';
|
||
badge.innerHTML = '<i></i>不可达';
|
||
return;
|
||
}
|
||
var ms = r.ms;
|
||
var cls = ms <= 300 ? 'fast' : (ms <= 800 ? 'mid' : 'slow');
|
||
badge.className = 'ping ' + cls;
|
||
badge.innerHTML = '<i></i>' + ms + ' ms';
|
||
}).catch(function () {
|
||
badge.className = 'ping fail';
|
||
badge.innerHTML = '<i></i>检测失败';
|
||
});
|
||
}
|
||
|
||
function render(sites) {
|
||
var list = document.getElementById('list');
|
||
if (!sites || !sites.length) {
|
||
list.innerHTML = '<div class="empty">暂无站点,请在下方添加</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = '';
|
||
sites.forEach(function (s) {
|
||
var host = '';
|
||
try { host = new URL(s.url).hostname; } catch (e) { host = s.url; }
|
||
var initial = esc((s.name || host || '?').trim().charAt(0).toUpperCase());
|
||
var el = document.createElement('button');
|
||
el.className = 'site';
|
||
el.type = 'button';
|
||
el.innerHTML = '<span class="dot">' + initial + '</span>'
|
||
+ '<span class="si"><span class="sn">' + esc(s.name || host)
|
||
+ (s.builtin ? '<span class="tag">内置</span>' : '') + '</span>'
|
||
+ '<span class="su">' + esc(s.url) + '</span></span>'
|
||
+ '<span class="ping wait"><i></i>检测中</span>'
|
||
+ (s.builtin ? '' : '<span class="del" title="删除该站点" role="button">' + X + '</span>')
|
||
+ '<span class="arrow">' + CHEV + '</span>';
|
||
pingSite(s.url, el.querySelector('.ping'));
|
||
el.addEventListener('click', function (e) {
|
||
var del = e.target.closest && e.target.closest('.del');
|
||
if (del) {
|
||
e.stopPropagation();
|
||
api().remove_site(s.url).then(function (r) { render(r.sites); });
|
||
return;
|
||
}
|
||
var remember = document.getElementById('remember').checked;
|
||
el.style.opacity = '.6';
|
||
api().open_site(s.url, remember);
|
||
});
|
||
list.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function addSite() {
|
||
var name = document.getElementById('addName').value.trim();
|
||
var url = document.getElementById('addUrl').value.trim();
|
||
if (!url) { setErr('请填写站点网址'); return; }
|
||
setErr('');
|
||
api().add_site(name, url).then(function (r) {
|
||
if (r.error) { setErr(r.error); return; }
|
||
document.getElementById('addName').value = '';
|
||
document.getElementById('addUrl').value = '';
|
||
render(r.sites);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
api().get_sites().then(function (r) {
|
||
render(r.sites);
|
||
document.getElementById('remember').checked = !!r.remember;
|
||
});
|
||
document.getElementById('addBtn').addEventListener('click', addSite);
|
||
document.getElementById('addUrl').addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') addSite();
|
||
});
|
||
}
|
||
|
||
if (api()) init();
|
||
else window.addEventListener('pywebviewready', init);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# 注入到云端页面的脚本:加悬浮按钮 + 账号选择浮层,调用本机桥打开浏览器。
|
||
# 设计:Glassmorphism 浮层 + SVG 图标(无 emoji)+ 悬浮过渡 + 可访问性(焦点/Esc/减少动效)。
|
||
INJECT_JS = r"""
|
||
(function () {
|
||
if (window.__dyLauncherInstalled) return;
|
||
window.__dyLauncherInstalled = true;
|
||
|
||
var NS = 'dyl';
|
||
var ICON = {
|
||
login: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>',
|
||
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
|
||
monitor: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
||
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||
swap: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>'
|
||
};
|
||
|
||
var ROOT = null; // Shadow root,隔离宿主站点 CSS,避免错位/层级冲突
|
||
|
||
function api() {
|
||
return (window.pywebview && window.pywebview.api) ? window.pywebview.api : null;
|
||
}
|
||
function token() {
|
||
try { return localStorage.getItem('kefu_token') || ''; } catch (e) { return ''; }
|
||
}
|
||
function apiBase() { return location.origin + '/api'; }
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
async function fetchJson(path, opts) {
|
||
opts = opts || {};
|
||
opts.headers = Object.assign({ 'Accept': 'application/json' }, opts.headers || {});
|
||
var t = token();
|
||
if (t) opts.headers['Authorization'] = 'Bearer ' + t;
|
||
var resp = await fetch(apiBase() + path, opts);
|
||
if (!resp.ok) {
|
||
var msg = '请求失败 ' + resp.status;
|
||
try { var j = await resp.json(); msg = (j && j.detail) ? (j.detail.message || j.detail) : msg; } catch (e) {}
|
||
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||
}
|
||
return resp.json();
|
||
}
|
||
|
||
var STYLE = `
|
||
:host{all:initial;}
|
||
*{box-sizing:border-box;font-family:'Poppins','PingFang SC','Microsoft YaHei',system-ui,-apple-system,'Segoe UI',sans-serif;}
|
||
.${NS}-btn{position:fixed;right:24px;bottom:24px;z-index:2147483646;display:inline-flex;
|
||
align-items:center;gap:8px;background:#fe2c55;color:#fff;border:1px solid rgba(255,255,255,.25);
|
||
border-radius:999px;padding:11px 18px 11px 16px;font-size:14px;font-weight:600;cursor:pointer;
|
||
box-shadow:0 8px 24px rgba(254,44,85,.38);transition:background .2s ease,box-shadow .2s ease,transform .2s ease;}
|
||
.${NS}-btn:hover{background:#e0214a;box-shadow:0 10px 28px rgba(254,44,85,.5);}
|
||
.${NS}-btn:active{transform:translateY(1px);}
|
||
.${NS}-btn:focus-visible{outline:3px solid rgba(254,44,85,.4);outline-offset:2px;}
|
||
.${NS}-btn svg{width:18px;height:18px;}
|
||
|
||
.${NS}-switch{position:fixed;right:24px;bottom:76px;z-index:2147483646;display:inline-flex;
|
||
align-items:center;gap:6px;background:rgba(15,23,42,.72);color:#fff;
|
||
border:1px solid rgba(255,255,255,.18);border-radius:999px;padding:8px 14px 8px 12px;
|
||
font-size:12.5px;font-weight:500;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
|
||
box-shadow:0 6px 18px rgba(15,23,42,.3);transition:background .2s ease,transform .2s ease;}
|
||
.${NS}-switch:hover{background:rgba(15,23,42,.88);}
|
||
.${NS}-switch:active{transform:translateY(1px);}
|
||
.${NS}-switch:focus-visible{outline:2px solid #fe2c55;outline-offset:2px;}
|
||
.${NS}-switch svg{width:14px;height:14px;}
|
||
|
||
.${NS}-mask{position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.45);
|
||
display:flex;align-items:center;justify-content:center;padding:20px;
|
||
animation:${NS}-fade .18s ease;}
|
||
.${NS}-panel{width:440px;max-width:100%;max-height:80vh;display:flex;flex-direction:column;
|
||
background:rgba(255,255,255,.88);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
|
||
border:1px solid rgba(255,255,255,.6);border-radius:18px;
|
||
box-shadow:0 20px 60px rgba(15,23,42,.28);overflow:hidden;animation:${NS}-pop .2s ease;}
|
||
.${NS}-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;
|
||
padding:18px 20px 14px;border-bottom:1px solid rgba(15,23,42,.08);}
|
||
.${NS}-title{margin:0;font-size:16px;font-weight:600;color:#0f172a;line-height:1.3;}
|
||
.${NS}-sub{margin:4px 0 0;font-size:12.5px;color:#475569;line-height:1.4;}
|
||
.${NS}-x{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
|
||
border:none;border-radius:9px;background:transparent;color:#64748b;cursor:pointer;
|
||
transition:background .2s ease,color .2s ease;}
|
||
.${NS}-x:hover{background:rgba(15,23,42,.06);color:#0f172a;}
|
||
.${NS}-x:focus-visible{outline:2px solid #fe2c55;outline-offset:1px;}
|
||
.${NS}-x svg{width:18px;height:18px;}
|
||
|
||
.${NS}-body{padding:14px 16px;overflow:auto;}
|
||
.${NS}-acc{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid #e2e8f0;
|
||
border-radius:12px;margin-bottom:10px;cursor:pointer;background:#fff;
|
||
transition:border-color .2s ease,background .2s ease,box-shadow .2s ease;}
|
||
.${NS}-acc:last-child{margin-bottom:0;}
|
||
.${NS}-acc:hover{border-color:#fe2c55;background:#fff5f7;box-shadow:0 4px 14px rgba(254,44,85,.12);}
|
||
.${NS}-acc:focus-visible{outline:2px solid #fe2c55;outline-offset:1px;}
|
||
.${NS}-acc[aria-disabled="true"]{opacity:.7;cursor:default;border-color:#e2e8f0;background:#fff;box-shadow:none;}
|
||
.${NS}-ava{flex:none;width:40px;height:40px;border-radius:50%;object-fit:cover;
|
||
display:flex;align-items:center;justify-content:center;font-weight:600;color:#fff;font-size:15px;
|
||
background:linear-gradient(135deg,#fe2c55,#ff7a59);overflow:hidden;}
|
||
.${NS}-ava img{width:100%;height:100%;object-fit:cover;}
|
||
.${NS}-info{flex:1;min-width:0;}
|
||
.${NS}-name{font-size:14px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.${NS}-meta{display:flex;align-items:center;gap:8px;margin-top:4px;font-size:12px;color:#475569;}
|
||
.${NS}-dot{display:inline-flex;align-items:center;gap:5px;}
|
||
.${NS}-dot i{width:7px;height:7px;border-radius:50%;display:inline-block;}
|
||
.${NS}-chip{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:500;}
|
||
.${NS}-chip.ok{background:#dcfce7;color:#15803d;}
|
||
.${NS}-chip.no{background:#fee2e2;color:#b91c1c;}
|
||
.${NS}-act{flex:none;color:#cbd5e1;display:inline-flex;transition:color .2s ease,transform .2s ease;}
|
||
.${NS}-acc:hover .${NS}-act{color:#fe2c55;transform:translateX(2px);}
|
||
.${NS}-act svg{width:18px;height:18px;}
|
||
|
||
.${NS}-spin{width:18px;height:18px;border:2px solid rgba(254,44,85,.25);border-top-color:#fe2c55;
|
||
border-radius:50%;animation:${NS}-rot .7s linear infinite;}
|
||
.${NS}-sk{height:62px;border-radius:12px;margin-bottom:10px;
|
||
background:linear-gradient(90deg,#f1f5f9 25%,#e8edf3 37%,#f1f5f9 63%);
|
||
background-size:400% 100%;animation:${NS}-sh 1.3s ease infinite;}
|
||
.${NS}-empty{padding:26px 8px;text-align:center;color:#64748b;font-size:13px;}
|
||
.${NS}-toast{display:flex;align-items:center;gap:8px;margin:0 16px 14px;padding:10px 12px;
|
||
border-radius:10px;font-size:12.5px;line-height:1.4;background:#fef2f2;color:#b91c1c;border:1px solid #fecaca;}
|
||
.${NS}-toast svg{width:16px;height:16px;flex:none;}
|
||
|
||
@keyframes ${NS}-fade{from{opacity:0}to{opacity:1}}
|
||
@keyframes ${NS}-pop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
||
@keyframes ${NS}-rot{to{transform:rotate(360deg)}}
|
||
@keyframes ${NS}-sh{0%{background-position:100% 0}100%{background-position:-100% 0}}
|
||
@media (prefers-reduced-motion: reduce){
|
||
.${NS}-mask,.${NS}-panel{animation:none!important;}
|
||
.${NS}-sk{animation:none!important;}
|
||
.${NS}-btn,.${NS}-acc,.${NS}-act,.${NS}-x{transition:none!important;}
|
||
}
|
||
`;
|
||
|
||
function ensureRoot() {
|
||
if (ROOT && ROOT.host && ROOT.host.isConnected) return ROOT;
|
||
var host = document.createElement('div');
|
||
host.id = NS + '-host';
|
||
document.body.appendChild(host);
|
||
ROOT = host.attachShadow({ mode: 'open' });
|
||
var s = document.createElement('style');
|
||
s.textContent = STYLE;
|
||
ROOT.appendChild(s);
|
||
return ROOT;
|
||
}
|
||
|
||
function closePanel() {
|
||
if (ROOT) {
|
||
var m = ROOT.querySelector('.' + NS + '-mask');
|
||
if (m) m.remove();
|
||
}
|
||
document.removeEventListener('keydown', onKey);
|
||
}
|
||
function onKey(e) { if (e.key === 'Escape') closePanel(); }
|
||
|
||
function statusInfo(s) {
|
||
s = String(s || '').toLowerCase();
|
||
if (s === 'online') return { c: '#22c55e', t: '在线' };
|
||
if (s === 'logging_in') return { c: '#f59e0b', t: '登录中' };
|
||
if (s === 'error') return { c: '#ef4444', t: '异常' };
|
||
return { c: '#94a3b8', t: '离线' };
|
||
}
|
||
|
||
function accountRow(a) {
|
||
var name = a.username || a.phone || ('账号 ' + a.id);
|
||
var hasCk = !!(a.has_cookie || a.cookie_count);
|
||
var st = statusInfo(a.status);
|
||
var initial = esc((name || '?').trim().charAt(0).toUpperCase());
|
||
var avatar = a.avatar_url
|
||
? '<span class="' + NS + '-ava"><img src="' + esc(a.avatar_url) + '" alt="" referrerpolicy="no-referrer"></span>'
|
||
: '<span class="' + NS + '-ava">' + initial + '</span>';
|
||
|
||
var row = document.createElement('div');
|
||
row.className = NS + '-acc';
|
||
row.setAttribute('role', 'button');
|
||
row.setAttribute('tabindex', '0');
|
||
row.innerHTML = avatar
|
||
+ '<div class="' + NS + '-info">'
|
||
+ '<div class="' + NS + '-name">' + esc(name) + '</div>'
|
||
+ '<div class="' + NS + '-meta">'
|
||
+ '<span class="' + NS + '-dot"><i style="background:' + st.c + '"></i>' + st.t + '</span>'
|
||
+ '<span class="' + NS + '-chip ' + (hasCk ? 'ok' : 'no') + '">' + (hasCk ? '已就绪' : '无登录态') + '</span>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<span class="' + NS + '-act">' + ICON.chevron + '</span>';
|
||
return row;
|
||
}
|
||
|
||
function rowBusy(row, text) {
|
||
row.setAttribute('aria-disabled', 'true');
|
||
var act = row.querySelector('.' + NS + '-act');
|
||
if (act) act.innerHTML = '<span class="' + NS + '-spin"></span>';
|
||
var meta = row.querySelector('.' + NS + '-meta');
|
||
if (meta) meta.innerHTML = '<span class="' + NS + '-dot"><i style="background:#fe2c55"></i>' + esc(text) + '</span>';
|
||
}
|
||
|
||
function showToast(panel, msg) {
|
||
var old = panel.querySelector('.' + NS + '-toast');
|
||
if (old) old.remove();
|
||
var t = document.createElement('div');
|
||
t.className = NS + '-toast';
|
||
t.setAttribute('role', 'alert');
|
||
t.innerHTML = ICON.alert + '<span>' + esc(msg) + '</span>';
|
||
var body = panel.querySelector('.' + NS + '-body');
|
||
panel.insertBefore(t, body);
|
||
}
|
||
|
||
async function openAccount(acc, row, panel) {
|
||
var bridge = api();
|
||
if (!bridge) { showToast(panel, '本机桥未就绪,请重启桌面软件后重试。'); return; }
|
||
rowBusy(row, '正在拉取登录态…');
|
||
try {
|
||
var ck = await fetchJson('/accounts/' + acc.id + '/cookie');
|
||
if (!ck || !ck.cookie_data) {
|
||
showToast(panel, '该账号在服务器上没有登录态,请先在后台扫码登录或导入 Cookie。');
|
||
closePanel();
|
||
return;
|
||
}
|
||
rowBusy(row, '正在本机打开浏览器…');
|
||
var res = await bridge.open_douyin(ck.cookie_data);
|
||
if (res && res.ok) {
|
||
rowBusy(row, '已打开浏览器');
|
||
setTimeout(closePanel, 700);
|
||
} else {
|
||
showToast(panel, '打开失败:' + ((res && res.message) ? res.message : '未知错误'));
|
||
row.removeAttribute('aria-disabled');
|
||
var act = row.querySelector('.' + NS + '-act');
|
||
if (act) act.innerHTML = ICON.chevron;
|
||
}
|
||
} catch (e) {
|
||
showToast(panel, '打开失败:' + (e && e.message ? e.message : e));
|
||
}
|
||
}
|
||
|
||
function buildPanel(root) {
|
||
var mask = document.createElement('div');
|
||
mask.className = NS + '-mask';
|
||
mask.setAttribute('role', 'dialog');
|
||
mask.setAttribute('aria-modal', 'true');
|
||
mask.addEventListener('click', function (e) { if (e.target === mask) closePanel(); });
|
||
|
||
var panel = document.createElement('div');
|
||
panel.className = NS + '-panel';
|
||
panel.innerHTML =
|
||
'<div class="' + NS + '-head">'
|
||
+ '<div><h3 class="' + NS + '-title">选择要本地登录的托管账号</h3>'
|
||
+ '<p class="' + NS + '-sub">将在你本机打开一个已登录该账号的浏览器</p></div>'
|
||
+ '<button class="' + NS + '-x" aria-label="关闭">' + ICON.close + '</button>'
|
||
+ '</div>'
|
||
+ '<div class="' + NS + '-body">'
|
||
+ '<div class="' + NS + '-sk"></div><div class="' + NS + '-sk"></div><div class="' + NS + '-sk"></div>'
|
||
+ '</div>';
|
||
mask.appendChild(panel);
|
||
panel.querySelector('.' + NS + '-x').addEventListener('click', closePanel);
|
||
root.appendChild(mask);
|
||
return { mask: mask, panel: panel, body: panel.querySelector('.' + NS + '-body') };
|
||
}
|
||
|
||
async function showPanel() {
|
||
var root = ensureRoot();
|
||
closePanel();
|
||
if (!token()) {
|
||
var ui0 = buildPanel(root);
|
||
ui0.body.innerHTML = '<div class="' + NS + '-empty">请先在本页面登录后台账号,再点「一键本地登录」。</div>';
|
||
document.addEventListener('keydown', onKey);
|
||
return;
|
||
}
|
||
var ui = buildPanel(root);
|
||
document.addEventListener('keydown', onKey);
|
||
|
||
try {
|
||
var accounts = await fetchJson('/accounts');
|
||
if (!accounts || !accounts.length) {
|
||
ui.body.innerHTML = '<div class="' + NS + '-empty">还没有托管账号,请先在后台添加。</div>';
|
||
return;
|
||
}
|
||
ui.body.innerHTML = '';
|
||
accounts.forEach(function (a) {
|
||
var row = accountRow(a);
|
||
var go = function () {
|
||
if (row.getAttribute('aria-disabled') === 'true') return;
|
||
openAccount(a, row, ui.panel);
|
||
};
|
||
row.addEventListener('click', go);
|
||
row.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
|
||
});
|
||
ui.body.appendChild(row);
|
||
});
|
||
} catch (e) {
|
||
ui.body.innerHTML = '<div class="' + NS + '-empty">加载账号失败:' + esc(e && e.message ? e.message : e) + '</div>';
|
||
}
|
||
}
|
||
|
||
function ensureButton() {
|
||
var root = ensureRoot();
|
||
if (!root.querySelector('.' + NS + '-btn')) {
|
||
var btn = document.createElement('button');
|
||
btn.className = NS + '-btn';
|
||
btn.innerHTML = ICON.login + '<span>一键本地登录</span>';
|
||
btn.title = '在本机打开已登录托管账号的抖音浏览器';
|
||
btn.addEventListener('click', showPanel);
|
||
root.appendChild(btn);
|
||
}
|
||
if (!root.querySelector('.' + NS + '-switch')) {
|
||
var sw = document.createElement('button');
|
||
sw.className = NS + '-switch';
|
||
sw.innerHTML = ICON.swap + '<span>切换站点</span>';
|
||
sw.title = '返回站点选择界面';
|
||
sw.addEventListener('click', function () {
|
||
var a = api();
|
||
if (a && a.go_picker) a.go_picker();
|
||
});
|
||
root.appendChild(sw);
|
||
}
|
||
}
|
||
|
||
// 供 Python 端回报本地登录错误:右下角弹出可关闭的提示条
|
||
window.__dylNotify = function (msg, ok) {
|
||
try {
|
||
var root = ensureRoot();
|
||
var old = root.querySelector('.' + NS + '-flash');
|
||
if (old) old.remove();
|
||
var t = document.createElement('div');
|
||
t.className = NS + '-flash';
|
||
t.setAttribute('role', 'alert');
|
||
t.style.cssText = 'position:fixed;right:24px;bottom:84px;z-index:2147483647;max-width:360px;'
|
||
+ 'display:flex;align-items:flex-start;gap:8px;padding:12px 14px;border-radius:12px;'
|
||
+ 'font-size:13px;line-height:1.5;color:#fff;box-shadow:0 10px 30px rgba(0,0,0,.35);'
|
||
+ 'background:' + (ok ? '#16a34a' : '#dc2626') + ';animation:' + NS + '-pop .2s ease;';
|
||
t.innerHTML = (ok ? '' : ICON.alert) + '<span>' + esc(msg) + '</span>';
|
||
root.appendChild(t);
|
||
setTimeout(function () { if (t && t.parentNode) t.remove(); }, 8000);
|
||
} catch (e) {}
|
||
};
|
||
|
||
// ---- 在线升级浮层(启动后异步检测,不阻塞窗口) ----
|
||
function updModal() { return ROOT && ROOT.querySelector('.' + NS + '-upd'); }
|
||
window.__updProgress = function (p) {
|
||
var m = updModal(); if (!m) return;
|
||
var bar = m.querySelector('.' + NS + '-bar');
|
||
var fill = m.querySelector('.' + NS + '-fill');
|
||
var pct = m.querySelector('.' + NS + '-pct');
|
||
if (bar) bar.style.display = 'block';
|
||
if (fill) fill.style.width = p + '%';
|
||
if (pct) { pct.style.display = 'block'; pct.textContent = p < 100 ? ('正在下载… ' + p + '%') : '下载完成,正在打开安装程序…'; }
|
||
};
|
||
window.__updError = function (msg) {
|
||
var m = updModal(); if (!m) return;
|
||
var pct = m.querySelector('.' + NS + '-pct');
|
||
if (pct) { pct.style.display = 'block'; pct.textContent = '升级失败:' + msg; }
|
||
var b = m.querySelector('.' + NS + '-up'); if (b) { b.disabled = false; b.textContent = '重试升级'; }
|
||
var s = m.querySelector('.' + NS + '-skip'); if (s) s.disabled = false;
|
||
};
|
||
window.__dylShowUpdate = function (version, notes, force) {
|
||
var root = ensureRoot();
|
||
if (updModal()) return;
|
||
var btnCss = 'border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;';
|
||
var mask = document.createElement('div');
|
||
mask.className = NS + '-mask ' + NS + '-upd';
|
||
mask.setAttribute('role', 'dialog');
|
||
mask.setAttribute('aria-modal', 'true');
|
||
var skipBtn = force ? '' :
|
||
'<button class="' + NS + '-skip" style="' + btnCss + 'background:#f1f5f9;color:#475569;">稍后再说</button>';
|
||
mask.innerHTML =
|
||
'<div class="' + NS + '-panel" style="width:440px;">'
|
||
+ '<div class="' + NS + '-head"><div>'
|
||
+ '<h3 class="' + NS + '-title">发现新版本 v' + esc(version) + '</h3>'
|
||
+ '<p class="' + NS + '-sub">' + (force ? '需要升级到最新版本后继续使用' : '建议升级到最新版本') + '</p>'
|
||
+ '</div></div>'
|
||
+ '<div class="' + NS + '-body">'
|
||
+ '<div style="white-space:pre-wrap;font-size:13.5px;line-height:1.6;color:#334155;background:#f8fafc;border:1px solid #eef2f7;border-radius:10px;padding:12px;max-height:180px;overflow:auto;">' + esc(notes || '暂无更新说明') + '</div>'
|
||
+ '<div class="' + NS + '-bar" style="display:none;height:8px;border-radius:999px;background:#eef2f7;overflow:hidden;margin:14px 0 6px;"><i class="' + NS + '-fill" style="display:block;height:100%;width:0;background:linear-gradient(90deg,#fe2c55,#ff7a59);transition:width .2s ease;"></i></div>'
|
||
+ '<div class="' + NS + '-pct" style="display:none;font-size:12px;color:#64748b;"></div>'
|
||
+ '</div>'
|
||
+ '<div style="display:flex;gap:10px;justify-content:flex-end;padding:14px 16px 18px;">' + skipBtn
|
||
+ '<button class="' + NS + '-up" style="' + btnCss + 'background:#fe2c55;color:#fff;">立即升级</button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
root.appendChild(mask);
|
||
var up = mask.querySelector('.' + NS + '-up');
|
||
up.addEventListener('click', function () {
|
||
up.disabled = true; up.textContent = '升级中…';
|
||
var s = mask.querySelector('.' + NS + '-skip'); if (s) s.disabled = true;
|
||
var a = api(); if (a) a.upd_start();
|
||
});
|
||
var sk = mask.querySelector('.' + NS + '-skip');
|
||
if (sk) sk.addEventListener('click', function () { mask.remove(); });
|
||
};
|
||
|
||
ensureButton();
|
||
// 宿主为单页应用,路由切换后若 host 被移除则重建
|
||
setInterval(ensureButton, 2000);
|
||
})();
|
||
"""
|
||
|
||
|
||
class Api:
|
||
"""暴露给网页 JS 的本机能力(含本地登录 + 在线升级)。"""
|
||
|
||
def __init__(self) -> None:
|
||
# 注意:必须用下划线前缀,否则 pywebview 在枚举 js_api 方法时会递归进
|
||
# 这个 Window 对象并抛异常,导致所有 API 方法都暴露失败
|
||
# (表现为 open_douyin is not a function)。
|
||
self._window = None
|
||
self._update_info: dict | None = None
|
||
|
||
# ---- 站点选择 ----
|
||
def get_sites(self) -> dict:
|
||
cfg = load_config()
|
||
return {"sites": get_all_sites(), "remember": bool(cfg.get("default_url"))}
|
||
|
||
def add_site(self, name: str, url: str) -> dict:
|
||
u = normalize_site_url(url)
|
||
host = urlparse(u).hostname if u else None
|
||
if not u or not host or "." not in host:
|
||
return {"error": "网址格式不对,示例:https://dev1.zhenyangtang.com.cn/", "sites": get_all_sites()}
|
||
if any(s["url"] == u for s in get_all_sites()):
|
||
return {"error": "该站点已存在", "sites": get_all_sites()}
|
||
cfg = load_config()
|
||
custom = cfg.get("custom_sites", [])
|
||
custom.append({"name": (name or "").strip() or host, "url": u})
|
||
cfg["custom_sites"] = custom
|
||
save_config(cfg)
|
||
return {"sites": get_all_sites()}
|
||
|
||
def remove_site(self, url: str) -> dict:
|
||
u = normalize_site_url(url)
|
||
cfg = load_config()
|
||
cfg["custom_sites"] = [
|
||
s for s in cfg.get("custom_sites", []) if normalize_site_url(s.get("url", "")) != u
|
||
]
|
||
if normalize_site_url(cfg.get("default_url", "")) == u:
|
||
cfg.pop("default_url", None)
|
||
save_config(cfg)
|
||
return {"sites": get_all_sites()}
|
||
|
||
def ping_site(self, url: str) -> dict:
|
||
"""测量站点响应时间(毫秒)。服务器有响应即算可达,包括 4xx/5xx。"""
|
||
import time
|
||
from urllib import error as _err, request as _req
|
||
|
||
u = normalize_site_url(url)
|
||
if not u:
|
||
return {"ok": False}
|
||
req = _req.Request(u, method="HEAD", headers={"User-Agent": "Mozilla/5.0"})
|
||
start = time.perf_counter()
|
||
try:
|
||
with _req.urlopen(req, timeout=8):
|
||
pass
|
||
except _err.HTTPError:
|
||
pass # 服务器已响应(如 403/405),延迟有效
|
||
except Exception: # noqa: BLE001
|
||
return {"ok": False}
|
||
return {"ok": True, "ms": int((time.perf_counter() - start) * 1000)}
|
||
|
||
def open_site(self, url: str, remember: bool = False) -> dict:
|
||
u = normalize_site_url(url)
|
||
cfg = load_config()
|
||
if remember:
|
||
cfg["default_url"] = u
|
||
else:
|
||
cfg.pop("default_url", None)
|
||
save_config(cfg)
|
||
if self._window:
|
||
self._window.load_url(u)
|
||
return {"ok": True}
|
||
|
||
def go_picker(self) -> dict:
|
||
"""从站点页面返回选择界面,并取消“记住选择”。"""
|
||
cfg = load_config()
|
||
cfg.pop("default_url", None)
|
||
save_config(cfg)
|
||
if self._window:
|
||
self._window.load_html(PICKER_HTML)
|
||
return {"ok": True}
|
||
|
||
# ---- 本地登录 ----
|
||
def _notify(self, msg: str, ok: bool = False) -> None:
|
||
if not self._window:
|
||
return
|
||
safe = msg.replace("\\", " ").replace('"', "'").replace("\n", " ")[:200]
|
||
try:
|
||
self._window.evaluate_js(
|
||
f'window.__dylNotify && window.__dylNotify("{safe}", {"true" if ok else "false"})'
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def open_douyin(self, cookie_data) -> dict:
|
||
"""收到某账号的 storage_state,本机打开可见浏览器。
|
||
|
||
同步等待「浏览器是否成功弹出」的结果再返回,让前端能明确提示成功/失败;
|
||
浏览器保活(等待用户关闭)在后台线程里继续,不阻塞界面。
|
||
pywebview 的 JS API 调用本身跑在工作线程,这里阻塞等待不会卡住窗口。
|
||
"""
|
||
result = {"ok": False, "message": ""}
|
||
done = threading.Event()
|
||
|
||
def run():
|
||
def on_ready():
|
||
result["ok"] = True
|
||
done.set()
|
||
|
||
def on_error(msg):
|
||
result["message"] = msg
|
||
done.set()
|
||
|
||
try:
|
||
storage_state, user_agent = normalize_storage_state(cookie_data)
|
||
open_logged_in_browser(
|
||
storage_state, user_agent, log=print,
|
||
on_ready=on_ready, on_error=on_error,
|
||
)
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[本地登录] 打开失败:{e}")
|
||
result["message"] = str(e)
|
||
finally:
|
||
done.set()
|
||
|
||
threading.Thread(target=run, daemon=True).start()
|
||
# 启动浏览器一般几秒内弹出;给足时间,超时也返回受控提示。
|
||
if not done.wait(timeout=90):
|
||
return {"ok": False, "message": "启动浏览器超时,请重试或重装软件"}
|
||
return {"ok": bool(result["ok"]), "message": result["message"]}
|
||
|
||
# ---- 在线升级 ----
|
||
def upd_start(self) -> dict:
|
||
"""开始下载安装包,完成后静默运行安装程序并退出当前进程。"""
|
||
info = self._update_info or {}
|
||
url = info.get("url")
|
||
if not url:
|
||
return {"ok": False}
|
||
|
||
def run():
|
||
def progress(p):
|
||
if self._window:
|
||
try:
|
||
self._window.evaluate_js(f"window.__updProgress && window.__updProgress({int(p)})")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
try:
|
||
installer = download_installer(url, progress_cb=progress)
|
||
except Exception as e: # noqa: BLE001
|
||
if self._window:
|
||
msg = str(e).replace("\\", " ").replace('"', "'")[:120]
|
||
try:
|
||
self._window.evaluate_js(f'window.__updError && window.__updError("{msg}")')
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return
|
||
try:
|
||
# 静默安装并在完成后自动重新启动(installer.iss 的 [Run] 已去掉 skipifsilent)
|
||
subprocess.Popen([installer, "/SILENT", "/NORESTART"], close_fds=True)
|
||
except Exception: # noqa: BLE001
|
||
try:
|
||
os.startfile(installer) # type: ignore[attr-defined]
|
||
except Exception: # noqa: BLE001
|
||
return
|
||
# 退出当前程序,释放文件占用,让安装程序覆盖更新
|
||
os._exit(0)
|
||
|
||
threading.Thread(target=run, daemon=True).start()
|
||
return {"ok": True}
|
||
|
||
|
||
def _apply_dark_titlebar() -> None:
|
||
"""把窗口标题栏改成深色,匹配应用的深色主题(Win10 2004+/Win11)。"""
|
||
try:
|
||
hwnd = ctypes.windll.user32.FindWindowW(None, WINDOW_TITLE)
|
||
if not hwnd:
|
||
return
|
||
dwm = ctypes.windll.dwmapi
|
||
# DWMWA_USE_IMMERSIVE_DARK_MODE:20(新版)/ 19(旧版),开启深色标题栏
|
||
enabled = ctypes.c_int(1)
|
||
for attr in (20, 19):
|
||
dwm.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(enabled), ctypes.sizeof(enabled))
|
||
# Win11 可进一步指定标题栏配色,匹配主题深紫黑 #1A1226(COLORREF=0x00BBGGRR)
|
||
caption = ctypes.c_int(0x0026121A)
|
||
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(caption), ctypes.sizeof(caption))
|
||
text = ctypes.c_int(0x00FFFFFF)
|
||
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(text), ctypes.sizeof(text))
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def _check_update_async(window, api) -> None:
|
||
"""后台线程检查更新,避免阻塞启动。有新版则在页面上弹出升级浮层。"""
|
||
try:
|
||
info = check_update()
|
||
except Exception: # noqa: BLE001
|
||
info = None
|
||
if not info:
|
||
return
|
||
api._update_info = info
|
||
import json as _json
|
||
|
||
version = _json.dumps(info.get("version", ""))
|
||
notes = _json.dumps(info.get("notes", ""))
|
||
force = "true" if info.get("force") else "false"
|
||
try:
|
||
window.evaluate_js(f"window.__dylShowUpdate && window.__dylShowUpdate({version},{notes},{force})")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def main() -> None:
|
||
api = Api()
|
||
# 上次勾选了“记住选择”则直接进该站点,否则先显示站点选择界面。
|
||
# 更新检测放到后台线程做,避免启动时同步联网卡住窗口。
|
||
cfg = load_config()
|
||
default_url = normalize_site_url(cfg.get("default_url", ""))
|
||
if default_url and any(s["url"] == default_url for s in get_all_sites()):
|
||
window = webview.create_window(
|
||
WINDOW_TITLE,
|
||
default_url,
|
||
js_api=api,
|
||
width=1280,
|
||
height=860,
|
||
text_select=True,
|
||
)
|
||
else:
|
||
window = webview.create_window(
|
||
WINDOW_TITLE,
|
||
html=PICKER_HTML,
|
||
js_api=api,
|
||
width=1280,
|
||
height=860,
|
||
text_select=True,
|
||
)
|
||
api._window = window
|
||
_update_checked = {"done": False}
|
||
|
||
def on_loaded():
|
||
# 仅在已配置站点的页面注入悬浮按钮脚本;选择页/about:blank 等不注入。
|
||
try:
|
||
current = window.get_current_url() or ""
|
||
except Exception: # noqa: BLE001
|
||
current = ""
|
||
host = urlparse(current).hostname or ""
|
||
if host and host in site_hosts():
|
||
try:
|
||
window.evaluate_js(INJECT_JS)
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"注入脚本失败:{e}")
|
||
if not _update_checked["done"]:
|
||
_update_checked["done"] = True
|
||
threading.Thread(
|
||
target=_check_update_async, args=(window, api), daemon=True
|
||
).start()
|
||
|
||
def on_shown():
|
||
_apply_dark_titlebar()
|
||
|
||
window.events.loaded += on_loaded
|
||
window.events.shown += on_shown
|
||
webview.start()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|