Skip to content

Commit bf91bdd

Browse files
committed
3.0.19: silent auto-update + close-to-tray watchdog
Windows quiet/manual update: one confirm → download Latest Setup → cleanup_old.ps1 (kill/registry/leftovers) → /VERYSILENT install → relaunch --tray. Close (X) forces tray; NightDisplay_Watchdog relaunches tray unless user quit. Ship cleanup_old.ps1 in resources; CI gates it.
1 parent f584ac5 commit bf91bdd

12 files changed

Lines changed: 607 additions & 18 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ jobs:
9494
# 打包门禁:驱动文件必须齐全,缺一个就失败,不产出“能打开但没虚拟屏”的包。
9595
test -f nightdisplay/resources/install_vdd.ps1
9696
test -f nightdisplay/resources/vdd_settings.xml
97+
test -f nightdisplay/resources/cleanup_old.ps1
9798
./dist/NightDisplay.exe --action env
9899
mkdir out && cp dist/NightDisplay.exe README.md LICENSE NOTICE.md tools/Recover.cmd out/ && cp -r third-party-licenses out/
99100
cd out && 7z a ../NightDisplay-Windows-x64.zip .

‎README.md‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
1111
作者:传康Kk。赞助:微信 1837620622(请备注来意)。
1212

13-
[下载 3.0.18](https://github.com/CKNB888/NightDisplay/releases/tag/v3.0.18) · [所有版本](https://github.com/CKNB888/NightDisplay/releases) · [反馈问题](https://github.com/CKNB888/NightDisplay/issues)
13+
[下载 3.0.19](https://github.com/CKNB888/NightDisplay/releases/tag/v3.0.19) · [所有版本](https://github.com/CKNB888/NightDisplay/releases) · [反馈问题](https://github.com/CKNB888/NightDisplay/issues)
1414

1515
| 下载 | 说明 |
1616
|---|---|
17-
| [NightDisplay-Windows-x64-Setup.exe](https://github.com/CKNB888/NightDisplay/releases/download/v3.0.18/NightDisplay-Windows-x64-Setup.exe) | Windows 10 2004+/11 安装版,按当前用户安装 |
18-
| [NightDisplay-Windows-x64.zip](https://github.com/CKNB888/NightDisplay/releases/download/v3.0.18/NightDisplay-Windows-x64.zip) | 免安装版,解压即用 |
17+
| [NightDisplay-Windows-x64-Setup.exe](https://github.com/CKNB888/NightDisplay/releases/download/v3.0.19/NightDisplay-Windows-x64-Setup.exe) | Windows 10 2004+/11 安装版,按当前用户安装 |
18+
| [NightDisplay-Windows-x64.zip](https://github.com/CKNB888/NightDisplay/releases/download/v3.0.19/NightDisplay-Windows-x64.zip) | 免安装版,解压即用 |
1919

2020
## 适合什么场景
2121

@@ -25,9 +25,9 @@
2525

2626
## 界面
2727

28-
![NightDisplay 3.0.18 界面巡览(Windows 真实窗口)](docs/promo/ui-tour.gif)
28+
![NightDisplay 3.0.19 界面巡览(Windows 真实窗口)](docs/promo/ui-tour.gif)
2929

30-
深色、圆角卡片、左侧栏的 Mac 风格界面。上面的动图和下面的截图,都是 GitHub Actions 的 Windows 机器上运行 **3.0.18** 真实程序窗口截下来的(`tools/capture_ui.py`),不是效果图。侧栏版本号、设置里的「黑屏快捷键」等,都和当前安装包一致。
30+
深色、圆角卡片、左侧栏的 Mac 风格界面。上面的动图和下面的截图,都是 GitHub Actions 的 Windows 机器上运行 **3.0.19** 真实程序窗口截下来的(`tools/capture_ui.py`),不是效果图。侧栏版本号、设置里的「黑屏快捷键」等,都和当前安装包一致。
3131

3232
| 概览:倒计时圆环 | 黑屏中:距离亮屏 |
3333
|---|---|
@@ -43,6 +43,14 @@ Windows 默认优先使用常驻虚拟屏。进入夜间时,桌面只输出到
4343

4444
NightDisplay 只负责显示、电源和时间计划。远程连接仍由 UU、ToDesk 等软件提供,不绑定设备,也不限制客户端数量;远程软件自身的会话规则继续生效。
4545

46+
## 检查更新(Windows)
47+
48+
程序打开约 3 秒后会自动查一次 [GitHub Releases Latest](https://github.com/CKNB888/NightDisplay/releases/latest)。有新正式版时只问一次「立即更新?」:点是就自动下载官方 Setup、跑 `cleanup_old.ps1` 清旧进程/注册表/残留,再 `/VERYSILENT` 静默安装并拉起新版托盘。设置页「检查更新」同样。**不会在未确认时自动安装。**
49+
50+
## 点 X 进托盘 / 后台守护(Windows)
51+
52+
点窗口右上角关闭,程序进托盘后台,计划与开机自启都还在;真正退出请用托盘菜单「退出 NightDisplay」。Windows 还会注册每 5 分钟一次的 `NightDisplay_Watchdog`:常驻进程异常退出后会再拉起托盘;你主动退出后不会强行拉起。
53+
4654
## 快捷键(Windows)
4755

4856
| 按键 | 作用 |
@@ -192,6 +200,11 @@ GitHub Actions 只跑 Windows:Windows 2022 和 Windows latest 单元测试 + V
192200

193201
## 更新记录
194202

203+
### 3.0.19
204+
205+
- **自动检查更新(Windows)**:启动约 3 秒后查 GitHub Releases Latest;有新版只问一次「立即更新?」。确认后自动下载官方 Setup、复用 `cleanup_old.ps1` 清旧进程/注册表/残留,再 `/VERYSILENT` 静默安装并拉起新版。设置里「检查更新」同样。
206+
- **点 X 强制后台**:关闭窗口只进托盘;托盘起不来时不会把窗口藏死。Windows 注册 `NightDisplay_Watchdog`(每 5 分钟):常驻进程挂了会再拉 `--tray`;托盘「退出」会写标记,守护不再拉起,直到你再次打开。
207+
195208
### 3.0.18
196209

197210
- README 界面图全部换成 Windows CI 上真实运行截下的 **3.0.18** 窗口(概览 / 黑屏中 / 时间 / 设置 + 巡览 GIF;来自 Actions run [42015681585](https://github.com/CKNB888/NightDisplay/actions/runs/42015681585) 的 `ui-shots-windows-latest`),设置页能看到「黑屏快捷键」C + K。不是 3.0.15 那批旧图。

‎nightdisplay/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "3.0.18"
1+
VERSION = "3.0.19"
22
APP = "NightDisplay"
33
CREDIT = "开发与赞助:传康Kk,联系方式 1837620622"
44

‎nightdisplay/app.py‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def resident_action(command):
5050

5151
@B.serialized
5252
def activate(mode, start, end):
53+
B.clear_user_stopped()
5354
start, end = ["%02d:%02d" % parse_hm(t) for t in (start, end)]
5455
if parse_hm(start) == parse_hm(end):
5556
raise RuntimeError("开始和结束时间不能相同")
@@ -279,6 +280,11 @@ def uninstall(overlay_cb=None):
279280
B.set_autostart(False)
280281
except Exception as e:
281282
log("卸载时关闭开机自启失败:%s" % e)
283+
try:
284+
B.remove_watchdog()
285+
except Exception as e:
286+
log("卸载时删除后台守护失败:%s" % e)
287+
B.mark_user_stopped()
282288
log("已卸载计划任务和自启项;日志与备份保留在 %s" % home())
283289

284290

@@ -742,6 +748,11 @@ def run_app(show_window=True, selftest=0):
742748
import tkinter as tk
743749

744750
from . import chord, ui
751+
# 正常打开/托盘自启:清掉「用户退出」标记,并确保 Windows 守护任务在。
752+
B.clear_user_stopped()
753+
if IS_WIN and not selftest:
754+
with contextlib.suppress(Exception):
755+
B.ensure_watchdog()
745756
if IS_WIN:
746757
try:
747758
import ctypes
@@ -889,6 +900,8 @@ def _act(name, data):
889900
uninstall(overlay)
890901
root.after(50, root.destroy)
891902
elif name == "quit":
903+
# 用户主动退出:写标记,避免 5 分钟守护又把进程拉起来。
904+
B.mark_user_stopped()
892905
root.after(50, root.destroy)
893906
else:
894907
raise ValueError("未知操作:%s" % name)
@@ -1041,6 +1054,28 @@ def tick():
10411054
with contextlib.suppress(tk.TclError):
10421055
root.destroy()
10431056

1057+
1058+
def run_watchdog():
1059+
"""计划任务每几分钟调用:若用户未主动退出且常驻进程挂了,就再拉托盘。"""
1060+
if not IS_WIN:
1061+
return 0
1062+
if B.user_stopped():
1063+
return 0
1064+
cfg = load()
1065+
# 开机自启关着、每日计划也关着:不拉进程。用户主动退出后标记还在则上面已返回。
1066+
if not cfg.get("autostart", True) and not cfg.get("enabled"):
1067+
return 0
1068+
try:
1069+
r = send_ipc("ping", timeout=3)
1070+
except Exception:
1071+
r = None
1072+
if r == "ok":
1073+
return 0
1074+
log("后台守护:常驻进程无响应,重新启动托盘")
1075+
B.spawn_detached(B.self_cmd() + ["--tray"])
1076+
return 0
1077+
1078+
10441079
# ---------------- 入口 ----------------
10451080

10461081
def _fix_stdout():
@@ -1055,7 +1090,7 @@ def main(argv=None):
10551090
ap.add_argument("--action", default="gui", choices=[
10561091
"gui", "tray", "night", "day", "reconcile", "test", "activate", "pause", "emergency", "uninstall",
10571092
"status", "awake", "backup", "restore", "displays", "plan-virtual", "install-vdd", "uninstall-vdd", "selfcheck",
1058-
"env", "setup"])
1093+
"env", "setup", "watchdog"])
10591094
ap.add_argument("--tray", action="store_true", help="开机自启:只显示托盘")
10601095
ap.add_argument("--home", help="指定数据目录(计划与辅助进程继承此路径)")
10611096
ap.add_argument("--mode")
@@ -1087,6 +1122,8 @@ def main(argv=None):
10871122
return run_app(show_window=not tray_only, selftest=a.selftest)
10881123
if a.delay:
10891124
time.sleep(a.delay)
1125+
if a.action == "watchdog":
1126+
return run_watchdog()
10901127
if a.action == "status":
10911128
update()
10921129
status = B.status_text()

‎nightdisplay/backends.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,8 @@ def go_day(overlay_cb=None, mode=None, raise_errors=True):
594594

595595
# ---------- 系统计划任务 ----------
596596
TASKS = ("NightDisplay_Night", "NightDisplay_Day")
597+
WATCHDOG_TASK = "NightDisplay_Watchdog"
598+
STOP_FLAG = "user_stopped" # 用户点托盘「退出」后,守护任务不再把进程拉起来
597599
LA_LABEL = "com.nightdisplay.agent"
598600
SD_UNIT = "nightdisplay"
599601

@@ -685,6 +687,7 @@ def schedule_install(cfg):
685687
run(["schtasks", "/Create", "/F", "/TN", task["name"], "/SC", "DAILY",
686688
"/ST", task["time"], "/TR", tr, "/IT"])
687689
set_autostart(cfg.get("autostart", True))
690+
ensure_watchdog()
688691
elif IS_MAC:
689692
import plistlib
690693
path = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % LA_LABEL)
@@ -718,6 +721,68 @@ def _win_delete_tasks():
718721
return n
719722

720723

724+
725+
def stop_flag_path():
726+
return os.path.join(home(), STOP_FLAG)
727+
728+
729+
def mark_user_stopped():
730+
"""托盘「退出」:写标记,守护任务不再自动拉起进程。下次正常启动会清掉。"""
731+
path = stop_flag_path()
732+
os.makedirs(os.path.dirname(path), exist_ok=True)
733+
with open(path, "w", encoding="utf-8") as f:
734+
f.write("1\n")
735+
736+
737+
def clear_user_stopped():
738+
with contextlib.suppress(OSError):
739+
os.remove(stop_flag_path())
740+
741+
742+
def user_stopped():
743+
return os.path.exists(stop_flag_path())
744+
745+
746+
def ensure_watchdog():
747+
"""Windows:每 5 分钟跑一次 --action watchdog,进程没了就再拉托盘。"""
748+
if not IS_WIN or sys.platform != "win32":
749+
# IS_WIN 可能在 Linux 单测里被打开;真正建任务只在 Windows 主机。
750+
return False
751+
cmd = self_cmd() + ["--action", "watchdog"]
752+
tr = subprocess.list2cmdline(cmd)
753+
try:
754+
p = run(["schtasks", "/Create", "/F", "/TN", WATCHDOG_TASK, "/SC", "MINUTE", "/MO", "5",
755+
"/TR", tr, "/IT"], check=False)
756+
except Exception as e:
757+
log("注册后台守护失败:%s" % e)
758+
return False
759+
if p.returncode != 0:
760+
log("注册后台守护失败:%s" % ((p.stderr or p.stdout or "").strip() or p.returncode))
761+
return False
762+
return True
763+
764+
765+
def remove_watchdog():
766+
if not IS_WIN or sys.platform != "win32":
767+
return False
768+
try:
769+
p = run(["schtasks", "/Delete", "/F", "/TN", WATCHDOG_TASK], check=False)
770+
except Exception:
771+
return False
772+
return p.returncode == 0
773+
774+
775+
def watchdog_status():
776+
if not IS_WIN or sys.platform != "win32":
777+
return False
778+
try:
779+
p = run(["schtasks", "/Query", "/TN", WATCHDOG_TASK], check=False)
780+
except Exception:
781+
return False
782+
return p.returncode == 0
783+
784+
785+
721786
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
722787
# Windows 可以把「启动」里的这一项标成禁用。Run 里的命令还在,登录时却不会运行。
723788
STARTUP_APPROVED_KEY = r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"
@@ -823,6 +888,12 @@ def schedule_remove(drop_autostart=True):
823888
n = _win_delete_tasks()
824889
if n:
825890
removed.append("计划任务 %d 个" % n)
891+
if drop_autostart:
892+
if remove_watchdog():
893+
removed.append("后台守护")
894+
else:
895+
# 暂停每日计划仍要托盘常驻,守护任务留下。
896+
ensure_watchdog()
826897
if drop_autostart and autostart_status():
827898
set_autostart(False); removed.append("开机自启(注册表 Run)")
828899
elif IS_MAC:
@@ -874,6 +945,7 @@ def status_text():
874945
"上次测试:%s %s 成功=%s 自动恢复=%s" % (cfg["last_test_mode"], cfg["last_test_at"][:19], cfg["last_test_ok"], cfg["last_recovery_at"][:19]),
875946
schedule_status(),
876947
"开机自启:配置=%s,实际=%s" % ("开" if cfg.get("autostart", True) else "关", "已注册" if autostart_status() else "未注册"),
948+
"后台守护:%s" % ("已注册" if watchdog_status() else "未注册"),
877949
"数据目录:%s" % home()])
878950

879951

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# NightDisplay 安装前清理:结束全部旧进程、静默卸载其他旧版本、
2+
# 把旧安装目录和旧安装包移到回收站、清理残余注册表值。只碰 NightDisplay 自己的东西。
3+
param([string]$NewDir = "", [string]$SetupExe = "", [string]$AppId = "")
4+
$ErrorActionPreference = "SilentlyContinue"
5+
Add-Type -AssemblyName Microsoft.VisualBasic
6+
function Log($m) { Write-Output "[cleanup] $m" }
7+
function Norm($p) { if (-not $p) { return "" }; return ([IO.Path]::GetFullPath($p.Trim('"'))).TrimEnd('\').ToLowerInvariant() }
8+
function Recycle($path) {
9+
if (-not (Test-Path -LiteralPath $path)) { return }
10+
try {
11+
if (Test-Path -LiteralPath $path -PathType Container) {
12+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($path, 'OnlyErrorDialogs', 'SendToRecycleBin')
13+
} else {
14+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($path, 'OnlyErrorDialogs', 'SendToRecycleBin')
15+
}
16+
Log "recycled $path"
17+
} catch { Remove-Item -LiteralPath $path -Recurse -Force; Log "deleted $path" }
18+
}
19+
function Kill-All {
20+
for ($i = 0; $i -lt 10; $i++) {
21+
$ps = @(Get-Process -Name NightDisplay -ErrorAction SilentlyContinue)
22+
# 旧版卸载器 unins*.exe 若在旧目录里卡住也一起结束
23+
$ps += @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath -match '\\NightDisplay[^\\]*\\unins\d+\.exe$' } | ForEach-Object { Get-Process -Id $_.ProcessId })
24+
$ps = @($ps | Where-Object { $_ })
25+
if ($ps.Count -eq 0) { return $true }
26+
foreach ($p in $ps) { Log "kill $($p.Id) $($p.Path)"; & taskkill.exe /F /T /PID $p.Id | Out-Null }
27+
Start-Sleep -Milliseconds 500
28+
}
29+
return (@(Get-Process -Name NightDisplay -ErrorAction SilentlyContinue).Count -eq 0)
30+
}
31+
32+
$newDirN = Norm $NewDir
33+
$ourKey = "$AppId" + "_is1"
34+
[void](Kill-All)
35+
36+
# 1) 找出所有 NightDisplay 卸载登记(HKCU/HKLM,含 32 位视图)
37+
$roots = @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall',
38+
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall',
39+
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
40+
$oldDirs = @()
41+
foreach ($r in $roots) {
42+
foreach ($k in @(Get-ChildItem -LiteralPath $r)) {
43+
$v = Get-ItemProperty -LiteralPath $k.PSPath
44+
if ("$($v.DisplayName)" -notmatch '^NightDisplay') { continue }
45+
$loc = Norm $v.InstallLocation
46+
if ($k.PSChildName -eq $ourKey -and $loc -eq $newDirN) { continue } # 就地升级,交给 Inno
47+
Log "old install: $($k.PSChildName) $loc"
48+
if ($loc) { $oldDirs += $loc }
49+
$un = "$($v.UninstallString)"
50+
$exe = if ($un -match '^"([^"]+)"') { $Matches[1] } else { ($un -split ' ')[0] }
51+
if ($exe -and (Test-Path -LiteralPath $exe)) {
52+
$p = Start-Process -FilePath $exe -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -PassThru -WindowStyle Hidden
53+
if (-not $p.WaitForExit(60000)) { & taskkill.exe /F /T /PID $p.Id | Out-Null }
54+
# Inno 卸载器会把自己复制到临时目录再跑,等它真正结束
55+
for ($i = 0; $i -lt 60 -and (Get-Process | Where-Object { $_.ProcessName -match '^_iu\d+' }); $i++) { Start-Sleep -Milliseconds 500 }
56+
}
57+
[void](Kill-All)
58+
if (Test-Path -LiteralPath $k.PSPath) { Remove-Item -LiteralPath $k.PSPath -Recurse -Force; Log "removed key $($k.PSChildName)" }
59+
}
60+
}
61+
62+
# 2) 旧安装目录(不是这次的目标目录)移到回收站
63+
$cands = $oldDirs + @("$env:LOCALAPPDATA\Programs\NightDisplay", "$env:ProgramFiles\NightDisplay", "${env:ProgramFiles(x86)}\NightDisplay")
64+
foreach ($d in ($cands | ForEach-Object { Norm $_ } | Where-Object { $_ } | Sort-Object -Unique)) {
65+
if ($d -eq $newDirN) { continue }
66+
if ((Split-Path $d -Leaf) -notmatch '^NightDisplay') { continue } # 只删名字是 NightDisplay* 的目录
67+
Recycle $d
68+
}
69+
70+
# 3) Run 里指向不存在程序的 NightDisplay* 残余值
71+
foreach ($rk in 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run','HKLM:\Software\Microsoft\Windows\CurrentVersion\Run') {
72+
$props = Get-ItemProperty -LiteralPath $rk
73+
foreach ($n in @($props.PSObject.Properties.Name | Where-Object { $_ -like 'NightDisplay*' })) {
74+
$val = "$($props.$n)"
75+
$exe = if ($val -match '^"([^"]+)"') { $Matches[1] } else { ($val -split ' ')[0] }
76+
if ($n -ne 'NightDisplay' -or -not (Test-Path -LiteralPath $exe)) { Remove-ItemProperty -LiteralPath $rk -Name $n; Log "removed Run $n" }
77+
}
78+
}
79+
80+
# 4) 失效快捷方式(桌面/开始菜单里指向已不存在 exe 的 NightDisplay*.lnk)
81+
$sh = New-Object -ComObject WScript.Shell
82+
$lnkDirs = @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('CommonDesktopDirectory'),
83+
[Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('CommonPrograms'))
84+
foreach ($ld in $lnkDirs) {
85+
if (-not $ld) { continue }
86+
foreach ($l in @(Get-ChildItem -LiteralPath $ld -Filter '*.lnk' -Recurse -Depth 1 | Where-Object { $_.FullName -match 'NightDisplay' })) {
87+
$t = $sh.CreateShortcut($l.FullName).TargetPath
88+
if ($t -and -not (Test-Path -LiteralPath $t)) { Recycle $l.FullName }
89+
}
90+
$g = Join-Path $ld 'NightDisplay'
91+
if ((Test-Path -LiteralPath $g) -and -not (Get-ChildItem -LiteralPath $g)) { Remove-Item -LiteralPath $g -Force }
92+
}
93+
94+
# 5) 下载目录里旧的 NightDisplay 安装包/压缩包(不含正在运行的这个)移到回收站
95+
$setupN = Norm $SetupExe
96+
$dl = Join-Path $env:USERPROFILE 'Downloads'
97+
foreach ($f in @(Get-ChildItem -LiteralPath $dl -File | Where-Object { $_.Name -match '^NightDisplay.*(Setup.*\.exe|Windows.*\.zip)$' })) {
98+
if ((Norm $f.FullName) -eq $setupN) { continue }
99+
Recycle $f.FullName
100+
}
101+
102+
if (Kill-All) { Log "no NightDisplay process left"; exit 0 } else { Log "process still alive"; exit 3 }

0 commit comments

Comments
 (0)