@clawlabz/clawskin 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/CHANGELOG.md +98 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/docs/ARCHITECTURE.md +132 -0
- package/docs/PROGRESS.md +15 -0
- package/docs/ROADMAP.md +130 -0
- package/package.json +27 -0
- package/public/_headers +13 -0
- package/public/_redirects +1 -0
- package/public/app.html +595 -0
- package/public/css/app.css +379 -0
- package/public/css/style.css +415 -0
- package/public/index.html +324 -0
- package/public/js/app/AgentSlot.js +596 -0
- package/public/js/app/AgentStateMapper.js +213 -0
- package/public/js/app/ClawSkinApp.js +551 -0
- package/public/js/app/ConnectionPanel.js +142 -0
- package/public/js/app/DeviceIdentity.js +118 -0
- package/public/js/app/GatewayClient.js +284 -0
- package/public/js/app/SettingsManager.js +47 -0
- package/public/js/character/AnimationManager.js +69 -0
- package/public/js/character/BubbleManager.js +157 -0
- package/public/js/character/CharacterSprite.js +98 -0
- package/public/js/game.js +116 -0
- package/public/js/pets/Pet.js +303 -0
- package/public/js/pets/PetManager.js +102 -0
- package/public/js/scenes/CafeScene.js +594 -0
- package/public/js/scenes/HackerScene.js +527 -0
- package/public/js/scenes/OfficeScene.js +404 -0
- package/public/js/sprites/SpriteGenerator.js +927 -0
- package/public/js/state/AgentStateSync.js +89 -0
- package/public/js/state/DemoMode.js +65 -0
- package/public/js/ui/CharacterEditor.js +145 -0
- package/public/js/ui/ScenePicker.js +29 -0
- package/serve.cjs +132 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentStateSync.js — Connects to OpenClaw Gateway for real-time Agent status
|
|
3
|
+
* Falls back to demo mode when no connection is available
|
|
4
|
+
*/
|
|
5
|
+
class AgentStateSync {
|
|
6
|
+
constructor(characterSprite, options = {}) {
|
|
7
|
+
this.character = characterSprite;
|
|
8
|
+
this.gatewayUrl = options.gatewayUrl || null;
|
|
9
|
+
this.agentId = options.agentId || 'default';
|
|
10
|
+
this.connected = false;
|
|
11
|
+
this.ws = null;
|
|
12
|
+
this.demoMode = new DemoMode(characterSprite);
|
|
13
|
+
this.onStateChange = options.onStateChange || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async connect() {
|
|
17
|
+
if (!this.gatewayUrl) {
|
|
18
|
+
console.log('[AgentStateSync] No gateway URL, starting demo mode');
|
|
19
|
+
this.demoMode.start();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const wsUrl = this.gatewayUrl.replace(/^http/, 'ws') + `/ws/agent/${this.agentId}/events`;
|
|
25
|
+
this.ws = new WebSocket(wsUrl);
|
|
26
|
+
|
|
27
|
+
this.ws.onopen = () => {
|
|
28
|
+
console.log('[AgentStateSync] Connected to Gateway');
|
|
29
|
+
this.connected = true;
|
|
30
|
+
this.demoMode.stop();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
this.ws.onmessage = (event) => {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(event.data);
|
|
36
|
+
this._handleEvent(data);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.warn('[AgentStateSync] Parse error:', e);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
this.ws.onclose = () => {
|
|
43
|
+
console.log('[AgentStateSync] Disconnected, falling back to demo');
|
|
44
|
+
this.connected = false;
|
|
45
|
+
this.demoMode.start();
|
|
46
|
+
// Reconnect after 5s
|
|
47
|
+
setTimeout(() => this.connect(), 5000);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
this.ws.onerror = () => {
|
|
51
|
+
this.connected = false;
|
|
52
|
+
this.demoMode.start();
|
|
53
|
+
};
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.warn('[AgentStateSync] Connection failed, using demo mode');
|
|
56
|
+
this.demoMode.start();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_handleEvent(data) {
|
|
61
|
+
const stateMap = {
|
|
62
|
+
'idle': 'idle', 'thinking': 'thinking', 'writing': 'typing',
|
|
63
|
+
'executing': 'executing', 'browsing': 'browsing', 'error': 'error',
|
|
64
|
+
'heartbeat': 'waving', 'sleeping': 'sleeping'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (data.type === 'state_change') {
|
|
68
|
+
const mapped = stateMap[data.state] || 'idle';
|
|
69
|
+
this.character.setState(mapped);
|
|
70
|
+
if (data.message) {
|
|
71
|
+
this.character.showBubble(data.message, 'speech', 4000);
|
|
72
|
+
}
|
|
73
|
+
if (this.onStateChange) this.onStateChange(mapped, data);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
update(dt) {
|
|
78
|
+
if (!this.connected) {
|
|
79
|
+
this.demoMode.update(dt);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
disconnect() {
|
|
84
|
+
if (this.ws) this.ws.close();
|
|
85
|
+
this.demoMode.stop();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof window !== 'undefined') window.AgentStateSync = AgentStateSync;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DemoMode.js — Automatic state cycling for demo/preview
|
|
3
|
+
* Simulates a real Agent's work day with random state changes and bubbles
|
|
4
|
+
*/
|
|
5
|
+
class DemoMode {
|
|
6
|
+
constructor(characterSprite) {
|
|
7
|
+
this.character = characterSprite;
|
|
8
|
+
this.active = false;
|
|
9
|
+
this.timer = 0;
|
|
10
|
+
this.nextChange = 3000;
|
|
11
|
+
this.stateIndex = 0;
|
|
12
|
+
|
|
13
|
+
this.stateSequence = [
|
|
14
|
+
{ state: 'idle', duration: [4000, 8000], bubbles: ['Just chilling...', 'Waiting for tasks', 'Hmm, what to do...', '☕ Coffee time soon'] },
|
|
15
|
+
{ state: 'thinking', duration: [3000, 5000], bubbles: ['Processing request...', 'Let me think...', 'Analyzing data...', 'Hmm, interesting...'] },
|
|
16
|
+
{ state: 'typing', duration: [5000, 10000], bubbles: ['Writing response...', 'Almost done...', 'def solve(x): ...', 'Drafting email...', 'Fixing that bug...'] },
|
|
17
|
+
{ state: 'executing', duration: [3000, 5000], bubbles: ['Running command...', 'npm install...', 'Deploying...', 'git push origin main'] },
|
|
18
|
+
{ state: 'browsing', duration: [4000, 7000], bubbles: ['Searching docs...', 'Reading Stack Overflow', 'Checking GitHub...', 'Interesting article!'] },
|
|
19
|
+
{ state: 'coffee', duration: [3000, 5000], bubbles: ['Ah, fresh coffee!', 'Need more caffeine', 'Perfect brew ☕', 'Break time!'] },
|
|
20
|
+
{ state: 'error', duration: [2000, 3000], bubbles: ['Oops! Error 404', 'Something broke...', 'Null pointer?!', 'Let me fix that...'] },
|
|
21
|
+
{ state: 'waving', duration: [2000, 3000], bubbles: ['Hey there! 👋', 'Hello world!', 'Hi boss!', 'Wave check!'] },
|
|
22
|
+
{ state: 'sleeping', duration: [4000, 6000], bubbles: ['zzZ...', 'Dreaming of code...', 'Recharging...'] },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start() {
|
|
27
|
+
this.active = true;
|
|
28
|
+
this.timer = 0;
|
|
29
|
+
this._triggerNext();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
stop() {
|
|
33
|
+
this.active = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
update(dt) {
|
|
37
|
+
if (!this.active) return;
|
|
38
|
+
this.timer += dt;
|
|
39
|
+
if (this.timer >= this.nextChange) {
|
|
40
|
+
this.timer = 0;
|
|
41
|
+
this._triggerNext();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_triggerNext() {
|
|
46
|
+
const entry = this.stateSequence[this.stateIndex % this.stateSequence.length];
|
|
47
|
+
this.stateIndex++;
|
|
48
|
+
|
|
49
|
+
this.character.setState(entry.state);
|
|
50
|
+
|
|
51
|
+
const [min, max] = entry.duration;
|
|
52
|
+
this.nextChange = min + Math.random() * (max - min);
|
|
53
|
+
|
|
54
|
+
// Show bubble with 70% probability
|
|
55
|
+
if (Math.random() < 0.7 && entry.bubbles.length > 0) {
|
|
56
|
+
const text = entry.bubbles[Math.floor(Math.random() * entry.bubbles.length)];
|
|
57
|
+
const type = entry.state === 'thinking' ? 'thought' : 'speech';
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
this.character.showBubble(text, type, this.nextChange * 0.6);
|
|
60
|
+
}, 500);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof window !== 'undefined') window.DemoMode = DemoMode;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CharacterEditor.js — Character customization UI (HTML overlay)
|
|
3
|
+
* Provides real-time preview as user adjusts appearance options
|
|
4
|
+
*/
|
|
5
|
+
class CharacterEditor {
|
|
6
|
+
constructor(characterSprite, containerId) {
|
|
7
|
+
this.character = characterSprite;
|
|
8
|
+
this.container = document.getElementById(containerId);
|
|
9
|
+
this.visible = false;
|
|
10
|
+
this.onChange = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
show() {
|
|
14
|
+
this.visible = true;
|
|
15
|
+
this._render();
|
|
16
|
+
this.container.style.display = 'block';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
hide() {
|
|
20
|
+
this.visible = false;
|
|
21
|
+
this.container.style.display = 'none';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toggle() {
|
|
25
|
+
this.visible ? this.hide() : this.show();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_render() {
|
|
29
|
+
const cfg = this.character.config;
|
|
30
|
+
const skinTones = SpriteGenerator.SKIN_TONES;
|
|
31
|
+
const hairColors = SpriteGenerator.HAIR_COLORS;
|
|
32
|
+
const outfitTypes = Object.keys(SpriteGenerator.OUTFIT_COLORS);
|
|
33
|
+
const hairNames = ['Short Messy', 'Spiky', 'Long', 'Curly', 'Buzz Cut'];
|
|
34
|
+
const outfitNames = { hoodie: 'Hoodie', shirt: 'Shirt', suit: 'Suit', labcoat: 'Lab Coat', tshirt: 'T-Shirt' };
|
|
35
|
+
const accessoryNames = { '': 'None', glasses: 'Glasses 🤓', hat: 'Hat 🎩', headphones: 'Headphones 🎧', cap: 'Cap 🧢' };
|
|
36
|
+
|
|
37
|
+
this.container.innerHTML = `
|
|
38
|
+
<div class="editor-panel">
|
|
39
|
+
<div class="editor-header">
|
|
40
|
+
<span>🎨 Character Creator</span>
|
|
41
|
+
<button onclick="window._charEditor.hide()" class="editor-close">✕</button>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="editor-body">
|
|
44
|
+
<div class="editor-row">
|
|
45
|
+
<label>Skin Tone</label>
|
|
46
|
+
<div class="color-swatches" id="ed-skin">
|
|
47
|
+
${skinTones.map((c, i) => `<div class="swatch ${cfg.skinColor === c ? 'active' : ''}" style="background:${c}" data-val="${c}" onclick="window._charEditor._set('skinColor','${c}')"></div>`).join('')}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="editor-row">
|
|
51
|
+
<label>Hair Style</label>
|
|
52
|
+
<div class="btn-group" id="ed-hair">
|
|
53
|
+
${hairNames.map((n, i) => `<button class="ed-btn ${cfg.hairType === i ? 'active' : ''}" onclick="window._charEditor._set('hairType',${i})">${n}</button>`).join('')}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="editor-row">
|
|
57
|
+
<label>Hair Color</label>
|
|
58
|
+
<div class="color-swatches" id="ed-haircolor">
|
|
59
|
+
${hairColors.map(c => `<div class="swatch ${cfg.hairColor === c ? 'active' : ''}" style="background:${c}" onclick="window._charEditor._set('hairColor','${c}')"></div>`).join('')}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="editor-row">
|
|
63
|
+
<label>Outfit</label>
|
|
64
|
+
<div class="btn-group">
|
|
65
|
+
${outfitTypes.map(t => `<button class="ed-btn ${cfg.outfitType === t ? 'active' : ''}" onclick="window._charEditor._set('outfitType','${t}')">${outfitNames[t]}</button>`).join('')}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="editor-row">
|
|
69
|
+
<label>Outfit Color</label>
|
|
70
|
+
<div class="color-swatches">
|
|
71
|
+
${[0,1,2,3,4].map(i => {
|
|
72
|
+
const ot = cfg.outfitType || 'hoodie';
|
|
73
|
+
const c = (SpriteGenerator.OUTFIT_COLORS[ot] || SpriteGenerator.OUTFIT_COLORS.hoodie)[i];
|
|
74
|
+
return `<div class="swatch ${cfg.outfitColorIdx === i ? 'active' : ''}" style="background:${c}" onclick="window._charEditor._set('outfitColorIdx',${i})"></div>`;
|
|
75
|
+
}).join('')}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="editor-row">
|
|
79
|
+
<label>Accessory</label>
|
|
80
|
+
<div class="btn-group">
|
|
81
|
+
${Object.entries(accessoryNames).map(([k, v]) => `<button class="ed-btn ${(cfg.accessory || '') === k ? 'active' : ''}" onclick="window._charEditor._set('accessory','${k || ''}')">${v}</button>`).join('')}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="editor-actions">
|
|
85
|
+
<button class="ed-action-btn" onclick="window._charEditor._randomize()">🎲 Random</button>
|
|
86
|
+
<button class="ed-action-btn" onclick="window._charEditor._save()">💾 Save</button>
|
|
87
|
+
<button class="ed-action-btn" onclick="window._charEditor._export()">📋 Export JSON</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_set(key, value) {
|
|
95
|
+
if (key === 'accessory' && value === '') value = null;
|
|
96
|
+
if (key === 'pet' && value === '') value = null;
|
|
97
|
+
this.character.updateConfig({ [key]: value });
|
|
98
|
+
this._render();
|
|
99
|
+
if (this.onChange) this.onChange(this.character.config);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_randomize() {
|
|
103
|
+
const cfg = CharacterSprite.randomConfig();
|
|
104
|
+
this.character.updateConfig(cfg);
|
|
105
|
+
this._render();
|
|
106
|
+
if (this.onChange) this.onChange(cfg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_save() {
|
|
110
|
+
const config = this.character.config;
|
|
111
|
+
localStorage.setItem('clawskin_character', JSON.stringify(config));
|
|
112
|
+
// Also persist to SettingsManager so ClawSkinApp.init() picks it up
|
|
113
|
+
if (window._app && window._app.settings) {
|
|
114
|
+
window._app.settings.update({ character: config });
|
|
115
|
+
}
|
|
116
|
+
this._showToast('Character saved! ✅');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_export() {
|
|
120
|
+
const json = JSON.stringify(this.character.config, null, 2);
|
|
121
|
+
navigator.clipboard.writeText(json).then(() => {
|
|
122
|
+
this._showToast('Config copied to clipboard! 📋');
|
|
123
|
+
}).catch(() => {
|
|
124
|
+
prompt('Copy this config:', json);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_showToast(msg) {
|
|
129
|
+
const toast = document.createElement('div');
|
|
130
|
+
toast.className = 'editor-toast';
|
|
131
|
+
toast.textContent = msg;
|
|
132
|
+
document.body.appendChild(toast);
|
|
133
|
+
setTimeout(() => toast.classList.add('show'), 10);
|
|
134
|
+
setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 2000);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static loadSaved() {
|
|
138
|
+
try {
|
|
139
|
+
const saved = localStorage.getItem('clawskin_character');
|
|
140
|
+
return saved ? JSON.parse(saved) : null;
|
|
141
|
+
} catch { return null; }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof window !== 'undefined') window.CharacterEditor = CharacterEditor;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScenePicker.js — Scene selection UI
|
|
3
|
+
*/
|
|
4
|
+
class ScenePicker {
|
|
5
|
+
constructor(scenes, onSelect) {
|
|
6
|
+
this.scenes = scenes;
|
|
7
|
+
this.currentIndex = 0;
|
|
8
|
+
this.onSelect = onSelect;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
render(containerId) {
|
|
12
|
+
const el = document.getElementById(containerId);
|
|
13
|
+
if (!el) return;
|
|
14
|
+
el.innerHTML = this.scenes.map((s, i) => `
|
|
15
|
+
<button class="scene-btn ${i === this.currentIndex ? 'active' : ''}"
|
|
16
|
+
onclick="window._scenePicker.select(${i})">
|
|
17
|
+
${s.label}
|
|
18
|
+
</button>
|
|
19
|
+
`).join('');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
select(index) {
|
|
23
|
+
this.currentIndex = index;
|
|
24
|
+
if (this.onSelect) this.onSelect(this.scenes[index], index);
|
|
25
|
+
this.render('scene-picker');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof window !== 'undefined') window.ScenePicker = ScenePicker;
|
package/serve.cjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ClawSkin Local Server
|
|
4
|
+
* Zero-dependency HTTP server for running ClawSkin locally.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npm start → opens http://localhost:3000 in browser
|
|
8
|
+
* npm start -- --no-open → start without opening browser
|
|
9
|
+
* npm start -- --port 8080 → custom port
|
|
10
|
+
* npx clawskin
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const MIME = {
|
|
18
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
19
|
+
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
20
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
21
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const portIdx = args.indexOf('--port');
|
|
26
|
+
const basePort = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) || 3000 : 3000;
|
|
27
|
+
const shouldOpen = !args.includes('--no-open');
|
|
28
|
+
const publicDir = path.join(__dirname, 'public');
|
|
29
|
+
|
|
30
|
+
// Security: only bind to localhost by default. Use --host 0.0.0.0 to expose.
|
|
31
|
+
const hostIdx = args.indexOf('--host');
|
|
32
|
+
const bindHost = hostIdx >= 0 ? args[hostIdx + 1] : '127.0.0.1';
|
|
33
|
+
|
|
34
|
+
// Read local OpenClaw config for auto-connect
|
|
35
|
+
function getLocalGatewayConfig() {
|
|
36
|
+
const os = require('os');
|
|
37
|
+
const configPaths = [
|
|
38
|
+
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
|
39
|
+
path.join(os.homedir(), '.openclaw', 'config.json'),
|
|
40
|
+
];
|
|
41
|
+
for (const p of configPaths) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
44
|
+
const config = JSON.parse(raw);
|
|
45
|
+
const gw = config.gateway || {};
|
|
46
|
+
const port = gw.port || 18789;
|
|
47
|
+
return {
|
|
48
|
+
gatewayUrl: `ws://localhost:${port}`,
|
|
49
|
+
// Only expose that auth is token-based, never send the actual token.
|
|
50
|
+
// The user must enter the token manually in the UI.
|
|
51
|
+
hasAuth: !!(gw.auth && gw.auth.mode === 'token'),
|
|
52
|
+
};
|
|
53
|
+
} catch { continue; }
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Security headers applied to every response
|
|
59
|
+
function setSecurityHeaders(res) {
|
|
60
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
61
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
62
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
63
|
+
res.setHeader('Content-Security-Policy',
|
|
64
|
+
"default-src 'self'; " +
|
|
65
|
+
"script-src 'self' 'unsafe-inline'; " +
|
|
66
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
67
|
+
"font-src 'self' https://fonts.gstatic.com; " +
|
|
68
|
+
"connect-src 'self' ws: wss:; " +
|
|
69
|
+
"img-src 'self' data:; " +
|
|
70
|
+
"frame-ancestors 'self'"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleRequest(req, res) {
|
|
75
|
+
// Only allow GET requests
|
|
76
|
+
if (req.method !== 'GET') {
|
|
77
|
+
res.writeHead(405); res.end('Method Not Allowed'); return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
81
|
+
|
|
82
|
+
// Local-only API: serve gateway config for auto-connect (token is NEVER exposed)
|
|
83
|
+
if (urlPath === '/api/config') {
|
|
84
|
+
const config = getLocalGatewayConfig();
|
|
85
|
+
const body = JSON.stringify(config || { gatewayUrl: 'ws://localhost:18789', hasAuth: false });
|
|
86
|
+
setSecurityHeaders(res);
|
|
87
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
88
|
+
res.end(body);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (urlPath === '/') urlPath = '/app.html';
|
|
93
|
+
const filePath = path.join(publicDir, urlPath);
|
|
94
|
+
if (!filePath.startsWith(publicDir)) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
95
|
+
fs.readFile(filePath, (err, data) => {
|
|
96
|
+
if (err) { res.writeHead(err.code === 'ENOENT' ? 404 : 500); res.end(err.code === 'ENOENT' ? 'Not Found' : 'Internal Error'); return; }
|
|
97
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
98
|
+
setSecurityHeaders(res);
|
|
99
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
|
100
|
+
res.end(data);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onReady(actualPort) {
|
|
105
|
+
const url = `http://localhost:${actualPort}`;
|
|
106
|
+
console.log(`\n 🐾 ClawSkin running at ${url}\n`);
|
|
107
|
+
console.log(` → Opens app.html by default`);
|
|
108
|
+
console.log(` → Auto-connects to OpenClaw Gateway at ws://localhost:18789`);
|
|
109
|
+
if (bindHost === '127.0.0.1') {
|
|
110
|
+
console.log(` → Bound to localhost only (use --host 0.0.0.0 to expose to network)\n`);
|
|
111
|
+
} else {
|
|
112
|
+
console.log(` ⚠ Bound to ${bindHost} — accessible from the network\n`);
|
|
113
|
+
}
|
|
114
|
+
if (shouldOpen) {
|
|
115
|
+
const { exec } = require('child_process');
|
|
116
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
117
|
+
exec(`${cmd} ${url}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function tryListen(port) {
|
|
122
|
+
const server = http.createServer(handleRequest);
|
|
123
|
+
server.once('error', (err) => {
|
|
124
|
+
if (err.code === 'EADDRINUSE') {
|
|
125
|
+
console.log(` ⚠ Port ${port} in use, trying ${port + 1}...`);
|
|
126
|
+
tryListen(port + 1);
|
|
127
|
+
} else { throw err; }
|
|
128
|
+
});
|
|
129
|
+
server.listen(port, bindHost, () => onReady(port));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
tryListen(basePort);
|