@7ots/cli 0.1.5 → 0.1.6
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/README.md +14 -20
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +122 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +28 -5
- package/cli/lib/i18n.mjs +57 -0
- package/cli/lib/inferences.mjs +76 -0
- package/cli/lib/integrations.mjs +414 -0
- package/cli/lib/orquesta.mjs +53 -0
- package/cli/lib/pet-core.mjs +7 -10
- package/cli/lib/pet-server.mjs +365 -22
- package/cli/lib/screen.mjs +125 -0
- package/cli/lib/stt-local.py +14 -0
- package/cli/lib/stt.mjs +68 -0
- package/cli/lib/tts-local.mjs +110 -0
- package/cli/lib/tts-local.py +18 -0
- package/cli/lib/vm.mjs +309 -0
- package/cli/lib/watchers.mjs +151 -0
- package/cli/pet/card.html +294 -0
- package/cli/pet/electron/director.cjs +175 -0
- package/cli/pet/electron/main.cjs +354 -49
- package/cli/pet/pet.html +328 -15
- package/cli/pet/settings.html +991 -57
- package/dist/7ots.esm.js +3 -3
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +3 -3
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +1 -1
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +77 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +100 -1
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +386 -0
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +3 -3
- package/src/i18n/messages/platform.js +42 -3
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* along the bottom of the screen. It resizes to what is in use and lets clicks through its empty parts. Launched by `7ots pet --mode desktop` with
|
|
4
4
|
* ~/.7ots/electron/…/electron cli/pet/electron/main.cjs (installed once by the CLI)
|
|
5
5
|
* and SEVENOTS_PET_URL / SEVENOTS_PET_TOKEN in the environment.
|
|
6
|
-
* Menu: right click on the pet, or the tray icon (Show / Hide / Talk / care / Settings / Quit).
|
|
6
|
+
* Menu: right click on the pet, or the tray icon (Show / Hide / Talk / Listen / care / Settings / Quit).
|
|
7
|
+
* Voice: Ctrl+Alt+Space (anywhere) makes it listen; the page records and the server transcribes (/stt).
|
|
8
|
+
* With a purpose: when something needs you (the agent finished or waits, a reminder opened a site) it walks to
|
|
9
|
+
* that window (found with xdotool on X11; elsewhere it comes to the cursor) and points at it.
|
|
7
10
|
* SEVENOTS_PET_OPAQUE=1 paints a solid card instead of transparency (Linux setups without a compositor).
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
const path = require('node:path');
|
|
11
|
-
const {
|
|
14
|
+
const { execFile } = require('node:child_process');
|
|
15
|
+
const { app, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, screen, session, shell } = require('electron');
|
|
12
16
|
|
|
13
17
|
const URL_ = process.env.SEVENOTS_PET_URL;
|
|
14
18
|
const TOKEN = process.env.SEVENOTS_PET_TOKEN;
|
|
@@ -18,14 +22,18 @@ const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
|
|
|
18
22
|
const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
|
|
19
23
|
const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
|
|
20
24
|
const SMALL = { w: 160, h: 164 }; // just the ot
|
|
25
|
+
// The smallest the window gets: the ot with room for its hands, a hop and small effects. Every resize of a
|
|
26
|
+
// transparent window flickers (and in 3D rescales the canvas), so it stays at least this big; click-through
|
|
27
|
+
// keeps the empty parts out of the way.
|
|
28
|
+
const MIN = { w: 288, h: 272 };
|
|
21
29
|
const linux = process.platform === 'linux';
|
|
22
30
|
|
|
23
31
|
const L = {
|
|
24
|
-
en: { settings: '⚙️ Settings', talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎
|
|
25
|
-
es: { settings: '⚙️ Configuración', talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎
|
|
26
|
-
pt: { settings: '⚙️ Configurações', talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎
|
|
32
|
+
en: { settings: '⚙️ Settings', talk: '💬 Talk', listen: '🎙️ Listen (Ctrl+Alt+Space)', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Treat', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
|
|
33
|
+
es: { settings: '⚙️ Configuración', talk: '💬 Hablar', listen: '🎙️ Escúchame (Ctrl+Alt+Espacio)', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Mimar', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
|
|
34
|
+
pt: { settings: '⚙️ Configurações', talk: '💬 Falar', listen: '🎙️ Me escute (Ctrl+Alt+Espaço)', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Mimar', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
|
|
27
35
|
}[LANG] || {};
|
|
28
|
-
const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
|
|
36
|
+
const tr = (k) => L[k] || { listen: 'Listen', show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
|
|
29
37
|
|
|
30
38
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
|
|
31
39
|
if (linux && !OPAQUE) {
|
|
@@ -39,13 +47,14 @@ const post = (p, body) =>
|
|
|
39
47
|
|
|
40
48
|
app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the compositor needs a moment
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// Every ot on the desktop: the one that lives here ('host') and visitors from the same account.
|
|
51
|
+
const DEBUG = !!process.env.SEVENOTS_PET_DEBUG;
|
|
52
|
+
const ots = new Map();
|
|
53
|
+
let tray = null;
|
|
54
|
+
let relaunching = false; // a 2D → 3D switch that needs the GPU (see setView)
|
|
46
55
|
|
|
56
|
+
function start() {
|
|
47
57
|
// Tray first: if there is one, the window can stay out of the taskbar; if not, it shows there so it can be found.
|
|
48
|
-
let tray = null;
|
|
49
58
|
try {
|
|
50
59
|
const img = nativeImage.createFromPath(ICON);
|
|
51
60
|
if (!img.isEmpty()) {
|
|
@@ -53,10 +62,70 @@ function start() {
|
|
|
53
62
|
tray.setToolTip('7ots pet');
|
|
54
63
|
}
|
|
55
64
|
} catch {}
|
|
65
|
+
ots.set('host', makeOt({ host: true }));
|
|
66
|
+
// Visitors come and go from the settings (Account → invite): polled, a window each.
|
|
67
|
+
const syncVisitors = async () => {
|
|
68
|
+
const list = await fetch(new URL('/visitors', URL_), { headers: { 'X-7ots-Token': TOKEN } }).then((r) => (r.ok ? r.json() : null), () => null);
|
|
69
|
+
if (!Array.isArray(list)) return;
|
|
70
|
+
const want = new Set(list.map((v) => v.id));
|
|
71
|
+
for (const [k, o] of ots) if (k !== 'host' && !want.has(k)) (o.close(), ots.delete(k));
|
|
72
|
+
list.forEach((v, i) => ots.has(v.id) || ((!v.url || v.token) && ots.set(v.id, makeOt({ id: v.id, name: v.name, index: i, url: v.url, token: v.token }))));
|
|
73
|
+
};
|
|
74
|
+
setTimeout(syncVisitors, 2500);
|
|
75
|
+
setInterval(syncVisitors, 4000);
|
|
76
|
+
require('./director.cjs').createDirector({ ots, lang: LANG });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Settings in a window of our own (not a browser tab); links elsewhere open in the browser.
|
|
80
|
+
const settingsWins = new Map(); // one per ot that has its own daemon
|
|
81
|
+
function openSettings(base = URL_, tok = TOKEN) {
|
|
82
|
+
let settingsWin = settingsWins.get(base);
|
|
83
|
+
if (settingsWin && !settingsWin.isDestroyed()) return settingsWin.show(), settingsWin.focus();
|
|
84
|
+
settingsWin = new BrowserWindow({ width: 1120, height: 820, minWidth: 420, minHeight: 480, title: '7ots', icon: ICON, autoHideMenuBar: true, backgroundColor: '#0f0d14', show: false });
|
|
85
|
+
settingsWin.removeMenu?.();
|
|
86
|
+
settingsWins.set(base, settingsWin);
|
|
87
|
+
settingsWin.loadURL(`${base}/settings?token=${encodeURIComponent(tok)}`);
|
|
88
|
+
settingsWin.once('ready-to-show', () => settingsWin.show());
|
|
89
|
+
const ours = (u) => String(u || '').startsWith(base);
|
|
90
|
+
settingsWin.webContents.setWindowOpenHandler(({ url }) => {
|
|
91
|
+
if (!ours(url)) return shell.openExternal(url).catch(() => {}), { action: 'deny' };
|
|
92
|
+
return { action: 'allow', overrideBrowserWindow: { width: 760, height: 900, icon: ICON, autoHideMenuBar: true, backgroundColor: '#0f0d14' } }; // e.g. an ot's card
|
|
93
|
+
});
|
|
94
|
+
settingsWin.webContents.on('will-navigate', (e, url) => ours(url) || (e.preventDefault(), shell.openExternal(url).catch(() => {})));
|
|
95
|
+
settingsWin.on('closed', () => settingsWins.delete(base));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeOt(opt = {}) {
|
|
99
|
+
const HOST = !!opt.host;
|
|
100
|
+
// a second ot with everything (its own daemon: brain, voice, memory, settings) or a visitor drawn by the host's
|
|
101
|
+
const base = opt.url || URL_;
|
|
102
|
+
const tok = opt.token || TOKEN;
|
|
103
|
+
const FULL = HOST || !!opt.url;
|
|
104
|
+
const postOt = (p, body) =>
|
|
105
|
+
fetch(new URL(p, base), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': tok }, body: JSON.stringify(body || {}) }).catch(() => {});
|
|
106
|
+
const area = screen.getPrimaryDisplay().workArea;
|
|
107
|
+
const right = area.x + area.width;
|
|
108
|
+
const floor = area.y + area.height;
|
|
56
109
|
|
|
57
110
|
// The ot lives at an anchor (bottom-center of the window). The window is sized to what is in use:
|
|
58
111
|
// SMALL is just the body; BIG (room for the bubble, the buttons and the talk box) only while needed.
|
|
59
|
-
|
|
112
|
+
// Home: where it lives on screen. By default the bottom-right corner; wherever you drop it, after a drag.
|
|
113
|
+
const HOME_FILE = path.join(require('node:os').homedir(), '.7ots', HOST ? 'pet-home.json' : `pet-home-${opt.id}.json`);
|
|
114
|
+
let home = null;
|
|
115
|
+
try {
|
|
116
|
+
const h = JSON.parse(require('node:fs').readFileSync(HOME_FILE, 'utf8'));
|
|
117
|
+
if (h.x >= area.x && h.x <= right && h.y >= area.y + 80 && h.y <= floor) home = { x: h.x, y: h.y };
|
|
118
|
+
} catch {}
|
|
119
|
+
let homeSave = null;
|
|
120
|
+
const setHome = (p) => {
|
|
121
|
+
home = { x: Math.round(p.x), y: Math.round(p.y) };
|
|
122
|
+
clearTimeout(homeSave);
|
|
123
|
+
homeSave = setTimeout(() => require('node:fs').promises.writeFile(HOME_FILE, JSON.stringify(home)).catch(() => {}), 800);
|
|
124
|
+
};
|
|
125
|
+
// a visitor turns up beside the host, a bit to the left
|
|
126
|
+
const hostHome = () => ots.get('host')?.home() || { x: right - BIG.w / 2 - 24, y: floor };
|
|
127
|
+
const homeAnchor = () => (home ? { ...home } : HOST ? { x: right - BIG.w / 2 - 24, y: floor } : { x: Math.max(area.x + 140, hostHome().x - 230 * ((opt.index || 0) + 1)), y: hostHome().y });
|
|
128
|
+
if (!HOST && !home) home = homeAnchor(); // a visitor roams beside the host, at its height (saved once dragged)
|
|
60
129
|
const pos = homeAnchor();
|
|
61
130
|
let size = BIG; // it says hi on start
|
|
62
131
|
const bounds = (s = size, a = pos, lift = 0) => ({ x: Math.round(a.x - s.w / 2), y: Math.round(a.y - s.h - lift), width: s.w, height: s.h });
|
|
@@ -76,15 +145,22 @@ function start() {
|
|
|
76
145
|
});
|
|
77
146
|
win.setAlwaysOnTop(true, 'floating');
|
|
78
147
|
win.setVisibleOnAllWorkspaces?.(true);
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
// The page's ⚙️ button opens the settings
|
|
148
|
+
let view3d = VIEW3D; // switched live from the settings (see setView)
|
|
149
|
+
const petURL = () => `${base}/pet?desktop=1${FULL ? '' : `&guest=${encodeURIComponent(opt.id)}`}${OPAQUE ? '&opaque=1' : ''}${view3d ? '&3d=1' : ''}&token=${encodeURIComponent(tok)}`;
|
|
150
|
+
win.loadURL(petURL());
|
|
151
|
+
// The page's ⚙️ button opens the settings window; anything else is denied.
|
|
83
152
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
84
|
-
if (url.startsWith(`${
|
|
153
|
+
if (url.startsWith(`${base}/settings`)) openSettings(base, tok);
|
|
85
154
|
return { action: 'deny' };
|
|
86
155
|
});
|
|
87
|
-
|
|
156
|
+
// Only our own pet page may load (its reload after a settings change or another ot: Electron routes
|
|
157
|
+
// location.reload() through will-navigate too, so blocking everything froze the old look until a restart).
|
|
158
|
+
win.webContents.on('will-navigate', (e, url) => String(url || e?.url || '').startsWith(`${base}/pet?`) || e.preventDefault());
|
|
159
|
+
win.webContents.on('console-message', (e, level, msg) => /\[7ots/.test(msg ?? e?.message) && console.log(msg ?? e?.message)); // to ~/.7ots/electron.log
|
|
160
|
+
// The microphone, only for our own page (voice control).
|
|
161
|
+
const ours = (u) => /^http:\/\/127\.0\.0\.1:\d+\//.test(String(u || '')); // our daemons
|
|
162
|
+
session.defaultSession.setPermissionRequestHandler((wc, perm, cb, d) => cb(perm === 'media' && ours(d?.requestingUrl || wc.getURL())));
|
|
163
|
+
session.defaultSession.setPermissionCheckHandler((wc, perm, origin) => perm === 'media' && ours(origin));
|
|
88
164
|
const alive = () => !win.isDestroyed();
|
|
89
165
|
const page = (js) => alive() && win.webContents.executeJavaScript(js).catch(() => {});
|
|
90
166
|
|
|
@@ -102,22 +178,40 @@ function start() {
|
|
|
102
178
|
win.showInactive();
|
|
103
179
|
win.moveTop();
|
|
104
180
|
};
|
|
181
|
+
const dismiss = () => post('/account/visitor', { id: opt.id, on: false }).finally(() => close());
|
|
182
|
+
const guestMenu = () =>
|
|
183
|
+
Menu.buildFromTemplate([
|
|
184
|
+
{ label: `${opt.name || opt.id}`, enabled: false },
|
|
185
|
+
{ type: 'separator' },
|
|
186
|
+
{ label: { es: '👋 Despedir', pt: '👋 Mandar embora' }[LANG] || '👋 Send home', click: dismiss },
|
|
187
|
+
{ label: tr('settings'), click: () => openSettings(base, tok) },
|
|
188
|
+
]);
|
|
105
189
|
const menu = () =>
|
|
106
190
|
Menu.buildFromTemplate([
|
|
107
191
|
win.isVisible() ? { label: tr('hide'), click: () => win.hide() } : { label: tr('show'), click: show },
|
|
108
192
|
{ type: 'separator' },
|
|
109
193
|
{ label: tr('talk'), click: () => { show(); win.focus(); page('postMessage({ talk: true })'); } },
|
|
110
|
-
{ label: tr('
|
|
111
|
-
{ label: tr('
|
|
112
|
-
{ label: tr('sleep'), click: () =>
|
|
113
|
-
{ label: tr('wake'), click: () =>
|
|
194
|
+
{ label: tr('listen'), click: listen },
|
|
195
|
+
{ label: tr('feed'), click: () => postOt('/event', { type: 'feed' }) },
|
|
196
|
+
{ label: tr('sleep'), click: () => postOt('/event', { type: 'sleep' }) },
|
|
197
|
+
{ label: tr('wake'), click: () => postOt('/event', { type: 'wake' }) },
|
|
114
198
|
{ type: 'separator' },
|
|
115
|
-
{ label: tr('settings'), click: openSettings },
|
|
199
|
+
{ label: tr('settings'), click: () => openSettings(base, tok) },
|
|
116
200
|
{ label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
|
|
117
|
-
{ label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
|
|
201
|
+
HOST ? { label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) } : { label: { es: '👋 Despedir', pt: '👋 Mandar embora' }[LANG] || '👋 Send home', click: dismiss },
|
|
118
202
|
]);
|
|
119
|
-
|
|
120
|
-
|
|
203
|
+
function listen() {
|
|
204
|
+
if (!win.isVisible()) win.showInactive();
|
|
205
|
+
page('postMessage({ listen: true })');
|
|
206
|
+
}
|
|
207
|
+
if (HOST) {
|
|
208
|
+
try {
|
|
209
|
+
globalShortcut.register('CommandOrControl+Alt+Space', listen);
|
|
210
|
+
} catch {}
|
|
211
|
+
app.on('will-quit', () => globalShortcut.unregisterAll());
|
|
212
|
+
}
|
|
213
|
+
win.webContents.on('context-menu', () => (FULL ? menu() : guestMenu()).popup({ window: win }));
|
|
214
|
+
if (HOST && tray) {
|
|
121
215
|
const refresh = () => tray.setContextMenu(menu()); // Linux trays only show the context menu
|
|
122
216
|
refresh();
|
|
123
217
|
win.on('show', refresh);
|
|
@@ -125,9 +219,11 @@ function start() {
|
|
|
125
219
|
tray.on('click', () => (win.isVisible() ? win.hide() : show()));
|
|
126
220
|
}
|
|
127
221
|
|
|
128
|
-
// The user dragged it:
|
|
222
|
+
// The user dragged it: that spot becomes its home (it roams around it, and comes back there after errands),
|
|
223
|
+
// remembered across restarts. Linux only emits 'move' (not 'moved'): our own setBounds also emits it, so
|
|
224
|
+
// only a position that differs from the last one we set counts as a drag.
|
|
129
225
|
let dragged = 0;
|
|
130
|
-
|
|
226
|
+
const onMove = () => {
|
|
131
227
|
if (!alive() || !lastBounds) return;
|
|
132
228
|
const [wx, wy] = win.getPosition();
|
|
133
229
|
if (Math.abs(wx - lastBounds.x) < 3 && Math.abs(wy - lastBounds.y) < 3) return;
|
|
@@ -137,46 +233,89 @@ function start() {
|
|
|
137
233
|
pos.y = wy + size.h;
|
|
138
234
|
lastBounds = { ...lastBounds, x: wx, y: wy };
|
|
139
235
|
plan = { mode: 'idle', until: Date.now() + 6000 };
|
|
140
|
-
|
|
236
|
+
setHome(pos);
|
|
237
|
+
};
|
|
238
|
+
win.on('move', onMove);
|
|
239
|
+
// Our own drag (the page has no drag region, so clicks reach it): the window follows the cursor while the
|
|
240
|
+
// button is down on the ot. Past 4 px it is a drag: the drop spot becomes home, as with a system drag.
|
|
241
|
+
let grabbing = null;
|
|
242
|
+
let grabTimer = null;
|
|
243
|
+
function grab(on) {
|
|
244
|
+
clearInterval(grabTimer);
|
|
245
|
+
if (!on) {
|
|
246
|
+
if (grabbing?.moved) (setHome(pos), (droppedSaid = false), (plan = { mode: 'idle', until: Date.now() + 6000 }));
|
|
247
|
+
grabbing = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const c = screen.getCursorScreenPoint();
|
|
251
|
+
grabbing = { dx: pos.x - c.x, dy: pos.y - c.y, x: c.x, y: c.y, moved: false };
|
|
252
|
+
grabTimer = setInterval(() => {
|
|
253
|
+
if (!alive() || !grabbing) return clearInterval(grabTimer);
|
|
254
|
+
const p = screen.getCursorScreenPoint();
|
|
255
|
+
if (!grabbing.moved && Math.hypot(p.x - grabbing.x, p.y - grabbing.y) < 4) return;
|
|
256
|
+
grabbing.moved = true;
|
|
257
|
+
dragged = Date.now();
|
|
258
|
+
pos.x = Math.max(area.x + size.w / 2 - 40, Math.min(right - size.w / 2 + 40, p.x + grabbing.dx));
|
|
259
|
+
pos.y = Math.max(area.y + size.h - 40, Math.min(floor + 40, p.y + grabbing.dy));
|
|
260
|
+
place();
|
|
261
|
+
}, 16);
|
|
262
|
+
}
|
|
263
|
+
win.on('moved', onMove);
|
|
141
264
|
|
|
142
265
|
// ── What the page is showing (polled): hit areas for click-through, and whether it needs the big window ──
|
|
143
266
|
let rects = [];
|
|
267
|
+
let need = null; // what the page really shows: { w, h } (bubble, effects around the body, talk box)
|
|
144
268
|
let busy = false; // bubble or talk box on screen
|
|
269
|
+
let asleep = false; // asleep it stays put (an urgent stir wakes it for a moment, see pet.html)
|
|
145
270
|
const HIT = `(() => {
|
|
146
271
|
const out = [];
|
|
147
272
|
for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
|
|
148
273
|
const cs = getComputedStyle(e);
|
|
149
|
-
|
|
274
|
+
// the menu counts while faded out too: reaching for it must not let the mouse through (and hide it)
|
|
275
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || (+cs.opacity < 0.3 && !e.classList.contains('actions'))) continue;
|
|
150
276
|
const r = e.getBoundingClientRect();
|
|
151
277
|
if (r.width && r.height) out.push([r.left, r.top, r.right, r.bottom]);
|
|
152
278
|
}
|
|
153
|
-
|
|
279
|
+
const goal = window.__goal || null;
|
|
280
|
+
window.__goal = null;
|
|
281
|
+
return { rects: out, goal, need: window.__need?.(), busy: document.body.classList.contains('talking') || document.body.classList.contains('listening') || !!document.querySelector('.bubble.on'), asleep: document.body.classList.contains('asleep') };
|
|
154
282
|
})()`;
|
|
155
283
|
const hitTimer = setInterval(() => {
|
|
156
284
|
if (!alive()) return;
|
|
157
|
-
win.webContents.executeJavaScript(HIT).then((r) =>
|
|
158
|
-
|
|
285
|
+
win.webContents.executeJavaScript(HIT).then((r) => {
|
|
286
|
+
({ rects, busy, asleep } = r);
|
|
287
|
+
need = r.need || null;
|
|
288
|
+
if (r.goal) goTo(r.goal);
|
|
289
|
+
}).catch(() => {});
|
|
290
|
+
}, 120);
|
|
159
291
|
|
|
160
292
|
// Click-through: only the ot, its buttons and the talk box take the mouse; the transparent rest lets
|
|
161
293
|
// clicks reach the window behind. The cursor is polled because `forward: true` is macOS/Windows only.
|
|
162
294
|
let passing = null;
|
|
163
295
|
let hoverUntil = 0;
|
|
296
|
+
let stillUntil = 0;
|
|
164
297
|
const pass = (p) => {
|
|
165
298
|
if (p === passing || !alive()) return;
|
|
166
299
|
passing = p;
|
|
167
300
|
win.setIgnoreMouseEvents(p, { forward: true });
|
|
168
301
|
};
|
|
169
302
|
let cursorNear = false;
|
|
303
|
+
let hovered = false;
|
|
170
304
|
let lastLook = null;
|
|
305
|
+
let shrinkAt = 0;
|
|
306
|
+
let shrinkTo = null;
|
|
171
307
|
let droppedSaid = true;
|
|
308
|
+
let dbgT = 0;
|
|
172
309
|
const mouseTimer = setInterval(() => {
|
|
173
310
|
if (!alive() || !win.isVisible()) return;
|
|
174
311
|
const c = screen.getCursorScreenPoint();
|
|
175
312
|
const [wx, wy] = win.getPosition();
|
|
176
313
|
const x = c.x - wx;
|
|
177
314
|
const y = c.y - wy;
|
|
178
|
-
const over = rects.some(([l, t, r, b]) => x >= l -
|
|
315
|
+
const over = rects.some(([l, t, r, b]) => x >= l - 14 && x <= r + 14 && y >= t - 14 && y <= b + 14);
|
|
179
316
|
if (over) hoverUntil = Date.now() + 900;
|
|
317
|
+
// anywhere over its window (not only the drawn parts): it stays put so it can be clicked or dragged
|
|
318
|
+
if (x >= 0 && y >= 0 && x <= size.w && y <= size.h) stillUntil = Date.now() + 1200;
|
|
180
319
|
// the eyes follow the cursor anywhere on screen (the page only sees it inside the window)
|
|
181
320
|
if (!lastLook || Math.abs(x - lastLook[0]) + Math.abs(y - lastLook[1]) > 6) {
|
|
182
321
|
lastLook = [x, y];
|
|
@@ -188,21 +327,73 @@ function start() {
|
|
|
188
327
|
page('postMessage({ dropped: true })');
|
|
189
328
|
}
|
|
190
329
|
cursorNear = Math.hypot(c.x - pos.x, c.y - (pos.y - SMALL.h / 2)) < 260;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
330
|
+
if (DEBUG && Date.now() - (dbgT || 0) > 1000) (dbgT = Date.now()), console.log('[7ots dbg]', HOST ? 'host' : opt.id, 'cur', x, y, 'over', over, 'busy', busy, 'pass', passing, 'rects', JSON.stringify(rects.map((q) => q.map(Math.round))), 'size', size.w, size.h, 'win', wx, wy);
|
|
331
|
+
pass(!(over || busy || grabbing) && rects.length > 0); // while dragged it keeps the mouse (the button-up must reach the page)
|
|
332
|
+
// the menu shows on hover: CSS :hover alone misses it when the window starts taking the mouse (or resizes)
|
|
333
|
+
// under a cursor that has not moved since
|
|
334
|
+
const h = Date.now() < hoverUntil;
|
|
335
|
+
if (h !== hovered) (hovered = h), page(`document.body.classList.toggle('hover', ${h})`);
|
|
336
|
+
resize();
|
|
337
|
+
}, 60);
|
|
338
|
+
// testing: SEVENOTS_PET_FX=thought plays that effect every 5 s
|
|
339
|
+
if (process.env.SEVENOTS_PET_FX) setInterval(() => page(`postMessage(${JSON.stringify({ effect: process.env.SEVENOTS_PET_FX })})`), 5000);
|
|
340
|
+
let held = null; // a scene holds one size from start to end (see director.cjs)
|
|
341
|
+
function resize() {
|
|
342
|
+
// Size: what the page shows (the bubble's real height, room for effects), big enough for the buttons on hover.
|
|
343
|
+
// Grows at once, shrinks only after it has wanted less for 2.5 s: an animation that swings the arms in and
|
|
344
|
+
// out would otherwise resize the window on every swing, and each resize of a transparent window flickers.
|
|
345
|
+
// 16 px steps to avoid resizing for every pixel.
|
|
346
|
+
const hover = Date.now() < hoverUntil;
|
|
347
|
+
const step = (v) => Math.ceil(v / 16) * 16;
|
|
348
|
+
const want = {
|
|
349
|
+
w: step(Math.min(area.width, Math.max(MIN.w, need?.w || 0, hover ? BIG.w : 0, held?.w || 0))),
|
|
350
|
+
h: step(Math.min(area.height, Math.max(MIN.h, need?.h || 0, hover ? BIG.h : 0, held?.h || 0))),
|
|
351
|
+
};
|
|
352
|
+
const grow = want.w > size.w || want.h > size.h;
|
|
353
|
+
const differs = want.w !== size.w || want.h !== size.h;
|
|
354
|
+
if (!differs) (shrinkAt = 0), (shrinkTo = null);
|
|
355
|
+
else if (!grow) shrinkTo = shrinkTo ? { w: Math.max(shrinkTo.w, want.w), h: Math.max(shrinkTo.h, want.h) } : want; // the most it wanted while waiting
|
|
356
|
+
if (differs && Date.now() - dragged > 400 && (grow || (!held && (shrinkAt ||= Date.now() + 6000) < Date.now()))) {
|
|
357
|
+
size = grow ? { w: Math.max(want.w, size.w), h: Math.max(want.h, size.h) } : shrinkTo || want;
|
|
358
|
+
shrinkAt = 0;
|
|
359
|
+
shrinkTo = null;
|
|
196
360
|
place();
|
|
197
361
|
}
|
|
198
|
-
}
|
|
362
|
+
}
|
|
363
|
+
// 2D ↔ 3D from the settings. A 2D start on Linux turned the GPU off (see the top), and WebGL cannot come
|
|
364
|
+
// back without it: then the whole shell relaunches as 3D (the daemons live on; visitors reopen from /visitors).
|
|
365
|
+
function setView(on) {
|
|
366
|
+
if (on === view3d) return;
|
|
367
|
+
if (on && linux && !OPAQUE && !VIEW3D) {
|
|
368
|
+
if (relaunching) return;
|
|
369
|
+
relaunching = true;
|
|
370
|
+
process.env.SEVENOTS_PET_3D = '1';
|
|
371
|
+
app.relaunch();
|
|
372
|
+
return app.exit(0);
|
|
373
|
+
}
|
|
374
|
+
view3d = on;
|
|
375
|
+
if (alive()) win.loadURL(petURL());
|
|
376
|
+
}
|
|
377
|
+
// An effect just started: the page says how much room it needs right away (see tellNeed in pet.html).
|
|
378
|
+
win.webContents.on('console-message', (e, level, msg) => {
|
|
379
|
+
const m = String(e?.message ?? msg ?? '');
|
|
380
|
+
if (m.startsWith('\u00a77drag')) return grab(JSON.parse(m.slice(6)).on);
|
|
381
|
+
if (m.startsWith('\u00a77bye')) return HOST || dismiss();
|
|
382
|
+
if (m.startsWith('\u00a77view')) return setView(m.slice(6) === '3d');
|
|
383
|
+
if (!m.startsWith('\u00a77need')) return;
|
|
384
|
+
try {
|
|
385
|
+
need = JSON.parse(m.slice(6));
|
|
386
|
+
if (alive() && win.isVisible()) resize();
|
|
387
|
+
} catch {}
|
|
388
|
+
});
|
|
199
389
|
|
|
200
390
|
// ── Movement: steering with ease-in/ease-out, a little gait bounce, hops, pauses, curiosity ──
|
|
201
391
|
const rnd = (a, b) => a + Math.random() * (b - a);
|
|
202
392
|
const BAND = 200; // how far above the taskbar it roams
|
|
203
|
-
const minX = area.x +
|
|
204
|
-
const maxX = right -
|
|
205
|
-
const
|
|
393
|
+
const minX = area.x + 136; // half the widest window (bubble + effects) stays on screen
|
|
394
|
+
const maxX = right - 136;
|
|
395
|
+
const homeY = () => home?.y ?? floor; // it roams in a band above its home's floor
|
|
396
|
+
const clampY = (y) => Math.max(homeY() - BAND, Math.min(homeY(), y));
|
|
206
397
|
const vel = { x: 0, y: 0 };
|
|
207
398
|
let plan = { mode: 'idle', until: Date.now() + 2500 };
|
|
208
399
|
let gait = 0;
|
|
@@ -218,7 +409,9 @@ function start() {
|
|
|
218
409
|
// a stroll: sometimes short and lazy, sometimes across the screen
|
|
219
410
|
const far = Math.random() < 0.3;
|
|
220
411
|
const dx = (far ? rnd(300, 900) : rnd(60, 260)) * (Math.random() < 0.5 ? -1 : 1);
|
|
221
|
-
|
|
412
|
+
// ...but never wanders far from home: past 320 px it heads back toward it
|
|
413
|
+
const hx = home?.x ?? pos.x;
|
|
414
|
+
const tx = Math.max(minX, Math.min(maxX, Math.abs(pos.x + dx - hx) > 320 ? hx + rnd(-120, 120) : pos.x + dx));
|
|
222
415
|
return { mode: 'go', tx, ty: clampY(pos.y + rnd(-60, 60)), speed: far ? rnd(110, 170) : rnd(45, 95) };
|
|
223
416
|
}
|
|
224
417
|
|
|
@@ -228,11 +421,15 @@ function start() {
|
|
|
228
421
|
const now = Date.now();
|
|
229
422
|
const dt = Math.min(0.05, (now - lastT) / 1000);
|
|
230
423
|
lastT = now;
|
|
231
|
-
|
|
424
|
+
const errand = plan.mode === 'point';
|
|
425
|
+
const scene = plan.mode === 'scene' || plan.mode === 'fling';
|
|
426
|
+
// the cursor on it stops it, scenes too (the director sees interrupted() and ends the scene): something
|
|
427
|
+
// that walks away from the mouse can't be grabbed or clicked
|
|
428
|
+
if (now - dragged < 6000 || now < stillUntil || (!errand && now < hoverUntil) || (!scene && (still || (!errand && (busy || asleep))))) {
|
|
232
429
|
// stop gently
|
|
233
430
|
vel.x *= 0.8;
|
|
234
431
|
vel.y *= 0.8;
|
|
235
|
-
if (Math.abs(vel.x) < 1 && facing !== 0) tellFacing(0);
|
|
432
|
+
if (Math.abs(vel.x) < 1 && facing !== 0 && plan.mode !== 'show') tellFacing(0); // keeps facing what it points at
|
|
236
433
|
return;
|
|
237
434
|
}
|
|
238
435
|
|
|
@@ -240,11 +437,24 @@ function start() {
|
|
|
240
437
|
let tx = pos.x;
|
|
241
438
|
let ty = pos.y;
|
|
242
439
|
let speed = 0;
|
|
440
|
+
if (plan.mode === 'show' && now > plan.until) plan = { mode: 'idle', until: now + rnd(2000, 5000) };
|
|
243
441
|
if (plan.mode === 'idle' && now > plan.until) {
|
|
244
442
|
plan = next();
|
|
245
443
|
if (plan.mode === 'follow') page('postMessage({ follow: true })');
|
|
246
444
|
}
|
|
247
|
-
if (plan.mode === 'go') ({ tx, ty, speed } = plan);
|
|
445
|
+
if (plan.mode === 'go' || plan.mode === 'point' || plan.mode === 'scene') ({ tx, ty, speed } = plan);
|
|
446
|
+
else if (plan.mode === 'fling') {
|
|
447
|
+
// knocked back: an arc, sliding to a stop where it lands
|
|
448
|
+
const t = (now - plan.start) / plan.ms;
|
|
449
|
+
if (t >= 1) plan = { mode: 'scene', tx: pos.x, ty: pos.y, speed: 0, dir: plan.dir };
|
|
450
|
+
else {
|
|
451
|
+
pos.x = Math.max(minX, Math.min(maxX, pos.x + plan.vx * dt * (1 - t)));
|
|
452
|
+
lift = Math.sin(Math.PI * t) * plan.height;
|
|
453
|
+
vel.x = vel.y = 0;
|
|
454
|
+
tx = pos.x;
|
|
455
|
+
ty = pos.y;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
248
458
|
else if (plan.mode === 'follow') {
|
|
249
459
|
const c = screen.getCursorScreenPoint();
|
|
250
460
|
// stays a polite distance from the cursor, on the floor band
|
|
@@ -270,8 +480,9 @@ function start() {
|
|
|
270
480
|
vel.x += (vx - vel.x) * k;
|
|
271
481
|
vel.y += (vy - vel.y) * k;
|
|
272
482
|
pos.x = Math.max(minX, Math.min(maxX, pos.x + vel.x * dt));
|
|
273
|
-
pos.y =
|
|
483
|
+
pos.y = Math.max(minY, Math.min(floor, pos.y + vel.y * dt)); // roams near the floor, errands go anywhere
|
|
274
484
|
if (plan.mode === 'go' && dist < 3 && Math.hypot(vel.x, vel.y) < 6) plan = { mode: 'idle', until: now + rnd(1500, 6000) };
|
|
485
|
+
if (plan.mode === 'point' && ((dist < 4 && Math.hypot(vel.x, vel.y) < 8) || now > plan.until)) arrived(plan);
|
|
275
486
|
|
|
276
487
|
// gait: a small bounce per step, scaled by speed
|
|
277
488
|
const v = Math.hypot(vel.x, vel.y);
|
|
@@ -279,22 +490,116 @@ function start() {
|
|
|
279
490
|
if (v > 4) lift += Math.abs(Math.sin(gait * Math.PI)) * Math.min(5, v / 22);
|
|
280
491
|
|
|
281
492
|
// facing: turn toward where it walks, back to the front when it stops
|
|
282
|
-
const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : 0;
|
|
493
|
+
const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : plan.mode === 'show' || scene ? plan.dir || 0 : 0;
|
|
283
494
|
if (Math.abs(f - facing) > 0.15 || (f === 0 && facing !== 0)) tellFacing(Math.round(f * 10) / 10);
|
|
284
495
|
|
|
285
496
|
place(Math.round(lift));
|
|
286
497
|
}, 16);
|
|
287
498
|
|
|
499
|
+
// ── Errands: walk to what needs you and point at it ──
|
|
500
|
+
const minY = area.y + BIG.h;
|
|
501
|
+
const TERM = /terminal|kitty|alacritty|konsole|tilix|wezterm|xterm|ghostty|warp|terminator|code|cursor|windsurf|zed|jetbrains|idea|pycharm|webstorm/i;
|
|
502
|
+
const BROWSER = /chrom|firefox|brave|vivaldi|opera|edge|epiphany|librewolf|zen/i;
|
|
503
|
+
const run = (cmd, args) => new Promise((res) => execFile(cmd, args, { timeout: 1500 }, (e, out) => res(e ? '' : String(out))));
|
|
504
|
+
// The top-most visible window whose class matches, if it is not mostly covered by others (no use pointing
|
|
505
|
+
// at a terminal hidden behind the browser: then it comes to the cursor instead).
|
|
506
|
+
async function findWindow(re) {
|
|
507
|
+
if (!linux || !process.env.DISPLAY) return null;
|
|
508
|
+
const root = await run('xprop', ['-root', '_NET_CLIENT_LIST_STACKING', '_NET_CURRENT_DESKTOP']);
|
|
509
|
+
const desk = root.match(/_NET_CURRENT_DESKTOP\(CARDINAL\) = (\d+)/)?.[1];
|
|
510
|
+
const ids = (root.match(/0x[0-9a-f]+/gi) || []).reverse(); // bottom → top, reversed
|
|
511
|
+
const above = [];
|
|
512
|
+
for (const hex of ids) {
|
|
513
|
+
const id = String(parseInt(hex, 16));
|
|
514
|
+
const props = await run('xprop', ['-id', id, 'WM_CLASS', '_NET_WM_STATE', '_NET_WM_DESKTOP', '_NET_WM_WINDOW_TYPE']);
|
|
515
|
+
const cls = props.match(/WM_CLASS\(STRING\) = (.*)/)?.[1] || '';
|
|
516
|
+
const d = props.match(/_NET_WM_DESKTOP\(CARDINAL\) = (\d+)/)?.[1];
|
|
517
|
+
if (/HIDDEN/.test(props) || /7ots|electron/i.test(cls) || (d != null && desk != null && d !== desk && d !== '4294967295')) continue;
|
|
518
|
+
if (/_TYPE_(DOCK|DESKTOP)/.test(props)) continue;
|
|
519
|
+
const g = await run('xdotool', ['getwindowgeometry', '--shell', id]);
|
|
520
|
+
const n = (k) => Number(g.match(new RegExp(`^${k}=(-?\\d+)`, 'm'))?.[1]);
|
|
521
|
+
const r = { x: n('X'), y: n('Y'), w: n('WIDTH'), h: n('HEIGHT') };
|
|
522
|
+
if (!(r.w > 50)) continue;
|
|
523
|
+
if (re.test(cls)) {
|
|
524
|
+
// how much of it the windows above cover (approximate: sum of overlaps, capped)
|
|
525
|
+
let hid = 0;
|
|
526
|
+
for (const o of above) hid += Math.max(0, Math.min(r.x + r.w, o.x + o.w) - Math.max(r.x, o.x)) * Math.max(0, Math.min(r.y + r.h, o.y + o.h) - Math.max(r.y, o.y));
|
|
527
|
+
return hid / (r.w * r.h) > 0.6 ? null : r;
|
|
528
|
+
}
|
|
529
|
+
above.push(r);
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
async function goTo(goal) {
|
|
534
|
+
if (still) return;
|
|
535
|
+
if (goal === 'browser') await new Promise((r) => setTimeout(r, 900)); // let the site open first
|
|
536
|
+
const c = screen.getCursorScreenPoint();
|
|
537
|
+
const rect = (goal === 'agent' ? await findWindow(TERM) : goal === 'browser' ? await findWindow(BROWSER) : null) || { x: c.x - 1, y: c.y - 1, w: 2, h: 2, cursor: true };
|
|
538
|
+
const cx = rect.x + rect.w / 2;
|
|
539
|
+
// stand beside the window if there is room, otherwise inside it, near its lower corner closest to us
|
|
540
|
+
const half = SMALL.w / 2 + 12;
|
|
541
|
+
const roomL = rect.x - half >= minX;
|
|
542
|
+
const roomR = rect.x + rect.w + half <= maxX;
|
|
543
|
+
let tx;
|
|
544
|
+
if (rect.cursor) tx = c.x + (c.x > pos.x ? -110 : 110);
|
|
545
|
+
else if (roomL && (!roomR || pos.x < cx)) tx = rect.x - half;
|
|
546
|
+
else if (roomR) tx = rect.x + rect.w + half;
|
|
547
|
+
else tx = pos.x < cx ? rect.x + half + 20 : rect.x + rect.w - half - 20;
|
|
548
|
+
tx = Math.max(minX, Math.min(maxX, tx));
|
|
549
|
+
const ty = Math.max(minY, Math.min(floor, rect.cursor ? c.y + SMALL.h / 2 : Math.min(rect.y + rect.h, rect.y + rect.h * 0.75 + SMALL.h / 2)));
|
|
550
|
+
const spot = rect.cursor ? c : { x: cx, y: rect.y + Math.min(rect.h / 2, 160) };
|
|
551
|
+
plan = { mode: 'point', tx, ty, speed: 260, until: Date.now() + 9000, spot };
|
|
552
|
+
page('postMessage({ errand: true })');
|
|
553
|
+
}
|
|
554
|
+
function arrived(p) {
|
|
555
|
+
const dir = p.spot.x >= pos.x ? 1 : -1;
|
|
556
|
+
plan = { mode: 'show', dir, until: Date.now() + 5000 };
|
|
557
|
+
tellFacing(dir);
|
|
558
|
+
const [wx, wy] = win.getPosition();
|
|
559
|
+
page(`postMessage({ point: ${dir}, at: [${Math.round(p.spot.x - wx)}, ${Math.round(p.spot.y - wy)}] })`);
|
|
560
|
+
}
|
|
561
|
+
|
|
288
562
|
function tellFacing(f) {
|
|
289
563
|
facing = f;
|
|
290
564
|
page(`postMessage({ walk: ${f} })`);
|
|
291
565
|
}
|
|
292
566
|
|
|
567
|
+
function close() {
|
|
568
|
+
if (alive()) win.destroy();
|
|
569
|
+
}
|
|
293
570
|
win.on('closed', () => {
|
|
294
571
|
clearInterval(stepTimer);
|
|
295
572
|
clearInterval(hitTimer);
|
|
296
573
|
clearInterval(mouseTimer);
|
|
297
574
|
});
|
|
575
|
+
|
|
576
|
+
// ── What the director (scenes between ots, see director.cjs) can do with this one ──
|
|
577
|
+
return {
|
|
578
|
+
id: HOST ? 'host' : opt.id,
|
|
579
|
+
host: HOST,
|
|
580
|
+
home: () => homeAnchor(),
|
|
581
|
+
pos: () => ({ ...pos }),
|
|
582
|
+
bounds: () => ({ minX, maxX, floor }),
|
|
583
|
+
close,
|
|
584
|
+
// free to join a scene: shown, loaded, not held still, dragged, hovered, talking or on an errand
|
|
585
|
+
free: () => alive() && win.isVisible() && !win.webContents.isLoading() && !still && !asleep && Date.now() - dragged > 8000 && Date.now() > hoverUntil + 1500 && !busy && /idle|go|hop/.test(plan.mode),
|
|
586
|
+
walkTo: (x, y = pos.y, speed = 120) => (plan = { mode: 'scene', tx: Math.max(minX, Math.min(maxX, x)), ty: Math.max(minY, Math.min(floor, y)), speed, dir: plan.dir || 0 }),
|
|
587
|
+
arrivedAt: () => plan.mode === 'scene' && Math.abs(plan.tx - pos.x) < 6 && Math.abs(plan.ty - pos.y) < 6 && Math.hypot(vel.x, vel.y) < 10,
|
|
588
|
+
face: (dir) => {
|
|
589
|
+
if (plan.mode === 'scene' || plan.mode === 'fling') plan.dir = dir;
|
|
590
|
+
tellFacing(dir);
|
|
591
|
+
},
|
|
592
|
+
fling: (vx, height = 46, ms = 650) => (plan = { mode: 'fling', vx, height, ms, start: Date.now(), dir: plan.dir || 0 }),
|
|
593
|
+
say: (msg) => page(`postMessage(${JSON.stringify(msg)})`),
|
|
594
|
+
release: () => {
|
|
595
|
+
plan = { mode: 'idle', until: Date.now() + rnd(2500, 6000) };
|
|
596
|
+
tellFacing(0);
|
|
597
|
+
},
|
|
598
|
+
hold: (s) => ((held = s), resize()),
|
|
599
|
+
inScene: () => plan.mode === 'scene' || plan.mode === 'fling',
|
|
600
|
+
// the mouse on it (to click or drag it) ends the scene
|
|
601
|
+
interrupted: () => !alive() || !win.isVisible() || Date.now() - dragged < 1000 || Date.now() < hoverUntil || Date.now() < stillUntil,
|
|
602
|
+
};
|
|
298
603
|
}
|
|
299
604
|
|
|
300
605
|
// Hidden is not closed: the pet lives on in the tray until Quit.
|