Files
douyin/douyin-login-launcher/desktop_app.py
T
2026-07-17 10:21:18 +08:00

965 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""抖音托管客服 · 桌面整合版
一个软件搞定一切:
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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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_MODE20(新版)/ 19(旧版),开启深色标题栏
enabled = ctypes.c_int(1)
for attr in (20, 19):
dwm.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(enabled), ctypes.sizeof(enabled))
# Win11 可进一步指定标题栏配色,匹配主题深紫黑 #1A1226COLORREF=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()