Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions index.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,49 @@
"description": "在 Studio 里编写 Steam 成就与统计量,并用蓝图节点解锁。Steam 不可用时写入本地镜像,itch 版、web 版与 Dev Mode 下同一套脚本照常工作。"
}
}
},
{
"id": "wangzixu.wakatime-plugin",
"name": "WakaTime",
"version": "1.0.0",
"description": "允许 NarraLeaf Studio 与 WakaTime 通讯,实时上报工作时间,努力可见(在命令面板中输入 WakaTime 以修改设置)",
"publisher": "WangZixu",
"path": "plugins/wangzixu.wakatime-plugin",
"targets": [
"studio"
],
"categories": [
"integration",
"workflow"
],
"keywords": [
"narraleaf",
"narraleaf-studio-plugin",
"wakatime",
"time-tracking",
"heartbeats",
"productivity"
],
"license": "MPL-2.0",
"contributes": {
"blueprintNodes": [],
"widgets": [],
"locales": []
},
"permissions": [],
"release": {
"tag": "wangzixu.wakatime-plugin@1.0.0",
"page": "https://github.com/NarraLeaf/Plugins/releases/tag/wangzixu.wakatime-plugin%401.0.0",
"download": "https://github.com/NarraLeaf/Plugins/releases/download/wangzixu.wakatime-plugin%401.0.0/wangzixu.wakatime-plugin-1.0.0.zip"
},
"icon": "https://raw.githubusercontent.com/NarraLeaf/Plugins/wangzixu.wakatime-plugin%401.0.0/plugins/wangzixu.wakatime-plugin/icon.png",
"studioVersion": ">=0.5.0",
"locales": {
"en": {
"name": "WakaTime",
"description": "Lets NarraLeaf Studio talk to WakaTime, reporting the hours as you put them in — so the effort is visible. (Type WakaTime in the command palette to change the settings.)"
}
}
}
]
}
4 changes: 4 additions & 0 deletions plugins/wangzixu.wakatime-plugin/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Matches NarraLeaf-Studio: real node_modules, no PnP.
# Each plugin resolves independently — this directory is not part of any workspace.
nodeLinker: node-modules
enableScripts: true
182 changes: 182 additions & 0 deletions plugins/wangzixu.wakatime-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# WakaTime

Reports the time you spend authoring a project in NarraLeaf Studio to
[WakaTime](https://wakatime.com).

The whole plugin is **one icon button** beside the Run control, opening a dialog
with three settings: an on/off switch, your API key, and the project name.
Nothing else is added to the workspace — no rail icon, no panel, no menu.

Editor-side only. There is no `runtime` entry, no blueprint node and no widget:
nothing this plugin does reaches a shipped game.

## Why a button and not a Settings page

Because a plugin cannot reach the Settings window. A studio entry loads in the
**workspace window only** — never Launcher, Settings, Project Wizard or Dev Mode
— and `PluginServices` has no settings contribution point, in the published
`narraleaf-studio@0.5.0` types or in Studio's current source. A standalone
registered action, which Studio draws as a single icon button next to Run, is the
lightest surface the plugin API actually has.

The dialog mounts its own React root (`react-dom/client` is published to plugins
through the workspace import map for exactly this). Studio's `ui` kit reads its
translations from a module store rather than a React provider, so `ui.Modal` and
the inputs render there identically. The one thing that does need workspace
context — `ui.useFreezeGuard` — is replaced by the documented service half,
`services.workspace.frozen` / `onFreezeChange`.

## What it sees, and what it does not

Studio's plugin API has no editing signal — no "document changed", no "the active
editor is now this scene", no save hook. What a studio entry *does* have is the
workspace window's DOM, so that is what the tracker reads: keystrokes, clicks,
wheel and IME commits, in the capture phase, for their **timing only**. No key,
no coordinate and no target element is inspected, stored or sent.

| WakaTime concept | What this plugin reports |
| ---------------- | ------------------------ |
| Project | The name you type in the dialog. |
| Entity / file | `NarraLeaf Studio`, always. The active document is not knowable through the plugin API, and a guessed file path would be a lie the dashboard cannot tell from a fact. |
| Language | `NarraLeaf`, fixed. |
| Category | `designing`, fixed. |
| Branch, lines, cursor | Not sent. |

So: correct per-project totals, correct per-day totals, no per-file breakdown.

**The project name has to be typed.** There is no project name or project path
anywhere in `PluginServices`, the workspace title bar is a hardcoded
`"NarraLeaf Studio"`, and no DOM element carries it — so there is nothing to read
it from. It is asked once per project and then versioned with the project, which
also means collaborators inherit it instead of each inventing their own.

## What leaves your machine

One `POST` to `…/users/current/heartbeats.bulk` per two minutes of activity, each
heartbeat carrying: the project name you chose, the fixed category and language
labels, a timestamp, the constant entity string above, and the plugin's
user-agent. **No story text, no scene or asset names, no file paths, no
keystrokes.** `buildHeartbeat` in `src/wakatime.ts` is the whole payload, and a
test asserts its field list so it cannot quietly grow.

Nothing is sent at all until the switch is on *and* an API key is entered *and*
the project is named.

## Where the API key lives

**In the workspace window's `localStorage`, not in the project.**

`app.services.storage` — the storage a Studio plugin is handed — writes into
`editor/services/`, inside the project's versioned working tree: the same tree
you commit and push. A credential written there is a credential in your
repository, and on a public repository that is a leak with no undo. So the key
and the on/off switch are machine-scoped and live outside the project; only the
project name is project data.

It is plaintext, which is exactly what `~/.wakatime.cfg` is. Studio exposes no
secret store to plugins, so the real choice here is not "encrypted or plaintext"
but "outside the project or in git".

## Offline

Heartbeats go into a local queue and are flushed in batches of 25, retried once a
minute. Being offline, or the server being down or rate-limiting, keeps the
queue; only a response that will never become valid drops a batch. A rejected
**API key** parks sending entirely rather than retrying forever, and raises one
notification so a plugin with no permanent UI does not fail silently — changing
the key resumes it.

The queue holds 1000 heartbeats, about 33 hours of unsent work, and drops its
oldest entries first.

Editor plugins normally get all of this for free by shelling out to
`wakatime-cli`. This one cannot: Studio's privileged facade exposes
`bash.execute`, but the main process handler answers *"Bash execution is not
implemented yet"*. So the plugin speaks the documented HTTP API directly and
reimplements the queue.

## Permissions

`"permissions": []` — none.

Network access is not a declared Studio permission: a studio entry runs in the
workspace renderer and uses `fetch` like any other renderer code. The two
endpoints used here (`heartbeats.bulk`, `statusbar/today`) were checked against
api.wakatime.com and pass a CORS preflight with an `Authorization` header.
`GET /users/current` does **not** — it answers its error responses without
`Access-Control-Allow-Origin` — which is why the dialog's **Test** button calls
`statusbar/today` instead.

Only wakatime.com is supported. A self-hosted server (Wakapi, Hackatime) would
need a fourth setting, and three is the budget.

## Setting it up

1. **Launcher → Plugins → Install from folder**, then approve and enable it.
2. Click the timer icon beside the Run button — or type **WakaTime** in the
command palette. (An icon-only action carries a tooltip rather than a label,
and the palette falls back to it, so the button is reachable by name.)
3. Click the `wakatime.com/settings/api-key` address under the key field to copy
it, open it in your browser, and paste the key back. Name the project, leave
the switch on.
4. **Test** — it records one heartbeat, sends it, and reports today's total, so a
success there means the write path works and not just the key.

That address copies rather than opens, because a plugin cannot open a browser:
the workspace window denies every `setWindowOpenHandler` request and blocks
`will-navigate` to anything outside its own entry, and `shell.openExternal` sits
behind an IPC the plugin facade does not carry. An `<a href>` there would be a
control that visibly does nothing.

The switch defaults to on, which is safe because it is inert: with no key and no
project name, nothing is recorded and nothing is sent.

## Frozen projects

While Studio's version control has the project frozen — restoring a past version,
or showing one read-only — every plugin toolbar action is disabled by Studio
itself (the exemption list is a table in Studio's source, not a flag a plugin can
set), so the button cannot be opened until the project thaws. If a freeze begins
while the dialog is open, the project-name field greys out and its write bails
before touching memory; the key and the switch stay live, because they are not
project data. The store re-reads through `registerReloader` after a restore.

The tracker keeps running while frozen. Time spent reading an old version is
still time spent on the project.

## Files

| File | Purpose |
| ---------------------- | ------- |
| `src/wakatime.ts` | The wire format and the HTTP client. No Studio types, no DOM. |
| `src/settings.ts` | The three settings' shapes, normalization, and the `localStorage` half. |
| `src/projectStore.ts` | The project-storage half: freeze bail-out and reloader. |
| `src/tracker.ts` | Activity detection, heartbeat rhythm, queue flushing. |
| `src/i18n.ts` | The dialog's own messages (`en`, `zh`). |
| `src/main.tsx` | Studio entry: the action, the dialog and its root, and the wiring. |
| `tools/make-icon.mjs` | Renders `icon.png` — a line-art stopwatch, drawn here rather than downloaded. |

## The icon

`icon.png` is generated by `yarn icon`, and it is **not WakaTime's logo**. A
plugin icon is the most prominent brand slot a package has, and this one is
published by someone other than WakaTime — their mark on it would read as an
official integration, which this is not. The plugin's *name* says WakaTime,
which is ordinary descriptive use of the service it talks to, and that is where
the association belongs. `narraleaf.steam-achievements` made the same call about
Valve's logo.

The drawing is a stopwatch, matching the lucide `Timer` the plugin puts on the
toolbar, so the thumbnail and the button are the same idea. Studio requires a
square PNG/WebP/JPEG between 64x64 and 512x512 and under 512 KB; this ships at
256x256 and about 6 KB. Render it at the size a list actually shows with
`node tools/make-icon.mjs icon.png --size=64`.

## Scripts

```bash
yarn build # bundle to dist/
yarn dev # same, unminified with sourcemaps
yarn test # vitest
yarn typecheck # tsc --noEmit
```
82 changes: 82 additions & 0 deletions plugins/wangzixu.wakatime-plugin/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Bundles each entry declared in manifest.json into dist/.
*
* Mirrors how NarraLeaf-Studio builds its own built-in plugins: one prebundled
* ESM file per entry, with the host modules left external. The host resolves
* `narraleaf-studio/*`, `react` and `react-dom` through an import map at load
* time — bundling them would produce a second, broken React instance.
*/

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";

const root = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.join(root, "dist");
const dev = process.argv.includes("--dev");

const EXTERNALS = [
"narraleaf-studio/plugin",
"narraleaf-studio/runtime",
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
"react/jsx-dev-runtime",
];

const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf-8"));

/** Map a declared entry (`main.js`) onto its source file (`src/main.ts`). */
function resolveSource(entry) {
const { name } = path.parse(entry);
for (const candidate of [`${name}.tsx`, `${name}.ts`, `${name}.jsx`, `${name}.js`]) {
const full = path.join(root, "src", candidate);
if (fs.existsSync(full)) {
return full;
}
}
throw new Error(`No source file found for declared entry "${entry}" (looked for src/${name}.{tsx,ts,jsx,js})`);
}

fs.rmSync(distDir, { recursive: true, force: true });
fs.mkdirSync(distDir, { recursive: true });

for (const target of ["studio", "runtime"]) {
const entry = manifest.entries?.[target];
if (typeof entry !== "string" || !entry.trim()) {
continue;
}
const outfile = path.join(distDir, entry);
fs.mkdirSync(path.dirname(outfile), { recursive: true });

await esbuild.build({
entryPoints: [resolveSource(entry)],
outfile,
bundle: true,
platform: "browser",
format: "esm",
target: ["chrome114"],
jsx: "automatic",
sourcemap: dev,
minify: !dev,
external: EXTERNALS,
});
console.log(`built ${target} -> dist/${entry}`);
}

// Studio reads manifest.json from the installed directory, so it ships too.
fs.copyFileSync(path.join(root, "manifest.json"), path.join(distDir, "manifest.json"));
console.log("copied manifest.json");

// Same for a declared icon (the starter has none — add `"icon": "icon.png"` to
// manifest.json and drop the file next to it). Studio refuses a package whose
// declared icon is missing, so anything the manifest points at ships too.
if (typeof manifest.icon === "string" && manifest.icon.trim()) {
const relative = manifest.icon.trim().split(/[\\/]+/);
const target = path.join(distDir, ...relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(root, ...relative), target);
console.log(`copied ${manifest.icon}`);
}
Binary file added plugins/wangzixu.wakatime-plugin/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions plugins/wangzixu.wakatime-plugin/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"manifestVersion": 2,
"id": "wangzixu.wakatime-plugin",
"name": "WakaTime",
"version": "1.0.0",
"description": "允许 NarraLeaf Studio 与 WakaTime 通讯,实时上报工作时间,努力可见(在命令面板中输入 WakaTime 以修改设置)",
"publisher": "WangZixu",
"icon": "icon.png",
"entries": {
"studio": "main.js"
},
"permissions": []
}
44 changes: 44 additions & 0 deletions plugins/wangzixu.wakatime-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@narraleaf-plugins/wakatime-plugin",
"version": "1.0.0",
"private": true,
"description": "允许 NarraLeaf Studio 与 WakaTime 通讯,实时上报工作时间,努力可见(在命令面板中输入 WakaTime 以修改设置)",
"license": "MPL-2.0",
"type": "module",
"keywords": [
"narraleaf",
"narraleaf-studio-plugin",
"wakatime",
"time-tracking",
"heartbeats",
"productivity"
],
"packageManager": "yarn@4.10.3",
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --dev",
"icon": "node tools/make-icon.mjs icon.png",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/react-dom": "^19.0.0",
"esbuild": "^0.25.0",
"narraleaf-studio": "^0.5.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"narraleaf": {
"categories": [
"integration",
"workflow"
],
"studioVersion": ">=0.5.0",
"locales": {
"en": {
"name": "WakaTime",
"description": "Lets NarraLeaf Studio talk to WakaTime, reporting the hours as you put them in — so the effort is visible. (Type WakaTime in the command palette to change the settings.)"
}
}
}
}
Loading
Loading