@oddessentials/agent-guild 0.25.0 → 0.26.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/README.md +44 -10
- package/bin/agent-guild.mjs +2 -0
- package/package.json +2 -2
- package/src/manager/folder-opener.mjs +96 -0
- package/src/manager/folder-picker.mjs +116 -0
- package/src/manager/folder-picker.ps1 +84 -0
- package/src/manager/github-views.mjs +171 -0
- package/src/manager/github.mjs +56 -1
- package/src/manager/main.mjs +4 -1
- package/src/manager/server.mjs +81 -12
- package/web/activity-favicon.js +71 -0
- package/web/app.js +1136 -94
- package/web/index.html +139 -91
- package/web/layout.js +75 -0
- package/web/repo-search.js +91 -0
- package/web/skins/gnomeland/skin.css +1 -1
- package/web/skins/goblinville/skin.css +2 -2
- package/web/skins/grove/skin.css +1 -1
- package/web/skins/guild/skin.css +3 -3
- package/web/skins/orbital/skin.css +1 -1
- package/web/styles.css +246 -38
package/src/manager/github.mjs
CHANGED
|
@@ -101,6 +101,34 @@ export function localState(target, fullName) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** The GitHub repository (lowercase owner/name) that `dir`, or the Git work tree holding it, has as its origin. */
|
|
105
|
+
export function folderOrigin(dir) {
|
|
106
|
+
for (let current = path.resolve(dir); ; current = path.dirname(current)) {
|
|
107
|
+
const dotGit = path.join(current, '.git');
|
|
108
|
+
let stat = null;
|
|
109
|
+
try { stat = fs.statSync(dotGit); } catch { /* look further up */ }
|
|
110
|
+
if (stat) {
|
|
111
|
+
try {
|
|
112
|
+
return remoteRepo(originUrl(fs.readFileSync(path.join(commonGitDir(dotGit, stat), 'config'), 'utf8')));
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (path.dirname(current) === current) return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A worktree's .git is a file naming its own Git folder, whose `commondir` names the folder holding the shared config. */
|
|
122
|
+
function commonGitDir(dotGit, stat) {
|
|
123
|
+
if (stat.isDirectory()) return dotGit;
|
|
124
|
+
const named = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf8'));
|
|
125
|
+
if (!named) throw new Error('no gitdir');
|
|
126
|
+
const gitDir = path.resolve(path.dirname(dotGit), named[1]);
|
|
127
|
+
let common = null;
|
|
128
|
+
try { common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim(); } catch { /* not a worktree */ }
|
|
129
|
+
return common ? path.resolve(gitDir, common) : gitDir;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/** Quote one word for the POSIX shell Git runs core.sshCommand with (Git for Windows ships one too). */
|
|
105
133
|
export function shellQuote(word) {
|
|
106
134
|
return `'${String(word).replaceAll('\'', '\'\\\'\'')}'`;
|
|
@@ -395,7 +423,7 @@ export class GitHub extends EventEmitter {
|
|
|
395
423
|
if (!res.ok) {
|
|
396
424
|
const body = await res.json().catch(() => null);
|
|
397
425
|
const limited = (res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0';
|
|
398
|
-
throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status });
|
|
426
|
+
throw refusal(502, 'github_error', limited ? 'GitHub API rate limit exceeded; try again later' : `GitHub answered HTTP ${res.status}${body?.message ? `: ${body.message}` : ''}`, { github: res.status, limited });
|
|
399
427
|
}
|
|
400
428
|
return { body: await res.json(), res };
|
|
401
429
|
}
|
|
@@ -613,6 +641,33 @@ export class GitHub extends EventEmitter {
|
|
|
613
641
|
};
|
|
614
642
|
}
|
|
615
643
|
|
|
644
|
+
/** Every signed-in account's repositories in one list. One account's failure does not hide the others'. */
|
|
645
|
+
async allRepos({ refresh = false } = {}) {
|
|
646
|
+
const settled = await Promise.all(this.accounts.map(async (account) => {
|
|
647
|
+
try {
|
|
648
|
+
return { account, page: await this.repos(account.id, { refresh }) };
|
|
649
|
+
} catch (err) {
|
|
650
|
+
return { account, err };
|
|
651
|
+
}
|
|
652
|
+
}));
|
|
653
|
+
const repos = [];
|
|
654
|
+
const errors = [];
|
|
655
|
+
let truncated = false;
|
|
656
|
+
let oldest = null;
|
|
657
|
+
for (const { account, page, err } of settled) {
|
|
658
|
+
if (err) {
|
|
659
|
+
errors.push({ accountId: account.id, login: account.login, code: err.code || 'github_error', message: err.status ? err.message : 'Could not list repositories' });
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
truncated ||= page.truncated;
|
|
663
|
+
const at = Date.parse(page.fetchedAt);
|
|
664
|
+
if (oldest === null || at < oldest) oldest = at;
|
|
665
|
+
for (const { target, local, ...repo } of page.repos) repos.push({ accountId: account.id, login: account.login, ...repo });
|
|
666
|
+
}
|
|
667
|
+
repos.sort((a, b) => (Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0) || a.fullName.localeCompare(b.fullName) || a.accountId - b.accountId);
|
|
668
|
+
return { repos, truncated, errors, fetchedAt: oldest === null ? null : new Date(oldest).toISOString() };
|
|
669
|
+
}
|
|
670
|
+
|
|
616
671
|
async _loadRepos(account) {
|
|
617
672
|
const seen = new Map();
|
|
618
673
|
let url = '/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member';
|
package/src/manager/main.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { ModelStats } from './model-stats.mjs';
|
|
|
12
12
|
import { NewsFeed } from './news.mjs';
|
|
13
13
|
import { Changelog } from './changelog.mjs';
|
|
14
14
|
import { GitHub } from './github.mjs';
|
|
15
|
-
import { createManagerServer } from './server.mjs';
|
|
15
|
+
import { createManagerServer, parseAllowedHosts } from './server.mjs';
|
|
16
16
|
import { SelfUpdate } from './self-update.mjs';
|
|
17
17
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
18
18
|
import { writeReportShims } from './report-shims.mjs';
|
|
@@ -39,6 +39,8 @@ const VERSION_REFRESH_MS = 60 * 60 * 1000;
|
|
|
39
39
|
|
|
40
40
|
/** `version`, `packageFile` and `github` (GitHub's URLs and client id) stand in for the real ones in tests. */
|
|
41
41
|
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {} } = {}) {
|
|
42
|
+
// Validate before creating files, processes or timers, so a typo fails startup cleanly.
|
|
43
|
+
const extraHosts = parseAllowedHosts(process.env.AGENT_GUILD_ALLOWED_HOSTS);
|
|
42
44
|
ensureDataDir();
|
|
43
45
|
const token = loadOrCreateToken();
|
|
44
46
|
const baseEnv = resolveBaseEnv();
|
|
@@ -133,6 +135,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
133
135
|
webDir,
|
|
134
136
|
version,
|
|
135
137
|
selfUpdate,
|
|
138
|
+
extraHosts,
|
|
136
139
|
extraOrigins,
|
|
137
140
|
launcher: launcherPath(),
|
|
138
141
|
onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
|
package/src/manager/server.mjs
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
import http from 'node:http';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { isIP } from 'node:net';
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
8
9
|
import { WebSocketServer } from 'ws';
|
|
9
10
|
import { timingSafeEqualString } from './session-manager.mjs';
|
|
11
|
+
import { folderOrigin } from './github.mjs';
|
|
12
|
+
import { createViews } from './github-views.mjs';
|
|
13
|
+
import { createFolderOpener } from './folder-opener.mjs';
|
|
14
|
+
import { createFolderPicker } from './folder-picker.mjs';
|
|
10
15
|
|
|
11
16
|
const require = createRequire(import.meta.url);
|
|
12
17
|
const API = '/api/v1';
|
|
@@ -57,15 +62,19 @@ class HttpError extends Error {
|
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
/** Exact Host authorities, also safe to include as CSP WebSocket sources. */
|
|
66
|
+
export function parseAllowedHosts(value = '') {
|
|
67
|
+
const entries = value.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const match = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([1-9]\d{0,4}))?$/.exec(entry);
|
|
70
|
+
const name = match?.[1];
|
|
71
|
+
const validName = name?.startsWith('[') ? isIP(name.slice(1, -1)) === 6
|
|
72
|
+
: name && name.length <= 253 && name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
73
|
+
if (!validName || (match[2] && Number(match[2]) > 65535)) {
|
|
74
|
+
throw new Error(`Invalid AGENT_GUILD_ALLOWED_HOSTS entry ${JSON.stringify(entry)}. Use a hostname with an optional port, such as guild.example.ts.net or guild.example.ts.net:8443; URLs and wildcards are not allowed.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...new Set(entries)];
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
function readJsonBody(req) {
|
|
@@ -115,18 +124,28 @@ export function createManagerServer({
|
|
|
115
124
|
webDir,
|
|
116
125
|
version = '0.0.0',
|
|
117
126
|
selfUpdate = null,
|
|
127
|
+
extraHosts = [],
|
|
118
128
|
extraOrigins = [],
|
|
129
|
+
folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
130
|
+
folderPicker = createFolderPicker({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
119
131
|
/** The double-click launcher file for this platform, or null when the package carries none. */
|
|
120
132
|
launcher = null,
|
|
121
133
|
/** @type {(opts: { restart: boolean }) => void} */
|
|
122
134
|
onShutdownRequest = () => {},
|
|
123
135
|
}) {
|
|
136
|
+
const proxyHosts = parseAllowedHosts(extraHosts.join(','));
|
|
137
|
+
const socketSources = proxyHosts.flatMap((authority) => [`ws://${authority}`, `wss://${authority}`]);
|
|
138
|
+
const securityHeaders = {
|
|
139
|
+
...SECURITY_HEADERS,
|
|
140
|
+
'Content-Security-Policy': SECURITY_HEADERS['Content-Security-Policy'].replace(
|
|
141
|
+
"connect-src 'self'", ["connect-src 'self'", ...socketSources].join(' ')),
|
|
142
|
+
};
|
|
124
143
|
const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
|
|
125
144
|
const startedAt = new Date().toISOString();
|
|
126
145
|
const vendor = vendorFiles();
|
|
127
146
|
let boundPort = port;
|
|
128
147
|
|
|
129
|
-
const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}
|
|
148
|
+
const allowedHosts = () => new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...proxyHosts]);
|
|
130
149
|
const allowedOrigins = () =>
|
|
131
150
|
new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`, ...extraOrigins]);
|
|
132
151
|
|
|
@@ -154,13 +173,24 @@ export function createManagerServer({
|
|
|
154
173
|
}
|
|
155
174
|
}
|
|
156
175
|
|
|
176
|
+
function sendJson(res, status, body) {
|
|
177
|
+
const payload = JSON.stringify(body);
|
|
178
|
+
res.writeHead(status, {
|
|
179
|
+
...securityHeaders,
|
|
180
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
181
|
+
'Cache-Control': 'no-store',
|
|
182
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
183
|
+
});
|
|
184
|
+
res.end(payload);
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
function serveFile(req, res, file, { cache = false } = {}) {
|
|
158
188
|
const notFound = () => sendJson(res, 404, { error: { code: 'not_found', message: 'not found' } });
|
|
159
189
|
fs.stat(file, (statErr, stat) => {
|
|
160
190
|
if (statErr || !stat.isFile()) return notFound();
|
|
161
191
|
const etag = `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
|
|
162
192
|
const headers = {
|
|
163
|
-
...
|
|
193
|
+
...securityHeaders,
|
|
164
194
|
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
|
|
165
195
|
'Cache-Control': cache ? 'public, max-age=3600' : 'no-cache',
|
|
166
196
|
ETag: etag,
|
|
@@ -239,8 +269,21 @@ export function createManagerServer({
|
|
|
239
269
|
warnings: registry.warnings,
|
|
240
270
|
upgrade: upgradeInfo(),
|
|
241
271
|
launcher,
|
|
272
|
+
folderOpener: folderOpener.describe(),
|
|
273
|
+
folderPicker: folderPicker.describe(),
|
|
242
274
|
});
|
|
243
275
|
}
|
|
276
|
+
if (route === '/open-folder' && method === 'POST') {
|
|
277
|
+
const { cwd } = await readJsonBody(req);
|
|
278
|
+
await folderOpener.open(cwd);
|
|
279
|
+
return sendJson(res, 200, { ok: true });
|
|
280
|
+
}
|
|
281
|
+
if (route === '/pick-folder' && method === 'POST') {
|
|
282
|
+
const { cwd } = await readJsonBody(req);
|
|
283
|
+
const gone = new AbortController();
|
|
284
|
+
res.once('close', () => gone.abort());
|
|
285
|
+
return sendJson(res, 200, { path: await folderPicker.pick(cwd, { signal: gone.signal }) });
|
|
286
|
+
}
|
|
244
287
|
if (route === '/upgrade' && method === 'POST') {
|
|
245
288
|
const session = await manager.upgrade();
|
|
246
289
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
@@ -362,6 +405,8 @@ export function createManagerServer({
|
|
|
362
405
|
throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
|
|
363
406
|
}
|
|
364
407
|
|
|
408
|
+
const views = github ? createViews(github) : null;
|
|
409
|
+
|
|
365
410
|
async function handleGitHub(req, res, url, route, method) {
|
|
366
411
|
const snapshot = () => ({ github: github.snapshot() });
|
|
367
412
|
if (route === '/github' && method === 'GET') return sendJson(res, 200, snapshot());
|
|
@@ -378,6 +423,30 @@ export function createManagerServer({
|
|
|
378
423
|
const session = manager.clone({ account: body.account, repo: body.repo, parent: body.parent });
|
|
379
424
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
380
425
|
}
|
|
426
|
+
if (route === '/github/repos' && method === 'GET') {
|
|
427
|
+
return sendJson(res, 200, await github.allRepos({ refresh: url.searchParams.get('refresh') === '1' }));
|
|
428
|
+
}
|
|
429
|
+
if (route === '/github/origin' && method === 'GET') {
|
|
430
|
+
const folder = manager.resolveCwd(url.searchParams.get('cwd'));
|
|
431
|
+
return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
|
|
432
|
+
}
|
|
433
|
+
const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls)(?:\/([^/]+))?$/);
|
|
434
|
+
if (view) {
|
|
435
|
+
let parts;
|
|
436
|
+
try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
|
|
437
|
+
const [, id, owner, name, kind, number] = parts;
|
|
438
|
+
if (kind === 'issues' && number === undefined && method === 'GET') {
|
|
439
|
+
return sendJson(res, 200, await views.issues(id, owner, name, { state: url.searchParams.get('state') || 'open' }));
|
|
440
|
+
}
|
|
441
|
+
if (kind === 'issues' && number === undefined && method === 'POST') {
|
|
442
|
+
return sendJson(res, 201, { issue: await views.createIssue(id, owner, name, await readJsonBody(req)) });
|
|
443
|
+
}
|
|
444
|
+
if (kind === 'issues' && number !== undefined && method === 'PATCH') {
|
|
445
|
+
return sendJson(res, 200, { issue: await views.updateIssue(id, owner, name, number, await readJsonBody(req)) });
|
|
446
|
+
}
|
|
447
|
+
if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
|
|
448
|
+
if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
|
|
449
|
+
}
|
|
381
450
|
const match = route.match(/^\/github\/accounts\/([^/]+)(\/repos|\/ssh)?$/);
|
|
382
451
|
if (match) {
|
|
383
452
|
const [, id, action] = match;
|
|
@@ -462,7 +531,7 @@ export function createManagerServer({
|
|
|
462
531
|
|
|
463
532
|
function handleEvents(ws) {
|
|
464
533
|
eventClients.add(ws);
|
|
465
|
-
safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
534
|
+
safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, folderOpener: folderOpener.describe(), folderPicker: folderPicker.describe(), upgrade: upgradeInfo(), sessions: manager.list() });
|
|
466
535
|
ws.on('close', () => eventClients.delete(ws));
|
|
467
536
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
468
537
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The favicon mirrors the card label, including providers that stay active.
|
|
2
|
+
export const isSessionWorking = (session) => session.status !== 'exited' && session.activity === 'active';
|
|
3
|
+
|
|
4
|
+
const FRAME_MS = 100;
|
|
5
|
+
const FRAME_COUNT = 24;
|
|
6
|
+
|
|
7
|
+
/** One cached animation per page. Browser throttling may leave any frame visible. */
|
|
8
|
+
export function createActivityFavicon({
|
|
9
|
+
link, reducedMotion, document = globalThis.document, Image = globalThis.Image,
|
|
10
|
+
setTimer = setTimeout, clearTimer = clearTimeout, now = () => performance.now(),
|
|
11
|
+
}) {
|
|
12
|
+
const original = link?.href;
|
|
13
|
+
let frames = [], timer = null, working = false, paused = false, started = 0;
|
|
14
|
+
|
|
15
|
+
function paint() {
|
|
16
|
+
clearTimer(timer);
|
|
17
|
+
timer = null;
|
|
18
|
+
if (!link) return;
|
|
19
|
+
const animate = working && !paused && !reducedMotion.matches && frames.length > 0;
|
|
20
|
+
const frame = animate ? Math.floor((now() - started) / FRAME_MS) % frames.length : 0;
|
|
21
|
+
const href = working && frames.length ? frames[frame] : original;
|
|
22
|
+
if (link.href !== href) link.href = href;
|
|
23
|
+
if (animate) timer = setTimer(paint, FRAME_MS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (link) {
|
|
27
|
+
const image = new Image();
|
|
28
|
+
image.onload = () => {
|
|
29
|
+
try {
|
|
30
|
+
const canvas = document.createElement('canvas');
|
|
31
|
+
canvas.width = canvas.height = 32;
|
|
32
|
+
const ctx = canvas.getContext('2d');
|
|
33
|
+
if (!ctx) return;
|
|
34
|
+
// Encode once, not on every tick. Every frame has a distinct working ring.
|
|
35
|
+
frames = Array.from({ length: FRAME_COUNT }, (_, index) => {
|
|
36
|
+
ctx.clearRect(0, 0, 32, 32);
|
|
37
|
+
ctx.drawImage(image, 5, 5, 22, 22);
|
|
38
|
+
ctx.lineWidth = 3;
|
|
39
|
+
ctx.strokeStyle = '#164e3b';
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
ctx.arc(16, 16, 14, 0, Math.PI * 2);
|
|
42
|
+
ctx.stroke();
|
|
43
|
+
ctx.strokeStyle = '#4ade80';
|
|
44
|
+
ctx.lineCap = 'round';
|
|
45
|
+
ctx.beginPath();
|
|
46
|
+
const angle = index * Math.PI * 2 / FRAME_COUNT - Math.PI / 2;
|
|
47
|
+
ctx.arc(16, 16, 14, angle, angle + Math.PI / 2);
|
|
48
|
+
ctx.stroke();
|
|
49
|
+
return canvas.toDataURL('image/png');
|
|
50
|
+
});
|
|
51
|
+
paint();
|
|
52
|
+
} catch { /* An optional indicator must never prevent the cards from rendering. */ }
|
|
53
|
+
};
|
|
54
|
+
image.onerror = () => {}; // Retain the original favicon if the asset cannot load.
|
|
55
|
+
image.src = original;
|
|
56
|
+
}
|
|
57
|
+
reducedMotion.addEventListener('change', paint);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
setWorking(value) {
|
|
61
|
+
if (working === value) return;
|
|
62
|
+
working = value;
|
|
63
|
+
if (working) started = now();
|
|
64
|
+
paint();
|
|
65
|
+
},
|
|
66
|
+
setPaused(value) {
|
|
67
|
+
paused = value;
|
|
68
|
+
paint();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|