Skip to content
Closed
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
66 changes: 33 additions & 33 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,8 @@ ipcMain.handle('luna:probe-weather', async (_event, raw: ProviderFields) =>
);

ipcMain.handle('luna:onboarding-submit', async (_event, raw: OnboardingFields): Promise<ProbeVerdict> => {
if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' };
if (onboardingInFlight) return { ok: false, error: 'Setup already in progress…' };
if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' };
if (onboardingInFlight) return { ok: false, error: '配置已经在进行中…' };
onboardingInFlight = true;
try {
const baseUrl = asStr(raw?.baseUrl);
Expand All @@ -497,7 +497,7 @@ ipcMain.handle('luna:onboarding-submit', async (_event, raw: OnboardingFields):
// Apply the keys live: re-spawn the sidecar against the new env (it may never have started).
supervisor?.restart(sidecarEnv(paths, parseEnvFile(merged)));
const up = await waitForPort(SERVER_PORT);
if (!up) return { ok: false, error: 'Saved, but the server did not start. Check the logs.' };
if (!up) return { ok: false, error: '已经保存,但服务没有启动。请检查日志。' };
// Swap the setup window for the real app window (createWindow reads the resolved petMode).
const fresh = createWindow('app');
for (const w of BrowserWindow.getAllWindows()) if (w !== fresh) w.close();
Expand Down Expand Up @@ -537,7 +537,7 @@ ipcMain.on('luna:open-setup', () => {
// if the renderer is a white screen. Standard roles keep copy/paste/devtools working.
function installAppMenu(): void {
const setupItem = {
label: 'Setup Wizard… / 重新配置',
label: '配置向导… / 重新配置',
accelerator: 'CmdOrCtrl+,',
click: (): void => openSetupWindow(),
};
Expand All @@ -547,7 +547,7 @@ function installAppMenu(): void {
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' },
{ label: 'Setup', submenu: [setupItem] },
{ label: '设置', submenu: [setupItem] },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
Expand All @@ -557,15 +557,15 @@ function installAppMenu(): void {
// are present (a bad key is never persisted, v0.28.0 rule), ONE luna.env merge + ONE sidecar
// restart at the end. Values ride this one direction; the verdict never echoes them.
ipcMain.handle('luna:wizard-submit', async (_event, raw: unknown): Promise<ProbeVerdict> => {
if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' };
if (onboardingInFlight) return { ok: false, error: 'Setup already in progress…' };
if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' };
if (onboardingInFlight) return { ok: false, error: '配置已经在进行中…' };
onboardingInFlight = true;
try {
const fields = filterWizardFields(raw);
const baseUrl = fields['ANTHROPIC_BASE_URL'];
const apiKey = fields['ANTHROPIC_API_KEY'];
if (baseUrl !== undefined || apiKey !== undefined) {
if (!baseUrl || !apiKey) return { ok: false, error: 'Enter a base URL and an API key.' };
if (!baseUrl || !apiKey) return { ok: false, error: '请填写接口地址和 API 密钥。' };
const verdict = await probeConnection(baseUrl, apiKey, fields['LUNA_MODEL'] ?? '');
if (!verdict.ok) return verdict;
}
Expand All @@ -577,7 +577,7 @@ ipcMain.handle('luna:wizard-submit', async (_event, raw: unknown): Promise<Probe
if (!attachedToExternal) {
supervisor?.restart(sidecarEnv(paths, parseEnvFile(merged)));
const up = await waitForPort(SERVER_PORT);
if (!up) return { ok: false, error: 'Saved, but the server did not start. Check the logs.' };
if (!up) return { ok: false, error: '已经保存,但服务没有启动。请检查日志。' };
}
const fresh = createWindow('app');
for (const w of BrowserWindow.getAllWindows()) if (w !== fresh) w.close();
Expand All @@ -600,7 +600,7 @@ function reloadAppWindows(): void {
}

function installModelAndReload(src: string): { ok: boolean; modelUrl?: string; error?: string } {
if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' };
if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' };
const result = installModelFolder(src, { modelsDir: paths.userModelsDir, envFile: paths.envFile });
if (result.ok) {
for (const w of BrowserWindow.getAllWindows()) {
Expand All @@ -613,19 +613,19 @@ function installModelAndReload(src: string): { ok: boolean; modelUrl?: string; e
// "Choose model folder…": pick a Live2D model dir via the native dialog.
ipcMain.handle('luna:choose-model', async (): Promise<{ ok: boolean; modelUrl?: string; error?: string }> => {
const picked = dialog.showOpenDialogSync({
title: 'Choose a Live2D model folder',
title: '选择 Live2D 模型文件夹',
properties: ['openDirectory'],
});
const src = picked?.[0];
if (!src) return { ok: false, error: 'cancelled' };
if (!src) return { ok: false, error: '已取消' };
return installModelAndReload(src);
});

// v0.35.2: the wizard drop zone — the preload resolves the dropped File to a real path
// (webUtils.getPathForFile) and only the path string crosses IPC.
ipcMain.handle('luna:install-model-path', async (_event, raw: unknown) => {
const src = asStr(raw);
if (!src) return { ok: false, error: 'No folder received.' };
if (!src) return { ok: false, error: '所选文件夹不存在。' };
return installModelAndReload(src);
});

Expand All @@ -634,7 +634,7 @@ ipcMain.handle('luna:install-model-path', async (_event, raw: unknown) => {
ipcMain.handle('luna:scan-voice-pack', async (_event, raw: unknown) => {
const root = asStr(raw);
if (!root || !existsSync(root) || !statSync(root).isDirectory())
return { ok: false, error: 'That is not a folder.' };
return { ok: false, error: '这不是一个文件夹。' };
const scan = scanVoicePack(root);
const valid = validateVoicePack(scan);
if (!valid.ok) return valid;
Expand All @@ -653,19 +653,19 @@ ipcMain.handle('luna:scan-voice-pack', async (_event, raw: unknown) => {

ipcMain.handle('luna:choose-tts-runtime', async () => {
const picked = dialog.showOpenDialogSync({
title: 'Choose your GPT-SoVITS folder',
title: '选择 GPT-SoVITS 文件夹',
properties: ['openDirectory'],
});
const dir = picked?.[0];
if (!dir) return { ok: false, error: 'cancelled' };
if (!dir) return { ok: false, error: '已取消' };
const check = validateRuntimeDir(dir);
if (!check.ok) return check;
return { ok: true, dir, venv: !!check.venvPython };
});

type VoiceInstallRaw = Record<string, unknown>;
ipcMain.handle('luna:install-voice-pack', async (_event, raw: VoiceInstallRaw) => {
if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' };
if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' };
const root = asStr(raw?.['root']);
const picks = {
gptCkpt: asStr(raw?.['gptCkpt']),
Expand All @@ -676,7 +676,7 @@ ipcMain.handle('luna:install-voice-pack', async (_event, raw: VoiceInstallRaw) =
// The picks must come from the scanned pack — a stray absolute path can't smuggle files in.
const inRoot = (p: string): boolean => p === '' || p.startsWith(root.endsWith(sep) ? root : root + sep);
if (!root || !inRoot(picks.gptCkpt) || !inRoot(picks.sovitsPth) || !inRoot(picks.referenceWav))
return { ok: false, error: 'Picked files must come from the dropped folder — re-scan it.' };
return { ok: false, error: '所选文件必须来自刚才拖入的文件夹——请重新扫描。' };
const installed = installVoicePack(root, picks, {
ttsDir: join(paths.userData, 'tts'),
envFile: paths.envFile,
Expand Down Expand Up @@ -774,16 +774,16 @@ function firstFileByExt(dir: string, ext: string): string | undefined {
// discipline as the wizard finish, minus its restart-and-swap. A .bak of the previous file lands
// first, so a bad save is one copy away from undone.
ipcMain.handle('luna:save-config', (_e, raw: unknown) => {
if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' };
if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' };
const fields = filterWizardFields(raw);
if (Object.keys(fields).length === 0) return { ok: false, error: 'Nothing to save.' };
if (Object.keys(fields).length === 0) return { ok: false, error: '没有需要保存的内容。' };
try {
const current = readFileSync(paths.envFile, 'utf8');
writeFileSync(`${paths.envFile}.bak`, current);
writeFileSync(paths.envFile, mergeEnvFile(current, fields));
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'write failed' };
return { ok: false, error: err instanceof Error ? err.message : '写入失败。' };
}
});

Expand All @@ -806,13 +806,13 @@ ipcMain.handle('luna:provision-status', () => {
return { ...provisionStatus, inFlight: provisionInFlight };
});
ipcMain.handle('luna:provision-start', () => {
if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' };
if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' };
const p = paths;
const env = freshUserEnv(p);
// v0.37.9: ON by default. A one-click install that first needs you to hand-edit a config file is
// not one-click — it is the terminal step this initiative exists to delete, wearing a disguise.
// LUNA_TTS_PROVISION=0 is the opt-OUT.
if (env['LUNA_TTS_PROVISION'] === '0') return { ok: false, error: 'One-click deploy is disabled (LUNA_TTS_PROVISION=0).' };
if (env['LUNA_TTS_PROVISION'] === '0') return { ok: false, error: '一键部署已关闭(LUNA_TTS_PROVISION=0)。' };
if (provisionInFlight) return { ok: true };
provisionInFlight = true;
const ttsDir = join(p.userData, 'tts');
Expand Down Expand Up @@ -887,7 +887,7 @@ async function smokeProbe(win: BrowserWindow): Promise<void> {
// actually rendered inside the packaged shell (the desktop-specific wiring a page probe misses).
document.querySelector('.settings-panel')?.classList.add('on');
const petInput = [...document.querySelectorAll('.settings-panel label')]
.find((l) => l.textContent.includes('Desktop pet'))?.querySelector('input');
.find((l) => l.textContent.includes('Desktop pet') || l.textContent.includes('桌面宠物'))?.querySelector('input');
// v0.44.2: the data surface, THROUGH the same-origin forward — proves serve.ts → sidecar.
const dataStatus = await fetch('/api/data/diaries').then((r) => r.status).catch(() => 0);
return JSON.stringify({
Expand Down Expand Up @@ -1052,9 +1052,9 @@ void app.whenReady().then(async () => {
if (!SMOKE)
dialog.showMessageBoxSync({
type: 'error',
message: 'Luna is already running',
detail: `The local web host port (${DESKTOP_WEB_PORT}) is in use — another Luna window likely has it. Close it and try again.`,
buttons: ['Close'],
message: 'Luna 已经在运行',
detail: `本地网页服务端口(${DESKTOP_WEB_PORT})已被占用——可能已经打开了另一个 Luna 窗口。请关闭它后重试。`,
buttons: ['关闭'],
});
app.quit();
},
Expand Down Expand Up @@ -1156,9 +1156,9 @@ void app.whenReady().then(async () => {
if (!up) {
const choice = dialog.showMessageBoxSync({
type: 'warning',
message: 'Luna\'s dev stack did not start',
detail: `No response on 127.0.0.1:${SERVER_PORT}. Check that bun + the repo are present (or set LUNA_BUN_PATH in ${p.envFile}).`,
buttons: ['Open Setup', 'Close'],
message: 'Luna 开发服务没有启动',
detail: `127.0.0.1:${SERVER_PORT} 没有响应。请确认 Bun 和项目目录存在(或在 ${p.envFile} 中设置 LUNA_BUN_PATH)。`,
buttons: ['打开设置', '关闭'],
defaultId: 0,
});
if (choice === 0) {
Expand All @@ -1180,9 +1180,9 @@ void app.whenReady().then(async () => {
// to the wizard right here instead of pointing at a file path.
const choice = dialog.showMessageBoxSync({
type: 'warning',
message: 'Luna\'s server did not start',
detail: `No response on 127.0.0.1:${SERVER_PORT}. Check ${p.envFile} and the logs — or re-run the setup wizard to fix the configuration.`,
buttons: ['Open Setup', 'Close'],
message: 'Luna 服务没有启动',
detail: `127.0.0.1:${SERVER_PORT} 没有响应。请检查 ${p.envFile} 和日志,或重新运行配置向导修复配置。`,
buttons: ['打开设置', '关闭'],
defaultId: 0,
});
if (choice === 0) {
Expand Down
14 changes: 11 additions & 3 deletions packages/desktop/src/modelInstall.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import {
mkdtempSync,
mkdirSync,
readdirSync,
rmSync,
writeFileSync,
existsSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installModelFolder, resolveModelDir } from './modelInstall';
Expand Down Expand Up @@ -65,7 +73,7 @@ describe('installModelFolder (v0.35.2)', () => {
writeFileSync(join(src, 'readme.txt'), 'hi');
const opts = setup();
const r = installModelFolder(src, opts);
expect(r).toEqual({ ok: false, error: 'No .model3.json found in that folder.' });
expect(r).toEqual({ ok: false, error: '这个文件夹里没有找到 .model3.json' });
expect(readdirSync(opts.modelsDir)).toEqual([]);
expect(parseEnvFile(readFileSync(opts.envFile, 'utf8'))['LUNA_MODEL_URL']).toBe('');
});
Expand All @@ -75,7 +83,7 @@ describe('installModelFolder (v0.35.2)', () => {
const file = join(base, 'model.zip');
writeFileSync(file, 'zip');
const r = installModelFolder(file, setup());
expect(r).toEqual({ ok: false, error: 'That is not a folder.' });
expect(r).toEqual({ ok: false, error: '这不是一个文件夹。' });
});

test('copy-only contract: the source folder is untouched after install', () => {
Expand Down
9 changes: 6 additions & 3 deletions packages/desktop/src/modelInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ export function installModelFolder(
opts: { modelsDir: string; envFile: string },
): ModelInstallResult {
if (!existsSync(src) || !statSync(src).isDirectory()) {
return { ok: false, error: 'That is not a folder.' };
return { ok: false, error: '这不是一个文件夹。' };
}
const resolved = resolveModelDir(src);
if (!resolved) return { ok: false, error: 'No .model3.json found in that folder.' };
if (!resolved) return { ok: false, error: '这个文件夹里没有找到 .model3.json' };
const name = basename(resolved.dir);
cpSync(resolved.dir, join(opts.modelsDir, name), { recursive: true });
const modelUrl = `/models/${name}/${resolved.manifest}`;
writeFileSync(opts.envFile, mergeEnvFile(readFileSync(opts.envFile, 'utf8'), { LUNA_MODEL_URL: modelUrl }));
writeFileSync(
opts.envFile,
mergeEnvFile(readFileSync(opts.envFile, 'utf8'), { LUNA_MODEL_URL: modelUrl }),
);
return { ok: true, modelUrl };
}
25 changes: 16 additions & 9 deletions packages/desktop/src/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { WIZARD_KEYS, classifyProbe, filterWizardFields, mergeEnvFile, needsOnboarding, wizardFlagEnabled, wizardPrefill } from './onboarding';
import {
WIZARD_KEYS,
classifyProbe,
filterWizardFields,
mergeEnvFile,
needsOnboarding,
wizardFlagEnabled,
wizardPrefill,
} from './onboarding';
import { parseEnvFile } from './envfile';

describe('wizardFlagEnabled (v0.35.4 default flip)', () => {
Expand Down Expand Up @@ -131,48 +139,47 @@ describe('classifyProbe', () => {
test('401/403 → key rejected', () => {
expect(classifyProbe(401)).toMatchObject({ ok: false });
expect(classifyProbe(403).ok).toBe(false);
expect(classifyProbe(401).error).toContain('key');
expect(classifyProbe(401).error).toContain('密钥');
});
test('404 → base URL / endpoint', () => {
expect(classifyProbe(404).ok).toBe(false);
expect(classifyProbe(404).error).toContain('base URL');
expect(classifyProbe(404).error).toContain('接口地址');
});
test('null (fetch threw) → unreachable URL', () => {
expect(classifyProbe(null).ok).toBe(false);
expect(classifyProbe(null).error).toContain('URL');
expect(classifyProbe(null).error).toContain('地址');
});
test('other non-2xx (5xx/429) → surfaced, not ok', () => {
expect(classifyProbe(500).ok).toBe(false);
expect(classifyProbe(429).ok).toBe(false);
});
});


describe('bug scenario: baseUrl with = characters', () => {
test('complete end-to-end: merge, write, read back URL with query params', () => {
// Simulate the exact scenario from the bug claim
const baseUrl = 'https://x.com?a=1&b=2';
const apiKey = 'sk-valid';
const model = 'claude-opus-4-8';

// Step 1: probeConnection succeeds (mocked as passed above)
// Step 2: mergeEnvFile is called with unescaped URL
const template = `# Luna desktop configuration
ANTHROPIC_API_KEY=
ANTHROPIC_BASE_URL=
LUNA_MODEL=claude-sonnet-4-6
`;

const merged = mergeEnvFile(template, {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: apiKey,
LUNA_MODEL: model,
});

// Step 3: file is written (simulated by the merged string)
// Step 4: sidecarEnv calls parseEnvFile to read the merged content
const parsed = parseEnvFile(merged);

// Verify the file is NOT corrupted — values must parse exactly
expect(parsed['ANTHROPIC_BASE_URL']).toBe(baseUrl);
expect(parsed['ANTHROPIC_API_KEY']).toBe(apiKey);
Expand Down
8 changes: 4 additions & 4 deletions packages/desktop/src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@ export type ProbeVerdict = { ok: boolean; error?: string };
// OR a 400 both mean the request was authenticated and reached the model (a 400 is a request-shape
// detail like max_tokens), so the key + URL are good. 401/403 = key rejected; 404 = wrong endpoint.
export function classifyProbe(status: number | null): ProbeVerdict {
if (status === null) return { ok: false, error: "Couldn't reach that URL — check the base URL." };
if (status === 401 || status === 403) return { ok: false, error: 'The API key was rejected.' };
if (status === null) return { ok: false, error: '无法访问这个地址——请检查接口地址。' };
if (status === 401 || status === 403) return { ok: false, error: 'API 密钥被拒绝了。' };
if (status === 404)
return { ok: false, error: 'Endpoint not found — check the base URL (e.g. https://api.anthropic.com).' };
return { ok: false, error: '找不到接口——请检查接口地址(例如 https://api.anthropic.com)。' };
if (status < 400 || status === 400) return { ok: true };
return { ok: false, error: `The server returned ${status}. Check your settings and try again.` };
return { ok: false, error: `服务器返回了 ${status}。请检查设置后重试。` };
}
Loading