Skip to content

Commit 45b9f38

Browse files
HarryXin0919claude
andcommitted
Native app window via pywebview (no terminal, no browser)
The packaged app now opens in its own window instead of popping a console and a browser tab. Built windowed (console=False); the entry starts the local server then opens a pywebview window, falling back to the browser if no system WebView is available (e.g. Linux without WebKitGTK). - viralens_app.py: stdout-rebind shim so windowed --vl-exec child processes still stream output to the parent's captured pipe (devnull for the windowless main process); dispatch workers before opening any window. - app.py: add non-blocking start_server(); main() now blocks on an Event. - spec: console=False; bundle pywebview when present, else browser-fallback. - pyproject [gui] extra = pywebview; CI installs it on Windows/macOS only. - Verified on Windows: native WebView2 window renders, child stdout streams. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9063387 commit 45b9f38

7 files changed

Lines changed: 92 additions & 16 deletions

File tree

.github/workflows/release.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ jobs:
3838
python -m pip install -e .
3939
python -m pip install pyinstaller
4040
41+
# 原生窗口后端只在 Windows/macOS 装(用系统自带 webview)。Linux 的 GTK webview
42+
# 打包困难,故不装 —— Linux 包会自动回退到「开浏览器」(见 viralens_app.py)。
43+
- name: Install native-window backend (Windows/macOS only)
44+
if: runner.os != 'Linux'
45+
run: python -m pip install pywebview
46+
4147
- name: Build (PyInstaller, onedir)
4248
run: python -m PyInstaller --noconfirm --clean packaging/viralens.spec
4349

@@ -91,6 +97,6 @@ jobs:
9197
> - **Windows**:若弹出「Windows 已保护你的电脑」,点 **更多信息 → 仍要运行**。
9298
> - **macOS**:先试 **右键 → 打开**;若提示「已损坏 / 无法验证开发者」(macOS 15 Sequoia 起常见),改去 **系统设置 → 隐私与安全性**,在底部点「**仍要打开**」;或在终端执行 `xattr -dr com.apple.quarantine /把/viralens.app/拖进来` 后再双击。
9399
94-
启动后浏览器会自动打开界面。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。
100+
启动后会直接弹出 **viralens 应用窗口**(没有终端、没有浏览器标签页;没装系统 WebView 的部分 Linux 环境会自动回退到用浏览器打开)。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。
95101
96102
> 可选功能「下视频分析开场镜头 + 配乐」需要自行安装 **ffmpeg**(其余抓取 / 分析 / 报告功能都无需它)。

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ because `play_per_day` inflates new uploads) caught two real declines: **a lifes
136136
### Easiest: download the app — no Python, no install
137137

138138
Grab the build for your OS from the **[latest release](https://github.com/HarryXin0919/viralens/releases/latest)**,
139-
unzip, and double-click. It opens the same local web UI in your browser — nothing leaves your machine.
139+
unzip, and double-click. It opens in **its own app window** — no terminal, no browser tab; nothing leaves your machine.
140+
(On a Linux box without a system WebView, it falls back to opening the UI in your browser.)
140141

141142
| OS | File | Open it |
142143
|---|---|---|

packaging/build-local.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ else { Write-Error "没找到 Python。先装 Python 3.10+ 并勾选 Add to PATH
1515

1616
Write-Host "[*] 用解释器:" -NoNewline; & $py @pyArgs --version
1717

18-
Write-Host "[*] 安装运行依赖(来自 pyproject)+ PyInstaller ..."
18+
Write-Host "[*] 安装运行依赖(来自 pyproject)+ 原生窗口后端 + PyInstaller ..."
1919
& $py @pyArgs -m pip install --upgrade pip
20-
& $py @pyArgs -m pip install -e .
20+
& $py @pyArgs -m pip install -e ".[gui]" # [gui] = pywebview,双击弹原生窗口
2121
& $py @pyArgs -m pip install pyinstaller
2222

2323
Write-Host "[*] 打包(onedir)..."

packaging/viralens.spec

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ for pkg in ("bilibili_api", "aiohttp", "jieba"):
3131
# matplotlib / numpy / Pillow 自带 PyInstaller hook,会自动带数据;这里补 Agg 后端保险
3232
hiddenimports += ["matplotlib.backends.backend_agg", "numpy", "PIL"]
3333

34+
# 原生窗口后端(可选):装了 pywebview 就连同其平台后端一起收进来,实现「双击弹原生窗口」;
35+
# 没装(比如 Linux 不打包 webview)则打成「开浏览器」回退版 —— 入口 viralens_app.py 会自动判断。
36+
try:
37+
import webview # noqa: F401
38+
_d, _b, _h = collect_all("webview")
39+
datas += _d
40+
binaries += _b
41+
hiddenimports += _h
42+
except Exception:
43+
pass
44+
3445
# —— 项目自己的脚本 ——
3546
# app 懒加载它们、viralens 通过 `--vl-exec <模块名>` 用 runpy 跑它们,
3647
# 静态分析有可能看不全,这里全部显式声明,确保都被冻进去。
@@ -78,7 +89,7 @@ exe = EXE(
7889
bootloader_ignore_signals=False,
7990
strip=False,
8091
upx=False,
81-
console=True, # 保留控制台:子进程 stdout 走管道给界面看进度 + 「关窗即停止」
92+
console=False, # 窗口模式:不弹终端。子进程 stdout 由 viralens_app._ensure_std() 接回管道
8293
icon=(ICON if os.path.exists(ICON) else None),
8394
)
8495

packaging/viralens_app.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,66 @@
11
"""
22
viralens · 打包成桌面 app 时的入口(被 PyInstaller 冻结,见 viralens.spec)。
33
4+
目标:双击直接弹出一个**原生应用窗口**(pywebview)——没有终端、没有浏览器标签页。
5+
缺原生 webview 运行时(如部分 Linux 没装 WebKitGTK)时,自动回退到「开浏览器」。
6+
47
两种角色,靠命令行第一个参数区分:
5-
· 正常双击启动 → 起本地网页界面(app.main()),浏览器自动打开。
6-
· 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:这是流水线某一步,
7-
由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出。
8-
(源码模式下这一步等价于 `python scripts/fetch_multi.py --force`。)
8+
· 正常双击启动 → 起本地服务器 + 开原生窗口(或回退浏览器)。
9+
· 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:流水线某一步,
10+
由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出(不开窗口)。
911
10-
为什么要这样:整个工具是一串脚本用 subprocess 互相调起来的。打包后 sys.executable
11-
变成 app 自己而不是 Python,所以让 app 自己充当「Python」——带上 --vl-exec 再跑一遍。
12+
注意:本 app 以「窗口模式」(console=False)打包,没有控制台。下面 _ensure_std() 负责:
13+
· 子步骤进程(被父进程用管道收 stdout 显示进度)→ 把标准输出接回那条管道;
14+
· 主窗口进程(双击,没有任何 std 句柄)→ 接到 devnull,避免 print 崩溃。
1215
"""
16+
import io
17+
import os
18+
import sys
19+
20+
21+
def _ensure_std():
22+
"""窗口模式下 sys.stdout/stderr 可能是 None。子进程的句柄是父进程给的管道(有效)→
23+
接回去让进度能被收集;主窗口进程没有有效句柄 → dup 失败,退到 devnull。"""
24+
for name, fd in (("stdout", 1), ("stderr", 2)):
25+
if getattr(sys, name, None) is None:
26+
try:
27+
stream = io.TextIOWrapper(os.fdopen(os.dup(fd), "wb"),
28+
encoding="utf-8", errors="replace", line_buffering=True)
29+
except Exception:
30+
stream = open(os.devnull, "w", encoding="utf-8", errors="replace")
31+
setattr(sys, name, stream)
32+
33+
34+
_ensure_std()
35+
1336
import runtime
1437

1538
runtime.bootstrap()
16-
runtime.dispatch_if_worker() # 若本进程是 --vl-exec 子步骤:跑完即退出,不会往下走
39+
runtime.dispatch_if_worker() # 若是 --vl-exec 子步骤:跑完即退出,绝不往下走(不开窗口)
1740

1841
import app
19-
app.main()
42+
43+
# —— 主进程:起服务器,开原生窗口;开不出来就回退浏览器 ——
44+
srv, url = app.start_server()
45+
46+
_opened = False
47+
try:
48+
import webview
49+
webview.create_window("viralens", url, width=1180, height=820, min_size=(900, 600))
50+
webview.start() # 阻塞,直到用户关掉窗口
51+
_opened = True
52+
except Exception:
53+
_opened = False
54+
55+
if not _opened:
56+
# 没有可用的原生 webview(如 Linux 缺 WebKitGTK)→ 开浏览器并挂住进程
57+
import threading
58+
import webbrowser
59+
try:
60+
webbrowser.open(url)
61+
except Exception:
62+
pass
63+
try:
64+
threading.Event().wait()
65+
except KeyboardInterrupt:
66+
pass

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ dependencies = [
2626
"numpy>=1.23.0",
2727
]
2828

29+
# 仅在「打包成桌面 app」时需要:原生窗口后端。源码 CLI / 网页界面都不依赖它。
30+
[project.optional-dependencies]
31+
gui = ["pywebview>=5.0"]
32+
2933
[project.urls]
3034
Homepage = "https://github.com/HarryXin0919/viralens"
3135
Repository = "https://github.com/HarryXin0919/viralens"

scripts/app.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,17 @@ def find_port(start=8722):
377377
return start
378378

379379

380-
def main():
380+
def start_server():
381+
"""起本地服务器(后台守护线程),立刻返回 (server, url)。
382+
打包后的「原生窗口 / 浏览器」入口用它先把服务器跑起来,再开窗口。"""
381383
port = find_port()
382384
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
383-
url = f"http://127.0.0.1:{port}"
385+
threading.Thread(target=srv.serve_forever, daemon=True).start()
386+
return srv, f"http://127.0.0.1:{port}"
387+
388+
389+
def main():
390+
srv, url = start_server()
384391
print("=" * 56)
385392
print(f" viralens 界面已启动 → {url}")
386393
print(" 浏览器没自动开就手动复制上面这个地址。")
@@ -391,7 +398,7 @@ def main():
391398
except Exception:
392399
pass
393400
try:
394-
srv.serve_forever()
401+
threading.Event().wait() # 一直挂着,等同 serve_forever(服务器在守护线程里)
395402
except KeyboardInterrupt:
396403
print("\n已停止。")
397404

0 commit comments

Comments
 (0)