@kitkat-vn/kitcode 0.2.0-staging.10
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/LICENSE +21 -0
- package/README.md +321 -0
- package/bin/kitcode.mjs +1266 -0
- package/package.json +41 -0
- package/src/api-access.mjs +119 -0
- package/src/api.mjs +175 -0
- package/src/app-identity.mjs +29 -0
- package/src/assets/kitcode-icon.png +0 -0
- package/src/atomic-file.mjs +85 -0
- package/src/break-progress.mjs +87 -0
- package/src/claim-bundle.mjs +38 -0
- package/src/companion-controls.mjs +40 -0
- package/src/companion-electron.mjs +85 -0
- package/src/companion-preload.cjs +10 -0
- package/src/companion-window-options.mjs +16 -0
- package/src/companion-window.mjs +59 -0
- package/src/cors.mjs +51 -0
- package/src/diagnostics.mjs +872 -0
- package/src/equals-ledger.mjs +166 -0
- package/src/focus-clock.mjs +86 -0
- package/src/fonts/fira-code/OFL.txt +93 -0
- package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
- package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
- package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
- package/src/fonts/vt323/OFL.txt +93 -0
- package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
- package/src/git.mjs +488 -0
- package/src/hook-installers.mjs +397 -0
- package/src/hook-prompt.mjs +217 -0
- package/src/install-id.mjs +94 -0
- package/src/integration-installers.mjs +28 -0
- package/src/integration-spec.mjs +410 -0
- package/src/log-file.mjs +60 -0
- package/src/milestones-reached.mjs +133 -0
- package/src/next-break-reminder.mjs +78 -0
- package/src/onboarding-electron.mjs +252 -0
- package/src/onboarding-platform.mjs +50 -0
- package/src/onboarding-preload.cjs +19 -0
- package/src/onboarding-window.mjs +914 -0
- package/src/open-dashboard.mjs +30 -0
- package/src/reward.mjs +274 -0
- package/src/runner-installer.mjs +153 -0
- package/src/runtime.mjs +1340 -0
- package/src/scan-worker.mjs +23 -0
- package/src/scanner.mjs +84 -0
- package/src/seen-lines.mjs +148 -0
- package/src/skill-installer.mjs +76 -0
- package/src/source-snapshot.mjs +733 -0
- package/src/store.mjs +595 -0
- package/src/tracker-instance.mjs +166 -0
- package/src/tracker-problems.mjs +31 -0
- package/src/tracker-stop.mjs +73 -0
- package/src/uninstall.mjs +80 -0
- package/src/vibe.mjs +4 -0
- package/src/window-guards.mjs +7 -0
- package/src/window-page.mjs +49 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {app, BrowserWindow, ipcMain, nativeTheme, screen, shell} from 'electron';
|
|
2
|
+
import {nameApp, showAppIcon} from './app-identity.mjs';
|
|
3
|
+
import {miniWindowOptions} from './companion-window-options.mjs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {reachableAddress} from './api-access.mjs';
|
|
7
|
+
import {renderCompanionWindow} from './companion-window.mjs';
|
|
8
|
+
import {resolveDashboardUrl} from './open-dashboard.mjs';
|
|
9
|
+
import {keepWindowOnItsPage} from './window-guards.mjs';
|
|
10
|
+
|
|
11
|
+
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const host = process.env.KITCODE_HOST ?? '127.0.0.1';
|
|
13
|
+
const port = process.env.KITCODE_PORT ?? '4747';
|
|
14
|
+
// The URL sets the Host header, and the API answers only a loopback Host (api-access.mjs).
|
|
15
|
+
const apiBase = `http://${reachableAddress(host)}:${port}`;
|
|
16
|
+
const companionPreload = path.join(sourceDirectory, 'companion-preload.cjs');
|
|
17
|
+
const miniWidth = 560;
|
|
18
|
+
const miniHeight = 76;
|
|
19
|
+
|
|
20
|
+
let miniWindow;
|
|
21
|
+
let quitting = false;
|
|
22
|
+
|
|
23
|
+
function miniPosition() {
|
|
24
|
+
const {workArea} = screen.getPrimaryDisplay();
|
|
25
|
+
return {
|
|
26
|
+
x: workArea.x + workArea.width - miniWidth - 28,
|
|
27
|
+
y: workArea.y + workArea.height - miniHeight - 72,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function trackerReady() {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${apiBase}/api/health`, {signal: AbortSignal.timeout(1200)});
|
|
34
|
+
const health = response.ok ? await response.json() : null;
|
|
35
|
+
return health?.status === 'ok' && health?.app === 'kitcode';
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createMiniWindow(url) {
|
|
42
|
+
const position = miniPosition();
|
|
43
|
+
miniWindow = new BrowserWindow(miniWindowOptions({width: miniWidth, height: miniHeight, position, preload: companionPreload}));
|
|
44
|
+
keepWindowOnItsPage(miniWindow);
|
|
45
|
+
miniWindow.setAlwaysOnTop(true, 'floating');
|
|
46
|
+
miniWindow.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true});
|
|
47
|
+
miniWindow.on('close', (event) => {
|
|
48
|
+
if (!quitting) {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
app.quit();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
miniWindow.loadURL(url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
nameApp(app);
|
|
57
|
+
|
|
58
|
+
app.whenReady().then(async () => {
|
|
59
|
+
showAppIcon(app);
|
|
60
|
+
nativeTheme.themeSource = 'dark';
|
|
61
|
+
const connected = await trackerReady();
|
|
62
|
+
const miniUrl = connected
|
|
63
|
+
? `${apiBase}/companion`
|
|
64
|
+
: `data:text/html;charset=utf-8,${encodeURIComponent(renderCompanionWindow(apiBase))}`;
|
|
65
|
+
createMiniWindow(miniUrl);
|
|
66
|
+
ipcMain.handle('kitcode:companion-hide', (event) => {
|
|
67
|
+
if (event.sender === miniWindow.webContents) app.quit();
|
|
68
|
+
return {hidden: true};
|
|
69
|
+
});
|
|
70
|
+
ipcMain.handle('kitcode:open-dashboard', (event) => {
|
|
71
|
+
if (event.sender !== miniWindow.webContents) {
|
|
72
|
+
return {opened: false};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
shell.openExternal(resolveDashboardUrl());
|
|
76
|
+
return {opened: true};
|
|
77
|
+
});
|
|
78
|
+
miniWindow.once('ready-to-show', () => miniWindow.showInactive());
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
app.on('before-quit', () => {
|
|
82
|
+
quitting = true;
|
|
83
|
+
ipcMain.removeHandler('kitcode:companion-hide');
|
|
84
|
+
ipcMain.removeHandler('kitcode:open-dashboard');
|
|
85
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const {contextBridge, ipcRenderer} = require('electron');
|
|
2
|
+
|
|
3
|
+
contextBridge.exposeInMainWorld('kitcodeCompanion', {
|
|
4
|
+
hide() {
|
|
5
|
+
return ipcRenderer.invoke('kitcode:companion-hide');
|
|
6
|
+
},
|
|
7
|
+
openDashboard() {
|
|
8
|
+
return ipcRenderer.invoke('kitcode:open-dashboard');
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {ICON_PATH} from './app-identity.mjs';
|
|
2
|
+
|
|
3
|
+
// The Mini's window options for Electron (src/companion-electron.mjs), kept apart from Electron so a test can read them.
|
|
4
|
+
// `roundedCorners: false`: macOS rounds a frameless window's corners itself, over whatever the page draws, so the Mini's
|
|
5
|
+
// square red frame (companion-window.mjs) needs it too (the operator, 2026-10-03). Other platforms ignore it.
|
|
6
|
+
export function miniWindowOptions({width, height, position, preload}) {
|
|
7
|
+
return {
|
|
8
|
+
icon: ICON_PATH,
|
|
9
|
+
width, height,
|
|
10
|
+
minWidth: width, minHeight: height, maxWidth: width, maxHeight: height,
|
|
11
|
+
x: position.x, y: position.y, frame: false, transparent: true, hasShadow: false, roundedCorners: false,
|
|
12
|
+
backgroundColor: '#00000000', resizable: false,
|
|
13
|
+
movable: true, alwaysOnTop: true, skipTaskbar: true, show: false,
|
|
14
|
+
webPreferences: {preload, contextIsolation: true, nodeIntegration: false, sandbox: true},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {COMPANION_HIDE_CSS, renderCompanionTitleBar} from './companion-controls.mjs';
|
|
2
|
+
import {DASHBOARD_URL} from './integration-spec.mjs';
|
|
3
|
+
import {contentSecurityPolicy, fontFaces} from './window-page.mjs';
|
|
4
|
+
|
|
5
|
+
export function renderCompanionWindow(apiBase = '') {
|
|
6
|
+
// The page's one stylesheet and one script, kept apart so that its policy allows exactly them. Served
|
|
7
|
+
// by the tracker (apiBase ''), the page reads the API from its own origin; the offline copy, from apiBase.
|
|
8
|
+
const style = `
|
|
9
|
+
${fontFaces(['vt323', 'fira-code'])}
|
|
10
|
+
:root{color-scheme:dark;--bg-color:#0c0c0c;--red-color:#f03030;--white-text:#e0e0e0;--font-mono:'Fira Code',monospace;--font-pixel:'VT323',monospace}
|
|
11
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
12
|
+
html,body{position:relative;width:100%;height:100%;margin:0;overflow:hidden;background:transparent;color:var(--white-text);font-family:var(--font-mono)}
|
|
13
|
+
button{cursor:pointer}
|
|
14
|
+
.widget-window{position:relative;width:100%;height:100%;display:flex;flex-direction:column;background:var(--bg-color);border:2px solid var(--red-color);border-radius:0;overflow:hidden;-webkit-app-region:drag}
|
|
15
|
+
.title-bar{padding:6px 10px 4px;display:flex;align-items:center;gap:7px;border-bottom:1px solid var(--red-color)}
|
|
16
|
+
.dot{width:12px;height:12px;border-radius:50%;border:1px solid var(--red-color);background:transparent}
|
|
17
|
+
.dot.filled{background-color:var(--red-color)}
|
|
18
|
+
.widget-body{flex:1;min-height:0;padding:8px 16px;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;-webkit-app-region:drag}
|
|
19
|
+
.brand-label{color:var(--red-color);font-family:var(--font-mono);font-size:10px;font-weight:700;line-height:1.15;letter-spacing:.6px;text-transform:uppercase;white-space:nowrap}
|
|
20
|
+
.percentage{font-family:var(--font-pixel),var(--font-mono);font-size:30px;letter-spacing:1px;line-height:1;color:var(--white-text);white-space:nowrap}
|
|
21
|
+
.progress-box{border-top:2px solid var(--white-text);border-bottom:2px solid var(--white-text);border-left:1px dashed var(--white-text);border-right:1px dashed var(--white-text);padding:3px 6px;width:120px;height:32px;display:flex;align-items:center;flex:0 0 120px;overflow:hidden}
|
|
22
|
+
.progress-fill{width:0;height:100%;display:flex;flex-direction:column;justify-content:center;gap:1px;overflow:hidden;transition:width .2s ease}
|
|
23
|
+
.progress-row{width:max-content;color:var(--white-text);font:700 9px/1 var(--font-mono);letter-spacing:1px;white-space:nowrap}
|
|
24
|
+
.metrics-group{display:flex;align-items:flex-end;gap:14px;min-width:0}
|
|
25
|
+
.metric-item{display:flex;flex-direction:column;align-items:center;min-width:0}
|
|
26
|
+
.metric-value{color:var(--white-text);font-family:var(--font-pixel),var(--font-mono);font-size:24px;line-height:1;margin-bottom:2px;letter-spacing:.5px;white-space:nowrap}
|
|
27
|
+
.metric-label{color:var(--red-color);font-family:var(--font-mono);font-size:7px;font-weight:500;letter-spacing:.3px;text-transform:uppercase;white-space:nowrap}
|
|
28
|
+
.claim-btn{-webkit-app-region:no-drag;background:transparent;color:var(--white-text);font-family:var(--font-mono);font-size:10px;font-weight:500;letter-spacing:1px;padding:3px 12px;cursor:pointer;text-transform:uppercase;border-top:2px solid var(--red-color);border-bottom:2px solid var(--red-color);border-left:1px dashed var(--red-color);border-right:1px dashed var(--red-color);white-space:normal;width:min-content;line-height:1.15;transition:background-color .2s ease}
|
|
29
|
+
.claim-btn:hover,.claim-btn:focus-visible{background:rgba(240,48,48,.15);outline:none}
|
|
30
|
+
.offline .percentage,.offline .metric-value{color:#6f6f6f}
|
|
31
|
+
.offline .progress-row{color:#6f6f6f}
|
|
32
|
+
${COMPANION_HIDE_CSS}
|
|
33
|
+
`;
|
|
34
|
+
const script = `
|
|
35
|
+
const API_BASE=${JSON.stringify(apiBase)};const percent=document.getElementById('percent'),countValue=document.getElementById('countValue'),workedValue=document.getElementById('workedValue'),idleValue=document.getElementById('idleValue'),progressFill=document.getElementById('progressFill'),main=document.getElementById('main'),dashboardButton=document.getElementById('dashboardButton');
|
|
36
|
+
function formatDuration(seconds){const totalSeconds=Math.max(0,Math.floor(Number(seconds)||0));if(totalSeconds<60)return totalSeconds+'s';const hours=Math.floor(totalSeconds/3600),minutes=Math.floor((totalSeconds%3600)/60);if(hours>0)return minutes>0?hours+'h '+minutes+'m':hours+'h';return minutes+'m'}
|
|
37
|
+
function updateProgress(value){progressFill.style.width=Math.max(0,Math.min(100,Number(value)||0))+'%'}
|
|
38
|
+
function render(summary){const reward=summary?.reward||{},global=summary?.global||{},value=Math.max(0,Math.min(100,Math.floor(Number(reward.progressPercent)||0)));percent.textContent=value+'%';countValue.textContent=String(Math.max(0,Math.floor(Number(reward.totalEquals)||0)));workedValue.textContent=formatDuration(global.totalActiveSeconds);idleValue.textContent=formatDuration(global.totalIdleSeconds);updateProgress(value);main.classList.remove('offline')}
|
|
39
|
+
function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);main.classList.add('offline')}
|
|
40
|
+
async function sync(){try{const response=await fetch(API_BASE+'/api/summary');if(!response.ok)throw new Error();render(await response.json())}catch{offline()}}
|
|
41
|
+
const hideButton=document.getElementById('hideButton');function hideCompanion(){try{const result=window.kitcodeCompanion?.hide?.();if(result&&typeof result.catch==='function')result.catch(()=>window.close());if(!result)window.close()}catch{window.close()}}
|
|
42
|
+
hideButton.addEventListener('pointerdown',(event)=>event.stopPropagation());hideButton.addEventListener('click',(event)=>{event.stopPropagation();hideCompanion()});
|
|
43
|
+
dashboardButton.addEventListener('pointerdown',(event)=>event.stopPropagation());
|
|
44
|
+
dashboardButton.addEventListener('click',()=>{if(window.kitcodeCompanion?.openDashboard){window.kitcodeCompanion.openDashboard();return}window.open(${JSON.stringify(DASHBOARD_URL)},'_blank','noopener')});
|
|
45
|
+
sync();const events=new EventSource(API_BASE+'/api/events');events.addEventListener('summary',(event)=>{try{render(JSON.parse(event.data))}catch{offline()}});events.onerror=offline;
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// The button opens the dashboard, where each milestone reached shows its message: "See more". Until 2026-10-01 it was
|
|
49
|
+
// "Show code", and lit while a 10–30 % code was ready to show there; the break codes are gone (the operator's decision
|
|
50
|
+
// 6), and a milestone reached stays reached, so nothing lights it now. The Mini cannot show a message itself: the
|
|
51
|
+
// messages are the claim backend's, and the tracker, which serves this page, never talks to it. Two short words, which
|
|
52
|
+
// wrap onto two lines, as KITCODE COUNTER does on the left: "Dashboard" or "Messages" took 28 to 35 px more, and the
|
|
53
|
+
// counters then ran into each other at long-campaign values (measured in the 560 x 76 window with 2500 = and
|
|
54
|
+
// 11h 48m, 2026-09-24 and 2026-10-01).
|
|
55
|
+
return `<!doctype html>
|
|
56
|
+
<html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${contentSecurityPolicy({script, style, connect: [apiBase || "'self'"]})}"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
57
|
+
<title>KitCode Counter</title><style>${style}</style></head>
|
|
58
|
+
<body><main class="widget-window" id="main" data-testid="companion-counter-bar">${renderCompanionTitleBar()}<div class="widget-body"><div class="brand-label" aria-label="KitCode Counter">KITCODE<br>COUNTER</div><strong class="percentage" id="percent" data-testid="mini-percent">--</strong><div class="progress-box" aria-hidden="true"><div class="progress-fill" id="progressFill"><div class="progress-row">========================</div><div class="progress-row">========================</div></div></div><div class="metrics-group" aria-label="KitCode counters"><div class="metric-item"><div class="metric-value" id="countValue" data-testid="mini-count">--</div><div class="metric-label">= COUNT</div></div><div class="metric-item"><div class="metric-value" id="workedValue" data-testid="mini-worked">--</div><div class="metric-label">WORKED</div></div><div class="metric-item"><div class="metric-value" id="idleValue" data-testid="mini-idle">--</div><div class="metric-label">IDLE</div></div></div><button class="claim-btn" id="dashboardButton" data-testid="companion-dashboard" type="button" title="Open the dashboard, where each milestone reached shows its message">See more</button></div></main><script>${script}</script></body></html>`;
|
|
59
|
+
}
|
package/src/cors.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The origins a tracker allows with nothing configured: the hosted dashboard's and the local development
|
|
2
|
+
// servers'. Pinned by local-api.test.mjs.
|
|
3
|
+
export const DEFAULT_ALLOWED_ORIGINS = new Set([
|
|
4
|
+
// The staging dashboard this tester build is for (scripts/tester-build.mjs in the repository).
|
|
5
|
+
"https://staging.kitcode.vn",
|
|
6
|
+
'http://localhost:8686',
|
|
7
|
+
'http://127.0.0.1:8686',
|
|
8
|
+
'http://localhost:3000',
|
|
9
|
+
'http://127.0.0.1:3000',
|
|
10
|
+
'http://localhost:5173',
|
|
11
|
+
'http://127.0.0.1:5173',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function configuredOrigins() {
|
|
15
|
+
if (!process.env.KITCODE_ALLOWED_ORIGINS) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return process.env.KITCODE_ALLOWED_ORIGINS.split(',')
|
|
20
|
+
.map((origin) => origin.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isAllowedOrigin(origin) {
|
|
25
|
+
return Boolean(origin) && (DEFAULT_ALLOWED_ORIGINS.has(origin) || configuredOrigins().includes(origin));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Only an allow-listed origin is told anything: whether it may read, and whether it may reach this
|
|
29
|
+
// private-network address at all. Any other origin gets none of these headers. The one thing any page
|
|
30
|
+
// may read is a refusal, which carries fixed words only (api-access.mjs).
|
|
31
|
+
export function corsMiddleware(req, res, next) {
|
|
32
|
+
const origin = req.headers.origin;
|
|
33
|
+
|
|
34
|
+
res.setHeader('Vary', 'Origin');
|
|
35
|
+
|
|
36
|
+
if (isAllowedOrigin(origin)) {
|
|
37
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
38
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
39
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
40
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
41
|
+
res.setHeader('Private-Network-Access-ID', '4b:69:74:43:6f:64');
|
|
42
|
+
res.setHeader('Private-Network-Access-Name', 'kitcode-local');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (req.method === 'OPTIONS') {
|
|
46
|
+
res.status(204).end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
next();
|
|
51
|
+
}
|