Skip to content
Open
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
10 changes: 10 additions & 0 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ All settings are available in the **Settings** panel of the Layla Server UI.

Layla Server proxies to any OpenAI-compatible endpoint. To use a different inference engine (LM Studio, Ollama, vLLM, etc.), just point the backend URL to it in Settings — no other changes needed.

### Choosing a Secret Generator

In Settings, **Secret Generator** offers the original 16-character alphanumeric
`Math.random()` secrets (the default) or secure 64-character hex secrets from
Node's `node:crypto`, accessed through Electron's preload bridge.
`Math.random()` is not cryptographically secure. Choosing a generator preserves
your current secret. Click **Generate New Secret**, then **Save** to replace it,
or enter your own secret in **Server Secret Key**. Restart the server and scan
the QR code again to test pairing with the saved secret.

---

## Using Your Own Models
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from "electron";
import * as path from "path";
import os from 'os';
import { randomBytes } from "node:crypto";
import { spawn, exec, ChildProcess } from "child_process";

let mainWindow: BrowserWindow | null = null;
ipcMain.handle("secret:generate-crypto", () => randomBytes(32).toString("hex"));
const isWindows = process.platform === "win32";


Expand Down Expand Up @@ -236,4 +238,4 @@ ipcMain.handle('dialog:alert', (_event, title: string, message: string) => {
message,
buttons: ['OK'],
});
});
});
3 changes: 2 additions & 1 deletion src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";

contextBridge.exposeInMainWorld("electronBridge", {
generateCryptoSecret: (): Promise<string> => ipcRenderer.invoke("secret:generate-crypto"),
getAppVersion: (): Promise<string> => ipcRenderer.invoke("get-app-version"),
openExternal: (url: string) => ipcRenderer.send("open-external", url),

Expand Down Expand Up @@ -78,4 +79,4 @@ contextBridge.exposeInMainWorld("electronBridge", {
* Show an alert dialog with a message and optional title.
*/
showAlert: (title: string, message: string) => ipcRenderer.invoke('dialog:alert', title, message),
});
});
4 changes: 4 additions & 0 deletions src/screens/LLMServerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,10 @@ const cssStyles = `
user-select: text;
font-family: 'Cascadia Mono', 'Fira Code', monospace;
letter-spacing: 1px;
/* Wrap long secrets at 16 chars per line (a 64-char hex secret shows as
4 rows): 16 glyphs + 16px letter-spacing + 20px padding, with 0.5ch slack. */
overflow-wrap: anywhere;
max-width: calc(16.5ch + 36px);
}
.modal-hint {
color: ${C.secondaryText};
Expand Down
73 changes: 70 additions & 3 deletions src/screens/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import UserSettingsService, {
UserSettingKey,
USER_SETTING_DEFAULTS,
generateSecretKey,
SecretGenerationMethod,
} from "../services/user-settings-service";

// ─── Theme ──────────────────────────────────────────────────────────────────────
Expand All @@ -28,7 +30,8 @@ interface SettingMeta {
key: UserSettingKey;
label: string;
description: string;
type: "text" | "file";
type: "text" | "file" | "select";
options?: { value: string; label: string }[];
placeholder?: string;
tag?: string;
}
Expand Down Expand Up @@ -68,10 +71,29 @@ const SETTING_SECTIONS: {
placeholder: "--threads 8 --ctx-size 4096",
tag: "CLI",
},
{
key: UserSettingKey.SECRET_GENERATION_METHOD,
label: "Secret Generator",
description:
"Choose how new secrets are generated. Changing this option keeps your current secret. Math.random() is not cryptographically secure.",
type: "select",
options: [
{
value: "crypto-hex",
label: "Node crypto — 64-character hex",
},
{
value: "math-random",
label: "Math.random() — 16-character secret (default)",
},
],
tag: "SECRET",
},
{
key: UserSettingKey.SERVER_SECRET_KEY,
label: "Server Secret Key",
description: "Secret key used to authenticate with the server.",
description:
"Enter a secret or generate one below, then Save. After saving a new secret, stop and start the server and scan the QR code again to test fresh pairing.",
type: "text",
placeholder: "Enter your server secret key",
tag: "SECRET",
Expand Down Expand Up @@ -547,13 +569,15 @@ interface SettingRowProps {
value: string;
onChange: (key: UserSettingKey, value: string) => void;
onClear: (key: UserSettingKey) => void;
onGenerate?: () => void;
}

const SettingRow: React.FC<SettingRowProps> = ({
meta,
value,
onChange,
onClear,
onGenerate,
}) => {
const [focused, setFocused] = useState(false);
const defaultValue = USER_SETTING_DEFAULTS[meta.key];
Expand Down Expand Up @@ -588,9 +612,25 @@ const SettingRow: React.FC<SettingRowProps> = ({

<div className="sp-setting-desc">{meta.description}</div>

{meta.type === "text" ? (
{meta.type === "select" ? (
<select
className="sp-input"
aria-label={meta.label}
value={value}
onChange={(e) => onChange(meta.key, e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
{meta.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : meta.type === "text" ? (
<input
className="sp-input"
aria-label={meta.label}
value={value}
onChange={(e) => onChange(meta.key, e.target.value)}
placeholder={meta.placeholder ?? ""}
Expand All @@ -616,6 +656,15 @@ const SettingRow: React.FC<SettingRowProps> = ({
<Btn label="Browse" onPress={pickFile} variant="outline" />
</div>
)}
{onGenerate && (
<div style={{ marginTop: 10 }}>
<Btn
label="Generate New Secret"
onPress={onGenerate}
variant="outline"
/>
</div>
)}
</div>
);
};
Expand Down Expand Up @@ -713,6 +762,19 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ onBack }) => {
[handleChange],
);

const handleGenerateSecret = useCallback(async () => {
if (!settings) return;
try {
const method = settings[
UserSettingKey.SECRET_GENERATION_METHOD
] as SecretGenerationMethod;
const secret = await generateSecretKey(method);
handleChange(UserSettingKey.SERVER_SECRET_KEY, secret);
} catch {
showToast("Failed to generate secret. Your current secret was kept.");
}
}, [settings, handleChange, showToast]);

const handleSave = useCallback(async () => {
if (!settings) return;
setSaving(true);
Expand Down Expand Up @@ -799,6 +861,11 @@ const SettingsPage: React.FC<SettingsPageProps> = ({ onBack }) => {
value={settings[item.key] ?? ""}
onChange={handleChange}
onClear={handleClear}
onGenerate={
item.key === UserSettingKey.SERVER_SECRET_KEY
? handleGenerateSecret
: undefined
}
/>
</React.Fragment>
))}
Expand Down
24 changes: 20 additions & 4 deletions src/services/user-settings-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
function generatePassword(length: number = 16): string {
export type SecretGenerationMethod = "crypto-hex" | "math-random";

function generateLegacySecretKey(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
return Array.from(
{ length: 16 },
() => chars[Math.floor(Math.random() * chars.length)],
).join("");
}

export async function generateSecretKey(
method: SecretGenerationMethod = "math-random",
): Promise<string> {
if (method === "math-random") return generateLegacySecretKey();
// Keep the existing stored option value while using Node crypto via IPC.
return window.electronBridge.generateCryptoSecret();
}

export enum UserSettingKey {
Expand All @@ -10,6 +23,7 @@ export enum UserSettingKey {
LOCAL_SERVER_PATH = 'local-server-path',
ADDITIONAL_SERVER_CMD_ARGS = 'additional-server-cmd-args',
SERVER_SECRET_KEY = 'server-secret-key',
SECRET_GENERATION_METHOD = 'secret-generation-method',
VERBOSE_SSE_LOGGING = 'verbose-sse-logging',
}

Expand All @@ -20,6 +34,7 @@ type UserSettingDefaults = {
[UserSettingKey.LOCAL_SERVER_PATH]: string;
[UserSettingKey.ADDITIONAL_SERVER_CMD_ARGS]: string | null;
[UserSettingKey.SERVER_SECRET_KEY]: string;
[UserSettingKey.SECRET_GENERATION_METHOD]: SecretGenerationMethod;
[UserSettingKey.VERBOSE_SSE_LOGGING]: boolean;
};

Expand All @@ -31,7 +46,8 @@ export const USER_SETTING_DEFAULTS: {
[UserSettingKey.VISION_MODEL_PATH]: null,
[UserSettingKey.LOCAL_SERVER_PATH]: './resources/server/llama-server.exe',
[UserSettingKey.ADDITIONAL_SERVER_CMD_ARGS]: null,
[UserSettingKey.SERVER_SECRET_KEY]: generatePassword(),
[UserSettingKey.SERVER_SECRET_KEY]: generateLegacySecretKey(),
[UserSettingKey.SECRET_GENERATION_METHOD]: "math-random",
// Logging every SSE chunk drives a React state update per token. Sustained,
// that grows renderer memory by hundreds of MB per long generation, so it is
// opt-in and intended only for debugging a stream.
Expand Down Expand Up @@ -116,4 +132,4 @@ class UserSettingsService {
}
}

export default UserSettingsService;
export default UserSettingsService;
3 changes: 2 additions & 1 deletion src/types/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface ElectronAPI {
generateCryptoSecret: () => Promise<string>;
getAppVersion: () => Promise<string>;
openExternal: (url: string) => void;
openFileDialog: () => Promise<string | null>;
Expand All @@ -20,4 +21,4 @@ declare global {
interface Window {
electronBridge: ElectronAPI;
}
}
}
115 changes: 115 additions & 0 deletions test/secret-generation.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Run with: node --test test/secret-generation.test.cjs
const assert = require("node:assert/strict");
const { randomBytes } = require("node:crypto");
const path = require("node:path");
const { test } = require("node:test");
const vm = require("node:vm");
const { buildSync } = require("esbuild");

const bundle = buildSync({
entryPoints: [path.join(__dirname, "../src/services/user-settings-service.ts")],
bundle: true,
platform: "browser",
format: "cjs",
write: false,
}).outputFiles[0].text;

function loadSettings(values = new Map(), bridge = {
generateCryptoSecret: async () => randomBytes(32).toString("hex"),
}) {
const context = {
module: { exports: {} },
window: { electronBridge: bridge },
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
},
};
vm.runInNewContext(bundle, context);
return context.module.exports;
}

test("fresh installs default to a persistent 16-character Math.random secret", async () => {
const values = new Map();
const { default: settings, UserSettingKey: key } = loadSettings(values);
const secret = await settings.getSetting(key.SERVER_SECRET_KEY);
assert.match(secret, /^[A-Za-z0-9]{16}$/);
assert.equal(await settings.getSetting(key.SECRET_GENERATION_METHOD), "math-random");
const reloaded = loadSettings(values);
assert.equal(await reloaded.default.getSetting(key.SERVER_SECRET_KEY), secret);
});

test("selecting and persisting a generator never replaces an existing secret", async () => {
const values = new Map([
["user-setting:server-secret-key", JSON.stringify("legacySecret1234")],
]);
const { default: settings, UserSettingKey: key } = loadSettings(values);
assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), "legacySecret1234");
await settings.saveSetting(key.SECRET_GENERATION_METHOD, "math-random");
const reloaded = loadSettings(values);
assert.equal(await reloaded.default.getSetting(key.SECRET_GENERATION_METHOD), "math-random");
assert.equal(await reloaded.default.getSetting(key.SERVER_SECRET_KEY), "legacySecret1234");
});

test("legacy generation uses Math.random and produces 16 alphanumeric characters", async () => {
let calls = 0;
const context = {
module: { exports: {} },

recordCall: () => calls++,
};
vm.createContext(context);
vm.runInContext("Math.random = () => { recordCall(); return 0.5; }", context);
vm.runInContext(bundle, context);
calls = 0;
const secret = await context.module.exports.generateSecretKey("math-random");
assert.match(secret, /^[A-Za-z0-9]{16}$/);
assert.equal(calls, 16);
});

test("generating a replacement leaves storage unchanged until explicitly saved", async () => {
const { default: settings, UserSettingKey: key, generateSecretKey } = loadSettings();
await settings.saveSetting(key.SERVER_SECRET_KEY, "manual-key");
for (const method of ["math-random", "crypto-hex"]) {
const replacement = await generateSecretKey(method);
assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), "manual-key");
await settings.saveSetting(key.SERVER_SECRET_KEY, replacement);
assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), replacement);
await settings.saveSetting(key.SERVER_SECRET_KEY, "manual-key");
}
});

test("secure generation failure never falls back to a legacy secret or changes storage", async () => {
const bridge = { generateCryptoSecret: async () => { throw new Error("random source unavailable"); } };
const { default: settings, UserSettingKey: key, generateSecretKey } = loadSettings(new Map(), bridge);
const original = await settings.getSetting(key.SERVER_SECRET_KEY);
await assert.rejects(generateSecretKey("crypto-hex"), /random source unavailable/);
assert.equal(await settings.getSetting(key.SERVER_SECRET_KEY), original);
});

test("secure generation uses the preload IPC bridge and Node crypto handler", async () => {
const handlers = new Map();
const bundleNode = (file) => buildSync({
entryPoints: [path.join(__dirname, '../src', file)],
bundle: true, platform: 'node', format: 'cjs', external: ['electron'], write: false,
}).outputFiles[0].text;
vm.runInNewContext(bundleNode('main.ts'), {
exports: {}, require: (name) => name === 'electron' ? {
app: { whenReady: () => new Promise(() => {}), on() {} },
ipcMain: { handle: (name, handler) => handlers.set(name, handler), on() {} },
} : require(name),
process, console, __dirname,
});
let bridge;
vm.runInNewContext(bundleNode('preload.ts'), {
require: (name) => name === 'electron' ? {
contextBridge: { exposeInMainWorld: (_name, api) => { bridge = api; } },
ipcRenderer: { invoke: async (name) => handlers.get(name)() },
} : require(name),
});
const { generateSecretKey } = loadSettings(new Map(), bridge);
const secret = await generateSecretKey('crypto-hex');
assert.match(secret, /^[0-9a-f]{64}$/);
assert.notEqual(await generateSecretKey('crypto-hex'), secret);
});