notchapp 0.1.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 +72 -0
- package/bin/notch.js +3 -0
- package/cli.mjs +324 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# notchapp
|
|
2
|
+
|
|
3
|
+
CLI for developing and building NotchApp widgets.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev notchapp
|
|
9
|
+
npm install @notchapp/api
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Inside a widget package:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx notchapp dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This starts the hot-reload workflow:
|
|
21
|
+
|
|
22
|
+
- builds the widget into `.notch/build/index.cjs`
|
|
23
|
+
- registers the local widget with NotchApp for development
|
|
24
|
+
- rebuilds when `package.json`, `src/`, or `assets/` change
|
|
25
|
+
- tells NotchApp to reload the widget after each successful build
|
|
26
|
+
|
|
27
|
+
Other commands:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx notchapp build
|
|
31
|
+
npx notchapp lint
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Widget Shape
|
|
35
|
+
|
|
36
|
+
A widget package needs:
|
|
37
|
+
|
|
38
|
+
- `package.json`
|
|
39
|
+
- `src/index.tsx`
|
|
40
|
+
- a `notch` manifest in `package.json`
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"name": "@acme/notchapp-widget-hello",
|
|
47
|
+
"private": true,
|
|
48
|
+
"scripts": {
|
|
49
|
+
"dev": "notchapp dev",
|
|
50
|
+
"build": "notchapp build",
|
|
51
|
+
"lint": "notchapp lint"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"notchapp": "^0.1.0"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@notchapp/api": "^0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"notch": {
|
|
60
|
+
"id": "com.acme.hello",
|
|
61
|
+
"title": "Hello",
|
|
62
|
+
"icon": "sparkles",
|
|
63
|
+
"minSpan": 3,
|
|
64
|
+
"maxSpan": 6,
|
|
65
|
+
"entry": "src/index.tsx"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
NotchApp itself currently runs on macOS. The SDK source and examples live in the main repository:
|
|
71
|
+
|
|
72
|
+
<https://github.com/itstauq/NotchApp>
|
package/bin/notch.js
ADDED
package/cli.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import esbuild from "esbuild";
|
|
7
|
+
|
|
8
|
+
const command = process.argv[2];
|
|
9
|
+
const packageDir = process.cwd();
|
|
10
|
+
const canonicalWidgetsRoot = path.join(
|
|
11
|
+
os.homedir(),
|
|
12
|
+
"Library",
|
|
13
|
+
"Application Support",
|
|
14
|
+
"NotchApp",
|
|
15
|
+
"Widgets",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function packageRootFromMeta(metaURL) {
|
|
19
|
+
return path.resolve(path.dirname(new URL(metaURL).pathname), "..", "..");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function developmentWidgetsRoot() {
|
|
23
|
+
const workspaceRoot = packageRootFromMeta(import.meta.url);
|
|
24
|
+
return path.join(workspaceRoot, "widgets");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readManifest(targetPackageDir) {
|
|
28
|
+
const manifestPath = path.join(targetPackageDir, "package.json");
|
|
29
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
30
|
+
const notch = manifest.notch ?? {};
|
|
31
|
+
|
|
32
|
+
if (!notch.id || !notch.title) {
|
|
33
|
+
throw new Error(`Invalid notch manifest in ${manifestPath}: missing id/title`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
!Number.isInteger(notch.minSpan) ||
|
|
38
|
+
!Number.isInteger(notch.maxSpan) ||
|
|
39
|
+
notch.minSpan <= 0 ||
|
|
40
|
+
notch.maxSpan < notch.minSpan
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`Invalid span range in ${manifestPath}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const entryFile = path.join(targetPackageDir, notch.entry ?? "src/index.tsx");
|
|
46
|
+
if (!fs.existsSync(entryFile)) {
|
|
47
|
+
throw new Error(`Missing widget entry file at ${entryFile}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { manifest, manifestPath, entryFile };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ensureCanonicalSymlink(targetPackageDir, manifest) {
|
|
54
|
+
fs.mkdirSync(canonicalWidgetsRoot, { recursive: true });
|
|
55
|
+
|
|
56
|
+
const widgetID = manifest.notch.id;
|
|
57
|
+
const linkPath = path.join(canonicalWidgetsRoot, widgetID);
|
|
58
|
+
const sourcePath = fs.realpathSync.native(targetPackageDir);
|
|
59
|
+
const linkStats = safeLstat(linkPath);
|
|
60
|
+
|
|
61
|
+
if (linkStats) {
|
|
62
|
+
if (!linkStats.isSymbolicLink()) {
|
|
63
|
+
throw new Error(`Cannot replace non-symlink widget install at ${linkPath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existingTarget = safeRealpath(linkPath);
|
|
67
|
+
if (existingTarget == null) {
|
|
68
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
69
|
+
fs.symlinkSync(sourcePath, linkPath, "dir");
|
|
70
|
+
return linkPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (existingTarget === sourcePath) {
|
|
74
|
+
return linkPath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!shouldReplaceSymlink(existingTarget)) {
|
|
78
|
+
throw new Error(`Refusing to replace unmanaged widget install at ${linkPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fs.symlinkSync(sourcePath, linkPath, "dir");
|
|
85
|
+
return linkPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeLstat(targetPath) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.lstatSync(targetPath);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === "ENOENT") {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function safeRealpath(targetPath) {
|
|
100
|
+
try {
|
|
101
|
+
return fs.realpathSync.native(targetPath);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error?.code === "ENOENT") {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shouldReplaceSymlink(targetPath) {
|
|
111
|
+
return (
|
|
112
|
+
targetPath.startsWith(developmentWidgetsRoot()) ||
|
|
113
|
+
targetPath.includes("/Contents/Resources/WidgetRuntime/widgets/")
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectWatchedFiles(targetPath) {
|
|
118
|
+
if (!fs.existsSync(targetPath)) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stats = fs.statSync(targetPath);
|
|
123
|
+
if (stats.isFile()) {
|
|
124
|
+
return [targetPath];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!stats.isDirectory()) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const files = [];
|
|
132
|
+
for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {
|
|
133
|
+
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".notch") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const entryPath = path.join(targetPath, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
files.push(...collectWatchedFiles(entryPath));
|
|
140
|
+
} else if (entry.isFile()) {
|
|
141
|
+
files.push(entryPath);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function snapshotWatchedFiles(watchRoots) {
|
|
149
|
+
const snapshot = new Map();
|
|
150
|
+
|
|
151
|
+
for (const watchRoot of watchRoots) {
|
|
152
|
+
for (const filePath of collectWatchedFiles(watchRoot)) {
|
|
153
|
+
const stats = fs.statSync(filePath);
|
|
154
|
+
snapshot.set(filePath, `${stats.size}:${stats.mtimeMs}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return snapshot;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function snapshotsDiffer(previousSnapshot, nextSnapshot) {
|
|
162
|
+
if (previousSnapshot.size !== nextSnapshot.size) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const [filePath, signature] of nextSnapshot) {
|
|
167
|
+
if (previousSnapshot.get(filePath) !== signature) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function notifyApp(event, widgetID, info = "") {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
const query = new URLSearchParams({ cwd: process.cwd() });
|
|
178
|
+
if (info) {
|
|
179
|
+
query.set("info", info);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const url = `notch://cli/${encodeURIComponent(widgetID)}/${event}?${query.toString()}`;
|
|
183
|
+
const child = spawn("open", [url], {
|
|
184
|
+
detached: true,
|
|
185
|
+
stdio: "ignore",
|
|
186
|
+
});
|
|
187
|
+
child.unref();
|
|
188
|
+
resolve();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function buildWidget(targetPackageDir, options = {}) {
|
|
193
|
+
const { manifest, entryFile } = readManifest(targetPackageDir);
|
|
194
|
+
const { registerCanonicalInstall = false } = options;
|
|
195
|
+
if (registerCanonicalInstall) {
|
|
196
|
+
ensureCanonicalSymlink(targetPackageDir, manifest);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const notch = manifest.notch;
|
|
200
|
+
const outputDir = path.join(targetPackageDir, ".notch", "build");
|
|
201
|
+
const outfile = path.join(outputDir, "index.cjs");
|
|
202
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
203
|
+
|
|
204
|
+
await esbuild.build({
|
|
205
|
+
entryPoints: [entryFile],
|
|
206
|
+
outfile,
|
|
207
|
+
bundle: true,
|
|
208
|
+
platform: "node",
|
|
209
|
+
format: "cjs",
|
|
210
|
+
target: "node22",
|
|
211
|
+
jsx: "automatic",
|
|
212
|
+
jsxImportSource: "@notchapp/api",
|
|
213
|
+
sourcemap: "inline",
|
|
214
|
+
logLevel: "silent",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
console.log(`Built ${notch.id} -> ${outfile}`);
|
|
218
|
+
return manifest;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function lintWidget(targetPackageDir) {
|
|
222
|
+
readManifest(targetPackageDir);
|
|
223
|
+
console.log(`Validated ${targetPackageDir}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function developWidget(targetPackageDir) {
|
|
227
|
+
const initial = readManifest(targetPackageDir);
|
|
228
|
+
ensureCanonicalSymlink(targetPackageDir, initial.manifest);
|
|
229
|
+
await notifyApp("start", initial.manifest.notch.id);
|
|
230
|
+
const watchRoots = ["package.json", "src", "assets"]
|
|
231
|
+
.map((relativePath) => path.join(targetPackageDir, relativePath))
|
|
232
|
+
.filter((targetPath) => fs.existsSync(targetPath));
|
|
233
|
+
|
|
234
|
+
let buildInFlight = false;
|
|
235
|
+
let buildQueued = false;
|
|
236
|
+
let debounceTimer;
|
|
237
|
+
let pollTimer;
|
|
238
|
+
let lastSnapshot = snapshotWatchedFiles(watchRoots);
|
|
239
|
+
|
|
240
|
+
const scheduleBuild = () => {
|
|
241
|
+
clearTimeout(debounceTimer);
|
|
242
|
+
debounceTimer = setTimeout(() => {
|
|
243
|
+
void runBuild();
|
|
244
|
+
}, 150);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const runBuild = async () => {
|
|
248
|
+
if (buildInFlight) {
|
|
249
|
+
buildQueued = true;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
buildInFlight = true;
|
|
254
|
+
try {
|
|
255
|
+
const manifest = await buildWidget(targetPackageDir, {
|
|
256
|
+
registerCanonicalInstall: true,
|
|
257
|
+
});
|
|
258
|
+
await notifyApp("build-success", manifest.notch.id);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const manifest = safeReadManifest(targetPackageDir);
|
|
261
|
+
const widgetID = manifest?.notch.id ?? path.basename(targetPackageDir);
|
|
262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
+
await notifyApp("build-failure", widgetID, message);
|
|
264
|
+
console.error(message);
|
|
265
|
+
} finally {
|
|
266
|
+
buildInFlight = false;
|
|
267
|
+
if (buildQueued) {
|
|
268
|
+
buildQueued = false;
|
|
269
|
+
scheduleBuild();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const stop = () => {
|
|
275
|
+
void (async () => {
|
|
276
|
+
clearTimeout(debounceTimer);
|
|
277
|
+
if (pollTimer) {
|
|
278
|
+
clearInterval(pollTimer);
|
|
279
|
+
}
|
|
280
|
+
await notifyApp("stop", initial.manifest.notch.id);
|
|
281
|
+
process.exit(0);
|
|
282
|
+
})();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP"]) {
|
|
286
|
+
process.once(signal, stop);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await runBuild();
|
|
290
|
+
|
|
291
|
+
pollTimer = setInterval(() => {
|
|
292
|
+
const nextSnapshot = snapshotWatchedFiles(watchRoots);
|
|
293
|
+
if (!snapshotsDiffer(lastSnapshot, nextSnapshot)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lastSnapshot = nextSnapshot;
|
|
298
|
+
scheduleBuild();
|
|
299
|
+
}, 250);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function safeReadManifest(targetPackageDir) {
|
|
303
|
+
try {
|
|
304
|
+
return readManifest(targetPackageDir).manifest;
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
switch (command) {
|
|
311
|
+
case "build":
|
|
312
|
+
await buildWidget(packageDir);
|
|
313
|
+
break;
|
|
314
|
+
case "lint":
|
|
315
|
+
await lintWidget(packageDir);
|
|
316
|
+
break;
|
|
317
|
+
case "develop":
|
|
318
|
+
case "dev":
|
|
319
|
+
await developWidget(packageDir);
|
|
320
|
+
break;
|
|
321
|
+
default:
|
|
322
|
+
console.error("Usage: notch <build|develop|dev|lint>");
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notchapp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for developing and building NotchApp widgets",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"notchapp": "./bin/notch.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"cli.mjs"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"notchapp",
|
|
15
|
+
"widgets",
|
|
16
|
+
"macos",
|
|
17
|
+
"cli",
|
|
18
|
+
"productivity"
|
|
19
|
+
],
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/itstauq/NotchApp.git",
|
|
24
|
+
"directory": "sdk/packages/notchapp"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/itstauq/NotchApp#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/itstauq/NotchApp/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"esbuild": "^0.25.10"
|
|
38
|
+
}
|
|
39
|
+
}
|