@freely01/opencode-notify 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -38
- package/cli.ts +27 -15
- package/config.ts +298 -37
- package/delayed-dispatcher.ts +135 -0
- package/dispatcher.ts +11 -6
- package/events.ts +35 -43
- package/index.ts +115 -62
- package/log.ts +90 -0
- package/message.ts +39 -2
- package/package.json +1 -1
- package/senders/screen-flash/index.ts +32 -0
- package/senders/screen-flash/linux.ts +29 -0
- package/senders/screen-flash/win32.ts +129 -0
- package/senders/system/darwin.ts +17 -0
- package/senders/system/index.ts +67 -0
- package/senders/system/linux.ts +16 -0
- package/senders/system/win32.ts +51 -0
- package/session-tracker.ts +133 -0
- package/store.ts +5 -4
- package/terminator-detect.ts +164 -0
- package/senders/screen-flash.ts +0 -42
- package/senders/system.ts +0 -88
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 屏幕跑马灯
|
|
3
|
+
*
|
|
4
|
+
* 使用 PowerShell + .NET Windows Forms 创建屏幕四边彩色闪烁边框。
|
|
5
|
+
* 将四边分成多个小段,逐帧偏移颜色,产生 LED 跑马灯流动效果。
|
|
6
|
+
*
|
|
7
|
+
* 所有数据通过 $form.Tag(Form 的真实属性)传递,避免事件回调作用域问题。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "node:child_process"
|
|
11
|
+
import type { ScreenFlashChannelConfig } from "../../config.js"
|
|
12
|
+
|
|
13
|
+
export async function flash(config: ScreenFlashChannelConfig): Promise<void> {
|
|
14
|
+
const duration = config.duration ?? 3.0
|
|
15
|
+
const speed = config.speed ?? 4.0
|
|
16
|
+
const borderWidth = 8
|
|
17
|
+
const totalMs = duration * 1000
|
|
18
|
+
const intervalMs = Math.max(20, Math.min(150, Math.round(60 / speed)))
|
|
19
|
+
const segSize = 10
|
|
20
|
+
|
|
21
|
+
const psScript = `
|
|
22
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
23
|
+
Add-Type -AssemblyName System.Drawing
|
|
24
|
+
|
|
25
|
+
$bc = @(
|
|
26
|
+
[System.Drawing.Color]::Red,
|
|
27
|
+
[System.Drawing.Color]::Orange,
|
|
28
|
+
[System.Drawing.Color]::Yellow,
|
|
29
|
+
[System.Drawing.Color]::LimeGreen,
|
|
30
|
+
[System.Drawing.Color]::Blue,
|
|
31
|
+
[System.Drawing.Color]::Purple
|
|
32
|
+
)
|
|
33
|
+
$g = New-Object System.Collections.ArrayList
|
|
34
|
+
for ($b = 0; $b -lt $bc.Length; $b++) {
|
|
35
|
+
$n = ($b + 1) % $bc.Length
|
|
36
|
+
for ($s = 0; $s -lt 8; $s++) {
|
|
37
|
+
$r = [int]($bc[$b].R + ($bc[$n].R - $bc[$b].R) * $s / 8)
|
|
38
|
+
$g_ = [int]($bc[$b].G + ($bc[$n].G - $bc[$b].G) * $s / 8)
|
|
39
|
+
$b_ = [int]($bc[$b].B + ($bc[$n].B - $bc[$b].B) * $s / 8)
|
|
40
|
+
[void]$g.Add([System.Drawing.Color]::FromArgb(255, [Math]::Min(255,$r), [Math]::Min(255,$g_), [Math]::Min(255,$b_)))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$form = New-Object System.Windows.Forms.Form
|
|
45
|
+
$form.WindowState = 'Maximized'
|
|
46
|
+
$form.FormBorderStyle = 'None'
|
|
47
|
+
$form.TopMost = $true
|
|
48
|
+
$form.ShowInTaskbar = $false
|
|
49
|
+
$form.BackColor = [System.Drawing.Color]::Fuchsia
|
|
50
|
+
$form.TransparencyKey = [System.Drawing.Color]::Fuchsia
|
|
51
|
+
# Tag 存哈希表: @{ offset=0; gradient=$g; segSize=${segSize}; bw=${borderWidth} }
|
|
52
|
+
$form.Tag = @{ offset = 0; gradient = $g; segSize = ${segSize}; bw = ${borderWidth} }
|
|
53
|
+
|
|
54
|
+
$form.Add_Paint({
|
|
55
|
+
$d = $form.Tag
|
|
56
|
+
$gfx = $args[1].Graphics
|
|
57
|
+
$w = $form.ClientSize.Width
|
|
58
|
+
$h = $form.ClientSize.Height
|
|
59
|
+
$gr = $d.gradient
|
|
60
|
+
$gl = $gr.Count
|
|
61
|
+
$ss = $d.segSize
|
|
62
|
+
$bw = $d.bw
|
|
63
|
+
$off = $d.offset
|
|
64
|
+
|
|
65
|
+
# 上边 (左→右)
|
|
66
|
+
for ($x = 0; $x -lt $w; $x += $ss) {
|
|
67
|
+
$gi = [int](($x / $ss) + $off) % $gl
|
|
68
|
+
$sw = [Math]::Min($ss, $w - $x)
|
|
69
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
70
|
+
$gfx.FillRectangle($br, $x, 0, $sw, $bw)
|
|
71
|
+
$br.Dispose()
|
|
72
|
+
}
|
|
73
|
+
# 右边 (上→下)
|
|
74
|
+
for ($y = 0; $y -lt $h; $y += $ss) {
|
|
75
|
+
$gi = [int](($w / $ss) + ($y / $ss) + $off) % $gl
|
|
76
|
+
$sh = [Math]::Min($ss, $h - $y)
|
|
77
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
78
|
+
$gfx.FillRectangle($br, $w - $bw, $y, $bw, $sh)
|
|
79
|
+
$br.Dispose()
|
|
80
|
+
}
|
|
81
|
+
# 下边 (右→左)
|
|
82
|
+
for ($x = 0; $x -lt $w; $x += $ss) {
|
|
83
|
+
$gi = [int](($w / $ss) + ($h / $ss) + ($x / $ss) + $off) % $gl
|
|
84
|
+
$sw = [Math]::Min($ss, $w - $x)
|
|
85
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
86
|
+
$gfx.FillRectangle($br, $w - $x - $sw, $h - $bw, $sw, $bw)
|
|
87
|
+
$br.Dispose()
|
|
88
|
+
}
|
|
89
|
+
# 左边 (下→上)
|
|
90
|
+
for ($y = 0; $y -lt $h; $y += $ss) {
|
|
91
|
+
$gi = [int](($w / $ss) * 2 + ($h / $ss) + ($y / $ss) + $off) % $gl
|
|
92
|
+
$sh = [Math]::Min($ss, $h - $y)
|
|
93
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
94
|
+
$gfx.FillRectangle($br, 0, $h - $y - $sh, $bw, $sh)
|
|
95
|
+
$br.Dispose()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
$form.Show()
|
|
100
|
+
|
|
101
|
+
# 点击穿透
|
|
102
|
+
$code = @'
|
|
103
|
+
[DllImport("user32.dll")]
|
|
104
|
+
public static extern int SetWindowLong(IntPtr h, int n, int v);
|
|
105
|
+
[DllImport("user32.dll")]
|
|
106
|
+
public static extern int GetWindowLong(IntPtr h, int n);
|
|
107
|
+
'@
|
|
108
|
+
$win32 = Add-Type -MemberDefinition $code -Name W -Namespace W -PassThru
|
|
109
|
+
$style = $win32::GetWindowLong($form.Handle, -20)
|
|
110
|
+
$win32::SetWindowLong($form.Handle, -20, $style -bor 0x20 -bor 0x80)
|
|
111
|
+
|
|
112
|
+
# 跑马灯循环
|
|
113
|
+
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
114
|
+
while ($sw.ElapsedMilliseconds -lt ${totalMs}) {
|
|
115
|
+
$d = $form.Tag
|
|
116
|
+
$d.offset = $d.offset + 1
|
|
117
|
+
$form.Invalidate()
|
|
118
|
+
[System.Windows.Forms.Application]::DoEvents()
|
|
119
|
+
Start-Sleep -Milliseconds ${intervalMs}
|
|
120
|
+
}
|
|
121
|
+
$sw.Stop()
|
|
122
|
+
$form.Close()
|
|
123
|
+
`.trim()
|
|
124
|
+
|
|
125
|
+
execSync(`powershell -NoProfile -Command ${JSON.stringify(psScript)}`, {
|
|
126
|
+
timeout: Math.max(5000, totalMs + 5000),
|
|
127
|
+
stdio: "ignore",
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS 系统通知
|
|
3
|
+
*
|
|
4
|
+
* 使用 osascript 调用原生通知中心。
|
|
5
|
+
* - display notification: 弹窗 + 声音
|
|
6
|
+
* - 系统内置,无需额外安装
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process"
|
|
10
|
+
|
|
11
|
+
export async function notify(title: string, body: string): Promise<void> {
|
|
12
|
+
const script = `display notification "${body}" with title "${title}" sound name "default"`
|
|
13
|
+
execSync(`osascript -e ${JSON.stringify(script)}`, {
|
|
14
|
+
timeout: 5000,
|
|
15
|
+
stdio: "ignore",
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统通知发送器
|
|
3
|
+
*
|
|
4
|
+
* 根据运行平台自动选择实现:
|
|
5
|
+
* - macOS → darwin.ts (osascript)
|
|
6
|
+
* - Linux → linux.ts (notify-send)
|
|
7
|
+
* - Windows → win32.ts (WinRT Native Toast + NotifyIcon 回退)
|
|
8
|
+
* - 其他平台 → 静默忽略
|
|
9
|
+
*
|
|
10
|
+
* Windows 首次调用时自动注册快捷方式,使 toast 出现在通知中心。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Sender } from "../types.js"
|
|
14
|
+
import type { Message } from "../../message.js"
|
|
15
|
+
import { notify as darwinNotify } from "./darwin.js"
|
|
16
|
+
import { notify as linuxNotify } from "./linux.js"
|
|
17
|
+
import { notify as win32Notify } from "./win32.js"
|
|
18
|
+
|
|
19
|
+
/** 平台通知函数注册表 */
|
|
20
|
+
const notifiers: Record<string, (title: string, body: string) => Promise<void>> = {
|
|
21
|
+
darwin: darwinNotify,
|
|
22
|
+
linux: linuxNotify,
|
|
23
|
+
win32: win32Notify,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SystemSender implements Sender {
|
|
27
|
+
readonly name = "system_message"
|
|
28
|
+
|
|
29
|
+
async send(msg: Message): Promise<void> {
|
|
30
|
+
const fn = notifiers[process.platform]
|
|
31
|
+
if (!fn) return // 其他平台静默忽略
|
|
32
|
+
|
|
33
|
+
const { title, body } = sanitize(msg.title, msg.body)
|
|
34
|
+
try {
|
|
35
|
+
await fn(title, body)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`系统通知失败: ${err}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 转义标题和正文中的特殊字符,防止 shell 注入
|
|
44
|
+
*
|
|
45
|
+
* 各平台实际使用的逃逸:
|
|
46
|
+
* - Linux: notify-send "${title}" — 只需转义 " $ ` \
|
|
47
|
+
* - macOS: osascript -e JSON 序列化 — 全自动处理
|
|
48
|
+
* - Windows: PowerShell '${title}' — 只需转义单引号(win32.ts 内部处理)
|
|
49
|
+
*/
|
|
50
|
+
function sanitize(
|
|
51
|
+
title: string,
|
|
52
|
+
body: string,
|
|
53
|
+
): { title: string; body: string } {
|
|
54
|
+
return {
|
|
55
|
+
title: escape(title),
|
|
56
|
+
body: escape(body),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escape(s: string): string {
|
|
61
|
+
return s
|
|
62
|
+
.replace(/"/g, '\\"')
|
|
63
|
+
.replace(/`/g, "\\`")
|
|
64
|
+
.replace(/\$/g, "\\$")
|
|
65
|
+
.replace(/\n/g, " ")
|
|
66
|
+
.replace(/\r/g, "")
|
|
67
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux 系统通知
|
|
3
|
+
*
|
|
4
|
+
* 使用 notify-send(来自 libnotify)。
|
|
5
|
+
* 桌面发行版通常预装,如缺失可:
|
|
6
|
+
* apt install libnotify-bin / yum install libnotify
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process"
|
|
10
|
+
|
|
11
|
+
export async function notify(title: string, body: string): Promise<void> {
|
|
12
|
+
execSync(`notify-send "${title}" "${body}"`, {
|
|
13
|
+
timeout: 5000,
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 系统通知
|
|
3
|
+
*
|
|
4
|
+
* 策略:
|
|
5
|
+
* 1. 通过注册表注册 opencode-notify 通知发送方
|
|
6
|
+
* 2. WinRT Native Toast — 使用 PowerShell UUID AppId(确认可弹窗)
|
|
7
|
+
* 3. NotifyIcon BalloonTip — 非阻塞式最终回退
|
|
8
|
+
*
|
|
9
|
+
* 注册表路径: HKCU\SOFTWARE\Classes\AppUserModelId\opencode-notify
|
|
10
|
+
* 此注册使通知出现在 Windows 操作中心。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "node:child_process"
|
|
14
|
+
|
|
15
|
+
export async function notify(title: string, body: string): Promise<void> {
|
|
16
|
+
const t = title.replace(/'/g, "''")
|
|
17
|
+
const b = body.replace(/'/g, "''")
|
|
18
|
+
|
|
19
|
+
const ps = `
|
|
20
|
+
# 注册 opencode-notify 到操作中心
|
|
21
|
+
New-Item -Path 'HKCU:\\SOFTWARE\\Classes\\AppUserModelId\\opencode-notify' -Force -ErrorAction Stop | Out-Null
|
|
22
|
+
New-ItemProperty -Path 'HKCU:\\SOFTWARE\\Classes\\AppUserModelId\\opencode-notify' -Name 'DisplayName' -Value 'opencode-notify' -PropertyType String -Force -ErrorAction Stop | Out-Null
|
|
23
|
+
New-ItemProperty -Path 'HKCU:\\SOFTWARE\\Classes\\AppUserModelId\\opencode-notify' -Name 'ShowInSettings' -Value 1 -PropertyType DWord -Force -ErrorAction Stop | Out-Null
|
|
24
|
+
|
|
25
|
+
# 策略 1: WinRT Native Toast (PowerShell AppId)
|
|
26
|
+
try {
|
|
27
|
+
[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] > \$null
|
|
28
|
+
[Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlDocument,ContentType=WindowsRuntime] > \$null
|
|
29
|
+
\$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent('ToastText02')
|
|
30
|
+
\$t.SelectSingleNode('//text[@id=\"1\"]').InnerText='${t}'
|
|
31
|
+
\$t.SelectSingleNode('//text[@id=\"2\"]').InnerText='${b}'
|
|
32
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show(\$t)
|
|
33
|
+
} catch {
|
|
34
|
+
# 策略 2: NotifyIcon 回退
|
|
35
|
+
try {
|
|
36
|
+
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
|
|
37
|
+
\$n=New-Object System.Windows.Forms.NotifyIcon
|
|
38
|
+
\$n.Icon=[System.Drawing.SystemIcons]::Information
|
|
39
|
+
\$n.Visible=\$true
|
|
40
|
+
\$n.ShowBalloonTip(10000,'${t}','${b}',[System.Windows.Forms.TooltipIcon]::None)
|
|
41
|
+
Start-Sleep -Milliseconds 500
|
|
42
|
+
\$n.Dispose()
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
`.trim()
|
|
46
|
+
|
|
47
|
+
execSync(`powershell -NoProfile -Command ${JSON.stringify(ps)}`, {
|
|
48
|
+
timeout: 15000,
|
|
49
|
+
stdio: "ignore",
|
|
50
|
+
})
|
|
51
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话状态追踪器
|
|
3
|
+
*
|
|
4
|
+
* 跟踪 opencode 每个会话的用户活跃状态,
|
|
5
|
+
* 用于会话感知的通知抑制(用户在操作某会话时跳过部分通知)。
|
|
6
|
+
*
|
|
7
|
+
* 状态更新事件:
|
|
8
|
+
* message.updated / permission.replied / question.replied
|
|
9
|
+
* command.executed / tui.command.execute
|
|
10
|
+
*
|
|
11
|
+
* 生命周期事件:
|
|
12
|
+
* session.created / session.deleted
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { warn } from "./log.js"
|
|
16
|
+
|
|
17
|
+
export interface SessionInfo {
|
|
18
|
+
sessionID: string
|
|
19
|
+
/** 用户最后操作时间戳 */
|
|
20
|
+
lastActivity: number
|
|
21
|
+
/** 会话创建时间 */
|
|
22
|
+
createdAt: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 分钟
|
|
26
|
+
|
|
27
|
+
export class SessionTracker {
|
|
28
|
+
private sessions = new Map<string, SessionInfo>()
|
|
29
|
+
private staleTimeoutMs: number
|
|
30
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param staleTimeoutMs 会话无操作自动淘汰阈值(默认 10 分钟)
|
|
34
|
+
*/
|
|
35
|
+
constructor(staleTimeoutMs = 600_000) {
|
|
36
|
+
this.staleTimeoutMs = staleTimeoutMs
|
|
37
|
+
this.startCleanupTimer()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============ 对外接口 ============
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 检查某会话在指定超时窗口内是否有用户活动
|
|
44
|
+
* @returns true = 用户正在操作,应抑制可抑制事件
|
|
45
|
+
*/
|
|
46
|
+
isSessionActive(sessionID: string, activityTimeoutMs: number): boolean {
|
|
47
|
+
if (sessionID === "unknown") return false
|
|
48
|
+
const info = this.sessions.get(sessionID)
|
|
49
|
+
if (!info) return false
|
|
50
|
+
return Date.now() - info.lastActivity < activityTimeoutMs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============ 事件更新 ============
|
|
54
|
+
|
|
55
|
+
/** 标记用户在该会话中有操作 */
|
|
56
|
+
markActivity(sessionID: string): void {
|
|
57
|
+
if (sessionID === "unknown") return
|
|
58
|
+
this.lazyCleanup()
|
|
59
|
+
const existing = this.sessions.get(sessionID)
|
|
60
|
+
if (existing) {
|
|
61
|
+
existing.lastActivity = Date.now()
|
|
62
|
+
} else {
|
|
63
|
+
this.sessions.set(sessionID, {
|
|
64
|
+
sessionID,
|
|
65
|
+
lastActivity: Date.now(),
|
|
66
|
+
createdAt: Date.now(),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 注册新会话 */
|
|
72
|
+
register(sessionID: string): void {
|
|
73
|
+
if (sessionID === "unknown") return
|
|
74
|
+
if (!this.sessions.has(sessionID)) {
|
|
75
|
+
this.sessions.set(sessionID, {
|
|
76
|
+
sessionID,
|
|
77
|
+
lastActivity: Date.now(),
|
|
78
|
+
createdAt: Date.now(),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 移除会话 */
|
|
84
|
+
remove(sessionID: string): void {
|
|
85
|
+
this.sessions.delete(sessionID)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============ 内部 ============
|
|
89
|
+
|
|
90
|
+
/** 惰性清除 — 每次操作时顺带清理少数过期条目 */
|
|
91
|
+
private lazyCleanup(): void {
|
|
92
|
+
const cutoff = Date.now() - this.staleTimeoutMs
|
|
93
|
+
// 每次随机检查最多 8 条(避免大 Map 遍历性能开销)
|
|
94
|
+
let checked = 0
|
|
95
|
+
for (const [id, info] of this.sessions) {
|
|
96
|
+
if (checked >= 8) break
|
|
97
|
+
if (info.lastActivity < cutoff) this.sessions.delete(id)
|
|
98
|
+
checked++
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 全量清理 */
|
|
103
|
+
private fullCleanup(): void {
|
|
104
|
+
const cutoff = Date.now() - this.staleTimeoutMs
|
|
105
|
+
for (const [id, info] of this.sessions) {
|
|
106
|
+
if (info.lastActivity < cutoff) this.sessions.delete(id)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 启动定时清理 */
|
|
111
|
+
private startCleanupTimer(): void {
|
|
112
|
+
this.cleanupTimer = setInterval(() => this.fullCleanup(), CLEANUP_INTERVAL_MS)
|
|
113
|
+
this.cleanupTimer.unref()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 销毁(进程退出时调用) */
|
|
117
|
+
destroy(): void {
|
|
118
|
+
if (this.cleanupTimer) {
|
|
119
|
+
clearInterval(this.cleanupTimer)
|
|
120
|
+
this.cleanupTimer = null
|
|
121
|
+
}
|
|
122
|
+
const count = this.sessions.size
|
|
123
|
+
this.sessions.clear()
|
|
124
|
+
if (count > 0) {
|
|
125
|
+
warn(`会话追踪器销毁,清理 ${count} 个未过期会话`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 当前追踪的会话数(用于调试) */
|
|
130
|
+
get size(): number {
|
|
131
|
+
return this.sessions.size
|
|
132
|
+
}
|
|
133
|
+
}
|
package/store.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
10
10
|
import { homedir } from "node:os"
|
|
11
11
|
import { join } from "node:path"
|
|
12
|
+
import { warn, error } from "./log.js"
|
|
12
13
|
|
|
13
14
|
interface StoreData {
|
|
14
15
|
lastSent: Record<string, number> // key → unix timestamp (seconds)
|
|
@@ -90,8 +91,8 @@ export class FileStore {
|
|
|
90
91
|
const raw = readFileSync(this.path, "utf-8")
|
|
91
92
|
const data = JSON.parse(raw) as StoreData
|
|
92
93
|
this.lastSent = data.lastSent ?? {}
|
|
93
|
-
} catch {
|
|
94
|
-
|
|
94
|
+
} catch (e) {
|
|
95
|
+
warn(`去重状态文件读取失败,使用空状态: ${e instanceof Error ? e.message : String(e)}`)
|
|
95
96
|
this.lastSent = {}
|
|
96
97
|
}
|
|
97
98
|
}
|
|
@@ -104,8 +105,8 @@ export class FileStore {
|
|
|
104
105
|
}
|
|
105
106
|
const data: StoreData = { lastSent: this.lastSent }
|
|
106
107
|
writeFileSync(this.path, JSON.stringify(data, null, 2), "utf-8")
|
|
107
|
-
} catch {
|
|
108
|
-
|
|
108
|
+
} catch (e) {
|
|
109
|
+
error(`去重状态持久化失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminator 子屏幕最大化检测
|
|
3
|
+
*
|
|
4
|
+
* 当用户在 Terminator 中最大化某个子屏幕时(Ctrl+Shift+X),
|
|
5
|
+
* 其他子屏幕被遮挡。通过 DBus 查询焦点终端 UUID,
|
|
6
|
+
* 与本进程的 TERMINATOR_UUID 比较,判定本屏是否被遮挡。
|
|
7
|
+
*
|
|
8
|
+
* 检测策略(依次尝试):
|
|
9
|
+
* 1. busctl(systemd 自带,Ubuntu 预装)
|
|
10
|
+
* 2. python3-dbus(备选)
|
|
11
|
+
* 3. gdbus(GLib 备选)
|
|
12
|
+
* 4. xdotool 窗口类检测(回退,仅判断是否在 Terminator 窗口中)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from "node:child_process"
|
|
16
|
+
import { warn, debug } from "./log.js"
|
|
17
|
+
|
|
18
|
+
/** 当前进程的 TERMINATOR_UUID */
|
|
19
|
+
const MY_UUID = process.env.TERMINATOR_UUID ?? null
|
|
20
|
+
|
|
21
|
+
/** Terminator DBus 信息(每实例唯一) */
|
|
22
|
+
const DBUS_NAME = process.env.TERMINATOR_DBUS_NAME ?? null
|
|
23
|
+
const DBUS_PATH = process.env.TERMINATOR_DBUS_PATH ?? null
|
|
24
|
+
|
|
25
|
+
/** 是否已确认在 Terminator 中 */
|
|
26
|
+
let insideTerminator: boolean | null = null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 检测当前子屏幕是否被遮挡(用户在其他子屏幕上操作)
|
|
30
|
+
*
|
|
31
|
+
* @returns true → 本子屏幕被遮挡,不应抑制通知
|
|
32
|
+
* false → 本子屏幕可见,正常抑制逻辑
|
|
33
|
+
* null → 无法确定(不在 Terminator 中或检测失败)
|
|
34
|
+
*/
|
|
35
|
+
export function isTerminalOccluded(): boolean | null {
|
|
36
|
+
if (!MY_UUID) {
|
|
37
|
+
if (insideTerminator === null) insideTerminator = false
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
insideTerminator = true
|
|
42
|
+
|
|
43
|
+
// 如果没有 DBus 名称/路径信息,直接回退到 xdotool
|
|
44
|
+
if (!DBUS_NAME || !DBUS_PATH) {
|
|
45
|
+
debug(`Terminator DBus 环境变量不全: name=${DBUS_NAME} path=${DBUS_PATH},回退到 xdotool`)
|
|
46
|
+
return callXdotool()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 策略 1: busctl
|
|
50
|
+
try {
|
|
51
|
+
const focused = callBusctl()
|
|
52
|
+
if (focused !== null) {
|
|
53
|
+
const occluded = !uuidEqual(focused, MY_UUID)
|
|
54
|
+
debug(`Terminator 焦点检测(busctl): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
|
|
55
|
+
return occluded
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
debug(`Terminator busctl 失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 策略 2: python3-dbus
|
|
62
|
+
try {
|
|
63
|
+
const focused = callPythonDBus()
|
|
64
|
+
if (focused !== null) {
|
|
65
|
+
const occluded = !uuidEqual(focused, MY_UUID)
|
|
66
|
+
debug(`Terminator 焦点检测(python): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
|
|
67
|
+
return occluded
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
debug(`Terminator python-dbus 失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 策略 3: gdbus
|
|
74
|
+
try {
|
|
75
|
+
const focused = callGDBus()
|
|
76
|
+
if (focused !== null) {
|
|
77
|
+
const occluded = !uuidEqual(focused, MY_UUID)
|
|
78
|
+
debug(`Terminator 焦点检测(gdbus): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
|
|
79
|
+
return occluded
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
debug(`Terminator gdbus 失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 策略 4: xdotool 窗口类检测(回退)
|
|
86
|
+
try {
|
|
87
|
+
const result = callXdotool()
|
|
88
|
+
debug(`Terminator 窗口检测(xdotool): ${result}`)
|
|
89
|
+
return result
|
|
90
|
+
} catch (e) {
|
|
91
|
+
warn(`Terminator 所有检测策略均失败,最后错误: ${e instanceof Error ? e.message : String(e)}`)
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 检查是否在 Terminator 环境中(基于环境变量)
|
|
98
|
+
*/
|
|
99
|
+
export function isInsideTerminator(): boolean {
|
|
100
|
+
if (insideTerminator !== null) return insideTerminator
|
|
101
|
+
insideTerminator = !!process.env.TERMINATOR_UUID
|
|
102
|
+
return insideTerminator
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── 工具函数 ───────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/** 比较两个 UUID(忽略 urn:uuid: 前缀) */
|
|
108
|
+
function uuidEqual(a: string, b: string): boolean {
|
|
109
|
+
const strip = (s: string) => s.replace(/^urn:uuid:/i, "")
|
|
110
|
+
return strip(a) === strip(b)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 取 UUID 前 8 位用于日志 */
|
|
114
|
+
function shortId(uuid: string): string {
|
|
115
|
+
return uuid.replace(/^urn:uuid:/i, "").slice(0, 8)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── 检测策略实现 ───────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/** 策略 1: busctl(systemd) */
|
|
121
|
+
function callBusctl(): string | null {
|
|
122
|
+
const out = execSync(
|
|
123
|
+
`busctl --user call ${DBUS_NAME} ${DBUS_PATH} ${DBUS_NAME} get_focused_terminal`,
|
|
124
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
125
|
+
).trim()
|
|
126
|
+
// 输出格式: s "uuid-string"
|
|
127
|
+
const m = out.match(/s\s+"([^"]+)"/)
|
|
128
|
+
return m ? m[1] : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 策略 2: python3-dbus */
|
|
132
|
+
function callPythonDBus(): string | null {
|
|
133
|
+
const out = execSync(
|
|
134
|
+
`python3 -c "
|
|
135
|
+
import dbus
|
|
136
|
+
bus = dbus.SessionBus()
|
|
137
|
+
proxy = bus.get_object('${DBUS_NAME}', '${DBUS_PATH}')
|
|
138
|
+
focused = proxy.get_focused_terminal(dbus_interface='${DBUS_NAME}')
|
|
139
|
+
print(focused)
|
|
140
|
+
"`,
|
|
141
|
+
{ encoding: "utf-8", timeout: 5000 },
|
|
142
|
+
).trim()
|
|
143
|
+
return out || null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 策略 3: gdbus(GLib) */
|
|
147
|
+
function callGDBus(): string | null {
|
|
148
|
+
const out = execSync(
|
|
149
|
+
`gdbus call --session --dest ${DBUS_NAME} --object-path ${DBUS_PATH} --method ${DBUS_NAME}.get_focused_terminal`,
|
|
150
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
151
|
+
).trim()
|
|
152
|
+
// 输出格式: ('uuid-string',)
|
|
153
|
+
const m = out.match(/\('([^']+)'/)
|
|
154
|
+
return m ? m[1] : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 策略 4: xdotool 窗口类检测 */
|
|
158
|
+
function callXdotool(): boolean | null {
|
|
159
|
+
const cls = execSync(
|
|
160
|
+
`xdotool getactivewindow getwindowclassname`,
|
|
161
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
162
|
+
).trim()
|
|
163
|
+
return cls === "Terminator" ? true : null
|
|
164
|
+
}
|
package/senders/screen-flash.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 屏幕跑马灯发送器(Linux X11 专用)
|
|
3
|
-
*
|
|
4
|
-
* 通知时在屏幕四边生成彩色高亮闪烁效果。
|
|
5
|
-
* 使用 Python + PyGObject(GTK 3) 创建透明覆盖窗口。
|
|
6
|
-
* Ubuntu GNOME 桌面内置依赖,无需额外安装。
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { spawn } from "node:child_process"
|
|
10
|
-
import { join, dirname } from "node:path"
|
|
11
|
-
import { fileURLToPath } from "node:url"
|
|
12
|
-
import type { Message } from "../message.js"
|
|
13
|
-
import type { Sender } from "./types.js"
|
|
14
|
-
import type { ScreenFlashChannelConfig } from "../config.js"
|
|
15
|
-
|
|
16
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
17
|
-
const MARQUEE_SCRIPT = join(__dirname, "..", "scripts", "marquee.py")
|
|
18
|
-
|
|
19
|
-
export class ScreenFlashSender implements Sender {
|
|
20
|
-
readonly name = "screen_flash"
|
|
21
|
-
private config: ScreenFlashChannelConfig
|
|
22
|
-
|
|
23
|
-
constructor(config: ScreenFlashChannelConfig) {
|
|
24
|
-
this.config = config
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async send(_msg: Message): Promise<void> {
|
|
28
|
-
if (process.platform !== "linux") return
|
|
29
|
-
|
|
30
|
-
const args = [
|
|
31
|
-
MARQUEE_SCRIPT,
|
|
32
|
-
String(this.config.duration ?? 3.0),
|
|
33
|
-
String(this.config.speed ?? 4.0),
|
|
34
|
-
String(this.config.intensity ?? 0.9),
|
|
35
|
-
]
|
|
36
|
-
const child = spawn("python3", args, {
|
|
37
|
-
stdio: "ignore",
|
|
38
|
-
detached: true,
|
|
39
|
-
})
|
|
40
|
-
child.unref()
|
|
41
|
-
}
|
|
42
|
-
}
|