mocode-pet-app 0.1.0 → 1.0.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/assets/mascot.svg +0 -2
- package/assets/pets/01-robo-cat.svg +56 -0
- package/assets/pets/02-robo-dog.svg +53 -0
- package/assets/pets/03-robo-fox.svg +53 -0
- package/assets/pets/04-robo-panda.svg +52 -0
- package/assets/pets/05-robo-owl.svg +46 -0
- package/assets/pets/06-robo-bunny.svg +52 -0
- package/assets/pets/07-robo-frog.svg +39 -0
- package/assets/pets/08-robo-bear.svg +48 -0
- package/assets/pets/09-robo-penguin.svg +49 -0
- package/assets/pets/10-robo-dino.svg +56 -0
- package/assets/pets/11-slime-blob.svg +43 -0
- package/assets/pets/12-ghost-byte.svg +41 -0
- package/assets/pets/13-cactus-bot.svg +52 -0
- package/assets/pets/14-crystal-bot.svg +32 -0
- package/assets/pets/15-satellite-bot.svg +55 -0
- package/assets/pets/16-jellyfish-bot.svg +47 -0
- package/assets/pets/17-mushroom-bot.svg +47 -0
- package/assets/pets/18-star-bot.svg +45 -0
- package/assets/pets/manifest.json +25 -0
- package/assets/signal-light.svg +55 -0
- package/assets/tray-icon.png +0 -0
- package/dist/assets/mascot.svg +0 -2
- package/dist/assets/pets/01-robo-cat.svg +56 -0
- package/dist/assets/pets/02-robo-dog.svg +53 -0
- package/dist/assets/pets/03-robo-fox.svg +53 -0
- package/dist/assets/pets/04-robo-panda.svg +52 -0
- package/dist/assets/pets/05-robo-owl.svg +46 -0
- package/dist/assets/pets/06-robo-bunny.svg +52 -0
- package/dist/assets/pets/07-robo-frog.svg +39 -0
- package/dist/assets/pets/08-robo-bear.svg +48 -0
- package/dist/assets/pets/09-robo-penguin.svg +49 -0
- package/dist/assets/pets/10-robo-dino.svg +56 -0
- package/dist/assets/pets/11-slime-blob.svg +43 -0
- package/dist/assets/pets/12-ghost-byte.svg +41 -0
- package/dist/assets/pets/13-cactus-bot.svg +52 -0
- package/dist/assets/pets/14-crystal-bot.svg +32 -0
- package/dist/assets/pets/15-satellite-bot.svg +55 -0
- package/dist/assets/pets/16-jellyfish-bot.svg +47 -0
- package/dist/assets/pets/17-mushroom-bot.svg +47 -0
- package/dist/assets/pets/18-star-bot.svg +45 -0
- package/dist/assets/pets/manifest.json +25 -0
- package/dist/assets/signal-light.svg +55 -0
- package/dist/assets/tray-icon.png +0 -0
- package/dist/config.js +42 -0
- package/dist/main.js +158 -16
- package/dist/protocol.js +25 -0
- package/dist/renderer/index.html +4 -1
- package/dist/renderer/preload.js +32 -3
- package/dist/renderer/renderer.js +74 -10
- package/dist/renderer/style.css +114 -9
- package/dist/skins.js +55 -0
- package/package.json +2 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="capGrad" x1="48" y1="30" x2="208" y2="110" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#ef5350"/>
|
|
5
|
+
<stop offset="1" stop-color="#c62828"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="stemGrad" x1="80" y1="110" x2="176" y2="220" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop offset="0" stop-color="#f5f5f5"/>
|
|
9
|
+
<stop offset="1" stop-color="#dcdcdc"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
|
12
|
+
<feGaussianBlur stdDeviation="3" result="blur"/>
|
|
13
|
+
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
14
|
+
</filter>
|
|
15
|
+
</defs>
|
|
16
|
+
<g id="pet-body-group">
|
|
17
|
+
|
|
18
|
+
<!-- Cap -->
|
|
19
|
+
<path d="M30 112 C30 54 72 22 128 22 C184 22 226 54 226 112 Z" fill="url(#capGrad)" stroke="#8e1c1c" stroke-width="2.5"/>
|
|
20
|
+
<circle cx="76" cy="70" r="10" fill="#fff" fill-opacity="0.85"/>
|
|
21
|
+
<circle cx="128" cy="50" r="8" fill="#fff" fill-opacity="0.85"/>
|
|
22
|
+
<circle cx="176" cy="74" r="9" fill="#fff" fill-opacity="0.85"/>
|
|
23
|
+
<circle cx="150" cy="98" r="6" fill="#fff" fill-opacity="0.75"/>
|
|
24
|
+
|
|
25
|
+
<!-- Stem / body -->
|
|
26
|
+
<rect x="72" y="108" width="112" height="120" rx="34" fill="url(#stemGrad)" stroke="#bdbdbd" stroke-width="2.5"/>
|
|
27
|
+
|
|
28
|
+
<!-- Screen face -->
|
|
29
|
+
<rect x="88" y="128" width="80" height="58" rx="12" fill="#0a1420" stroke="#2afadf" stroke-opacity="0.4" stroke-width="1.5"/>
|
|
30
|
+
|
|
31
|
+
<!-- Eyes -->
|
|
32
|
+
<g filter="url(#glow)">
|
|
33
|
+
<rect x="104" y="146" width="11" height="20" rx="5" fill="#2afadf">
|
|
34
|
+
<animate attributeName="height" values="20;2;20" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
|
|
35
|
+
<animate attributeName="y" values="146;155;146" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
|
|
36
|
+
</rect>
|
|
37
|
+
<rect x="142" y="146" width="11" height="20" rx="5" fill="#2afadf">
|
|
38
|
+
<animate attributeName="height" values="20;2;20" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
|
|
39
|
+
<animate attributeName="y" values="146;155;146" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
|
|
40
|
+
</rect>
|
|
41
|
+
</g>
|
|
42
|
+
|
|
43
|
+
<!-- Root feet -->
|
|
44
|
+
<ellipse cx="104" cy="238" rx="16" ry="8" fill="#bdbdbd"/>
|
|
45
|
+
<ellipse cx="152" cy="238" rx="16" ry="8" fill="#bdbdbd"/>
|
|
46
|
+
</g>
|
|
47
|
+
</svg>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="starGrad" x1="48" y1="30" x2="208" y2="220" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#ffd54f"/>
|
|
5
|
+
<stop offset="1" stop-color="#ff8f00"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
|
8
|
+
<feGaussianBlur stdDeviation="3" result="blur"/>
|
|
9
|
+
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
10
|
+
</filter>
|
|
11
|
+
</defs>
|
|
12
|
+
<g id="pet-body-group">
|
|
13
|
+
|
|
14
|
+
<!-- Star body: 5-point star -->
|
|
15
|
+
<path d="M128 20
|
|
16
|
+
L156 92
|
|
17
|
+
L232 92
|
|
18
|
+
L170 138
|
|
19
|
+
L194 214
|
|
20
|
+
L128 168
|
|
21
|
+
L62 214
|
|
22
|
+
L86 138
|
|
23
|
+
L24 92
|
|
24
|
+
L100 92 Z"
|
|
25
|
+
fill="url(#starGrad)" stroke="#fff3c4" stroke-width="3">
|
|
26
|
+
<animateTransform attributeName="transform" type="rotate" values="0 128 128;6 128 128;0 128 128;-6 128 128;0 128 128" dur="4s" repeatCount="indefinite"/>
|
|
27
|
+
</path>
|
|
28
|
+
|
|
29
|
+
<!-- Screen face -->
|
|
30
|
+
<rect x="88" y="104" width="80" height="56" rx="12" fill="#0a1420" stroke="#ffe082" stroke-opacity="0.5" stroke-width="1.5"/>
|
|
31
|
+
|
|
32
|
+
<!-- Eyes -->
|
|
33
|
+
<g filter="url(#glow)">
|
|
34
|
+
<circle cx="112" cy="130" r="8" fill="#ffe082">
|
|
35
|
+
<animate attributeName="r" values="8;2;8" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
|
|
36
|
+
</circle>
|
|
37
|
+
<circle cx="146" cy="130" r="8" fill="#ffe082">
|
|
38
|
+
<animate attributeName="r" values="8;2;8" keyTimes="0;0.5;1" dur="3.2s" repeatCount="indefinite"/>
|
|
39
|
+
</circle>
|
|
40
|
+
</g>
|
|
41
|
+
|
|
42
|
+
<!-- Smile -->
|
|
43
|
+
<path d="M114 148 Q128 158 142 148" stroke="#ffe082" stroke-width="3" fill="none" stroke-linecap="round"/>
|
|
44
|
+
</g>
|
|
45
|
+
</svg>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_description": "候选桌宠素材索引,供未来 desktop-pet '选皮' 功能读取。当前 packages/pet-app 实现仅使用 assets/mascot.svg(单一形象+CSS状态动画),这些素材是为后续换皮功能预留的候选库。",
|
|
3
|
+
"viewBox": "0 0 256 256",
|
|
4
|
+
"style": "赛博终端风,深色机身(#1b263b/#0d1b2a)+ 屏幕脸(黑底彩色像素眼,呼吸/眨眼动画),与 assets/mascot.svg 视觉语言一致",
|
|
5
|
+
"pets": [
|
|
6
|
+
{ "id": "robo-cat", "file": "01-robo-cat.svg", "name": "机械猫", "accent": "#2afadf" },
|
|
7
|
+
{ "id": "robo-dog", "file": "02-robo-dog.svg", "name": "机械犬", "accent": "#ffb74d" },
|
|
8
|
+
{ "id": "robo-fox", "file": "03-robo-fox.svg", "name": "机械狐", "accent": "#ff7043" },
|
|
9
|
+
{ "id": "robo-panda", "file": "04-robo-panda.svg", "name": "机械熊猫", "accent": "#2afadf" },
|
|
10
|
+
{ "id": "robo-owl", "file": "05-robo-owl.svg", "name": "机械猫头鹰", "accent": "#7c4dff" },
|
|
11
|
+
{ "id": "robo-bunny", "file": "06-robo-bunny.svg", "name": "机械兔", "accent": "#ff6ec7" },
|
|
12
|
+
{ "id": "robo-frog", "file": "07-robo-frog.svg", "name": "机械蛙", "accent": "#00e676" },
|
|
13
|
+
{ "id": "robo-bear", "file": "08-robo-bear.svg", "name": "机械熊", "accent": "#ffa726" },
|
|
14
|
+
{ "id": "robo-penguin", "file": "09-robo-penguin.svg", "name": "机械企鹅", "accent": "#42a5f5" },
|
|
15
|
+
{ "id": "robo-dino", "file": "10-robo-dino.svg", "name": "机械龙", "accent": "#00c853" },
|
|
16
|
+
{ "id": "slime-blob", "file": "11-slime-blob.svg", "name": "史莱姆", "accent": "#00acc1" },
|
|
17
|
+
{ "id": "ghost-byte", "file": "12-ghost-byte.svg", "name": "字节幽灵", "accent": "#4dd0e1" },
|
|
18
|
+
{ "id": "cactus-bot", "file": "13-cactus-bot.svg", "name": "仙人掌兽", "accent": "#66bb6a" },
|
|
19
|
+
{ "id": "crystal-bot", "file": "14-crystal-bot.svg", "name": "水晶精灵", "accent": "#7c4dff" },
|
|
20
|
+
{ "id": "satellite-bot", "file": "15-satellite-bot.svg", "name": "卫星兽", "accent": "#42a5f5" },
|
|
21
|
+
{ "id": "jellyfish-bot", "file": "16-jellyfish-bot.svg", "name": "水母兽", "accent": "#ba68c8" },
|
|
22
|
+
{ "id": "mushroom-bot", "file": "17-mushroom-bot.svg", "name": "菌菇兽", "accent": "#c62828" },
|
|
23
|
+
{ "id": "star-bot", "file": "18-star-bot.svg", "name": "星星兽", "accent": "#ff8f00" }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<svg id="signal-light-svg-root" width="90" height="456" viewBox="0 0 90 456" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="lampBodyGrad" x1="8" y1="8" x2="82" y2="212" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#1b263b"/>
|
|
5
|
+
<stop offset="1" stop-color="#0d1b2a"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="lampAccentGrad" x1="8" y1="8" x2="82" y2="212" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop offset="0" stop-color="#2afadf"/>
|
|
9
|
+
<stop offset="1" stop-color="#42a5f5"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<filter id="lampGlow" x="-100%" y="-100%" width="300%" height="300%">
|
|
12
|
+
<feGaussianBlur stdDeviation="4.5" result="blur"/>
|
|
13
|
+
<feMerge>
|
|
14
|
+
<feMergeNode in="blur"/>
|
|
15
|
+
<feMergeNode in="SourceGraphic"/>
|
|
16
|
+
</feMerge>
|
|
17
|
+
</filter>
|
|
18
|
+
</defs>
|
|
19
|
+
|
|
20
|
+
<!-- Housing -->
|
|
21
|
+
<rect x="10" y="8" width="72" height="204" rx="20" fill="url(#lampBodyGrad)" stroke="url(#lampAccentGrad)" stroke-width="3"/>
|
|
22
|
+
|
|
23
|
+
<!-- Lens visor hoods (purely decorative, echo the terminal-screen look of mascot.svg) -->
|
|
24
|
+
<path d="M20 40 Q46 24 72 40" stroke="url(#lampAccentGrad)" stroke-width="2" fill="none" stroke-opacity="0.45"/>
|
|
25
|
+
<path d="M20 95 Q46 79 72 95" stroke="url(#lampAccentGrad)" stroke-width="2" fill="none" stroke-opacity="0.45"/>
|
|
26
|
+
<path d="M20 150 Q46 134 72 150" stroke="url(#lampAccentGrad)" stroke-width="2" fill="none" stroke-opacity="0.45"/>
|
|
27
|
+
|
|
28
|
+
<!-- Lamps: default = dim (25% opacity of their own hue). CSS drives the active/blinking state
|
|
29
|
+
per PetState (see style.css "信号灯状态映射表"); ids are stable and must not be renamed. -->
|
|
30
|
+
<circle id="pet-lamp-red" cx="46" cy="55" r="20" fill="#f87171" fill-opacity="0.22" filter="url(#lampGlow)"/>
|
|
31
|
+
<circle id="pet-lamp-yellow" cx="46" cy="110" r="20" fill="#fbbf24" fill-opacity="0.22" filter="url(#lampGlow)"/>
|
|
32
|
+
<circle id="pet-lamp-green" cx="46" cy="165" r="20" fill="#4ade80" fill-opacity="0.22" filter="url(#lampGlow)"/>
|
|
33
|
+
|
|
34
|
+
<!-- Post: a single plain pole running from the housing all the way down toward ground level. -->
|
|
35
|
+
<rect x="41" y="212" width="10" height="200" rx="5" fill="url(#lampBodyGrad)" stroke="url(#lampAccentGrad)" stroke-width="3"/>
|
|
36
|
+
|
|
37
|
+
<!-- Collar ring: subtle detail where the pole meets the flared base, like real cast-iron
|
|
38
|
+
street lamp posts (a raised band, not just a bare pole-to-base joint). -->
|
|
39
|
+
<rect x="36" y="404" width="20" height="6" rx="3" fill="url(#lampAccentGrad)" fill-opacity="0.55"/>
|
|
40
|
+
|
|
41
|
+
<!-- Flared base: bell-shaped skirt widening from the pole down to the foot plate, echoing
|
|
42
|
+
the classic cast-iron lamp post base silhouette instead of a bare pole-in-a-block. -->
|
|
43
|
+
<path d="M40 410 C40 424 30 428 28 440 L64 440 C62 428 52 424 52 410 Z"
|
|
44
|
+
fill="url(#lampBodyGrad)" stroke="url(#lampAccentGrad)" stroke-width="3"/>
|
|
45
|
+
|
|
46
|
+
<!-- Foot plate: flat plinth the base is bolted to, slightly wider than the flare for a
|
|
47
|
+
grounded, load-bearing look; corner bolts add the "real hardware" detail. -->
|
|
48
|
+
<rect x="22" y="440" width="48" height="12" rx="4" fill="url(#lampBodyGrad)" stroke="url(#lampAccentGrad)" stroke-width="3"/>
|
|
49
|
+
<circle cx="29" cy="446" r="2" fill="url(#lampAccentGrad)" fill-opacity="0.7"/>
|
|
50
|
+
<circle cx="63" cy="446" r="2" fill="url(#lampAccentGrad)" fill-opacity="0.7"/>
|
|
51
|
+
|
|
52
|
+
<!-- Soft contact shadow: grounds the fixture visually, matching the drop-shadow treatment
|
|
53
|
+
used under other floating/standing pet skins. -->
|
|
54
|
+
<ellipse cx="46" cy="454" rx="26" ry="4" fill="#000000" fill-opacity="0.18"/>
|
|
55
|
+
</svg>
|
|
Binary file
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// 桌宠本地持久化配置(悬浮窗位置 + 当前皮肤),存于 Electron userData 目录下的 pet-config.json。
|
|
2
|
+
// 读写失败均静默降级为默认值——配置持久化是纯增强功能,不能影响桌宠正常启动/运行。
|
|
3
|
+
import { app } from 'electron';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
function configPath() {
|
|
7
|
+
return path.join(app.getPath('userData'), 'pet-config.json');
|
|
8
|
+
}
|
|
9
|
+
/** 读取持久化配置;文件不存在/内容非法均返回 {}(全部走默认值)。 */
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
const p = configPath();
|
|
13
|
+
if (!existsSync(p))
|
|
14
|
+
return {};
|
|
15
|
+
const raw = readFileSync(p, 'utf8');
|
|
16
|
+
const obj = JSON.parse(raw);
|
|
17
|
+
if (typeof obj !== 'object' || obj === null)
|
|
18
|
+
return {};
|
|
19
|
+
const o = obj;
|
|
20
|
+
const cfg = {};
|
|
21
|
+
if (typeof o.x === 'number')
|
|
22
|
+
cfg.x = o.x;
|
|
23
|
+
if (typeof o.y === 'number')
|
|
24
|
+
cfg.y = o.y;
|
|
25
|
+
if (typeof o.skinId === 'string')
|
|
26
|
+
cfg.skinId = o.skinId;
|
|
27
|
+
return cfg;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 合并写入持久化配置(浅合并;patch 里的字段覆盖旧值,未提供的字段保留)。 */
|
|
34
|
+
export function saveConfig(patch) {
|
|
35
|
+
try {
|
|
36
|
+
const next = { ...loadConfig(), ...patch };
|
|
37
|
+
writeFileSync(configPath(), JSON.stringify(next), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// 静默:持久化失败不影响当前运行,下次启动回退默认值
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// 桌宠 Electron 主进程:WS Server 单例 + 最新连接覆盖 + BrowserWindow 生命周期。
|
|
2
2
|
// 不感知 mocode CLI 内部逻辑,只是纯粹的状态转发枢纽:接收 WS state 消息 → IPC 推给渲染进程。
|
|
3
|
-
import { app, BrowserWindow, screen } from 'electron';
|
|
3
|
+
import { app, BrowserWindow, screen, Tray, Menu, nativeImage, ipcMain, } from 'electron';
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
5
|
import { createServer as createHttpServer } from 'node:http';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { parseClientMessage, } from './protocol.js';
|
|
9
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
10
|
+
import { listSkinEntries, resolveSkinPath } from './skins.js';
|
|
9
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
12
|
/** 默认端口;MOCODE_PET_PORT 环境变量覆盖(与 mocode 主包 src/pet/bridge.ts 保持一致的默认假设)。 */
|
|
11
13
|
const DEFAULT_PORT = 47821;
|
|
@@ -23,7 +25,10 @@ const TRANSIENT_STATE_TIMEOUT_MS = 1500;
|
|
|
23
25
|
const connections = new Map();
|
|
24
26
|
let activeSocket = null;
|
|
25
27
|
let mainWindow = null;
|
|
28
|
+
let tray = null;
|
|
26
29
|
let transientTimer = null;
|
|
30
|
+
/** 当前生效的皮肤 id('default' = assets/mascot.svg);启动时从持久化配置读取,运行期随 set_skin/托盘菜单变化。 */
|
|
31
|
+
let currentSkinId = 'default';
|
|
27
32
|
/** 把状态推给渲染进程(IPC)。渲染进程窗口未就绪时静默丢弃(下次状态到达会重推)。 */
|
|
28
33
|
function broadcastToRenderer(state, meta) {
|
|
29
34
|
if (!mainWindow || mainWindow.isDestroyed())
|
|
@@ -45,6 +50,8 @@ function broadcastToRenderer(state, meta) {
|
|
|
45
50
|
broadcastToRenderer('idle');
|
|
46
51
|
}, TRANSIENT_STATE_TIMEOUT_MS);
|
|
47
52
|
}
|
|
53
|
+
// waiting_human 不设超时:人工介入面板可能长时间悬挂等待用户响应,不能自动回落——
|
|
54
|
+
// 面板关闭后 CLI 侧(ask-human.ts / repl/index.ts)会显式广播下一个真实状态覆盖它。
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
50
57
|
* 新连接建立时的处理:注册 ConnectionRecord,立即成为唯一 active(覆盖之前的 active)。
|
|
@@ -113,10 +120,58 @@ function onMessage(socket, raw) {
|
|
|
113
120
|
return; // 非活跃连接的状态消息静默丢弃
|
|
114
121
|
broadcastToRenderer(msg.state, msg.meta);
|
|
115
122
|
return;
|
|
123
|
+
case 'shutdown':
|
|
124
|
+
// 关闭桌宠(方案C的 CLI 入口):不要求发送方是活跃连接,任何已连接的 mocode 进程均可请求关闭。
|
|
125
|
+
console.log('[pet-app] 收到 shutdown 请求,进程退出。');
|
|
126
|
+
app.quit();
|
|
127
|
+
return;
|
|
128
|
+
case 'set_skin':
|
|
129
|
+
applySkin(msg.skinId);
|
|
130
|
+
return;
|
|
131
|
+
case 'list_skins':
|
|
132
|
+
send(socket, {
|
|
133
|
+
type: 'skin_list',
|
|
134
|
+
skins: listSkinEntries().map((e) => ({ id: e.id, name: e.name })),
|
|
135
|
+
currentSkinId,
|
|
136
|
+
ts: Date.now(),
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
116
139
|
default:
|
|
117
140
|
return;
|
|
118
141
|
}
|
|
119
142
|
}
|
|
143
|
+
/** 切换皮肤:校验 skinId、更新内存态、持久化、通知渲染进程重新 inline 对应 SVG。
|
|
144
|
+
* 未知 skinId(非 'default' 且不在 manifest 里)静默忽略,不改变当前皮肤——选皮是可选增强,不能崩。 */
|
|
145
|
+
function applySkin(skinId) {
|
|
146
|
+
if (skinId !== 'default' && !listSkinEntries().some((e) => e.id === skinId))
|
|
147
|
+
return;
|
|
148
|
+
currentSkinId = skinId;
|
|
149
|
+
saveConfig({ skinId });
|
|
150
|
+
pushSkinToRenderer();
|
|
151
|
+
rebuildTrayMenu();
|
|
152
|
+
}
|
|
153
|
+
/** 当前皮肤对应的素材相对路径(相对 renderer/index.html,供 fetch)。
|
|
154
|
+
* renderer/index.html 位于 dist/renderer/,mascot.svg 在 dist/assets/,候选皮肤在 dist/assets/pets/。 */
|
|
155
|
+
function currentSkinAssetPath() {
|
|
156
|
+
const abs = resolveSkinPath(currentSkinId);
|
|
157
|
+
return abs ? `../assets/pets/${path.basename(abs)}` : '../assets/mascot.svg';
|
|
158
|
+
}
|
|
159
|
+
/** 把当前皮肤推给渲染进程(运行期切换用,如托盘菜单点击 / CLI set_skin 消息)。
|
|
160
|
+
* 注:启动时的初始皮肤不走这条路径——渲染进程通过 'pet:get-skin' invoke 主动拉取,
|
|
161
|
+
* 避免 did-finish-load 与渲染进程异步注册监听器之间的时序竞争(IPC 消息不会缓冲,
|
|
162
|
+
* 若渲染进程监听器尚未注册,send 过去的消息会直接丢失,导致重启后持久化的皮肤不生效)。 */
|
|
163
|
+
function pushSkinToRenderer() {
|
|
164
|
+
if (!mainWindow || mainWindow.isDestroyed())
|
|
165
|
+
return;
|
|
166
|
+
try {
|
|
167
|
+
mainWindow.webContents.send('pet:skin', { assetPath: currentSkinAssetPath() });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// 静默:渲染进程未就绪时忽略,下次运行期切换会重新推送
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** 渲染进程启动时主动拉取当前皮肤(同步于其自身初始化时机,不依赖 did-finish-load 的时序假设)。 */
|
|
174
|
+
ipcMain.handle('pet:get-skin', () => ({ assetPath: currentSkinAssetPath() }));
|
|
120
175
|
function send(socket, msg) {
|
|
121
176
|
try {
|
|
122
177
|
socket.send(JSON.stringify(msg));
|
|
@@ -225,16 +280,21 @@ function probeIsPetServer(port) {
|
|
|
225
280
|
});
|
|
226
281
|
});
|
|
227
282
|
}
|
|
228
|
-
|
|
283
|
+
const WIN_WIDTH = 260;
|
|
284
|
+
const WIN_HEIGHT = 220;
|
|
285
|
+
/** 跨平台悬浮窗配置(design.md 跨平台 BrowserWindow 配置差异表)。
|
|
286
|
+
* 位置:优先用持久化配置里的 x/y(拖拽放置后记住的位置);无记忆位置时回退默认右下角。 */
|
|
229
287
|
function createPetWindow() {
|
|
230
|
-
const
|
|
231
|
-
const
|
|
288
|
+
const cfg = loadConfig();
|
|
289
|
+
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
|
232
290
|
const margin = 24;
|
|
291
|
+
const defaultX = width - WIN_WIDTH - margin;
|
|
292
|
+
const defaultY = height - WIN_HEIGHT - margin;
|
|
233
293
|
const win = new BrowserWindow({
|
|
234
|
-
width:
|
|
235
|
-
height:
|
|
236
|
-
x:
|
|
237
|
-
y:
|
|
294
|
+
width: WIN_WIDTH,
|
|
295
|
+
height: WIN_HEIGHT,
|
|
296
|
+
x: typeof cfg.x === 'number' ? cfg.x : defaultX,
|
|
297
|
+
y: typeof cfg.y === 'number' ? cfg.y : defaultY,
|
|
238
298
|
transparent: true,
|
|
239
299
|
frame: false,
|
|
240
300
|
alwaysOnTop: true,
|
|
@@ -244,7 +304,8 @@ function createPetWindow() {
|
|
|
244
304
|
fullscreenable: false,
|
|
245
305
|
minimizable: false,
|
|
246
306
|
maximizable: false,
|
|
247
|
-
focusable
|
|
307
|
+
// 注:focusable 不再固定为 false——拖拽放置(方案:hover 时取消鼠标穿透)需要窗口在悬停时可交互。
|
|
308
|
+
// 仍不出现在任务栏/Alt-Tab(skipTaskbar),不会抢主输入焦点造成困扰。
|
|
248
309
|
webPreferences: {
|
|
249
310
|
preload: path.join(__dirname, 'renderer', 'preload.js'),
|
|
250
311
|
contextIsolation: true,
|
|
@@ -258,8 +319,14 @@ function createPetWindow() {
|
|
|
258
319
|
else {
|
|
259
320
|
win.setAlwaysOnTop(true);
|
|
260
321
|
}
|
|
261
|
-
//
|
|
262
|
-
|
|
322
|
+
// 默认鼠标穿透(不遮挡桌面操作);forward:true 转发 mousemove,使渲染进程能收到 mouseenter/mouseleave
|
|
323
|
+
// 从而按 Electron 官方推荐模式动态请求取消穿透(见 preload.ts setIgnoreMouseEvents 转发 + renderer.ts 拖拽逻辑)。
|
|
324
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
325
|
+
// 拖拽放置后持久化窗口位置(见 renderer.ts 的 -webkit-app-region:drag 拖拽 + 此处 'moved' 事件落盘)。
|
|
326
|
+
win.on('moved', () => {
|
|
327
|
+
const [x, y] = win.getPosition();
|
|
328
|
+
saveConfig({ x, y });
|
|
329
|
+
});
|
|
263
330
|
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
|
264
331
|
win.webContents.on('render-process-gone', () => {
|
|
265
332
|
// 渲染进程崩溃但主进程(WS Server)存活:重建一次窗口,不重启 WS Server、不断开现有客户端连接。
|
|
@@ -271,14 +338,89 @@ function createPetWindow() {
|
|
|
271
338
|
});
|
|
272
339
|
return win;
|
|
273
340
|
}
|
|
341
|
+
/** 渲染进程请求切换鼠标穿透状态(悬停时取消穿透以便拖拽,离开后恢复穿透)。见 preload.ts 转发。 */
|
|
342
|
+
ipcMain.on('pet:set-ignore-mouse-events', (event, ignore) => {
|
|
343
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
344
|
+
win?.setIgnoreMouseEvents(ignore, { forward: true });
|
|
345
|
+
});
|
|
346
|
+
/** 拖拽会话的起始 bounds(仅在 mousedown 时读取一次,拖拽过程中绝不重新读取 win.getBounds())。
|
|
347
|
+
* 这是修复"越拖越大"的关键:Windows 在非 100% DPI 缩放下,setBounds/setPosition 存在已知的舍入误差
|
|
348
|
+
* (见 https://github.com/electron/electron/issues/27651、#9477),每调一次尺寸就可能被系统悄悄放大
|
|
349
|
+
* 一点。如果每次 mousemove 都用"读当前 getSize() → 原样传回 setBounds"的方式,这个误差会不断累加——
|
|
350
|
+
* 上一次被放大的尺寸被读回来又原样设进去,越拖越大,永远回不去。正确做法是把宽高钉死为拖拽开始那一刻
|
|
351
|
+
* 的固定值,拖拽期间只用"起始坐标 + 累计位移"算新位置,never 把 setBounds 返回后可能已变化的尺寸再喂回去。 */
|
|
352
|
+
let dragStartBounds = null;
|
|
353
|
+
/** 拖拽开始:记录窗口初始 bounds(渲染进程 mousedown 时调用一次)。 */
|
|
354
|
+
ipcMain.on('pet:drag-start', (event) => {
|
|
355
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
356
|
+
if (!win || win.isDestroyed())
|
|
357
|
+
return;
|
|
358
|
+
const b = win.getBounds();
|
|
359
|
+
dragStartBounds = { x: b.x, y: b.y, width: b.width, height: b.height };
|
|
360
|
+
});
|
|
361
|
+
/** 拖拽中:渲染进程传来"相对拖拽起点的累计位移"(不是相对上一帧的增量),
|
|
362
|
+
* 主进程据此从固定的起始 bounds 计算新位置,宽高恒为起始值,绝不重新读取当前窗口尺寸。 */
|
|
363
|
+
ipcMain.on('pet:drag-move', (event, totalDx, totalDy) => {
|
|
364
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
365
|
+
if (!win || win.isDestroyed() || !dragStartBounds)
|
|
366
|
+
return;
|
|
367
|
+
win.setBounds({
|
|
368
|
+
x: Math.round(dragStartBounds.x + totalDx),
|
|
369
|
+
y: Math.round(dragStartBounds.y + totalDy),
|
|
370
|
+
width: dragStartBounds.width,
|
|
371
|
+
height: dragStartBounds.height,
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
/** 拖拽结束:清空起始 bounds 记录(渲染进程 mouseup 时调用)。 */
|
|
375
|
+
ipcMain.on('pet:drag-end', () => {
|
|
376
|
+
dragStartBounds = null;
|
|
377
|
+
});
|
|
378
|
+
/** 构建/刷新托盘右键菜单:退出桌宠 + 选择宠物子菜单(单选,当前皮肤打勾)。 */
|
|
379
|
+
function rebuildTrayMenu() {
|
|
380
|
+
if (!tray)
|
|
381
|
+
return;
|
|
382
|
+
const skinItems = [
|
|
383
|
+
{
|
|
384
|
+
label: '默认(mascot)',
|
|
385
|
+
type: 'radio',
|
|
386
|
+
checked: currentSkinId === 'default',
|
|
387
|
+
click: () => applySkin('default'),
|
|
388
|
+
},
|
|
389
|
+
...listSkinEntries().map((e) => ({
|
|
390
|
+
label: e.name,
|
|
391
|
+
type: 'radio',
|
|
392
|
+
checked: currentSkinId === e.id,
|
|
393
|
+
click: () => applySkin(e.id),
|
|
394
|
+
})),
|
|
395
|
+
];
|
|
396
|
+
const template = [
|
|
397
|
+
{ label: 'mocode 桌宠', enabled: false },
|
|
398
|
+
{ type: 'separator' },
|
|
399
|
+
{ label: '选择宠物', submenu: skinItems },
|
|
400
|
+
{ type: 'separator' },
|
|
401
|
+
{ label: '退出桌宠', click: () => app.quit() },
|
|
402
|
+
];
|
|
403
|
+
tray.setContextMenu(Menu.buildFromTemplate(template));
|
|
404
|
+
}
|
|
405
|
+
/** 创建系统托盘图标(方案C的桌面侧退出/选皮入口,弥补悬浮窗本身鼠标穿透+无边框导致的不可交互问题)。 */
|
|
406
|
+
function createTray() {
|
|
407
|
+
// dist/main.js 与 dist/assets/ 同级(scripts/copy-static.mjs 把源码 assets/ 整体复制到 dist/assets/)。
|
|
408
|
+
const iconPath = path.join(__dirname, 'assets', 'tray-icon.png');
|
|
409
|
+
const icon = nativeImage.createFromPath(iconPath);
|
|
410
|
+
const t = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
|
411
|
+
t.setToolTip('mocode 桌宠');
|
|
412
|
+
return t;
|
|
413
|
+
}
|
|
274
414
|
app.whenReady().then(async () => {
|
|
415
|
+
const cfg = loadConfig();
|
|
416
|
+
currentSkinId = cfg.skinId ?? 'default';
|
|
275
417
|
const port = petPort();
|
|
276
418
|
await startServer(port);
|
|
277
419
|
startHeartbeatSweep();
|
|
278
420
|
mainWindow = createPetWindow();
|
|
421
|
+
tray = createTray();
|
|
422
|
+
rebuildTrayMenu();
|
|
279
423
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
app.quit();
|
|
284
|
-
});
|
|
424
|
+
// 不再监听 window-all-closed → app.quit():悬浮窗本身不可关闭(frame:false 且无关闭按钮),
|
|
425
|
+
// 该事件设计上永不触发。退出统一走两个显式入口:托盘菜单"退出桌宠" 或 CLI 侧 shutdown 消息
|
|
426
|
+
// (src/pet/bridge.ts killPetProcess,经 /pet quit 命令触发)。
|
package/dist/protocol.js
CHANGED
|
@@ -10,6 +10,7 @@ export const PET_STATES = [
|
|
|
10
10
|
'done',
|
|
11
11
|
'aborted',
|
|
12
12
|
'error',
|
|
13
|
+
'waiting_human',
|
|
13
14
|
];
|
|
14
15
|
/** 判断值是否为合法 PetState(供消息校验,非法值丢弃不崩)。 */
|
|
15
16
|
export function isValidPetState(v) {
|
|
@@ -56,6 +57,16 @@ export function parseClientMessage(raw) {
|
|
|
56
57
|
if (typeof m.clientId !== 'string')
|
|
57
58
|
return null;
|
|
58
59
|
return { type: 'bye', clientId: m.clientId, ts: m.ts };
|
|
60
|
+
case 'shutdown':
|
|
61
|
+
if (typeof m.clientId !== 'string')
|
|
62
|
+
return null;
|
|
63
|
+
return { type: 'shutdown', clientId: m.clientId, ts: m.ts };
|
|
64
|
+
case 'set_skin':
|
|
65
|
+
if (typeof m.clientId !== 'string' || typeof m.skinId !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
return { type: 'set_skin', clientId: m.clientId, skinId: m.skinId, ts: m.ts };
|
|
68
|
+
case 'list_skins':
|
|
69
|
+
return { type: 'list_skins', ts: m.ts };
|
|
59
70
|
default:
|
|
60
71
|
return null;
|
|
61
72
|
}
|
|
@@ -81,6 +92,20 @@ export function parseServerMessage(raw) {
|
|
|
81
92
|
return { type: 'welcome', isActive: m.isActive, ts: m.ts };
|
|
82
93
|
case 'pong':
|
|
83
94
|
return { type: 'pong', ts: m.ts };
|
|
95
|
+
case 'skin_list': {
|
|
96
|
+
if (!Array.isArray(m.skins) || typeof m.currentSkinId !== 'string')
|
|
97
|
+
return null;
|
|
98
|
+
const skins = [];
|
|
99
|
+
for (const s of m.skins) {
|
|
100
|
+
if (!s || typeof s !== 'object')
|
|
101
|
+
continue;
|
|
102
|
+
const ss = s;
|
|
103
|
+
if (typeof ss.id === 'string' && typeof ss.name === 'string') {
|
|
104
|
+
skins.push({ id: ss.id, name: ss.name });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { type: 'skin_list', skins, currentSkinId: m.currentSkinId, ts: m.ts };
|
|
108
|
+
}
|
|
84
109
|
default:
|
|
85
110
|
return null;
|
|
86
111
|
}
|
package/dist/renderer/index.html
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
<link rel="stylesheet" href="style.css" />
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
|
-
<div id="pet-
|
|
10
|
+
<div id="pet-stage" class="pet-idle">
|
|
11
|
+
<div id="pet-container"></div>
|
|
12
|
+
<div id="signal-light-container"></div>
|
|
13
|
+
</div>
|
|
11
14
|
<script src="renderer.js" type="module"></script>
|
|
12
15
|
</body>
|
|
13
16
|
</html>
|
package/dist/renderer/preload.js
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
1
2
|
// Electron preload:通过 contextBridge 把主进程 IPC 推送的状态安全暴露给渲染进程(contextIsolation=true,
|
|
2
3
|
// 不给渲染进程任何 Node/Electron API 访问权限,只转发一个只读回调注册接口)。
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
const electron_1 = require("electron");
|
|
6
|
+
electron_1.contextBridge.exposeInMainWorld('petBridge', {
|
|
5
7
|
onState: (callback) => {
|
|
6
|
-
ipcRenderer.on('pet:state', (_event, payload) => {
|
|
8
|
+
electron_1.ipcRenderer.on('pet:state', (_event, payload) => {
|
|
7
9
|
callback(payload.state, payload.meta);
|
|
8
10
|
});
|
|
9
11
|
},
|
|
12
|
+
/** 主进程推来的皮肤切换通知(运行期切换:托盘菜单选择 或 CLI set_skin 消息触发)。 */
|
|
13
|
+
onSkin: (callback) => {
|
|
14
|
+
electron_1.ipcRenderer.on('pet:skin', (_event, payload) => {
|
|
15
|
+
callback(payload.assetPath);
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
/** 启动时主动拉取当前皮肤(避免 did-finish-load 推送与监听器注册之间的时序竞争)。 */
|
|
19
|
+
getInitialSkin: () => electron_1.ipcRenderer.invoke('pet:get-skin'),
|
|
20
|
+
/** 转发鼠标穿透状态请求(拖拽放置功能:悬停/拖拽时取消穿透,离开后恢复穿透)。 */
|
|
21
|
+
setIgnoreMouseEvents: (ignore) => {
|
|
22
|
+
electron_1.ipcRenderer.send('pet:set-ignore-mouse-events', ignore);
|
|
23
|
+
},
|
|
24
|
+
/** 手动拖拽(替代不可靠的 -webkit-app-region:drag,见 style.css 顶部注释):
|
|
25
|
+
* dragStart 在 mousedown 时调用一次,让主进程记住窗口起始 bounds;
|
|
26
|
+
* dragMove 在 mousemove 时传"相对起点的累计位移"(不是相对上一帧的增量)——
|
|
27
|
+
* 这样主进程永远从固定的起始尺寸算新位置,不会因反复读取可能已被 Windows DPI 舍入误差
|
|
28
|
+
* 放大的当前尺寸而越拖越大(见 main.ts dragStartBounds 注释);
|
|
29
|
+
* dragEnd 在 mouseup 时调用,清空起始记录。 */
|
|
30
|
+
dragStart: () => {
|
|
31
|
+
electron_1.ipcRenderer.send('pet:drag-start');
|
|
32
|
+
},
|
|
33
|
+
dragMove: (totalDx, totalDy) => {
|
|
34
|
+
electron_1.ipcRenderer.send('pet:drag-move', totalDx, totalDy);
|
|
35
|
+
},
|
|
36
|
+
dragEnd: () => {
|
|
37
|
+
electron_1.ipcRenderer.send('pet:drag-end');
|
|
38
|
+
},
|
|
10
39
|
});
|