@tinyaxis/voice-brief-cinnamon 0.4.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/LICENSE +21 -0
- package/README.md +46 -0
- package/extension/voice-brief@tinyaxis/dialogue-overlay.js +683 -0
- package/extension/voice-brief@tinyaxis/extension.js +93 -0
- package/extension/voice-brief@tinyaxis/lib/hook-server.js +137 -0
- package/extension/voice-brief@tinyaxis/lib/protocol.js +97 -0
- package/extension/voice-brief@tinyaxis/metadata.json +11 -0
- package/extension/voice-brief@tinyaxis/settings-schema.json +24 -0
- package/extension/voice-brief@tinyaxis/stylesheet.css +74 -0
- package/package.json +29 -0
- package/scripts/install.js +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geequlim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Voice Brief Cinnamon
|
|
2
|
+
|
|
3
|
+
Voice Brief 的 Cinnamon 桌面扩展子包。源码使用 TypeScript 编写,由 `tsc` 编译为 Cinnamon 可加载的 GJS JavaScript;扩展运行时不依赖 Node.js。
|
|
4
|
+
|
|
5
|
+
扩展启用后监听 `$XDG_RUNTIME_DIR/voice-brief/cinnamon.sock`,接收一行 NDJSON 格式的 Voice Brief Hook 事件。Hook Server 启动后,扩展通过 `voice-brief runtime configure` 按自身动画常量设置播放启动延迟;收到单向的 `playback.ready` 时在主显示器依次执行头像与完整对话入场动画。合成和排队事件不会产生可见中间态,播放结束、失败或跳过时关闭。
|
|
6
|
+
|
|
7
|
+
## 开发
|
|
8
|
+
|
|
9
|
+
- `src/extension.ts`:扩展生命周期入口。
|
|
10
|
+
- `src/dialogue-overlay.ts`:桌面对话展示与事件生命周期。
|
|
11
|
+
- `src/lib/protocol.ts`:Hook 协议输入校验。
|
|
12
|
+
- `src/lib/hook-server.ts`:Gio Unix Socket 服务。
|
|
13
|
+
- `assets/`:Cinnamon 扩展元数据和样式。
|
|
14
|
+
- `dist/voice-brief@tinyaxis/`:生成的可安装扩展目录,不提交到仓库。
|
|
15
|
+
|
|
16
|
+
构建扩展:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
yarn workspace @tinyaxis/voice-brief-cinnamon build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
构建、安装到当前用户并让 Cinnamon 重载扩展:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
yarn tiny develop/voice-brief/cinnamon
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 视觉冒烟测试
|
|
29
|
+
|
|
30
|
+
扩展安装并启用后,可以直接向 Hook Socket 注入测试事件,不会请求 TTS 或播放语音:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
yarn tiny develop/voice-brief/cinnamon/smoke
|
|
34
|
+
yarn tiny develop/voice-brief/cinnamon/smoke/entrance
|
|
35
|
+
yarn tiny develop/voice-brief/cinnamon/smoke/dialogue
|
|
36
|
+
yarn tiny develop/voice-brief/cinnamon/smoke/multiline
|
|
37
|
+
yarn tiny develop/voice-brief/cinnamon/smoke/no-session
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
默认读取 `~/.config/voice-brief/personas/甜妹助理.md`。可以通过 `VOICE_BRIEF_SMOKE_PERSONA` 指定其他人设文件,通过 `VOICE_BRIEF_CINNAMON_SOCKET` 指定其他 Socket。
|
|
41
|
+
|
|
42
|
+
## 测试
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
yarn workspace @tinyaxis/voice-brief-cinnamon test
|
|
46
|
+
```
|
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const DialogueGio = imports.gi.Gio;
|
|
3
|
+
const DialogueGLib = imports.gi.GLib;
|
|
4
|
+
const DialogueClutter = imports.gi.Clutter;
|
|
5
|
+
const DialoguePango = imports.gi.Pango;
|
|
6
|
+
const St = imports.gi.St;
|
|
7
|
+
const Main = imports.ui.main;
|
|
8
|
+
const FINAL_WIDTH = 1800;
|
|
9
|
+
const CARD_INSET_X = 18;
|
|
10
|
+
const CARD_INSET_Y = 14;
|
|
11
|
+
const CONTENT_GAP = 16;
|
|
12
|
+
const AVATAR_ENTER_DURATION_MS = 220;
|
|
13
|
+
const AVATAR_ENTER_SETTLE_DURATION_MS = 120;
|
|
14
|
+
const AVATAR_ENTER_HOLD_MS = 420;
|
|
15
|
+
const AVATAR_ENTER_OVERSHOOT_SCALE = 1.24;
|
|
16
|
+
const DIALOGUE_EXPAND_DURATION_MS = 190;
|
|
17
|
+
const PANEL_EMPHASIS_SCALE_Y = 1.06;
|
|
18
|
+
const PANEL_EMPHASIS_EXPAND_DURATION_MS = 90;
|
|
19
|
+
const PANEL_EMPHASIS_SETTLE_DURATION_MS = 110;
|
|
20
|
+
const PLAYBACK_START_DELAY_MS = AVATAR_ENTER_DURATION_MS +
|
|
21
|
+
AVATAR_ENTER_SETTLE_DURATION_MS +
|
|
22
|
+
AVATAR_ENTER_HOLD_MS +
|
|
23
|
+
DIALOGUE_EXPAND_DURATION_MS +
|
|
24
|
+
PANEL_EMPHASIS_EXPAND_DURATION_MS +
|
|
25
|
+
PANEL_EMPHASIS_SETTLE_DURATION_MS;
|
|
26
|
+
const CONTENT_FADE_DURATION_MS = 130;
|
|
27
|
+
const EXIT_COLLAPSE_DURATION_MS = 200;
|
|
28
|
+
const EXIT_PANEL_FADE_DURATION_MS = 110;
|
|
29
|
+
const EXIT_AVATAR_HOLD_MS = 360;
|
|
30
|
+
const EXIT_AVATAR_PULSE_DURATION_MS = 120;
|
|
31
|
+
const EXIT_AVATAR_DURATION_MS = 150;
|
|
32
|
+
const EXIT_AVATAR_OVERSHOOT_SCALE = 1.22;
|
|
33
|
+
const DEFAULT_ACCENT_COLOR = '#eb4272';
|
|
34
|
+
const AVATAR_LAYOUT_STYLE = 'width: 96px; height: 96px;';
|
|
35
|
+
const HIDE_HOT_KEY_NAME = 'voice-brief-hide-overlay';
|
|
36
|
+
class DialogueOverlay {
|
|
37
|
+
$root;
|
|
38
|
+
$surface;
|
|
39
|
+
$avatar;
|
|
40
|
+
$dialoguePanel;
|
|
41
|
+
$dialogueHideButton;
|
|
42
|
+
$nameLabel;
|
|
43
|
+
$contextLabel;
|
|
44
|
+
$textLabel;
|
|
45
|
+
$avatarTimeoutId;
|
|
46
|
+
$briefId;
|
|
47
|
+
$dismissedBriefIds = new Set();
|
|
48
|
+
$escapeHotKeyRegistered = false;
|
|
49
|
+
$layout;
|
|
50
|
+
$pendingBriefIds = new Set();
|
|
51
|
+
$phase = 'idle';
|
|
52
|
+
$position;
|
|
53
|
+
$screenMargin;
|
|
54
|
+
constructor(position, screenMargin) {
|
|
55
|
+
this.$position = position;
|
|
56
|
+
this.$screenMargin = screenMargin;
|
|
57
|
+
this.$root = new St.Widget({
|
|
58
|
+
layout_manager: new DialogueClutter.FixedLayout(),
|
|
59
|
+
reactive: false,
|
|
60
|
+
});
|
|
61
|
+
this.$root.set_pivot_point(0.5, 0.5);
|
|
62
|
+
this.$surface = new St.Widget({ style_class: 'voice-brief-dialogue-surface' });
|
|
63
|
+
this.$surface.set_pivot_point(0.5, 0.5);
|
|
64
|
+
this.$surface.set_offscreen_redirect(DialogueClutter.OffscreenRedirect.ALWAYS);
|
|
65
|
+
this.$dialoguePanel = new St.BoxLayout({
|
|
66
|
+
style_class: 'voice-brief-dialogue-panel',
|
|
67
|
+
vertical: true,
|
|
68
|
+
});
|
|
69
|
+
const header = new St.BoxLayout({
|
|
70
|
+
style_class: 'voice-brief-dialogue-header',
|
|
71
|
+
x_expand: true,
|
|
72
|
+
});
|
|
73
|
+
this.$nameLabel = new St.Label({
|
|
74
|
+
style_class: 'voice-brief-dialogue-name',
|
|
75
|
+
y_align: DialogueClutter.ActorAlign.START,
|
|
76
|
+
});
|
|
77
|
+
this.$contextLabel = new St.Label({
|
|
78
|
+
style_class: 'voice-brief-dialogue-runtime voice-brief-dialogue-context-label',
|
|
79
|
+
x_align: DialogueClutter.ActorAlign.FILL,
|
|
80
|
+
x_expand: true,
|
|
81
|
+
y_align: DialogueClutter.ActorAlign.START,
|
|
82
|
+
});
|
|
83
|
+
this.$contextLabel.clutter_text.ellipsize = DialoguePango.EllipsizeMode.END;
|
|
84
|
+
this.$contextLabel.clutter_text.line_wrap = false;
|
|
85
|
+
this.$textLabel = new St.Label({
|
|
86
|
+
style_class: 'voice-brief-dialogue-text',
|
|
87
|
+
x_expand: true,
|
|
88
|
+
});
|
|
89
|
+
this.$textLabel.clutter_text.ellipsize = DialoguePango.EllipsizeMode.NONE;
|
|
90
|
+
this.$textLabel.clutter_text.line_wrap = true;
|
|
91
|
+
this.$textLabel.clutter_text.line_wrap_mode = DialoguePango.WrapMode.WORD_CHAR;
|
|
92
|
+
this.$dialogueHideButton = new St.Button({
|
|
93
|
+
style_class: 'voice-brief-dialogue-hide-button',
|
|
94
|
+
label: '[Esc] 隐藏',
|
|
95
|
+
reactive: true,
|
|
96
|
+
can_focus: true,
|
|
97
|
+
});
|
|
98
|
+
this.$dialogueHideButton.connect('clicked', () => this.$dismissCurrentBrief());
|
|
99
|
+
const dialogueHideControl = new St.Bin({
|
|
100
|
+
child: this.$dialogueHideButton,
|
|
101
|
+
style_class: 'voice-brief-dialogue-hide-row',
|
|
102
|
+
x_align: St.Align.END,
|
|
103
|
+
x_expand: true,
|
|
104
|
+
x_fill: false,
|
|
105
|
+
});
|
|
106
|
+
header.add_child(this.$nameLabel);
|
|
107
|
+
header.add_child(this.$contextLabel);
|
|
108
|
+
this.$dialoguePanel.add_child(header);
|
|
109
|
+
this.$dialoguePanel.add_child(this.$textLabel);
|
|
110
|
+
this.$dialoguePanel.add_child(dialogueHideControl);
|
|
111
|
+
this.$dialoguePanel.set_offscreen_redirect(DialogueClutter.OffscreenRedirect.ALWAYS);
|
|
112
|
+
this.$avatar = new St.Bin({
|
|
113
|
+
style_class: 'voice-brief-dialogue-avatar',
|
|
114
|
+
y_align: DialogueClutter.ActorAlign.CENTER,
|
|
115
|
+
});
|
|
116
|
+
this.$avatar.set_pivot_point(0.5, 0.5);
|
|
117
|
+
this.$root.add_child(this.$surface);
|
|
118
|
+
this.$root.add_child(this.$dialoguePanel);
|
|
119
|
+
this.$root.add_child(this.$avatar);
|
|
120
|
+
this.$root.hide();
|
|
121
|
+
Main.layoutManager.addChrome(this.$root, {
|
|
122
|
+
visibleInFullscreen: true,
|
|
123
|
+
affectsInputRegion: false,
|
|
124
|
+
affectsStruts: false,
|
|
125
|
+
});
|
|
126
|
+
Main.layoutManager.trackChrome(this.$dialogueHideButton, {
|
|
127
|
+
visibleInFullscreen: true,
|
|
128
|
+
affectsInputRegion: true,
|
|
129
|
+
affectsStruts: false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
handleEvent(event) {
|
|
133
|
+
const isTerminalEvent = event.event === 'audio.failed' ||
|
|
134
|
+
event.event === 'playback.completed' ||
|
|
135
|
+
event.event === 'playback.failed' ||
|
|
136
|
+
event.event === 'playback.skipped';
|
|
137
|
+
if (this.$dismissedBriefIds.has(event.briefId)) {
|
|
138
|
+
if (isTerminalEvent) {
|
|
139
|
+
this.$dismissedBriefIds.delete(event.briefId);
|
|
140
|
+
this.$removePendingBrief(event.briefId);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (event.event === 'playback.queued') {
|
|
145
|
+
this.$pendingBriefIds.add(event.briefId);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (event.event === 'playback.ready') {
|
|
149
|
+
if (this.$phase === 'holding') {
|
|
150
|
+
this.$replaceDialogue(event);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.$showDialogue(event);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.event === 'playback.started') {
|
|
157
|
+
this.$pendingBriefIds.delete(event.briefId);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (isTerminalEvent)
|
|
161
|
+
this.$handleTerminalEvent(event.briefId);
|
|
162
|
+
}
|
|
163
|
+
setPosition(position) {
|
|
164
|
+
this.$position = position;
|
|
165
|
+
this.$updateRootPosition();
|
|
166
|
+
}
|
|
167
|
+
setScreenMargin(screenMargin) {
|
|
168
|
+
this.$screenMargin = screenMargin;
|
|
169
|
+
this.$updateRootPosition();
|
|
170
|
+
}
|
|
171
|
+
destroy() {
|
|
172
|
+
this.$clearTimers();
|
|
173
|
+
this.$briefId = undefined;
|
|
174
|
+
this.$dismissedBriefIds.clear();
|
|
175
|
+
this.$pendingBriefIds.clear();
|
|
176
|
+
this.$disableEscapeHotKey();
|
|
177
|
+
this.$stopTransitions();
|
|
178
|
+
Main.layoutManager.untrackChrome(this.$dialogueHideButton);
|
|
179
|
+
Main.layoutManager.removeChrome(this.$root);
|
|
180
|
+
this.$root.destroy();
|
|
181
|
+
}
|
|
182
|
+
$showDialogue(event) {
|
|
183
|
+
if (this.$phase !== 'idle')
|
|
184
|
+
return;
|
|
185
|
+
this.$briefId = event.briefId;
|
|
186
|
+
const hasAvatar = this.$configureLayout(event);
|
|
187
|
+
if (!this.$layout)
|
|
188
|
+
return;
|
|
189
|
+
if (!hasAvatar) {
|
|
190
|
+
this.$showDialogueDirect(event);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.$phase = 'introducing';
|
|
194
|
+
this.$stopTransitions();
|
|
195
|
+
this.$surface.hide();
|
|
196
|
+
this.$dialoguePanel.hide();
|
|
197
|
+
const avatarTranslation = this.$getAvatarTranslation(this.$layout.avatar.initial);
|
|
198
|
+
this.$avatar.translation_x = avatarTranslation.x;
|
|
199
|
+
this.$avatar.translation_y = avatarTranslation.y;
|
|
200
|
+
this.$avatar.opacity = 0;
|
|
201
|
+
this.$avatar.scale_x = 0.65;
|
|
202
|
+
this.$avatar.scale_y = 0.65;
|
|
203
|
+
this.$avatar.show();
|
|
204
|
+
this.$root.opacity = 255;
|
|
205
|
+
this.$root.scale_x = 1;
|
|
206
|
+
this.$root.scale_y = 1;
|
|
207
|
+
this.$root.translation_y = 0;
|
|
208
|
+
this.$root.show();
|
|
209
|
+
this.$animateAvatarEntrance(event.briefId);
|
|
210
|
+
}
|
|
211
|
+
$animateAvatarEntrance(briefId) {
|
|
212
|
+
this.$avatar.ease({
|
|
213
|
+
opacity: 255,
|
|
214
|
+
scale_x: AVATAR_ENTER_OVERSHOOT_SCALE,
|
|
215
|
+
scale_y: AVATAR_ENTER_OVERSHOOT_SCALE,
|
|
216
|
+
duration: AVATAR_ENTER_DURATION_MS,
|
|
217
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
218
|
+
onStopped: () => {
|
|
219
|
+
if (briefId !== this.$briefId || this.$phase !== 'introducing')
|
|
220
|
+
return;
|
|
221
|
+
this.$avatar.ease({
|
|
222
|
+
scale_x: 1,
|
|
223
|
+
scale_y: 1,
|
|
224
|
+
duration: AVATAR_ENTER_SETTLE_DURATION_MS,
|
|
225
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
226
|
+
onStopped: () => this.$holdAvatarBeforeDialogue(briefId),
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
$holdAvatarBeforeDialogue(briefId) {
|
|
232
|
+
if (briefId !== this.$briefId || this.$phase !== 'introducing')
|
|
233
|
+
return;
|
|
234
|
+
this.$avatarTimeoutId = DialogueGLib.timeout_add(DialogueGLib.PRIORITY_DEFAULT, AVATAR_ENTER_HOLD_MS, () => {
|
|
235
|
+
this.$avatarTimeoutId = undefined;
|
|
236
|
+
if (briefId !== this.$briefId || this.$phase !== 'introducing')
|
|
237
|
+
return DialogueGLib.SOURCE_REMOVE;
|
|
238
|
+
if (this.$layout) {
|
|
239
|
+
const initialSurface = {
|
|
240
|
+
x: this.$layout.avatar.initial.x,
|
|
241
|
+
y: this.$layout.avatar.initial.y,
|
|
242
|
+
width: this.$layout.avatar.width,
|
|
243
|
+
height: this.$layout.avatar.height,
|
|
244
|
+
};
|
|
245
|
+
this.$expandDialogue(briefId, initialSurface);
|
|
246
|
+
}
|
|
247
|
+
return DialogueGLib.SOURCE_REMOVE;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
$expandDialogue(briefId, initialSurface) {
|
|
251
|
+
if (!this.$layout)
|
|
252
|
+
return;
|
|
253
|
+
this.$enableEscapeHotKey();
|
|
254
|
+
this.$phase = 'expanding';
|
|
255
|
+
const initialScaleX = initialSurface.width / this.$layout.finalSurface.width;
|
|
256
|
+
const initialScaleY = initialSurface.height / this.$layout.finalSurface.height;
|
|
257
|
+
const initialTranslationX = initialSurface.x + (initialSurface.width - this.$layout.finalSurface.width) / 2;
|
|
258
|
+
const initialTranslationY = initialSurface.y + (initialSurface.height - this.$layout.finalSurface.height) / 2;
|
|
259
|
+
this.$setRect(this.$dialoguePanel, this.$layout.finalPanel);
|
|
260
|
+
this.$dialoguePanel.opacity = 0;
|
|
261
|
+
this.$dialoguePanel.translation_y = 6;
|
|
262
|
+
this.$dialoguePanel.show();
|
|
263
|
+
this.$surface.scale_x = initialScaleX;
|
|
264
|
+
this.$surface.scale_y = initialScaleY;
|
|
265
|
+
this.$surface.translation_x = initialTranslationX;
|
|
266
|
+
this.$surface.translation_y = initialTranslationY;
|
|
267
|
+
this.$surface.opacity = 255;
|
|
268
|
+
this.$surface.show();
|
|
269
|
+
this.$surface.ease({
|
|
270
|
+
scale_x: 1,
|
|
271
|
+
scale_y: 1,
|
|
272
|
+
translation_x: 0,
|
|
273
|
+
translation_y: 0,
|
|
274
|
+
duration: DIALOGUE_EXPAND_DURATION_MS,
|
|
275
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
276
|
+
onStopped: () => {
|
|
277
|
+
if (briefId !== this.$briefId || this.$phase !== 'expanding')
|
|
278
|
+
return;
|
|
279
|
+
if (!this.$layout)
|
|
280
|
+
return;
|
|
281
|
+
this.$emphasizeDialogue(briefId, () => {
|
|
282
|
+
this.$phase = 'dialogue';
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
this.$avatar.ease({
|
|
287
|
+
translation_x: 0,
|
|
288
|
+
translation_y: 0,
|
|
289
|
+
duration: DIALOGUE_EXPAND_DURATION_MS,
|
|
290
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
291
|
+
});
|
|
292
|
+
this.$dialoguePanel.ease({
|
|
293
|
+
delay: 40,
|
|
294
|
+
opacity: 255,
|
|
295
|
+
translation_y: 0,
|
|
296
|
+
duration: CONTENT_FADE_DURATION_MS,
|
|
297
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
$emphasizeDialogue(briefId, onComplete) {
|
|
301
|
+
if (!this.$layout)
|
|
302
|
+
return;
|
|
303
|
+
const visibleRect = this.$layout.finalSurface;
|
|
304
|
+
const pivotY = (visibleRect.y + visibleRect.height / 2) / this.$layout.root.height;
|
|
305
|
+
this.$root.set_pivot_point(0.5, pivotY);
|
|
306
|
+
this.$root.ease({
|
|
307
|
+
scale_y: PANEL_EMPHASIS_SCALE_Y,
|
|
308
|
+
duration: PANEL_EMPHASIS_EXPAND_DURATION_MS,
|
|
309
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
310
|
+
onStopped: () => {
|
|
311
|
+
if (briefId !== this.$briefId || this.$phase !== 'expanding')
|
|
312
|
+
return;
|
|
313
|
+
this.$root.ease({
|
|
314
|
+
scale_y: 1,
|
|
315
|
+
duration: PANEL_EMPHASIS_SETTLE_DURATION_MS,
|
|
316
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
317
|
+
onStopped: () => {
|
|
318
|
+
if (briefId === this.$briefId && this.$phase === 'expanding')
|
|
319
|
+
onComplete();
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
$showDialogueDirect(event) {
|
|
326
|
+
this.$briefId = event.briefId;
|
|
327
|
+
this.$clearTimers();
|
|
328
|
+
this.$stopTransitions();
|
|
329
|
+
if (!this.$layout)
|
|
330
|
+
return;
|
|
331
|
+
this.$enableEscapeHotKey();
|
|
332
|
+
this.$phase = 'expanding';
|
|
333
|
+
this.$setRect(this.$surface, this.$layout.finalSurface);
|
|
334
|
+
this.$surface.scale_x = 1;
|
|
335
|
+
this.$surface.scale_y = 1;
|
|
336
|
+
this.$surface.translation_x = 0;
|
|
337
|
+
this.$surface.translation_y = 0;
|
|
338
|
+
this.$surface.opacity = 0;
|
|
339
|
+
this.$surface.show();
|
|
340
|
+
this.$setRect(this.$dialoguePanel, this.$layout.finalPanel);
|
|
341
|
+
this.$dialoguePanel.opacity = 0;
|
|
342
|
+
this.$dialoguePanel.translation_y = 5;
|
|
343
|
+
this.$dialoguePanel.show();
|
|
344
|
+
this.$placeActor(this.$avatar, this.$layout.avatar.final);
|
|
345
|
+
this.$avatar.translation_x = 0;
|
|
346
|
+
this.$avatar.translation_y = 0;
|
|
347
|
+
this.$avatar.opacity = 255;
|
|
348
|
+
this.$avatar.scale_x = 1;
|
|
349
|
+
this.$avatar.scale_y = 1;
|
|
350
|
+
this.$root.opacity = 255;
|
|
351
|
+
this.$root.scale_x = 1;
|
|
352
|
+
this.$root.scale_y = 1;
|
|
353
|
+
this.$root.translation_y = 0;
|
|
354
|
+
this.$root.show();
|
|
355
|
+
this.$surface.ease({
|
|
356
|
+
opacity: 255,
|
|
357
|
+
duration: CONTENT_FADE_DURATION_MS,
|
|
358
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
359
|
+
onStopped: () => {
|
|
360
|
+
if (event.briefId !== this.$briefId || this.$phase !== 'expanding')
|
|
361
|
+
return;
|
|
362
|
+
if (!this.$layout)
|
|
363
|
+
return;
|
|
364
|
+
this.$emphasizeDialogue(event.briefId, () => {
|
|
365
|
+
this.$phase = 'dialogue';
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
this.$dialoguePanel.ease({
|
|
370
|
+
opacity: 255,
|
|
371
|
+
translation_y: 0,
|
|
372
|
+
duration: CONTENT_FADE_DURATION_MS,
|
|
373
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
$replaceDialogue(event) {
|
|
377
|
+
this.$briefId = event.briefId;
|
|
378
|
+
this.$clearTimers();
|
|
379
|
+
this.$stopTransitions();
|
|
380
|
+
this.$configureLayout(event);
|
|
381
|
+
if (!this.$layout)
|
|
382
|
+
return;
|
|
383
|
+
this.$enableEscapeHotKey();
|
|
384
|
+
this.$phase = 'expanding';
|
|
385
|
+
this.$showDialogueSteady();
|
|
386
|
+
this.$emphasizeDialogue(event.briefId, () => {
|
|
387
|
+
this.$phase = 'dialogue';
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
$showDialogueSteady() {
|
|
391
|
+
if (!this.$layout)
|
|
392
|
+
return;
|
|
393
|
+
this.$setRect(this.$surface, this.$layout.finalSurface);
|
|
394
|
+
this.$surface.scale_x = 1;
|
|
395
|
+
this.$surface.scale_y = 1;
|
|
396
|
+
this.$surface.translation_x = 0;
|
|
397
|
+
this.$surface.translation_y = 0;
|
|
398
|
+
this.$surface.opacity = 255;
|
|
399
|
+
this.$surface.show();
|
|
400
|
+
this.$setRect(this.$dialoguePanel, this.$layout.finalPanel);
|
|
401
|
+
this.$dialoguePanel.opacity = 255;
|
|
402
|
+
this.$dialoguePanel.translation_y = 0;
|
|
403
|
+
this.$dialoguePanel.show();
|
|
404
|
+
this.$placeActor(this.$avatar, this.$layout.avatar.final);
|
|
405
|
+
this.$avatar.translation_x = 0;
|
|
406
|
+
this.$avatar.translation_y = 0;
|
|
407
|
+
this.$avatar.opacity = 255;
|
|
408
|
+
this.$avatar.scale_x = 1;
|
|
409
|
+
this.$avatar.scale_y = 1;
|
|
410
|
+
this.$root.opacity = 255;
|
|
411
|
+
this.$root.scale_x = 1;
|
|
412
|
+
this.$root.scale_y = 1;
|
|
413
|
+
this.$root.translation_y = 0;
|
|
414
|
+
this.$root.show();
|
|
415
|
+
}
|
|
416
|
+
$handleTerminalEvent(briefId) {
|
|
417
|
+
this.$removePendingBrief(briefId);
|
|
418
|
+
if (briefId !== this.$briefId)
|
|
419
|
+
return;
|
|
420
|
+
if (this.$pendingBriefIds.size === 0) {
|
|
421
|
+
this.$hide(briefId);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
this.$holdDialogue(briefId);
|
|
425
|
+
}
|
|
426
|
+
$removePendingBrief(briefId) {
|
|
427
|
+
this.$pendingBriefIds.delete(briefId);
|
|
428
|
+
if (this.$phase === 'holding' && this.$pendingBriefIds.size === 0 && this.$briefId) {
|
|
429
|
+
this.$hide(this.$briefId);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
$holdDialogue(briefId) {
|
|
433
|
+
if (briefId !== this.$briefId)
|
|
434
|
+
return;
|
|
435
|
+
this.$clearTimers();
|
|
436
|
+
this.$stopTransitions();
|
|
437
|
+
this.$showDialogueSteady();
|
|
438
|
+
this.$phase = 'holding';
|
|
439
|
+
}
|
|
440
|
+
$hide(briefId) {
|
|
441
|
+
if (briefId !== this.$briefId)
|
|
442
|
+
return;
|
|
443
|
+
this.$clearTimers();
|
|
444
|
+
this.$phase = 'exiting';
|
|
445
|
+
this.$stopTransitions();
|
|
446
|
+
this.$disableEscapeHotKey();
|
|
447
|
+
this.$dialogueHideButton.hide();
|
|
448
|
+
if (!this.$root.visible) {
|
|
449
|
+
this.$finishHide(briefId);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.$dialoguePanel.ease({
|
|
453
|
+
opacity: 0,
|
|
454
|
+
duration: 70,
|
|
455
|
+
mode: DialogueClutter.AnimationMode.EASE_IN_QUAD,
|
|
456
|
+
});
|
|
457
|
+
if (!this.$layout || !this.$avatar.visible) {
|
|
458
|
+
this.$fadeRoot(briefId);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!this.$surface.visible) {
|
|
462
|
+
this.$fadeAvatar(briefId);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const initialAvatarTranslation = this.$getAvatarTranslation(this.$layout.avatar.initial);
|
|
466
|
+
this.$surface.ease({
|
|
467
|
+
scale_x: 0.96,
|
|
468
|
+
scale_y: 0.96,
|
|
469
|
+
opacity: 0,
|
|
470
|
+
duration: EXIT_PANEL_FADE_DURATION_MS,
|
|
471
|
+
mode: DialogueClutter.AnimationMode.EASE_IN_QUAD,
|
|
472
|
+
});
|
|
473
|
+
this.$avatar.ease({
|
|
474
|
+
translation_x: initialAvatarTranslation.x,
|
|
475
|
+
translation_y: initialAvatarTranslation.y,
|
|
476
|
+
duration: EXIT_COLLAPSE_DURATION_MS,
|
|
477
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
478
|
+
onStopped: () => this.$fadeAvatar(briefId),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
$dismissCurrentBrief() {
|
|
482
|
+
if (!this.$briefId)
|
|
483
|
+
return;
|
|
484
|
+
this.$dismissedBriefIds.add(this.$briefId);
|
|
485
|
+
this.$hide(this.$briefId);
|
|
486
|
+
}
|
|
487
|
+
$enableEscapeHotKey() {
|
|
488
|
+
if (this.$escapeHotKeyRegistered)
|
|
489
|
+
return;
|
|
490
|
+
this.$escapeHotKeyRegistered = Main.keybindingManager.addHotKey(HIDE_HOT_KEY_NAME, 'Escape', () => this.$dismissCurrentBrief());
|
|
491
|
+
}
|
|
492
|
+
$disableEscapeHotKey() {
|
|
493
|
+
if (!this.$escapeHotKeyRegistered)
|
|
494
|
+
return;
|
|
495
|
+
Main.keybindingManager.removeHotKey(HIDE_HOT_KEY_NAME);
|
|
496
|
+
this.$escapeHotKeyRegistered = false;
|
|
497
|
+
}
|
|
498
|
+
$fadeAvatar(briefId) {
|
|
499
|
+
if (briefId !== this.$briefId || this.$phase !== 'exiting')
|
|
500
|
+
return;
|
|
501
|
+
this.$avatarTimeoutId = DialogueGLib.timeout_add(DialogueGLib.PRIORITY_DEFAULT, EXIT_AVATAR_HOLD_MS, () => {
|
|
502
|
+
this.$avatarTimeoutId = undefined;
|
|
503
|
+
if (briefId === this.$briefId && this.$phase === 'exiting')
|
|
504
|
+
this.$pulseAvatarBeforeExit(briefId);
|
|
505
|
+
return DialogueGLib.SOURCE_REMOVE;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
$pulseAvatarBeforeExit(briefId) {
|
|
509
|
+
this.$avatar.ease({
|
|
510
|
+
scale_x: EXIT_AVATAR_OVERSHOOT_SCALE,
|
|
511
|
+
scale_y: EXIT_AVATAR_OVERSHOOT_SCALE,
|
|
512
|
+
duration: EXIT_AVATAR_PULSE_DURATION_MS,
|
|
513
|
+
mode: DialogueClutter.AnimationMode.EASE_OUT_CUBIC,
|
|
514
|
+
onStopped: () => {
|
|
515
|
+
if (briefId !== this.$briefId || this.$phase !== 'exiting')
|
|
516
|
+
return;
|
|
517
|
+
this.$avatar.ease({
|
|
518
|
+
opacity: 0,
|
|
519
|
+
scale_x: 0.65,
|
|
520
|
+
scale_y: 0.65,
|
|
521
|
+
duration: EXIT_AVATAR_DURATION_MS,
|
|
522
|
+
mode: DialogueClutter.AnimationMode.EASE_IN_QUAD,
|
|
523
|
+
onStopped: () => this.$finishHide(briefId),
|
|
524
|
+
});
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
$fadeRoot(briefId) {
|
|
529
|
+
this.$root.ease({
|
|
530
|
+
opacity: 0,
|
|
531
|
+
translation_y: -8,
|
|
532
|
+
duration: EXIT_COLLAPSE_DURATION_MS,
|
|
533
|
+
mode: DialogueClutter.AnimationMode.EASE_IN_QUAD,
|
|
534
|
+
onStopped: () => this.$finishHide(briefId),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
$finishHide(briefId) {
|
|
538
|
+
if (briefId !== this.$briefId || this.$phase !== 'exiting')
|
|
539
|
+
return;
|
|
540
|
+
this.$root.hide();
|
|
541
|
+
this.$surface.hide();
|
|
542
|
+
this.$dialoguePanel.hide();
|
|
543
|
+
this.$root.scale_x = 1;
|
|
544
|
+
this.$root.scale_y = 1;
|
|
545
|
+
this.$briefId = undefined;
|
|
546
|
+
this.$layout = undefined;
|
|
547
|
+
this.$phase = 'idle';
|
|
548
|
+
}
|
|
549
|
+
$configureLayout(event) {
|
|
550
|
+
const accentColor = this.$resolveAccentColor(event.persona?.color);
|
|
551
|
+
const personaName = event.persona?.name ?? 'Voice Brief';
|
|
552
|
+
const metadata = [event.source?.agent, event.source?.model]
|
|
553
|
+
.filter((value) => value !== undefined)
|
|
554
|
+
.join(' · ');
|
|
555
|
+
const contextText = event.source?.session || metadata;
|
|
556
|
+
this.$surface.set_style(`border-color: ${accentColor};`);
|
|
557
|
+
this.$nameLabel.text = personaName;
|
|
558
|
+
this.$nameLabel.set_style(`color: ${accentColor};`);
|
|
559
|
+
this.$contextLabel.text = contextText;
|
|
560
|
+
if (contextText)
|
|
561
|
+
this.$contextLabel.show();
|
|
562
|
+
else
|
|
563
|
+
this.$contextLabel.hide();
|
|
564
|
+
this.$textLabel.text = event.brief.text;
|
|
565
|
+
this.$dialogueHideButton.show();
|
|
566
|
+
const hasAvatar = this.$setAvatar(event.persona?.avatar);
|
|
567
|
+
const [, measuredAvatarWidth] = this.$avatar.get_preferred_width(-1);
|
|
568
|
+
const [, measuredAvatarHeight] = this.$avatar.get_preferred_height(measuredAvatarWidth);
|
|
569
|
+
const avatarWidth = hasAvatar ? measuredAvatarWidth : 0;
|
|
570
|
+
const avatarHeight = hasAvatar ? measuredAvatarHeight : 0;
|
|
571
|
+
const monitor = Main.layoutManager.primaryMonitor;
|
|
572
|
+
const finalWidth = Math.min(FINAL_WIDTH, monitor.width - 64);
|
|
573
|
+
const avatarGap = hasAvatar ? CONTENT_GAP : 0;
|
|
574
|
+
const finalPanelWidth = finalWidth - CARD_INSET_X * 2 - avatarWidth - avatarGap;
|
|
575
|
+
this.$dialoguePanel.set_size(finalPanelWidth, -1);
|
|
576
|
+
this.$dialoguePanel.show();
|
|
577
|
+
const [, finalPanelHeight] = this.$dialoguePanel.get_preferred_height(finalPanelWidth);
|
|
578
|
+
const finalHeight = Math.max(avatarHeight + CARD_INSET_Y * 2, finalPanelHeight + CARD_INSET_Y * 2);
|
|
579
|
+
const rootHeight = finalHeight;
|
|
580
|
+
const finalY = this.$getContentY(rootHeight, finalHeight);
|
|
581
|
+
const finalAvatarX = CARD_INSET_X;
|
|
582
|
+
this.$layout = {
|
|
583
|
+
root: {
|
|
584
|
+
x: monitor.x + Math.round((monitor.width - finalWidth) / 2),
|
|
585
|
+
y: this.$getRootY(rootHeight),
|
|
586
|
+
width: finalWidth,
|
|
587
|
+
height: rootHeight,
|
|
588
|
+
},
|
|
589
|
+
finalSurface: { x: 0, y: finalY, width: finalWidth, height: finalHeight },
|
|
590
|
+
avatar: {
|
|
591
|
+
width: avatarWidth,
|
|
592
|
+
height: avatarHeight,
|
|
593
|
+
initial: {
|
|
594
|
+
x: Math.round((finalWidth - avatarWidth) / 2),
|
|
595
|
+
y: finalY + Math.round((finalHeight - avatarHeight) / 2),
|
|
596
|
+
},
|
|
597
|
+
final: {
|
|
598
|
+
x: finalAvatarX,
|
|
599
|
+
y: finalY + Math.round((finalHeight - avatarHeight) / 2),
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
finalPanel: {
|
|
603
|
+
x: finalAvatarX + avatarWidth + avatarGap,
|
|
604
|
+
y: finalY + CARD_INSET_Y,
|
|
605
|
+
width: finalPanelWidth,
|
|
606
|
+
height: finalPanelHeight,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
this.$setRect(this.$root, this.$layout.root);
|
|
610
|
+
this.$setRect(this.$surface, this.$layout.finalSurface);
|
|
611
|
+
this.$placeActor(this.$avatar, this.$layout.avatar.final);
|
|
612
|
+
this.$dialoguePanel.hide();
|
|
613
|
+
return hasAvatar;
|
|
614
|
+
}
|
|
615
|
+
$getContentY(rootHeight, contentHeight) {
|
|
616
|
+
if (this.$position === 'bottom')
|
|
617
|
+
return rootHeight - contentHeight;
|
|
618
|
+
return 0;
|
|
619
|
+
}
|
|
620
|
+
$getRootY(rootHeight) {
|
|
621
|
+
const monitor = Main.layoutManager.primaryMonitor;
|
|
622
|
+
if (this.$position === 'bottom')
|
|
623
|
+
return monitor.y + monitor.height - this.$screenMargin - rootHeight;
|
|
624
|
+
return monitor.y + this.$screenMargin;
|
|
625
|
+
}
|
|
626
|
+
$updateRootPosition() {
|
|
627
|
+
if (!this.$layout)
|
|
628
|
+
return;
|
|
629
|
+
this.$layout.root.y = this.$getRootY(this.$layout.root.height);
|
|
630
|
+
this.$root.set_position(this.$layout.root.x, this.$layout.root.y);
|
|
631
|
+
}
|
|
632
|
+
$setAvatar(avatarPath) {
|
|
633
|
+
if (!avatarPath || !DialogueGLib.file_test(avatarPath, DialogueGLib.FileTest.IS_REGULAR)) {
|
|
634
|
+
this.$avatar.hide();
|
|
635
|
+
this.$avatar.set_style('');
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
const avatarUri = DialogueGio.File.new_for_path(avatarPath).get_uri();
|
|
639
|
+
this.$avatar.set_style(`${AVATAR_LAYOUT_STYLE} background-image: url("${avatarUri}"); background-size: cover;`);
|
|
640
|
+
this.$avatar.show();
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
$setRect(actor, rect) {
|
|
644
|
+
actor.set_position(rect.x, rect.y);
|
|
645
|
+
actor.set_size(rect.width, rect.height);
|
|
646
|
+
}
|
|
647
|
+
$placeActor(actor, point) {
|
|
648
|
+
actor.set_position(point.x, point.y);
|
|
649
|
+
}
|
|
650
|
+
$getAvatarTranslation(point) {
|
|
651
|
+
if (!this.$layout)
|
|
652
|
+
return { x: 0, y: 0 };
|
|
653
|
+
return {
|
|
654
|
+
x: point.x - this.$layout.avatar.final.x,
|
|
655
|
+
y: point.y - this.$layout.avatar.final.y,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
$stopTransitions() {
|
|
659
|
+
this.$root.remove_all_transitions();
|
|
660
|
+
this.$surface.remove_all_transitions();
|
|
661
|
+
this.$avatar.remove_all_transitions();
|
|
662
|
+
this.$dialoguePanel.remove_all_transitions();
|
|
663
|
+
}
|
|
664
|
+
$clearTimers() {
|
|
665
|
+
if (this.$avatarTimeoutId !== undefined) {
|
|
666
|
+
DialogueGLib.Source.remove(this.$avatarTimeoutId);
|
|
667
|
+
this.$avatarTimeoutId = undefined;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
$resolveAccentColor(color) {
|
|
671
|
+
if (color && /^#[0-9a-f]{6}$/i.test(color))
|
|
672
|
+
return color;
|
|
673
|
+
return DEFAULT_ACCENT_COLOR;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon exposes top-level functions through its legacy GJS module loader.
|
|
677
|
+
function createDialogueOverlay(position, screenMargin) {
|
|
678
|
+
return new DialogueOverlay(position, screenMargin);
|
|
679
|
+
}
|
|
680
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon exposes top-level functions through its legacy GJS module loader.
|
|
681
|
+
function getDialoguePlaybackStartDelayMs() {
|
|
682
|
+
return PLAYBACK_START_DELAY_MS;
|
|
683
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { createHookServer: createExtensionHookServer, getDefaultSocketPath: getExtensionSocketPath, } = require('./lib/hook-server');
|
|
3
|
+
const { parseHookEvent: parseExtensionHookEvent } = require('./lib/protocol');
|
|
4
|
+
const { createDialogueOverlay: createExtensionDialogueOverlay, getDialoguePlaybackStartDelayMs: getExtensionPlaybackStartDelayMs, } = require('./dialogue-overlay');
|
|
5
|
+
const ExtensionGio = imports.gi.Gio;
|
|
6
|
+
const ExtensionGLib = imports.gi.GLib;
|
|
7
|
+
const CinnamonSettings = imports.ui.settings;
|
|
8
|
+
class VoiceBriefExtension {
|
|
9
|
+
$uuid;
|
|
10
|
+
$screenMargin = 112;
|
|
11
|
+
$screenPosition = 'top';
|
|
12
|
+
$settings;
|
|
13
|
+
$hookServer;
|
|
14
|
+
$dialogueOverlay;
|
|
15
|
+
constructor(metadata) {
|
|
16
|
+
this.$uuid = metadata.uuid;
|
|
17
|
+
}
|
|
18
|
+
enable() {
|
|
19
|
+
const settings = new CinnamonSettings.ExtensionSettings(this, this.$uuid);
|
|
20
|
+
settings.bind('screen-position', '$screenPosition', () => {
|
|
21
|
+
this.$dialogueOverlay?.setPosition(this.$screenPosition);
|
|
22
|
+
});
|
|
23
|
+
settings.bind('screen-margin', '$screenMargin', () => {
|
|
24
|
+
this.$dialogueOverlay?.setScreenMargin(this.$screenMargin);
|
|
25
|
+
});
|
|
26
|
+
const dialogueOverlay = createExtensionDialogueOverlay(this.$screenPosition, this.$screenMargin);
|
|
27
|
+
const hookServer = createExtensionHookServer(getExtensionSocketPath(), parseExtensionHookEvent, event => dialogueOverlay.handleEvent(event), error => global.logError(error, '[voice-brief] rejected hook event'));
|
|
28
|
+
try {
|
|
29
|
+
hookServer.start();
|
|
30
|
+
this.$configurePlaybackStartDelay();
|
|
31
|
+
this.$settings = settings;
|
|
32
|
+
this.$dialogueOverlay = dialogueOverlay;
|
|
33
|
+
this.$hookServer = hookServer;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
settings.finalize();
|
|
37
|
+
dialogueOverlay.destroy();
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
$configurePlaybackStartDelay() {
|
|
42
|
+
const executable = ExtensionGLib.find_program_in_path('voice-brief');
|
|
43
|
+
if (!executable) {
|
|
44
|
+
global.log('[voice-brief] executable not found; playback start delay was not configured');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const delayMs = getExtensionPlaybackStartDelayMs();
|
|
48
|
+
try {
|
|
49
|
+
const process = ExtensionGio.Subprocess.new([
|
|
50
|
+
executable,
|
|
51
|
+
'runtime',
|
|
52
|
+
'configure',
|
|
53
|
+
'--playback-start-delay-ms',
|
|
54
|
+
String(delayMs),
|
|
55
|
+
], ExtensionGio.SubprocessFlags.STDOUT_SILENCE | ExtensionGio.SubprocessFlags.STDERR_SILENCE);
|
|
56
|
+
process.wait_check_async(null, (_source, result) => {
|
|
57
|
+
try {
|
|
58
|
+
process.wait_check_finish(result);
|
|
59
|
+
global.log(`[voice-brief] playback start delay configured to ${delayMs}ms`);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
global.logError(error, '[voice-brief] failed to configure playback start delay');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
global.logError(error, '[voice-brief] failed to start playback delay configuration');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
disable() {
|
|
71
|
+
this.$hookServer?.stop();
|
|
72
|
+
this.$settings?.finalize();
|
|
73
|
+
this.$dialogueOverlay?.destroy();
|
|
74
|
+
this.$settings = undefined;
|
|
75
|
+
this.$hookServer = undefined;
|
|
76
|
+
this.$dialogueOverlay = undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let extension;
|
|
80
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon invokes this lifecycle function through its extension loader.
|
|
81
|
+
function init(metadata) {
|
|
82
|
+
extension = new VoiceBriefExtension(metadata);
|
|
83
|
+
}
|
|
84
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon invokes this lifecycle function through its extension loader.
|
|
85
|
+
function enable() {
|
|
86
|
+
if (!extension)
|
|
87
|
+
throw new Error('Voice Brief extension was not initialized');
|
|
88
|
+
extension.enable();
|
|
89
|
+
}
|
|
90
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon invokes this lifecycle function through its extension loader.
|
|
91
|
+
function disable() {
|
|
92
|
+
extension?.disable();
|
|
93
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const Gio = imports.gi.Gio;
|
|
3
|
+
const GLib = imports.gi.GLib;
|
|
4
|
+
const MAX_MESSAGE_BYTES = 64 * 1024;
|
|
5
|
+
const READ_CHUNK_BYTES = 4096;
|
|
6
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon exposes top-level functions through its legacy GJS module loader.
|
|
7
|
+
function getDefaultSocketPath() {
|
|
8
|
+
return GLib.build_filenamev([GLib.get_user_runtime_dir(), 'voice-brief', 'cinnamon.sock']);
|
|
9
|
+
}
|
|
10
|
+
class HookServer {
|
|
11
|
+
$socketPath;
|
|
12
|
+
$parseEvent;
|
|
13
|
+
$onEvent;
|
|
14
|
+
$onError;
|
|
15
|
+
$service;
|
|
16
|
+
$cancellable;
|
|
17
|
+
$incomingId;
|
|
18
|
+
$connections = new Set();
|
|
19
|
+
constructor($socketPath, $parseEvent, $onEvent, $onError) {
|
|
20
|
+
this.$socketPath = $socketPath;
|
|
21
|
+
this.$parseEvent = $parseEvent;
|
|
22
|
+
this.$onEvent = $onEvent;
|
|
23
|
+
this.$onError = $onError;
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
if (this.$service)
|
|
27
|
+
throw new Error('Hook server is already running');
|
|
28
|
+
const directory = GLib.path_get_dirname(this.$socketPath);
|
|
29
|
+
if (GLib.mkdir_with_parents(directory, 0o700) !== 0) {
|
|
30
|
+
throw new Error(`Unable to create socket directory: ${directory}`);
|
|
31
|
+
}
|
|
32
|
+
if (GLib.chmod(directory, 0o700) !== 0) {
|
|
33
|
+
throw new Error(`Unable to secure socket directory: ${directory}`);
|
|
34
|
+
}
|
|
35
|
+
this.$removeSocketFile();
|
|
36
|
+
const cancellable = new Gio.Cancellable();
|
|
37
|
+
const service = new Gio.SocketService();
|
|
38
|
+
const address = new Gio.UnixSocketAddress({ path: this.$socketPath });
|
|
39
|
+
try {
|
|
40
|
+
service.add_address(address, Gio.SocketType.STREAM, Gio.SocketProtocol.DEFAULT, null);
|
|
41
|
+
const incomingId = service.connect('incoming', (_service, connection) => {
|
|
42
|
+
this.$accept(connection, cancellable);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
service.start();
|
|
46
|
+
this.$cancellable = cancellable;
|
|
47
|
+
this.$service = service;
|
|
48
|
+
this.$incomingId = incomingId;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
service.close();
|
|
52
|
+
this.$removeSocketFile();
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
stop() {
|
|
57
|
+
if (!this.$service || !this.$cancellable || this.$incomingId === undefined)
|
|
58
|
+
return;
|
|
59
|
+
this.$cancellable.cancel();
|
|
60
|
+
for (const connection of this.$connections)
|
|
61
|
+
connection.close(null);
|
|
62
|
+
this.$connections.clear();
|
|
63
|
+
this.$service.disconnect(this.$incomingId);
|
|
64
|
+
this.$service.stop();
|
|
65
|
+
this.$service.close();
|
|
66
|
+
this.$service = undefined;
|
|
67
|
+
this.$cancellable = undefined;
|
|
68
|
+
this.$incomingId = undefined;
|
|
69
|
+
this.$removeSocketFile();
|
|
70
|
+
}
|
|
71
|
+
$accept(connection, cancellable) {
|
|
72
|
+
this.$connections.add(connection);
|
|
73
|
+
this.$readChunk(connection, connection.get_input_stream(), [], cancellable);
|
|
74
|
+
}
|
|
75
|
+
$readChunk(connection, input, buffer, cancellable) {
|
|
76
|
+
input.read_bytes_async(READ_CHUNK_BYTES, GLib.PRIORITY_DEFAULT, cancellable, (_stream, result) => {
|
|
77
|
+
let bytes;
|
|
78
|
+
try {
|
|
79
|
+
bytes = input.read_bytes_finish(result).get_data() ?? new Uint8Array();
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!cancellable.is_cancelled())
|
|
83
|
+
this.$onError(error);
|
|
84
|
+
this.$closeConnection(connection);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (bytes.length === 0) {
|
|
88
|
+
this.$onError(new Error('Hook connection closed before newline'));
|
|
89
|
+
this.$closeConnection(connection);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const newlineIndex = bytes.indexOf(10);
|
|
93
|
+
const chunkLength = newlineIndex === -1 ? bytes.length : newlineIndex;
|
|
94
|
+
if (buffer.length + chunkLength > MAX_MESSAGE_BYTES) {
|
|
95
|
+
this.$onError(new Error(`Hook message exceeds ${MAX_MESSAGE_BYTES} bytes`));
|
|
96
|
+
this.$closeConnection(connection);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (let index = 0; index < chunkLength; index++)
|
|
100
|
+
buffer.push(bytes[index]);
|
|
101
|
+
if (newlineIndex === -1) {
|
|
102
|
+
this.$readChunk(connection, input, buffer, cancellable);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (buffer.at(-1) === 13)
|
|
106
|
+
buffer.pop();
|
|
107
|
+
try {
|
|
108
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(buffer));
|
|
109
|
+
this.$onEvent(this.$parseEvent(text));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
this.$onError(error);
|
|
113
|
+
}
|
|
114
|
+
this.$closeConnection(connection);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
$closeConnection(connection) {
|
|
118
|
+
if (!this.$connections.delete(connection))
|
|
119
|
+
return;
|
|
120
|
+
connection.close_async(GLib.PRIORITY_DEFAULT, null, (_stream, result) => {
|
|
121
|
+
try {
|
|
122
|
+
connection.close_finish(result);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.$onError(error);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
$removeSocketFile() {
|
|
130
|
+
if (GLib.file_test(this.$socketPath, GLib.FileTest.EXISTS))
|
|
131
|
+
GLib.unlink(this.$socketPath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon exposes top-level functions through its legacy GJS module loader.
|
|
135
|
+
function createHookServer(socketPath, parseEvent, onEvent, onError) {
|
|
136
|
+
return new HookServer(socketPath, parseEvent, onEvent, onError);
|
|
137
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const EVENT_NAMES = new Set([
|
|
3
|
+
'brief.skipped',
|
|
4
|
+
'audio.preparing',
|
|
5
|
+
'audio.ready',
|
|
6
|
+
'audio.failed',
|
|
7
|
+
'playback.queued',
|
|
8
|
+
'playback.ready',
|
|
9
|
+
'playback.started',
|
|
10
|
+
'playback.completed',
|
|
11
|
+
'playback.failed',
|
|
12
|
+
'playback.skipped',
|
|
13
|
+
]);
|
|
14
|
+
const BRIEF_KINDS = new Set(['final', 'progress', 'test']);
|
|
15
|
+
const PRIORITIES = new Set(['normal', 'high']);
|
|
16
|
+
const AUDIO_SOURCES = new Set(['cache', 'provider']);
|
|
17
|
+
const SKIP_REASONS = new Set(['disabled', 'duplicate', 'empty_text', 'player_disabled', 'throttled']);
|
|
18
|
+
function requireRecord(value, path) {
|
|
19
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
20
|
+
throw new Error(`${path} must be an object`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function requireString(value, path, allowEmpty = false) {
|
|
25
|
+
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
|
|
26
|
+
throw new Error(`${path} must be a string`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function validateOptionalString(value, path) {
|
|
31
|
+
if (value !== undefined)
|
|
32
|
+
requireString(value, path);
|
|
33
|
+
}
|
|
34
|
+
function validateSource(value) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
return;
|
|
37
|
+
const source = requireRecord(value, 'source');
|
|
38
|
+
validateOptionalString(source['agent'], 'source.agent');
|
|
39
|
+
validateOptionalString(source['model'], 'source.model');
|
|
40
|
+
validateOptionalString(source['session'], 'source.session');
|
|
41
|
+
}
|
|
42
|
+
function validatePersona(value) {
|
|
43
|
+
if (value === undefined)
|
|
44
|
+
return;
|
|
45
|
+
const persona = requireRecord(value, 'persona');
|
|
46
|
+
requireString(persona['name'], 'persona.name');
|
|
47
|
+
validateOptionalString(persona['avatar'], 'persona.avatar');
|
|
48
|
+
validateOptionalString(persona['color'], 'persona.color');
|
|
49
|
+
}
|
|
50
|
+
function validateAudio(value) {
|
|
51
|
+
if (value === undefined)
|
|
52
|
+
return;
|
|
53
|
+
const audio = requireRecord(value, 'audio');
|
|
54
|
+
requireString(audio['provider'], 'audio.provider');
|
|
55
|
+
if (!AUDIO_SOURCES.has(audio['source']))
|
|
56
|
+
throw new Error('audio.source is invalid');
|
|
57
|
+
if (audio['durationMs'] !== undefined && (typeof audio['durationMs'] !== 'number' || audio['durationMs'] < 0)) {
|
|
58
|
+
throw new Error('audio.durationMs must be a non-negative number');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function validateError(value) {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return;
|
|
64
|
+
const eventError = requireRecord(value, 'error');
|
|
65
|
+
requireString(eventError['stage'], 'error.stage');
|
|
66
|
+
requireString(eventError['message'], 'error.message');
|
|
67
|
+
}
|
|
68
|
+
// oxlint-disable-next-line no-unused-vars -- Cinnamon exposes top-level functions through its legacy GJS module loader.
|
|
69
|
+
function parseHookEvent(text) {
|
|
70
|
+
const event = requireRecord(JSON.parse(text), 'event');
|
|
71
|
+
if (event['protocol'] !== 'voice-brief.hook-event')
|
|
72
|
+
throw new Error('unsupported hook protocol');
|
|
73
|
+
if (event['version'] !== 2)
|
|
74
|
+
throw new Error('unsupported hook protocol version');
|
|
75
|
+
requireString(event['eventId'], 'eventId');
|
|
76
|
+
requireString(event['occurredAt'], 'occurredAt');
|
|
77
|
+
requireString(event['briefId'], 'briefId');
|
|
78
|
+
if (!EVENT_NAMES.has(event['event']))
|
|
79
|
+
throw new Error('event name is invalid');
|
|
80
|
+
if (!Number.isInteger(event['sequence']) || event['sequence'] < 1) {
|
|
81
|
+
throw new Error('sequence must be a positive integer');
|
|
82
|
+
}
|
|
83
|
+
const brief = requireRecord(event['brief'], 'brief');
|
|
84
|
+
requireString(brief['text'], 'brief.text', true);
|
|
85
|
+
if (!BRIEF_KINDS.has(brief['kind']))
|
|
86
|
+
throw new Error('brief.kind is invalid');
|
|
87
|
+
if (!PRIORITIES.has(brief['priority']))
|
|
88
|
+
throw new Error('brief.priority is invalid');
|
|
89
|
+
validateSource(event['source']);
|
|
90
|
+
validatePersona(event['persona']);
|
|
91
|
+
validateAudio(event['audio']);
|
|
92
|
+
validateError(event['error']);
|
|
93
|
+
if (event['reason'] !== undefined && !SKIP_REASONS.has(event['reason'])) {
|
|
94
|
+
throw new Error('reason is invalid');
|
|
95
|
+
}
|
|
96
|
+
return event;
|
|
97
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"uuid": "voice-brief@tinyaxis",
|
|
3
|
+
"name": "Voice Brief",
|
|
4
|
+
"description": "Display Voice Brief playback events as desktop dialogue",
|
|
5
|
+
"author": "TinyAxis",
|
|
6
|
+
"url": "https://github.com/Geequlim/voice-brief",
|
|
7
|
+
"version": 1,
|
|
8
|
+
"cinnamon-version": [
|
|
9
|
+
"6.6"
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display": {
|
|
3
|
+
"type": "section",
|
|
4
|
+
"description": "显示"
|
|
5
|
+
},
|
|
6
|
+
"screen-position": {
|
|
7
|
+
"type": "combobox",
|
|
8
|
+
"description": "屏幕位置",
|
|
9
|
+
"default": "top",
|
|
10
|
+
"options": {
|
|
11
|
+
"靠顶部": "top",
|
|
12
|
+
"靠底部": "bottom"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"screen-margin": {
|
|
16
|
+
"type": "spinbutton",
|
|
17
|
+
"description": "屏幕边距",
|
|
18
|
+
"default": 112,
|
|
19
|
+
"min": 0,
|
|
20
|
+
"max": 512,
|
|
21
|
+
"step": 8,
|
|
22
|
+
"units": "px"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
.voice-brief-dialogue-surface {
|
|
2
|
+
border: 2px solid #eb4272;
|
|
3
|
+
border-radius: 32px;
|
|
4
|
+
background-gradient-direction: vertical;
|
|
5
|
+
background-gradient-start: rgba(29, 25, 36, 0.97);
|
|
6
|
+
background-gradient-end: rgba(16, 17, 25, 0.97);
|
|
7
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.92);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.voice-brief-dialogue-avatar {
|
|
11
|
+
width: 96px;
|
|
12
|
+
height: 96px;
|
|
13
|
+
border: 3px solid rgba(255, 255, 255, 0.96);
|
|
14
|
+
border-radius: 999px;
|
|
15
|
+
background-color: #ffffff;
|
|
16
|
+
background-size: cover;
|
|
17
|
+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.78);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.voice-brief-dialogue-panel {
|
|
21
|
+
spacing: 8px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.voice-brief-dialogue-header {
|
|
25
|
+
spacing: 10px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.voice-brief-dialogue-context-label {
|
|
29
|
+
padding-right: 18px;
|
|
30
|
+
text-align: right;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.voice-brief-dialogue-name {
|
|
34
|
+
font-size: 20px;
|
|
35
|
+
font-weight: bold;
|
|
36
|
+
color: #eb4272;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.voice-brief-dialogue-text {
|
|
40
|
+
padding: 1px 0;
|
|
41
|
+
font-size: 20px;
|
|
42
|
+
font-weight: bold;
|
|
43
|
+
color: #ffffff;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.voice-brief-dialogue-runtime {
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
color: rgba(255, 255, 255, 0.46);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.voice-brief-dialogue-hide-row {
|
|
52
|
+
padding-right: 18px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.voice-brief-dialogue-hide-button {
|
|
56
|
+
padding: 1px 8px;
|
|
57
|
+
border: 0;
|
|
58
|
+
border-radius: 999px;
|
|
59
|
+
background-color: transparent;
|
|
60
|
+
box-shadow: none;
|
|
61
|
+
font-size: 11px;
|
|
62
|
+
color: rgba(255, 255, 255, 0.38);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.voice-brief-dialogue-hide-button:hover,
|
|
66
|
+
.voice-brief-dialogue-hide-button:focus {
|
|
67
|
+
background-color: rgba(255, 255, 255, 0.08);
|
|
68
|
+
color: rgba(255, 255, 255, 0.7);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.voice-brief-dialogue-hide-button:active {
|
|
72
|
+
background-color: rgba(255, 255, 255, 0.12);
|
|
73
|
+
color: rgba(255, 255, 255, 0.9);
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tinyaxis/voice-brief-cinnamon",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Cinnamon desktop overlay for Voice Brief hook events",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Geequlim/voice-brief.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/Geequlim/voice-brief/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Geequlim/voice-brief#readme",
|
|
14
|
+
"os": [
|
|
15
|
+
"linux"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24.0.0"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"extension",
|
|
22
|
+
"scripts",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"postinstall": "node scripts/install.js"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @tinyaxis/voice-brief-cinnamon 的 postinstall 安装脚本。
|
|
4
|
+
*
|
|
5
|
+
* 仅复制扩展文件到当前用户的 Cinnamon 扩展目录:
|
|
6
|
+
* - 优先 $XDG_DATA_HOME,否则 ~/.local/share
|
|
7
|
+
* - 先复制到同父目录临时目录,验证通过后 rename
|
|
8
|
+
* - 替换已有安装时保留备份,失败自动恢复,成功后删除备份
|
|
9
|
+
* - 不执行 gdbus,不强制重载或启用扩展
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const UUID = 'voice-brief@tinyaxis';
|
|
18
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
19
|
+
const extensionSource = path.join(packageRoot, 'extension', UUID);
|
|
20
|
+
|
|
21
|
+
function fail(message) {
|
|
22
|
+
console.error(`[voice-brief-cinnamon] ${message}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateExtension(directory, label) {
|
|
27
|
+
const metadataFile = path.join(directory, 'metadata.json');
|
|
28
|
+
if (!fs.existsSync(metadataFile)) fail(`${label} 缺少 metadata.json`);
|
|
29
|
+
let metadata;
|
|
30
|
+
try {
|
|
31
|
+
metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
fail(`${label} 的 metadata.json 无法解析: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
if (metadata.uuid !== UUID) fail(`${label} 的 uuid 与预期不符: ${metadata.uuid}`);
|
|
36
|
+
if (!fs.existsSync(path.join(directory, 'extension.js'))) fail(`${label} 缺少扩展入口 extension.js`);
|
|
37
|
+
return metadata;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function copyDirectory(source, target) {
|
|
41
|
+
fs.mkdirSync(target, { recursive: true });
|
|
42
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
43
|
+
const from = path.join(source, entry.name);
|
|
44
|
+
const to = path.join(target, entry.name);
|
|
45
|
+
if (entry.isDirectory()) copyDirectory(from, to);
|
|
46
|
+
else if (entry.isFile()) fs.copyFileSync(from, to);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function main() {
|
|
51
|
+
validateExtension(extensionSource, '扩展产物');
|
|
52
|
+
|
|
53
|
+
const dataHome = process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.trim()
|
|
54
|
+
? process.env.XDG_DATA_HOME
|
|
55
|
+
: path.join(os.homedir(), '.local', 'share');
|
|
56
|
+
const extensionsRoot = path.join(dataHome, 'cinnamon', 'extensions');
|
|
57
|
+
const targetDirectory = path.join(extensionsRoot, UUID);
|
|
58
|
+
const stagingDirectory = path.join(extensionsRoot, `.voice-brief-staging-${process.pid}-${Date.now()}`);
|
|
59
|
+
const backupDirectory = path.join(extensionsRoot, `.voice-brief-backup-${UUID}`);
|
|
60
|
+
|
|
61
|
+
fs.mkdirSync(extensionsRoot, { recursive: true });
|
|
62
|
+
try {
|
|
63
|
+
copyDirectory(extensionSource, stagingDirectory);
|
|
64
|
+
validateExtension(stagingDirectory, '临时安装目录');
|
|
65
|
+
|
|
66
|
+
const hadExisting = fs.existsSync(targetDirectory);
|
|
67
|
+
if (hadExisting) fs.renameSync(targetDirectory, backupDirectory);
|
|
68
|
+
try {
|
|
69
|
+
fs.renameSync(stagingDirectory, targetDirectory);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (hadExisting) {
|
|
72
|
+
fs.rmSync(targetDirectory, { recursive: true, force: true });
|
|
73
|
+
fs.renameSync(backupDirectory, targetDirectory);
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
fs.rmSync(backupDirectory, { recursive: true, force: true });
|
|
78
|
+
} finally {
|
|
79
|
+
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`[voice-brief-cinnamon] 已安装 ${UUID} 到 ${targetDirectory}`);
|
|
83
|
+
console.log('[voice-brief-cinnamon] 如需立即生效,请在 Cinnamon 中重载或启用该扩展');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main();
|