dsh-capyreporter 0.1.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,160 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>CapyReporter · DSH Pet</title>
6
+ <style>
7
+ html,
8
+ body {
9
+ margin: 0;
10
+ height: 100%;
11
+ background: transparent;
12
+ overflow: hidden;
13
+ user-select: none;
14
+ -webkit-user-select: none;
15
+ font-family: system-ui, 'Segoe UI', sans-serif;
16
+ color-scheme: dark;
17
+ }
18
+ #root {
19
+ position: relative;
20
+ width: 100%;
21
+ height: 100%;
22
+ }
23
+ #bubble {
24
+ position: absolute;
25
+ /* JS pins this just above the pet image on every size change;
26
+ the default covers the unscaled 182px pet. */
27
+ bottom: 190px;
28
+ left: 50%;
29
+ transform: translateX(-50%);
30
+ min-width: 90px;
31
+ max-width: 1000px; /* wide bubble so status text fits without ellipsizing; the window re-fits around it */
32
+ background: #fdfdf7;
33
+ color: #22242c;
34
+ border-radius: 14px;
35
+ padding: 8px 12px;
36
+ font-size: 12.5px;
37
+ line-height: 1.45;
38
+ box-shadow: 0 4px 14px #0007;
39
+ display: none;
40
+ cursor: pointer;
41
+ z-index: 5;
42
+ flex-direction: column;
43
+ gap: 3px;
44
+ box-sizing: border-box;
45
+ }
46
+ #bubble::after {
47
+ content: '';
48
+ position: absolute;
49
+ bottom: -8px;
50
+ left: 50%;
51
+ transform: translateX(-50%);
52
+ border: 8px solid transparent;
53
+ border-top-color: #fdfdf7;
54
+ border-bottom: 0;
55
+ }
56
+ .bub-head {
57
+ font-weight: 600;
58
+ display: -webkit-box;
59
+ -webkit-line-clamp: 2;
60
+ -webkit-box-orient: vertical;
61
+ overflow: hidden;
62
+ overflow-wrap: break-word;
63
+ }
64
+ .bub-detail {
65
+ display: -webkit-box;
66
+ -webkit-line-clamp: 3;
67
+ -webkit-box-orient: vertical;
68
+ overflow: hidden;
69
+ overflow-wrap: break-word;
70
+ opacity: 0.85;
71
+ }
72
+ .bub-hint {
73
+ font-size: 10.5px;
74
+ opacity: 0.55;
75
+ white-space: nowrap; /* "click to expand · double-click to hide" stays on one line */
76
+ }
77
+ .bub-log {
78
+ display: none;
79
+ flex-direction: column;
80
+ gap: 2px;
81
+ max-height: 150px;
82
+ overflow-y: auto;
83
+ font-size: 12px;
84
+ line-height: 1.4;
85
+ scrollbar-width: thin;
86
+ }
87
+ .bub-log.is-on {
88
+ display: flex;
89
+ }
90
+ .bub-log::-webkit-scrollbar {
91
+ width: 6px;
92
+ }
93
+ .bub-log::-webkit-scrollbar-thumb {
94
+ background: #00000044;
95
+ border-radius: 3px;
96
+ }
97
+ .bub-line {
98
+ overflow-wrap: break-word;
99
+ white-space: normal;
100
+ }
101
+ #img {
102
+ position: absolute;
103
+ bottom: 0;
104
+ left: 50%;
105
+ transform: translateX(-50%);
106
+ height: 182px;
107
+ width: auto;
108
+ pointer-events: auto;
109
+ cursor: grab;
110
+ filter: drop-shadow(0 6px 16px #0008);
111
+ }
112
+ #menu {
113
+ position: absolute;
114
+ bottom: 6px;
115
+ right: 6px;
116
+ flex-direction: column;
117
+ background: #23262f;
118
+ color: #e8eaf0;
119
+ border: 1px solid #ffffff22;
120
+ border-radius: 10px;
121
+ overflow: hidden;
122
+ box-shadow: 0 8px 24px #0009;
123
+ z-index: 9;
124
+ display: none;
125
+ }
126
+ #menu .mi {
127
+ appearance: none;
128
+ border: 0;
129
+ background: transparent;
130
+ color: #e8eaf0;
131
+ padding: 8px 14px;
132
+ font-size: 12px;
133
+ text-align: left;
134
+ cursor: pointer;
135
+ font-family: inherit;
136
+ white-space: nowrap;
137
+ }
138
+ #menu .mi:hover {
139
+ background: #ffffff14;
140
+ }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <div id="root">
145
+ <div id="bubble">
146
+ <span id="bubbleHead" class="bub-head"></span>
147
+ <span id="bubbleDetail" class="bub-detail"></span>
148
+ <div id="bubbleLog" class="bub-log"></div>
149
+ <span id="bubbleHint" class="bub-hint"></span>
150
+ </div>
151
+ <img id="img" draggable="false" alt="" />
152
+ <div id="menu">
153
+ <button class="mi" id="menuRestore">Restore bubble</button>
154
+ <button class="mi" id="menuSite">Open DeepSeek Harness</button>
155
+ <button class="mi" id="menuHide">Hide desktop pet</button>
156
+ </div>
157
+ </div>
158
+ <script src="./renderer.js"></script>
159
+ </body>
160
+ </html>
@@ -0,0 +1,140 @@
1
+ // dsh-capyreporter desktop helper - Electron main process.
2
+ // One transparent, frameless, always-on-top window showing CapyReporter + a speech bubble.
3
+ // The renderer fetches data from the DSH host over HTTP (/dsh-capyreporter/*) - no stdin IPC.
4
+ //
5
+ // Z-order notes (Windows):
6
+ // - `focusable: false` + `alwaysOnTop` is unreliable: Windows demotes the window
7
+ // once another app activates, so the pet would slip behind other windows.
8
+ // We keep the window focusable instead, show it inactive (no focus steal),
9
+ // and re-assert the topmost level on blur and on a short interval.
10
+ const { app, BrowserWindow, ipcMain, screen, shell } = require('electron');
11
+ const path = require('node:path');
12
+
13
+ app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
14
+ // Hybrid-GPU laptops (NVIDIA + Intel iGPU) can crash the Chromium GPU process
15
+ // during transparent-window startup, which fails the page load with ERR_FAILED
16
+ // and quits the app. A 220x250 overlay renders perfectly in software mode, so
17
+ // opt out of hardware acceleration entirely.
18
+ app.disableHardwareAcceleration();
19
+
20
+ let win = null;
21
+ let topmostTimer = null;
22
+ const baseUrl = process.env.DSH_CAPYREP_BASE_URL || 'http://127.0.0.1:3080';
23
+ const W = 220;
24
+ const H = 250;
25
+
26
+ function reassertTopmost() {
27
+ if (!win || win.isDestroyed()) return;
28
+ try {
29
+ win.setAlwaysOnTop(true, 'screen-saver');
30
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
31
+ } catch (e) {}
32
+ }
33
+
34
+ function createWindow() {
35
+ const area = screen.getPrimaryDisplay().workArea;
36
+ win = new BrowserWindow({
37
+ width: W,
38
+ height: H,
39
+ x: area.x + area.width - W - 40,
40
+ y: area.y + area.height - H - 40,
41
+ show: false,
42
+ useContentSize: true,
43
+ transparent: true,
44
+ frame: false,
45
+ alwaysOnTop: true,
46
+ skipTaskbar: true,
47
+ resizable: false,
48
+ hasShadow: false,
49
+ focusable: true,
50
+ webPreferences: {
51
+ preload: path.join(__dirname, 'preload.js'),
52
+ contextIsolation: true,
53
+ nodeIntegration: false,
54
+ sandbox: false,
55
+ },
56
+ });
57
+ win.setAlwaysOnTop(true, 'screen-saver');
58
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
59
+ // Click-through by default so transparent pixels don't block apps below;
60
+ // renderer flips interactive when the pointer is over the pet body.
61
+ win.setIgnoreMouseEvents(true, { forward: true });
62
+ win.webContents.on('context-menu', (event) => event.preventDefault());
63
+ // Show without stealing the user's focus; re-assert topmost right after.
64
+ win.once('ready-to-show', () => {
65
+ if (win && !win.isDestroyed()) {
66
+ win.showInactive();
67
+ reassertTopmost();
68
+ }
69
+ });
70
+ // When the user switches to another app, this window blurs; re-assert topmost
71
+ // so Windows does not demote it below the newly activated window.
72
+ win.on('blur', () => reassertTopmost());
73
+ win.on('closed', () => {
74
+ win = null;
75
+ if (topmostTimer) {
76
+ clearInterval(topmostTimer);
77
+ topmostTimer = null;
78
+ }
79
+ app.quit();
80
+ });
81
+ win
82
+ .loadFile('index.html', { query: { baseUrl, w: String(W), h: String(H) } })
83
+ .catch((error) => {
84
+ console.error('[dsh-capyreporter-helper] page load failed:', error);
85
+ win.destroy();
86
+ });
87
+ }
88
+
89
+ app.whenReady().then(() => {
90
+ createWindow();
91
+ // Belt-and-suspenders: re-assert the topmost level periodically, so even
92
+ // Alt-Tab / fullscreen-app transitions restore the pet to the front.
93
+ topmostTimer = setInterval(reassertTopmost, 2000);
94
+
95
+ ipcMain.on('homura:interactive', (event, interactive) => {
96
+ const w = BrowserWindow.fromWebContents(event.sender);
97
+ if (w && !w.isDestroyed()) w.setIgnoreMouseEvents(!interactive, { forward: true });
98
+ });
99
+
100
+ ipcMain.on('homura:open-site', (event, url) => {
101
+ if (typeof url === 'string' && /^https?:[/][/]/.test(url)) {
102
+ shell.openExternal(url).catch(() => {});
103
+ }
104
+ });
105
+
106
+ ipcMain.on('homura:resize', (event, size) => {
107
+ const w = BrowserWindow.fromWebContents(event.sender);
108
+ if (!w || w.isDestroyed()) return;
109
+ const cw = Math.round(Number(size && size.w) || 0);
110
+ const ch = Math.round(Number(size && size.h) || 0);
111
+ if (cw < 80 || ch < 80 || cw > 4000 || ch > 4000) return;
112
+ const b = w.getBounds();
113
+ const area = screen.getPrimaryDisplay().workArea;
114
+ const width = Math.min(cw, area.width);
115
+ const height = Math.min(ch, area.height);
116
+ // keep the bottom edge (the pet's feet) put; grow upward and outward
117
+ let x = Math.round(b.x + (b.width - width) / 2);
118
+ let y = Math.round(b.y + (b.height - height));
119
+ x = Math.min(Math.max(area.x, x), area.x + area.width - width);
120
+ y = Math.min(Math.max(area.y, y), area.y + area.height - height);
121
+ w.setBounds({ x: x, y: y, width: width, height: height });
122
+ reassertTopmost();
123
+ });
124
+
125
+ ipcMain.on('homura:move', (event, p) => {
126
+ const w = BrowserWindow.fromWebContents(event.sender);
127
+ if (!w || w.isDestroyed()) return;
128
+ const x = Number(p && p.x);
129
+ const y = Number(p && p.y);
130
+ if (Number.isFinite(x) && Number.isFinite(y)) w.setPosition(Math.round(x), Math.round(y), false);
131
+ });
132
+ });
133
+
134
+ app.on('window-all-closed', () => {
135
+ if (topmostTimer) {
136
+ clearInterval(topmostTimer);
137
+ topmostTimer = null;
138
+ }
139
+ app.quit();
140
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "dsh-homura-pet-desktop-helper",
3
+ "private": true,
4
+ "main": "main.js"
5
+ }
@@ -0,0 +1,17 @@
1
+ // preload bridge: expose only the window-control primitives (no node access in the renderer).
2
+ const { contextBridge, ipcRenderer } = require('electron');
3
+
4
+ contextBridge.exposeInMainWorld('petBridge', {
5
+ setInteractive(interactive) {
6
+ ipcRenderer.send('homura:interactive', !!interactive);
7
+ },
8
+ openSite(url) {
9
+ ipcRenderer.send('homura:open-site', url);
10
+ },
11
+ move(x, y) {
12
+ ipcRenderer.send('homura:move', { x, y });
13
+ },
14
+ resize(size) {
15
+ ipcRenderer.send('homura:resize', size);
16
+ },
17
+ });
@@ -0,0 +1,249 @@
1
+ // renderer for the always-on-top CapyReporter window. Fetches image + activity from the
2
+ // DSH host over HTTP; drags to move; left-click opens DSH; right-click shows a menu.
3
+ //
4
+ // Bubble UX: compact by default (headline + latest status line); single click
5
+ // expands into a scrollable step log; double click dismisses; the right-click
6
+ // menu can restore the bubble after a dismissal.
7
+ (function () {
8
+ const params = new URLSearchParams(location.search);
9
+ const base = params.get('baseUrl') || 'http://127.0.0.1:3080';
10
+ const ROOT = base + '/dsh-capyreporter';
11
+
12
+ const img = document.getElementById('img');
13
+ const bubble = document.getElementById('bubble');
14
+ const head = document.getElementById('bubbleHead');
15
+ const detail = document.getElementById('bubbleDetail');
16
+ const log = document.getElementById('bubbleLog');
17
+ const hint = document.getElementById('bubbleHint');
18
+ const menu = document.getElementById('menu');
19
+ const root = document.getElementById('root');
20
+
21
+ let version = 0;
22
+ let hasCustom = null;
23
+ let lastSig = null;
24
+ let suppressedSig = null; // bubble hidden by double-click; auto-clears when content changes
25
+ let expanded = false;
26
+ let lastCharge = null; // last rendered status {kind, head, lines, preview, hint, at}
27
+
28
+ // Content signature of the activity feed: the bubble auto-(re)appears whenever
29
+ // the signature changes (new step line, task count change, completion).
30
+ function activitySig(a) {
31
+ if (!a) return 'none';
32
+ if (a.completed) return 'done:' + a.completed.at;
33
+ if (a.runningCount > 0) {
34
+ const f = (a.running && a.running[0]) || {};
35
+ const lines = (f.lines || []).join('|');
36
+ return 'work:' + a.runningCount + ':' + (f.title || '') + ':' + lines;
37
+ }
38
+ return 'none';
39
+ }
40
+
41
+ function chargeOf(a) {
42
+ if (a && a.completed) {
43
+ return { kind: 'done', head: '\u2705 Task complete \u2014 ' + (a.completed.title || 'Task'), lines: [], preview: null, hint: 'click to expand \u00B7 double-click to dismiss', at: a.completed.at };
44
+ }
45
+ if (a && a.runningCount > 0) {
46
+ const f = a.running[0] || {};
47
+ const headStr = a.runningCount > 1 ? '\u23F3 ' + a.runningCount + ' tasks running \u2014 ' + (f.title || 'Session') : '\u23F3 Working: ' + (f.title || 'Session');
48
+ return { kind: 'working', head: headStr, lines: f.lines || [], preview: f.preview || null, hint: 'click to expand \u00B7 double-click to hide', at: null };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // Pet size: a uniform multiplier owned by the host config (shared with the
54
+ // in-page pet and the Settings slider). Plain wheel over the window resizes;
55
+ // the image keeps its aspect ratio, the bubble is re-pinned just above it.
56
+ // refit() fits the window around BOTH the pet and the bubble, so the bubble
57
+ // can report a fuller status (expanded step log) without the window blowing up.
58
+ const BASE_IMG_H = 182;
59
+ let scale = 1;
60
+ function refit() {
61
+ const imgH = Math.round(BASE_IMG_H * scale);
62
+ img.style.height = imgH + 'px';
63
+ bubble.style.bottom = (imgH + 8) + 'px';
64
+ const aspect = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 0.65;
65
+ let w = Math.max(150, Math.round(220 * scale), Math.round(imgH * aspect) + 24);
66
+ let h = Math.max(120, imgH + Math.max(Math.round(72 * scale), 96));
67
+ if (bubble.style.display !== 'none' && bubble.scrollWidth > 0) {
68
+ const bw = Math.min(Math.ceil(bubble.scrollWidth) + 28, 1060); // bubble is capped at 1000px; grow the window with it
69
+ const bhCap = expanded ? 220 : 170;
70
+ const bh = Math.min(Math.ceil(bubble.scrollHeight) + 16, bhCap);
71
+ w = Math.max(w, bw);
72
+ h = Math.max(h, imgH + 12 + bh);
73
+ }
74
+ window.petBridge.resize({ w: Math.round(w), h: Math.round(h) });
75
+ }
76
+ img.addEventListener('load', () => refit());
77
+ window.addEventListener('wheel', (e) => {
78
+ e.preventDefault();
79
+ const next = Math.min(2.5, Math.max(0.5, Math.round((scale + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10));
80
+ if (next === scale) return;
81
+ scale = next;
82
+ refit();
83
+ post('/scale', { scale: scale });
84
+ }, { passive: false });
85
+
86
+ async function loadImage() {
87
+ img.src = ROOT + '/image?v=' + ++version + '&t=' + Date.now();
88
+ }
89
+
90
+ async function post(path, body) {
91
+ try {
92
+ await fetch(ROOT + path, {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify(body || {}),
96
+ });
97
+ } catch (e) {}
98
+ }
99
+
100
+ function renderBubble(c) {
101
+ head.textContent = c.head;
102
+ if (expanded && c.lines.length) {
103
+ detail.style.display = 'none';
104
+ log.classList.add('is-on');
105
+ while (log.firstChild) log.removeChild(log.firstChild);
106
+ for (const l of c.lines) {
107
+ const d = document.createElement('div');
108
+ d.className = 'bub-line';
109
+ d.textContent = l;
110
+ log.appendChild(d);
111
+ }
112
+ log.scrollTop = log.scrollHeight;
113
+ } else {
114
+ log.classList.remove('is-on');
115
+ const cd = c.lines.length ? c.lines[c.lines.length - 1] : c.preview;
116
+ if (cd) {
117
+ detail.textContent = cd;
118
+ detail.style.display = '-webkit-box';
119
+ } else {
120
+ detail.textContent = '';
121
+ detail.style.display = 'none';
122
+ }
123
+ }
124
+ if (c.hint) {
125
+ hint.textContent = c.hint;
126
+ hint.style.display = 'inline';
127
+ } else {
128
+ hint.style.display = 'none';
129
+ }
130
+ bubble.style.display = 'flex';
131
+ bubble.dataset.mode = c.kind;
132
+ if (c.at !== undefined && c.at !== null) bubble.dataset.at = c.at;
133
+ else delete bubble.dataset.at;
134
+ refit();
135
+ }
136
+
137
+ function hideBubble() {
138
+ bubble.style.display = 'none';
139
+ refit();
140
+ }
141
+
142
+ function restoreBubble() {
143
+ suppressedSig = null;
144
+ expanded = false;
145
+ if (lastCharge) renderBubble(lastCharge);
146
+ }
147
+
148
+ async function tick() {
149
+ // activity -> bubble
150
+ try {
151
+ const a = await (await fetch(ROOT + '/activity')).json();
152
+ const sig = activitySig(a);
153
+ lastSig = sig;
154
+ if (suppressedSig !== null && sig !== suppressedSig) suppressedSig = null; // content changed -> show again
155
+ if (suppressedSig !== null) {
156
+ hideBubble();
157
+ } else {
158
+ const c = chargeOf(a);
159
+ lastCharge = c;
160
+ if (c) renderBubble(c);
161
+ else hideBubble();
162
+ }
163
+ } catch (e) {}
164
+
165
+ // state -> refresh image when custom image changes; close if host hid the pet
166
+ try {
167
+ const s = await (await fetch(ROOT + '/state')).json();
168
+ if (s.desktop === false) {
169
+ window.close();
170
+ return;
171
+ }
172
+ if (hasCustom === null) hasCustom = s.hasCustom;
173
+ else if (hasCustom !== s.hasCustom) {
174
+ hasCustom = s.hasCustom;
175
+ loadImage();
176
+ }
177
+ if (typeof s.scale === 'number' && Math.abs(s.scale - scale) > 0.001) {
178
+ scale = s.scale;
179
+ refit();
180
+ }
181
+ } catch (e) {}
182
+ }
183
+
184
+ // single click -> expand/collapse the step log
185
+ bubble.addEventListener('click', (ev) => {
186
+ ev.stopPropagation();
187
+ expanded = !expanded;
188
+ if (lastCharge) renderBubble(lastCharge);
189
+ });
190
+
191
+ // double click -> dismiss (hide until the content changes); completed bubbles get dismissed server-side too
192
+ bubble.addEventListener('dblclick', (ev) => {
193
+ ev.stopPropagation();
194
+ expanded = false;
195
+ if (lastSig) suppressedSig = lastSig;
196
+ const at = bubble.dataset.at;
197
+ if (at) {
198
+ try {
199
+ post('/dismiss', { at: Number(at) });
200
+ } catch (e) {}
201
+ }
202
+ hideBubble();
203
+ });
204
+
205
+ // drag
206
+ let drag = null;
207
+ img.addEventListener('pointerdown', (e) => {
208
+ if (e.button !== 0) return;
209
+ drag = { sx: e.screenX, sy: e.screenY, wx: window.screenX, wy: window.screenY };
210
+ window.petBridge.setInteractive(true);
211
+ e.preventDefault();
212
+ });
213
+ window.addEventListener('pointermove', (e) => {
214
+ if (drag) window.petBridge.move(drag.wx + (e.screenX - drag.sx), drag.wy + (e.screenY - drag.sy));
215
+ });
216
+ window.addEventListener('pointerup', () => {
217
+ drag = null;
218
+ });
219
+
220
+ // hover -> interactive (so clicks work); leave -> click-through
221
+ root.addEventListener('mouseenter', () => window.petBridge.setInteractive(true));
222
+ root.addEventListener('mouseleave', () => window.petBridge.setInteractive(false));
223
+
224
+ // left-click (not a drag) -> open DSH in default browser
225
+ img.addEventListener('click', () => window.petBridge.openSite(base));
226
+
227
+ // right-click menu
228
+ img.addEventListener('contextmenu', (e) => {
229
+ e.preventDefault();
230
+ menu.style.display = menu.style.display === 'flex' ? 'none' : 'flex';
231
+ });
232
+ document.getElementById('menuRestore').addEventListener('click', () => {
233
+ menu.style.display = 'none';
234
+ restoreBubble();
235
+ });
236
+ document.getElementById('menuSite').addEventListener('click', () => {
237
+ menu.style.display = 'none';
238
+ window.petBridge.openSite(base);
239
+ });
240
+ document.getElementById('menuHide').addEventListener('click', async () => {
241
+ menu.style.display = 'none';
242
+ await post('/desktop/hide');
243
+ window.close();
244
+ });
245
+
246
+ loadImage();
247
+ tick();
248
+ setInterval(tick, 1200);
249
+ })();