@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.
@@ -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
+ }
@@ -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
- }