lucynui 0.0.0-stage → 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/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "lucynui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Desktop apps from plain HTML, CSS and JS. No React, no Rust, no Electron.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "module": "src/index.ts",
8
+ "bin": {
9
+ "lucyn": "./bin/lucyn.ts",
10
+ "lucynui": "./bin/lucyn.ts"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "cli/",
15
+ "src/",
16
+ "webview-bun/src/",
17
+ "webview-bun/build/"
18
+ ],
19
+ "scripts": {
20
+ "dev": "bun src/lucyn.ts --dev",
21
+ "lucynbuild": "bun src/build.ts",
22
+ "typecheck": "bun check",
23
+ "postinstall": "bun ./cli/postinstall.ts"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/LucyNBrowserEngine/lucynui.git"
28
+ },
29
+ "keywords": [
30
+ "bun",
31
+ "webview",
32
+ "desktop",
33
+ "html",
34
+ "css",
35
+ "javascript"
36
+ ],
37
+ "dependencies": {
38
+ "elysia": "^1.4.30"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest"
42
+ },
43
+ "peerDependencies": {
44
+ "typescript": "^7"
45
+ }
46
+ }
package/src/build.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { loadConfig } from "./config";
4
+
5
+ const root = resolve(process.cwd());
6
+ const entry = join(root, "app.ts");
7
+
8
+ if (!(await Bun.file(entry).exists())) {
9
+ console.error(`[LucyN UI] No app.ts in ${root}. Create one that calls createApp() and build again.`);
10
+ process.exit(1);
11
+ }
12
+
13
+ const { title } = await loadConfig(root);
14
+ const name = binaryName(title);
15
+ const outfile = join(root, "dist", name);
16
+
17
+ await mkdir(join(root, "dist"), { recursive: true });
18
+ console.log(`[LucyN UI] Building ./dist/${name} from app.ts`);
19
+
20
+ const build = Bun.spawn([process.execPath, "build", "--compile", "--minify", entry, "--outfile", outfile], {
21
+ stdout: "inherit",
22
+ stderr: "inherit",
23
+ });
24
+
25
+ const code = await build.exited;
26
+
27
+ if (code !== 0) {
28
+ console.error("[LucyN UI] Build failed.");
29
+ process.exit(code);
30
+ }
31
+
32
+ console.log(`[LucyN UI] Done — ./dist/${name}`);
33
+
34
+ function binaryName(title: string) {
35
+ return (
36
+ title
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9]+/g, "-")
39
+ .replace(/^-+|-+$/g, "") || "lucyn-app"
40
+ );
41
+ }
package/src/config.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { resolve } from "node:path";
2
+
3
+ /** Every field is optional — LucyN UI fills in smart defaults. */
4
+ export interface LucynConfig {
5
+ /** Window title. Default: "LucyN App" */
6
+ title?: string;
7
+ /** Window width. Default: 1024 */
8
+ width?: number;
9
+ /** Window height. Default: 768 */
10
+ height?: number;
11
+ /** Port for the local file server. Default: 3927 (falls back to the next free port) */
12
+ port?: number;
13
+ /** Enables hot reload and devtools. Default: false */
14
+ dev?: boolean;
15
+ }
16
+
17
+ /** Config with every field present. */
18
+ export type ResolvedConfig = Required<LucynConfig>;
19
+
20
+ export const DEFAULTS: ResolvedConfig = {
21
+ title: "LucyN App",
22
+ width: 1024,
23
+ height: 768,
24
+ port: 3927,
25
+ dev: false,
26
+ };
27
+
28
+ const CONFIG_FILE = "lucyn.config.ts";
29
+
30
+ /**
31
+ * Helper for `lucyn.config.ts` so editors give you autocomplete.
32
+ */
33
+ export function defineConfig(config: LucynConfig): LucynConfig {
34
+ return config;
35
+ }
36
+
37
+ /**
38
+ * Reads `lucyn.config.ts` from the project root. Missing file or bad config
39
+ * silently falls back to defaults.
40
+ */
41
+ export async function loadConfig(root: string = process.cwd()): Promise<ResolvedConfig> {
42
+ const path = resolve(root, CONFIG_FILE);
43
+
44
+ if (!(await Bun.file(path).exists())) return { ...DEFAULTS };
45
+
46
+ try {
47
+ const module = await import(path);
48
+ return { ...DEFAULTS, ...(module.default ?? {}) };
49
+ } catch (error) {
50
+ console.warn(`[LucyN UI] Could not read ${CONFIG_FILE}, using defaults.`);
51
+ console.warn(error);
52
+ return { ...DEFAULTS };
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { createApp } from "./lucyn";
2
+ export { defineConfig } from "./config";
3
+ export type { LucynConfig } from "./config";
4
+ export * from "../webview-bun/src/index";
5
+ export const LUCYN_VERSION = "0.1.0";
package/src/lucyn.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { resolve } from "node:path";
2
+ import { SizeHint, Webview } from "../webview-bun/src/webview";
3
+ import { DEFAULTS, loadConfig, type LucynConfig } from "./config";
4
+ import { errorPage, startServer, watchForChanges } from "./server";
5
+
6
+ const WINDOWS_NOTICE =
7
+ "You are on Windows. LucyN UI on Windows is not top grade as Windows builds differ from Linux where the framework was made and tested. If you encounter any bugs or build errors please dont hesitate to open an issue at the LucyN org. Thank you.";
8
+
9
+ /**
10
+ * Starts the file server, opens the native window and blocks until it closes.
11
+ *
12
+ * Reads `lucyn.config.ts` from the project root; anything passed here wins.
13
+ */
14
+ export async function createApp(config: LucynConfig = {}) {
15
+ if (process.platform === "win32") console.warn(WINDOWS_NOTICE);
16
+
17
+ const root = resolve(process.cwd());
18
+ const { title, width, height, port, dev } = { ...DEFAULTS, ...(await loadConfig(root)), ...config };
19
+
20
+ const server = await startServer(root, port);
21
+
22
+ console.log(dev ? `LucyN UI dev server running on http://localhost:${server.port}` : `LucyN UI running on http://localhost:${server.port}`);
23
+
24
+ const webview = new Webview(dev, { width, height, hint: SizeHint.NONE });
25
+ webview.title = title;
26
+
27
+ if (await Bun.file(resolve(root, "index.html")).exists()) {
28
+ webview.navigate(`http://localhost:${server.port}`);
29
+ } else {
30
+ webview.setHTML(
31
+ errorPage(
32
+ "Missing index.html",
33
+ "No index.html found",
34
+ `<p>LucyN UI looked for <code>${resolve(root, "index.html")}</code> but it isn't there.</p>
35
+ <p>Create an <code>index.html</code> in your project root — that's the page this window shows.</p>`,
36
+ ),
37
+ );
38
+ }
39
+
40
+ const watcher = dev ? watchForChanges(root, () => webview.eval("window.location.reload()")) : null;
41
+
42
+ webview.run();
43
+
44
+ watcher?.close();
45
+ await server.stop();
46
+ }
47
+
48
+ if (import.meta.main) {
49
+ await createApp(process.argv.includes("--dev") ? { dev: true } : {});
50
+ }
package/src/server.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { Elysia } from "elysia";
2
+ import { statSync, watch, type FSWatcher } from "node:fs";
3
+ import { extname, resolve, sep } from "node:path";
4
+
5
+ const MIME_TYPES: Record<string, string> = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".css": "text/css; charset=utf-8",
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".mjs": "text/javascript; charset=utf-8",
10
+ ".json": "application/json; charset=utf-8",
11
+ ".txt": "text/plain; charset=utf-8",
12
+ ".svg": "image/svg+xml",
13
+ ".png": "image/png",
14
+ ".jpg": "image/jpeg",
15
+ ".jpeg": "image/jpeg",
16
+ ".gif": "image/gif",
17
+ ".webp": "image/webp",
18
+ ".ico": "image/x-icon",
19
+ ".woff": "font/woff",
20
+ ".woff2": "font/woff2",
21
+ ".ttf": "font/ttf",
22
+ ".wasm": "application/wasm",
23
+ };
24
+
25
+ /** Only these files trigger a reload in dev mode. */
26
+ const RELOAD_EXTENSIONS = new Set([".html", ".css", ".js"]);
27
+ const RELOAD_DEBOUNCE_MS = 100;
28
+
29
+ /** Folders that should never trigger a reload. */
30
+ const IGNORED = /(^|[\\/])(node_modules|\.git|dist|out)([\\/]|$)/;
31
+
32
+ const PORT_ATTEMPTS = 100;
33
+
34
+ export interface LucynServer {
35
+ /** The port actually in use — may be higher than the requested one. */
36
+ port: number;
37
+ stop(): Promise<void>;
38
+ }
39
+
40
+ /**
41
+ * The LucyN branded error page used for 404s and other fatal problems.
42
+ */
43
+ export function errorPage(status: string, title: string, message: string) {
44
+ return `<!DOCTYPE html>
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="UTF-8" />
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
49
+ <title>${status} — LucyN UI</title>
50
+ <style>
51
+ :root { color-scheme: dark; }
52
+ * { box-sizing: border-box; }
53
+ body {
54
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
55
+ background: #0d0f14; color: #e8eaf0; padding: 2rem;
56
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
57
+ }
58
+ .card {
59
+ max-width: 42rem; padding: 2rem; border: 1px solid #232838;
60
+ border-radius: 14px; background: #141824;
61
+ }
62
+ .brand {
63
+ margin-bottom: 1.25rem; font-size: .75rem; letter-spacing: .22em;
64
+ text-transform: uppercase; color: #e0b34d;
65
+ }
66
+ h1 { margin: 0 0 .75rem; font-size: 1.4rem; }
67
+ p { margin: 0 0 .75rem; line-height: 1.65; color: #a3abbf; }
68
+ code { padding: .15rem .4rem; border-radius: 5px; background: #1d2231; color: #e8eaf0; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <div class="card">
73
+ <div class="brand">LucyN UI</div>
74
+ <h1>${title}</h1>
75
+ ${message}
76
+ </div>
77
+ </body>
78
+ </html>`;
79
+ }
80
+
81
+ /**
82
+ * Serves every file in `root` and keeps serving until the window closes.
83
+ * Falls back to `port + 1` while the requested port is taken.
84
+ */
85
+ export async function startServer(root: string, port: number): Promise<LucynServer> {
86
+ // `reusePort` would let a second app silently share a taken port, so the
87
+ // port-in-use fallback below would never trigger.
88
+ const app = new Elysia({ serve: { reusePort: false } }).get("/*", ({ params }) => serveFile(root, params["*"] ?? ""));
89
+
90
+ for (let offset = 0; offset < PORT_ATTEMPTS; offset++) {
91
+ try {
92
+ app.listen(port + offset);
93
+ return {
94
+ port: port + offset,
95
+ stop: async () => {
96
+ await app.stop();
97
+ },
98
+ };
99
+ } catch (error) {
100
+ if (!isPortInUse(error)) throw error;
101
+ }
102
+ }
103
+
104
+ throw new Error(`[LucyN UI] Ports ${port}-${port + PORT_ATTEMPTS - 1} are all in use.`);
105
+ }
106
+
107
+ /**
108
+ * Watches the project for changes to `.html`, `.css` and `.js` files and calls
109
+ * `onReload` at most once every 100ms.
110
+ */
111
+ export function watchForChanges(root: string, onReload: () => void): FSWatcher {
112
+ let timer: ReturnType<typeof setTimeout> | null = null;
113
+
114
+ return watch(root, { recursive: true }, (_event, filename) => {
115
+ if (filename) {
116
+ const name = String(filename);
117
+ if (IGNORED.test(name)) return;
118
+ if (!RELOAD_EXTENSIONS.has(extname(name).toLowerCase())) return;
119
+ }
120
+
121
+ if (timer) clearTimeout(timer);
122
+ timer = setTimeout(() => {
123
+ timer = null;
124
+ onReload();
125
+ }, RELOAD_DEBOUNCE_MS);
126
+ });
127
+ }
128
+
129
+ async function serveFile(root: string, urlPath: string) {
130
+ const relative = decodeURIComponent(urlPath).replace(/^\/+/, "");
131
+ const target = resolve(root, relative || "index.html");
132
+
133
+ if (target !== root && !target.startsWith(root + sep)) {
134
+ return new Response(errorPage("403", "Not allowed", "<p>That path lives outside the project root.</p>"), {
135
+ status: 403,
136
+ headers: { "Content-Type": "text/html; charset=utf-8" },
137
+ });
138
+ }
139
+
140
+ const stats = statSync(target, { throwIfNoEntry: false });
141
+ if (!stats?.isFile()) {
142
+ return new Response(
143
+ errorPage(
144
+ "404",
145
+ "File not found",
146
+ `<p>Nothing at <code>/${relative || "index.html"}</code>.</p>
147
+ <p>LucyN UI serves every file inside <code>${root}</code>, so check the path or create the file.</p>`,
148
+ ),
149
+ { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } },
150
+ );
151
+ }
152
+
153
+ return new Response(Bun.file(target), {
154
+ headers: { "Content-Type": MIME_TYPES[extname(target).toLowerCase()] ?? "application/octet-stream" },
155
+ });
156
+ }
157
+
158
+ function isPortInUse(error: unknown) {
159
+ if (typeof error !== "object" || error === null) return false;
160
+ if ((error as { code?: string }).code === "EADDRINUSE") return true;
161
+ return /port .*in use|address already in use/i.test((error as Error).message ?? "");
162
+ }
Binary file
@@ -0,0 +1,90 @@
1
+ import { dlopen, FFIType, ptr } from "bun:ffi";
2
+ import { Webview } from "./webview";
3
+
4
+ export function encodeCString(value: string) {
5
+ return ptr(new TextEncoder().encode(value + "\0"));
6
+ }
7
+
8
+ export const instances: Webview[] = [];
9
+
10
+ /**
11
+ * Unload the library and destroy all webview instances. Should only be run
12
+ * once all windows are closed.
13
+ */
14
+ export function unload() {
15
+ for (const instance of instances) instance.destroy();
16
+ lib.close();
17
+ }
18
+
19
+ let lib_file;
20
+
21
+ if (process.env.WEBVIEW_PATH) {
22
+ lib_file = { default: process.env.WEBVIEW_PATH };
23
+ } else if (process.platform === "win32") {
24
+ //@ts-expect-error
25
+ lib_file = await import("../build/libwebview.dll");
26
+ } else if (process.platform === "linux") {
27
+ lib_file = await import(`../build/libwebview-${process.arch}.so`);
28
+ } else if (process.platform === "darwin") {
29
+ //@ts-expect-error
30
+ lib_file = await import("../build/libwebview.dylib");
31
+ }
32
+
33
+ export const lib = dlopen(lib_file.default, {
34
+ webview_create: {
35
+ args: [FFIType.i32, FFIType.ptr],
36
+ returns: FFIType.ptr,
37
+ },
38
+ webview_destroy: {
39
+ args: [FFIType.ptr],
40
+ returns: FFIType.void,
41
+ },
42
+ webview_run: {
43
+ args: [FFIType.ptr],
44
+ returns: FFIType.void,
45
+ },
46
+ webview_terminate: {
47
+ args: [FFIType.ptr],
48
+ returns: FFIType.void,
49
+ },
50
+ webview_get_window: {
51
+ args: [FFIType.ptr],
52
+ returns: FFIType.ptr,
53
+ },
54
+ webview_set_title: {
55
+ args: [FFIType.ptr, FFIType.ptr],
56
+ returns: FFIType.void,
57
+ },
58
+ webview_set_size: {
59
+ args: [FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32],
60
+ returns: FFIType.void,
61
+ },
62
+ webview_navigate: {
63
+ args: [FFIType.ptr, FFIType.ptr],
64
+ returns: FFIType.void,
65
+ },
66
+ webview_set_html: {
67
+ args: [FFIType.ptr, FFIType.ptr],
68
+ returns: FFIType.void,
69
+ },
70
+ webview_init: {
71
+ args: [FFIType.ptr, FFIType.ptr],
72
+ returns: FFIType.void,
73
+ },
74
+ webview_eval: {
75
+ args: [FFIType.ptr, FFIType.ptr],
76
+ returns: FFIType.void,
77
+ },
78
+ webview_bind: {
79
+ args: [FFIType.ptr, FFIType.ptr, FFIType.function, FFIType.ptr],
80
+ returns: FFIType.void,
81
+ },
82
+ webview_unbind: {
83
+ args: [FFIType.ptr, FFIType.ptr],
84
+ returns: FFIType.void,
85
+ },
86
+ webview_return: {
87
+ args: [FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.ptr],
88
+ returns: FFIType.void,
89
+ },
90
+ });
@@ -0,0 +1,3 @@
1
+ export { unload } from "./ffi";
2
+ export * from "./webview";
3
+ export const WEBVIEW_VERSION = "0.12.0";