diff --git a/apps/api/src/ai-routes.test.mjs b/apps/api/src/ai-routes.test.mjs index 4a4ad05e..681cb5fb 100644 --- a/apps/api/src/ai-routes.test.mjs +++ b/apps/api/src/ai-routes.test.mjs @@ -637,3 +637,724 @@ describe("video outline", () => { const input=videoOutlineBody();input.blocks=Array.from({length:10},(_,i)=>({start:i,text:"x".repeat(6000)}));const res=await createApp().request("/api/v1/ai/video-outline/prepare",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(input)},environment);expect(res.status).toBe(413); }); }); + +const jsonRequest = (method, body) => ({ + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +const createProvider = async (app, databaseEnvironment, overrides = {}) => { + const response = await app.request( + "/api/v1/ai/providers", + jsonRequest("POST", { ...validSettings, ...overrides }), + databaseEnvironment, + ); + expect(response.status).toBe(201); + return response.json(); +}; + +const transcriptionSettings = { + provider: "openai-compatible", + displayName: "Speech", + baseUrl: "https://speech.example.com/v1", + apiKey: "speech-secret", + isEnabled: true, +}; + +const createTranscriptionProvider = async (app, databaseEnvironment, overrides = {}) => { + const response = await app.request( + "/api/v1/ai/transcription-providers", + jsonRequest("POST", { ...transcriptionSettings, ...overrides }), + databaseEnvironment, + ); + expect(response.status).toBe(201); + return response.json(); +}; + +const auditActionsOf = (sqlite) => + sqlite.query("SELECT action FROM audit_events ORDER BY rowid").all().map((row) => row.action); + +const otherWorkspaceAuth = { ...auth, actorId: "usr_other", workspaceId: "ws_other" }; +const agentAuth = { ...auth, kind: "agent", actorType: "agent", scopes: ["ai:generate"] }; + +describe("speech transcription settings routes", () => { + test("rejects invalid transcription settings with a dedicated error code", async () => { + const response = await createApp().request( + "/api/v1/ai/transcription-providers", + jsonRequest("POST", { ...transcriptionSettings, displayName: "" }), + environment, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: { + code: "ai_transcription_request_invalid", + message: "The speech transcription settings are invalid.", + }, + }); + }); + + test("requires an interactive user to read transcription settings", async () => { + const response = await createApp({ currentAuth: agentAuth }).request( + "/api/v1/ai/transcription-settings", + {}, + environment, + ); + expect(response.status).toBe(403); + }); + + test("refuses transcription credentials and targets in demo mode", async () => { + const app = createApp({ demoMode: true }); + const target = await app.request("/api/v1/memos/m/resources/r/transcription-target", { method: "POST" }, environment); + const credential = await app.request( + "/api/v1/ai/transcription-providers/atp_x/direct-credential", + { method: "POST" }, + environment, + ); + expect(target.status).toBe(403); + expect(credential.status).toBe(403); + }); + + test("returns 404 for an unknown speech provider credential", async () => { + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const response = await createApp().request( + "/api/v1/ai/transcription-providers/atp_missing/direct-credential", + { method: "POST" }, + databaseEnvironment, + ); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ error: { message: "Speech service not found." } }); + sqlite.close(); + }); + + test("fails closed when no credential encryption key is configured", async () => { + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const { EDGE_EVER_AUTH_PASSWORD: _password, ...withoutKey } = databaseEnvironment; + const response = await createApp().request( + "/api/v1/ai/transcription-providers", + jsonRequest("POST", transcriptionSettings), + withoutKey, + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } }); + expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_transcription_providers").get().count).toBe(0); + sqlite.close(); + }); + + test("manages providers, models and the default model inside one workspace", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run("ws_other", "Other"); + + const created = await createTranscriptionProvider(app, databaseEnvironment); + expect(created.providers).toHaveLength(1); + expect(created.providers[0].models).toEqual([]); + expect(created.defaultModelId).toBeNull(); + expect(JSON.stringify(created)).not.toContain("speech-secret"); + const providerId = created.providers[0].id; + + const addModel = await app.request( + `/api/v1/ai/transcription-providers/${providerId}/models`, + jsonRequest("POST", { modelId: "whisper-1" }), + databaseEnvironment, + ); + expect(addModel.status).toBe(201); + const withModel = await addModel.json(); + const model = withModel.providers[0].models[0]; + expect(model).toMatchObject({ modelId: "whisper-1", displayName: "whisper-1" }); + // The first model of an enabled service becomes the workspace default. + expect(withModel.defaultModelId).toBe(model.id); + expect(withModel.enabled).toBe(true); + + const second = await app.request( + `/api/v1/ai/transcription-providers/${providerId}/models`, + jsonRequest("POST", { modelId: "whisper-large", displayName: "Large" }), + databaseEnvironment, + ); + const withTwo = await second.json(); + expect(withTwo.defaultModelId).toBe(model.id); + const secondModel = withTwo.providers[0].models.find((entry) => entry.modelId === "whisper-large"); + expect(secondModel.displayName).toBe("Large"); + + const switchDefault = await app.request( + "/api/v1/ai/transcription-default-model", + jsonRequest("PUT", { modelConfigId: secondModel.id }), + databaseEnvironment, + ); + expect(switchDefault.status).toBe(200); + expect((await switchDefault.json()).defaultModelId).toBe(secondModel.id); + + const clearDefault = await app.request( + "/api/v1/ai/transcription-default-model", + jsonRequest("PUT", { modelConfigId: null }), + databaseEnvironment, + ); + expect(clearDefault.status).toBe(200); + expect((await clearDefault.json()).defaultModelId).toBeNull(); + + // Another workspace can neither see nor select these models. + const otherApp = createApp({ currentAuth: otherWorkspaceAuth }); + const foreignDefault = await otherApp.request( + "/api/v1/ai/transcription-default-model", + jsonRequest("PUT", { modelConfigId: secondModel.id }), + databaseEnvironment, + ); + expect(foreignDefault.status).toBe(400); + expect(await foreignDefault.json()).toMatchObject({ error: { code: "ai_transcription_default_unavailable" } }); + const foreignSettings = await (await otherApp.request("/api/v1/ai/transcription-settings", {}, databaseEnvironment)).json(); + expect(foreignSettings.providers).toEqual([]); + for (const [path, init] of [ + [`/api/v1/ai/transcription-providers/${providerId}`, jsonRequest("PUT", transcriptionSettings)], + [`/api/v1/ai/transcription-providers/${providerId}`, { method: "DELETE" }], + [`/api/v1/ai/transcription-providers/${providerId}/models`, jsonRequest("POST", { modelId: "x" })], + [`/api/v1/ai/transcription-providers/${providerId}/models/${model.id}`, { method: "DELETE" }], + [`/api/v1/ai/transcription-providers/${providerId}/direct-credential`, { method: "POST" }], + ]) { + const response = await otherApp.request(path, init, databaseEnvironment); + expect(response.status, `${init.method} ${path}`).toBe(404); + } + + const wrongProvider = await app.request( + `/api/v1/ai/transcription-providers/atp_other/models/${model.id}`, + { method: "DELETE" }, + databaseEnvironment, + ); + expect(wrongProvider.status).toBe(404); + expect(await wrongProvider.json()).toMatchObject({ error: { message: "Speech model not found." } }); + + const removeModel = await app.request( + `/api/v1/ai/transcription-providers/${providerId}/models/${model.id}`, + { method: "DELETE" }, + databaseEnvironment, + ); + expect(removeModel.status).toBe(200); + expect((await removeModel.json()).providers[0].models.map((entry) => entry.modelId)).toEqual(["whisper-large"]); + + const removeProvider = await app.request( + `/api/v1/ai/transcription-providers/${providerId}`, + { method: "DELETE" }, + databaseEnvironment, + ); + expect(removeProvider.status).toBe(200); + expect((await removeProvider.json()).providers).toEqual([]); + + expect(auditActionsOf(sqlite)).toEqual([ + "workspace.ai_transcription_provider.create", + "workspace.ai_transcription_model.create", + "workspace.ai_transcription_model.create", + "workspace.ai_transcription_default.update", + "workspace.ai_transcription_default.update", + "workspace.ai_transcription_model.delete", + "workspace.ai_transcription_provider.delete", + ]); + sqlite.close(); + }); + + test("updates a speech provider and keeps the stored key unless a new one is sent", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const created = await createTranscriptionProvider(app, databaseEnvironment, { initialModelId: "whisper-1" }); + const providerId = created.providers[0].id; + const credentialPath = `/api/v1/ai/transcription-providers/${providerId}/direct-credential`; + + const renamed = await app.request( + `/api/v1/ai/transcription-providers/${providerId}`, + jsonRequest("PUT", { ...transcriptionSettings, apiKey: undefined, displayName: "Renamed", isEnabled: false }), + databaseEnvironment, + ); + expect(renamed.status).toBe(200); + expect((await renamed.json()).providers[0]).toMatchObject({ displayName: "Renamed", isEnabled: false }); + expect(await (await app.request(credentialPath, { method: "POST" }, databaseEnvironment)).json()) + .toEqual({ apiKey: "speech-secret" }); + + // A model of a disabled service cannot become the default. + const disabledDefault = await app.request( + "/api/v1/ai/transcription-default-model", + jsonRequest("PUT", { modelConfigId: created.providers[0].models[0].id }), + databaseEnvironment, + ); + expect(disabledDefault.status).toBe(400); + + const rekeyed = await app.request( + `/api/v1/ai/transcription-providers/${providerId}`, + jsonRequest("PUT", { ...transcriptionSettings, apiKey: "rotated-secret" }), + databaseEnvironment, + ); + expect(rekeyed.status).toBe(200); + expect(await (await app.request(credentialPath, { method: "POST" }, databaseEnvironment)).json()) + .toEqual({ apiKey: "rotated-secret" }); + const stored = sqlite.query("SELECT api_key_encrypted FROM ai_transcription_providers WHERE id = ?").get(providerId); + expect(stored.api_key_encrypted).not.toContain("rotated-secret"); + + const metadata = sqlite.query( + "SELECT metadata_json FROM audit_events WHERE action = 'workspace.ai_transcription_provider.update' ORDER BY rowid", + ).all().map((row) => JSON.parse(row.metadata_json).apiKeyUpdated); + expect(metadata).toEqual([false, true]); + + const missing = await app.request( + "/api/v1/ai/transcription-providers/atp_missing", + jsonRequest("PUT", transcriptionSettings), + databaseEnvironment, + ); + expect(missing.status).toBe(404); + sqlite.close(); + }); + + test("blocks speech setting changes in demo mode and for API tokens", async () => { + for (const app of [createApp({ demoMode: true }), createApp({ currentAuth: agentAuth })]) { + for (const [path, init] of [ + ["/api/v1/ai/transcription-providers", jsonRequest("POST", transcriptionSettings)], + ["/api/v1/ai/transcription-providers/atp_x", jsonRequest("PUT", transcriptionSettings)], + ["/api/v1/ai/transcription-providers/atp_x", { method: "DELETE" }], + ["/api/v1/ai/transcription-providers/atp_x/models", jsonRequest("POST", { modelId: "m" })], + ["/api/v1/ai/transcription-providers/atp_x/models/atm_x", { method: "DELETE" }], + ["/api/v1/ai/transcription-default-model", jsonRequest("PUT", { modelConfigId: null })], + ]) { + const response = await app.request(path, init, environment); + expect(response.status, `${init.method} ${path}`).toBe(403); + } + } + }); +}); + +describe("AI model provider settings routes", () => { + test("blocks provider changes in demo mode and for API tokens", async () => { + for (const app of [createApp({ demoMode: true }), createApp({ currentAuth: agentAuth })]) { + for (const [path, init] of [ + ["/api/v1/ai/providers/aip_x", jsonRequest("PUT", validSettings)], + ["/api/v1/ai/providers/aip_x", { method: "DELETE" }], + ["/api/v1/ai/providers/aip_x/models", jsonRequest("POST", { modelId: "m" })], + ["/api/v1/ai/providers/aip_x/models/aim_x", { method: "DELETE" }], + ["/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: null })], + ]) { + const response = await app.request(path, init, environment); + expect(response.status, `${init.method} ${path}`).toBe(403); + } + } + }); + + test("keeps the saved API key on update unless a new one is provided", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const created = await createProvider(app, databaseEnvironment); + const providerId = created.providers[0].id; + const credentialPath = `/api/v1/ai/providers/${providerId}/direct-credential`; + const { apiKey: _apiKey, initialModelId: _model, ...fields } = validSettings; + + const renamed = await app.request( + `/api/v1/ai/providers/${providerId}`, + jsonRequest("PUT", { ...fields, displayName: "Renamed" }), + databaseEnvironment, + ); + expect(renamed.status).toBe(200); + expect((await renamed.json()).providers[0].displayName).toBe("Renamed"); + const kept = await app.request(credentialPath, jsonRequest("POST", {}), databaseEnvironment); + expect(await kept.json()).toEqual({ + provider: "openai-compatible", + baseUrl: "https://models.example.com/v1", + apiKey: "secret", + modelId: "", + }); + + await app.request( + `/api/v1/ai/providers/${providerId}`, + jsonRequest("PUT", { ...fields, apiKey: "rotated" }), + databaseEnvironment, + ); + expect((await (await app.request(credentialPath, jsonRequest("POST", {}), databaseEnvironment)).json()).apiKey) + .toBe("rotated"); + + const missing = await app.request( + "/api/v1/ai/providers/aip_missing", + jsonRequest("PUT", fields), + databaseEnvironment, + ); + expect(missing.status).toBe(404); + expect(await missing.json()).toMatchObject({ error: { message: "AI provider not found." } }); + sqlite.close(); + }); + + test("rejects provider creation without an encryption key", async () => { + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const { EDGE_EVER_AUTH_PASSWORD: _password, ...withoutKey } = databaseEnvironment; + const response = await createApp().request("/api/v1/ai/providers", jsonRequest("POST", validSettings), withoutKey); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } }); + sqlite.close(); + }); + + test("isolates providers, models and the default model per workspace", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run("ws_other", "Other"); + const created = await createProvider(app, databaseEnvironment); + const provider = created.providers[0]; + const model = provider.models[0]; + + const otherApp = createApp({ currentAuth: otherWorkspaceAuth }); + for (const [path, init] of [ + [`/api/v1/ai/providers/${provider.id}`, jsonRequest("PUT", validSettings)], + [`/api/v1/ai/providers/${provider.id}`, { method: "DELETE" }], + [`/api/v1/ai/providers/${provider.id}/models`, jsonRequest("POST", { modelId: "x" })], + [`/api/v1/ai/providers/${provider.id}/models/${model.id}`, { method: "DELETE" }], + [`/api/v1/ai/providers/${provider.id}/direct-credential`, jsonRequest("POST", {})], + ]) { + const response = await otherApp.request(path, init, databaseEnvironment); + expect(response.status, `${init.method} ${path}`).toBe(404); + } + const foreignDefault = await otherApp.request( + "/api/v1/ai/default-model", + jsonRequest("PUT", { modelConfigId: model.id }), + databaseEnvironment, + ); + expect(foreignDefault.status).toBe(400); + expect(await foreignDefault.json()).toMatchObject({ error: { code: "ai_default_model_unavailable" } }); + + const ownSettings = await (await app.request("/api/v1/ai/settings", {}, databaseEnvironment)).json(); + expect(ownSettings.providers[0].displayName).toBe(validSettings.displayName); + expect(ownSettings.defaultModelId).toBe(model.id); + sqlite.close(); + }); + + test("adds, selects and removes models and deletes the provider", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const created = await createProvider(app, databaseEnvironment, { initialModelId: undefined }); + const providerId = created.providers[0].id; + expect(created.defaultModelId).toBeNull(); + + const added = await app.request( + `/api/v1/ai/providers/${providerId}/models`, + jsonRequest("POST", { modelId: "model-b" }), + databaseEnvironment, + ); + expect(added.status).toBe(201); + const withModel = await added.json(); + const model = withModel.providers[0].models[0]; + expect(model).toMatchObject({ modelId: "model-b", displayName: "model-b" }); + expect(withModel.defaultModelId).toBe(model.id); + + const cleared = await app.request("/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: null }), databaseEnvironment); + expect(cleared.status).toBe(200); + expect((await cleared.json()).defaultModelId).toBeNull(); + + const selected = await app.request("/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: model.id }), databaseEnvironment); + expect(selected.status).toBe(200); + expect((await selected.json()).defaultModelId).toBe(model.id); + + const unknown = await app.request("/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: "aim_missing" }), databaseEnvironment); + expect(unknown.status).toBe(400); + + const wrongProvider = await app.request( + `/api/v1/ai/providers/aip_other/models/${model.id}`, + { method: "DELETE" }, + databaseEnvironment, + ); + expect(wrongProvider.status).toBe(404); + expect(await wrongProvider.json()).toMatchObject({ error: { message: "AI model not found." } }); + + const removedModel = await app.request( + `/api/v1/ai/providers/${providerId}/models/${model.id}`, + { method: "DELETE" }, + databaseEnvironment, + ); + expect(removedModel.status).toBe(200); + expect((await removedModel.json()).providers[0].models).toEqual([]); + + const missingProvider = await app.request( + "/api/v1/ai/providers/aip_missing/models", + jsonRequest("POST", { modelId: "x" }), + databaseEnvironment, + ); + expect(missingProvider.status).toBe(404); + + const removedProvider = await app.request(`/api/v1/ai/providers/${providerId}`, { method: "DELETE" }, databaseEnvironment); + expect(removedProvider.status).toBe(200); + expect((await removedProvider.json()).providers).toEqual([]); + expect((await app.request(`/api/v1/ai/providers/${providerId}`, { method: "DELETE" }, databaseEnvironment)).status).toBe(404); + + expect(auditActionsOf(sqlite)).toEqual([ + "workspace.ai_provider.create", + "workspace.ai_model.create", + "workspace.ai_default_model.update", + "workspace.ai_default_model.update", + "workspace.ai_model.delete", + "workspace.ai_provider.delete", + ]); + sqlite.close(); + }); + + test("does not auto-select a default model for a disabled provider", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const created = await createProvider(app, databaseEnvironment, { initialModelId: undefined, isEnabled: false }); + const providerId = created.providers[0].id; + + const added = await app.request( + `/api/v1/ai/providers/${providerId}/models`, + jsonRequest("POST", { modelId: "model-c", displayName: "Model C" }), + databaseEnvironment, + ); + const body = await added.json(); + expect(body.providers[0].models[0].displayName).toBe("Model C"); + expect(body.defaultModelId).toBeNull(); + + const selected = await app.request( + "/api/v1/ai/default-model", + jsonRequest("PUT", { modelConfigId: body.providers[0].models[0].id }), + databaseEnvironment, + ); + expect(selected.status).toBe(400); + sqlite.close(); + }); + + test("reports provider errors for malformed direct credential requests", async () => { + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const response = await createApp().request( + "/api/v1/ai/providers/aip_x/direct-credential", + { method: "POST", headers: { "content-type": "application/json" }, body: "{not json" }, + databaseEnvironment, + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error.code).toBe("ai_credential_failed"); + expect(typeof body.error.message).toBe("string"); + + const tokenResponse = await createApp({ currentAuth: agentAuth }).request( + "/api/v1/ai/providers/aip_x/direct-credential", + jsonRequest("POST", {}), + databaseEnvironment, + ); + expect(tokenResponse.status).toBe(403); + sqlite.close(); + }); +}); + +describe("AI preparation routes without a configured model", () => { + const prepareRequests = [ + ["/api/v1/ai/direct-target", {}], + ["/api/v1/ai/tag-suggestions/prepare", jsonRequest("POST", { title: "T", contentMarkdown: "Body", currentTags: [] })], + [ + "/api/v1/ai/infographic-agent/prepare", + jsonRequest("POST", { prompt: "List", currentContent: "", candidates: ["list-grid-simple"], history: [] }), + ], + ["/api/v1/ai/generate/prepare", jsonRequest("POST", { action: "summarize", title: "T", contentMarkdown: "Body" })], + ]; + + test("report that AI is not configured", async () => { + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + for (const [path, init] of prepareRequests) { + const response = await createApp().request(path, init, databaseEnvironment); + expect(response.status, path).toBe(409); + expect(await response.json()).toMatchObject({ error: { code: "ai_not_configured" } }); + } + sqlite.close(); + }); + + test("require an interactive user session", async () => { + for (const [path, init] of prepareRequests) { + const response = await createApp({ currentAuth: agentAuth }).request(path, init, environment); + expect(response.status, path).toBe(403); + } + }); +}); + +describe("AI generation preparation", () => { + test("includes only the parameters the action needs", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + await createProvider(app, databaseEnvironment); + + const translate = await app.request( + "/api/v1/ai/generate/prepare", + jsonRequest("POST", { action: "translate", title: "Note", contentMarkdown: "Hallo", targetLanguage: "en", tone: "friendly" }), + databaseEnvironment, + ); + expect(translate.status).toBe(200); + + const tone = await app.request( + "/api/v1/ai/generate/prepare", + jsonRequest("POST", { action: "change-tone", title: "Note", contentMarkdown: "Body" }), + databaseEnvironment, + ); + expect(tone.status).toBe(400); + expect(await tone.json()).toMatchObject({ error: { code: "ai_request_invalid" } }); + + const custom = await app.request( + "/api/v1/ai/generate/prepare", + jsonRequest("POST", { action: "custom", title: "Note", contentMarkdown: "Body", instruction: " Keep every date unique-marker. " }), + databaseEnvironment, + ); + expect(custom.status).toBe(200); + expect(JSON.stringify(await custom.json())).toContain("Keep every date unique-marker."); + sqlite.close(); + }); + + test("uses a saved workspace prompt's instruction", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + await createProvider(app, databaseEnvironment); + const now = "2026-08-12T01:00:00.000Z"; + sqlite.query( + `INSERT INTO ai_prompt_templates ( + id, workspace_id, seed_key, action, parameter_kind, result_mode, + name, description, instruction, + name_customized, description_customized, instruction_customized, + created_at, updated_at + ) VALUES (?, ?, NULL, 'custom', 'none', 'replace', ?, NULL, ?, 1, 1, 1, ?, ?)`, + ).run("aiprompt_saved", auth.workspaceId, "Saved", "Saved-prompt-marker instruction.", now, now); + + const response = await app.request( + "/api/v1/ai/generate/prepare", + jsonRequest("POST", { action: "translate", promptId: "aiprompt_saved", title: "Note", contentMarkdown: "Body" }), + databaseEnvironment, + ); + expect(response.status).toBe(200); + expect(JSON.stringify(await response.json())).toContain("Saved-prompt-marker instruction."); + + sqlite.query( + `INSERT INTO ai_prompt_templates ( + id, workspace_id, seed_key, action, parameter_kind, result_mode, + name, description, instruction, + name_customized, description_customized, instruction_customized, + created_at, updated_at + ) VALUES (?, ?, NULL, 'custom', 'target-language', 'replace', ?, NULL, ?, 1, 1, 1, ?, ?)`, + ).run("aiprompt_language", auth.workspaceId, "Language", "Translate it.", now, now); + const missingLanguage = await app.request( + "/api/v1/ai/generate/prepare", + jsonRequest("POST", { action: "custom", promptId: "aiprompt_language", title: "Note", contentMarkdown: "Body" }), + databaseEnvironment, + ); + expect(missingLanguage.status).toBe(400); + expect(await missingLanguage.json()).toMatchObject({ error: { code: "ai_target_language_required" } }); + + const withLanguage = await app.request( + "/api/v1/ai/generate/prepare", + jsonRequest("POST", { + action: "custom", + promptId: "aiprompt_language", + title: "Note", + contentMarkdown: "Body", + targetLanguage: "en", + }), + databaseEnvironment, + ); + expect(withLanguage.status).toBe(200); + sqlite.close(); + }); + + test("maps existing tag spellings onto the canonical workspace tags", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + await createProvider(app, databaseEnvironment); + const response = await app.request( + "/api/v1/ai/tag-suggestions/prepare", + jsonRequest("POST", { title: "Note", contentMarkdown: "Body", currentTags: ["draft"], locale: "en-US" }), + databaseEnvironment, + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.currentTags).toEqual(["draft"]); + expect(body.canonicalTags).toEqual({}); + sqlite.close(); + }); +}); + +describe("video outline preparation", () => { + test("requires the ai:generate scope for API tokens", async () => { + const response = await createApp({ currentAuth: { ...agentAuth, scopes: ["read:memos"] } }).request( + "/api/v1/ai/video-outline/prepare", + jsonRequest("POST", videoOutlineBody()), + environment, + ); + expect(response.status).toBe(403); + }); + + test("returns model credentials with the outline prompt for a configured workspace", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + await createProvider(app, databaseEnvironment); + const response = await createApp({ currentAuth: agentAuth }).request( + "/api/v1/ai/video-outline/prepare", + jsonRequest("POST", videoOutlineBody()), + databaseEnvironment, + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ apiKey: "secret", modelId: "model-a", maxOutputTokens: 1200 }); + expect(body.prompt).toContain("Hello world"); + expect(typeof body.system).toBe("string"); + sqlite.close(); + }); +}); + +describe("AI settings storage failures", () => { + test("rejects duplicate model ids without changing the stored models", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const provider = (await createProvider(app, databaseEnvironment)).providers[0]; + const duplicate = await app.request( + `/api/v1/ai/providers/${provider.id}/models`, + jsonRequest("POST", { modelId: "model-a" }), + databaseEnvironment, + ); + expect(duplicate.status).toBe(400); + expect(await duplicate.json()).toMatchObject({ error: { code: "ai_model_create_failed" } }); + + const speech = (await createTranscriptionProvider(app, databaseEnvironment, { initialModelId: "whisper-1" })).providers[0]; + const duplicateSpeech = await app.request( + `/api/v1/ai/transcription-providers/${speech.id}/models`, + jsonRequest("POST", { modelId: "whisper-1" }), + databaseEnvironment, + ); + expect(duplicateSpeech.status).toBe(400); + expect(await duplicateSpeech.json()).toMatchObject({ error: { code: "ai_transcription_model_create_failed" } }); + + expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_models").get().count).toBe(1); + expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_transcription_models").get().count).toBe(1); + expect(auditActionsOf(sqlite).filter((action) => action.endsWith("model.create"))).toEqual([]); + sqlite.close(); + }); + + test("refuses to rotate keys when the encryption key is no longer configured", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const provider = (await createProvider(app, databaseEnvironment)).providers[0]; + const speech = (await createTranscriptionProvider(app, databaseEnvironment)).providers[0]; + const { EDGE_EVER_AUTH_PASSWORD: _password, ...withoutKey } = databaseEnvironment; + const { initialModelId: _model, ...providerFields } = validSettings; + + const providerUpdate = await app.request( + `/api/v1/ai/providers/${provider.id}`, + jsonRequest("PUT", { ...providerFields, apiKey: "rotated" }), + withoutKey, + ); + expect(providerUpdate.status).toBe(400); + expect(await providerUpdate.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } }); + + const speechUpdate = await app.request( + `/api/v1/ai/transcription-providers/${speech.id}`, + jsonRequest("PUT", { ...transcriptionSettings, apiKey: "rotated" }), + withoutKey, + ); + expect(speechUpdate.status).toBe(400); + expect(await speechUpdate.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } }); + sqlite.close(); + }); + + test("reports an undecryptable speech credential instead of returning it", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const speech = (await createTranscriptionProvider(app, databaseEnvironment)).providers[0]; + sqlite.query("UPDATE ai_transcription_providers SET api_key_encrypted = ? WHERE id = ?").run("corrupted", speech.id); + + const response = await app.request( + `/api/v1/ai/transcription-providers/${speech.id}/direct-credential`, + { method: "POST" }, + databaseEnvironment, + ); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: { code: "ai_credentials_unavailable" } }); + sqlite.close(); + }); +}); diff --git a/apps/api/src/template-routes.test.mjs b/apps/api/src/template-routes.test.mjs index b6790694..e436994d 100644 --- a/apps/api/src/template-routes.test.mjs +++ b/apps/api/src/template-routes.test.mjs @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { globSync, readFileSync } from "node:fs"; +import { Database } from "bun:sqlite"; import { Hono } from "hono"; import { listMemoTemplateGallery } from "@edgeever/shared/memo-template-gallery"; import { mapMemoTemplateRow, registerTemplateRoutes } from "./template-routes.ts"; @@ -50,3 +52,386 @@ describe("template gallery route", () => { expect(body.templates).toEqual(listMemoTemplateGallery("pl")); }); }); + +class SqliteD1PreparedStatement { + constructor(db, sql, bindings = []) { + this.db = db; + this.sql = sql; + this.bindings = bindings; + } + + bind(...bindings) { + return new SqliteD1PreparedStatement(this.db, this.sql, bindings); + } + + async all() { + return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} }; + } + + async first() { + return this.db.query(this.sql).get(...this.bindings) ?? null; + } + + async run() { + this.db.query(this.sql).run(...this.bindings); + return { success: true, meta: {} }; + } +} + +class SqliteD1Database { + constructor(db) { + this.db = db; + } + + prepare(sql) { + return new SqliteD1PreparedStatement(this.db, sql); + } + + async batch(statements) { + return this.db.transaction(() => statements.map((statement) => + this.db.query(statement.sql).run(...statement.bindings)))(); + } +} + +const ownerAuth = { + kind: "user", + actorType: "user", + actorId: "usr_owner", + username: "owner", + scopes: [], + workspaceId: "ws_owner", + role: "owner", +}; + +const otherAuth = { ...ownerAuth, actorId: "usr_other", username: "other", workspaceId: "ws_other" }; + +const createTemplateHarness = ({ memos = {} } = {}) => { + const sqlite = new Database(":memory:"); + for (const migration of globSync("migrations/*.sql").sort()) { + sqlite.exec(readFileSync(migration, "utf8")); + } + for (const id of ["ws_owner", "ws_other"]) { + sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run(id, id); + } + // Start from an empty template table so assertions only see rows created here. + sqlite.query("DELETE FROM memo_templates").run(); + + const memoDetailCalls = []; + const createdMemos = []; + const dependencies = { + getMemoDetail: async (_db, workspaceId, id) => { + memoDetailCalls.push({ workspaceId, id }); + return memos[`${workspaceId}/${id}`] ?? null; + }, + createMemoRecord: async (_db, workspaceId, input, actor, actorLabel) => { + const memo = { id: `memo_${createdMemos.length + 1}`, workspaceId, ...input }; + createdMemos.push({ workspaceId, input, actor, actorLabel }); + return memo; + }, + }; + + const createApp = (currentAuth = ownerAuth) => { + const app = new Hono(); + app.use("/api/v1/*", async (context, next) => { + context.set("auth", currentAuth); + await next(); + }); + registerTemplateRoutes(app, dependencies); + return app; + }; + + const environment = { storage: { db: new SqliteD1Database(sqlite), resources: {} } }; + const request = (path, init = {}, currentAuth = ownerAuth) => { + const { json, ...rest } = init; + return createApp(currentAuth).request( + path, + json === undefined + ? rest + : { ...rest, headers: { "content-type": "application/json" }, body: JSON.stringify(json) }, + environment, + ); + }; + + const auditActions = () => + sqlite.query("SELECT action, entity_id, actor_id, metadata_json FROM audit_events ORDER BY rowid").all(); + + return { sqlite, request, memoDetailCalls, createdMemos, auditActions }; +}; + +describe("template CRUD routes", () => { + test("creates a template from markdown, trims fields and records an audit event", async () => { + const harness = createTemplateHarness(); + + const response = await harness.request("/api/v1/templates", { + method: "POST", + json: { + name: " Standup ", + description: " Daily notes ", + title: " Today ", + contentMarkdown: "# Standup", + tags: ["daily"], + }, + }); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.template).toMatchObject({ + name: "Standup", + description: "Daily notes", + title: "Today", + contentMarkdown: "# Standup", + tags: ["daily"], + }); + expect(body.template.id).toStartWith("template"); + expect(body.template.contentJson.type).toBe("doc"); + expect(harness.memoDetailCalls).toEqual([]); + + const stored = harness.sqlite.query("SELECT workspace_id FROM memo_templates WHERE id = ?").get(body.template.id); + expect(stored.workspace_id).toBe("ws_owner"); + expect(harness.auditActions()).toEqual([{ + action: "template.create", + entity_id: body.template.id, + actor_id: "usr_owner", + metadata_json: JSON.stringify({ memoId: null }), + }]); + }); + + test("stores blank optional text as null and defaults tags to an empty list", async () => { + const harness = createTemplateHarness(); + + const response = await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Blank", description: " ", title: " ", contentMarkdown: "" }, + }); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.template).toMatchObject({ description: null, title: null, contentMarkdown: "", tags: [] }); + }); + + test("copies title, content and tags from a memo in the caller's workspace", async () => { + const memoDoc = { type: "doc", content: [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Source" }] }] }; + const harness = createTemplateHarness({ + memos: { + "ws_owner/memo_source": { + id: "memo_source", + title: "Source", + contentMarkdown: "# Source", + contentJson: memoDoc, + tags: ["from-memo"], + }, + }, + }); + + const response = await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "From memo", memoId: "memo_source", title: "ignored", contentMarkdown: "ignored", tags: ["ignored"] }, + }); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.template).toMatchObject({ + title: "Source", + contentMarkdown: "# Source", + contentJson: memoDoc, + tags: ["from-memo"], + }); + expect(harness.memoDetailCalls).toEqual([{ workspaceId: "ws_owner", id: "memo_source" }]); + expect(JSON.parse(harness.auditActions()[0].metadata_json)).toEqual({ memoId: "memo_source" }); + }); + + test("rejects a memo source that is missing or belongs to another workspace", async () => { + const harness = createTemplateHarness({ + memos: { "ws_other/memo_foreign": { id: "memo_foreign", title: "Foreign", contentMarkdown: "x", contentJson: {}, tags: [] } }, + }); + + const response = await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Steal", memoId: "memo_foreign" }, + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: { code: "not_found", message: "Memo not found" } }); + expect(harness.memoDetailCalls).toEqual([{ workspaceId: "ws_owner", id: "memo_foreign" }]); + expect(harness.sqlite.query("SELECT COUNT(*) AS count FROM memo_templates").get().count).toBe(0); + expect(harness.auditActions()).toEqual([]); + }); + + test("rejects invalid create payloads before touching storage", async () => { + const harness = createTemplateHarness(); + + for (const json of [ + { name: "No content" }, + { name: " ", contentMarkdown: "x" }, + { name: "x".repeat(161), contentMarkdown: "x" }, + { name: "Bad tags", contentMarkdown: "x", tags: "daily" }, + ]) { + const response = await harness.request("/api/v1/templates", { method: "POST", json }); + expect(response.status).toBe(400); + } + expect(harness.sqlite.query("SELECT COUNT(*) AS count FROM memo_templates").get().count).toBe(0); + }); + + test("lists only the caller's workspace templates, most recently updated first", async () => { + const harness = createTemplateHarness(); + const insert = harness.sqlite.query( + `INSERT INTO memo_templates (id, workspace_id, name, content_json, content_markdown, tags_json, updated_at) + VALUES (?, ?, ?, '{"type":"doc","content":[]}', '', '[]', ?)`, + ); + insert.run("template_old", "ws_owner", "Old", "2026-01-01T00:00:00.000Z"); + insert.run("template_new", "ws_owner", "New", "2026-02-01T00:00:00.000Z"); + insert.run("template_foreign", "ws_other", "Foreign", "2026-03-01T00:00:00.000Z"); + + const response = await harness.request("/api/v1/templates"); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.templates.map((template) => template.id)).toEqual(["template_new", "template_old"]); + }); + + test("updates only the provided fields and re-renders content from markdown", async () => { + const harness = createTemplateHarness(); + const created = await (await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Original", description: "Desc", title: "Title", contentMarkdown: "Old body", tags: ["a"] }, + })).json(); + const id = created.template.id; + + const renamed = await harness.request(`/api/v1/templates/${id}`, { method: "PATCH", json: { name: "Renamed" } }); + const renamedBody = await renamed.json(); + expect(renamed.status).toBe(200); + expect(renamedBody.template).toMatchObject({ + name: "Renamed", + description: "Desc", + title: "Title", + contentMarkdown: "Old body", + contentJson: created.template.contentJson, + tags: ["a"], + }); + + const rewritten = await harness.request(`/api/v1/templates/${id}`, { + method: "PATCH", + json: { description: null, title: " ", contentMarkdown: "# New body", tags: [] }, + }); + const rewrittenBody = await rewritten.json(); + expect(rewritten.status).toBe(200); + expect(rewrittenBody.template).toMatchObject({ + name: "Renamed", + description: null, + title: null, + contentMarkdown: "# New body", + tags: [], + }); + expect(rewrittenBody.template.contentJson.content[0].type).toBe("heading"); + expect(harness.auditActions().map((event) => event.action)).toEqual([ + "template.create", + "template.update", + "template.update", + ]); + }); + + test("does not let another workspace update, use or delete a template", async () => { + const harness = createTemplateHarness(); + const created = await (await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Private", contentMarkdown: "secret" }, + })).json(); + const id = created.template.id; + + const update = await harness.request(`/api/v1/templates/${id}`, { method: "PATCH", json: { name: "Hijacked" } }, otherAuth); + const use = await harness.request(`/api/v1/templates/${id}/use`, { method: "POST", json: { notebookId: "nb_1" } }, otherAuth); + const remove = await harness.request(`/api/v1/templates/${id}`, { method: "DELETE" }, otherAuth); + + for (const response of [update, use, remove]) { + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: { code: "not_found", message: "Template not found" } }); + } + expect(harness.createdMemos).toEqual([]); + expect(harness.sqlite.query("SELECT name FROM memo_templates WHERE id = ?").get(id)).toEqual({ name: "Private" }); + expect(harness.auditActions().map((event) => event.action)).toEqual(["template.create"]); + }); + + test("returns 404 for unknown template ids", async () => { + const harness = createTemplateHarness(); + + expect((await harness.request("/api/v1/templates/template_missing", { method: "PATCH", json: {} })).status).toBe(404); + expect((await harness.request("/api/v1/templates/template_missing", { method: "DELETE" })).status).toBe(404); + expect((await harness.request("/api/v1/templates/template_missing/use", { + method: "POST", + json: { notebookId: "nb_1" }, + })).status).toBe(404); + }); + + test("rejects invalid update and use payloads", async () => { + const harness = createTemplateHarness(); + + expect((await harness.request("/api/v1/templates/template_1", { method: "PATCH", json: { name: "" } })).status).toBe(400); + expect((await harness.request("/api/v1/templates/template_1", { method: "PATCH", json: { tags: [1] } })).status).toBe(400); + expect((await harness.request("/api/v1/templates/template_1/use", { method: "POST", json: {} })).status).toBe(400); + expect((await harness.request("/api/v1/templates/template_1/use", { + method: "POST", + json: { notebookId: " " }, + })).status).toBe(400); + }); + + test("creates a memo from a template in the caller's notebook and audits the use", async () => { + const harness = createTemplateHarness(); + const created = await (await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Meeting", title: "Meeting notes", contentMarkdown: "## Agenda", tags: ["meeting"] }, + })).json(); + const id = created.template.id; + + const response = await harness.request(`/api/v1/templates/${id}/use`, { + method: "POST", + json: { notebookId: "nb_1", locale: "pl-PL" }, + }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.memo.id).toBe("memo_1"); + expect(harness.createdMemos).toEqual([{ + workspaceId: "ws_owner", + input: { notebookId: "nb_1", title: "Meeting notes", contentMarkdown: "## Agenda", tags: ["meeting"] }, + actor: { actorType: "user", actorId: "usr_owner" }, + actorLabel: "user:usr_owner", + }]); + const useEvent = harness.auditActions().at(-1); + expect(useEvent.action).toBe("template.use"); + expect(useEvent.entity_id).toBe(id); + expect(JSON.parse(useEvent.metadata_json)).toEqual({ memoId: "memo_1" }); + }); + + test("passes an untitled template to memo creation without a title", async () => { + const harness = createTemplateHarness(); + const created = await (await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Untitled", contentMarkdown: "Body" }, + })).json(); + + await harness.request(`/api/v1/templates/${created.template.id}/use`, { method: "POST", json: { notebookId: "nb_2" } }); + + expect(harness.createdMemos[0].input).toEqual({ + notebookId: "nb_2", + title: undefined, + contentMarkdown: "Body", + tags: [], + }); + }); + + test("deletes a template from the caller's workspace", async () => { + const harness = createTemplateHarness(); + const created = await (await harness.request("/api/v1/templates", { + method: "POST", + json: { name: "Disposable", contentMarkdown: "x" }, + })).json(); + const id = created.template.id; + + const response = await harness.request(`/api/v1/templates/${id}`, { method: "DELETE" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(harness.sqlite.query("SELECT COUNT(*) AS count FROM memo_templates").get().count).toBe(0); + expect(harness.auditActions().at(-1)).toMatchObject({ action: "template.delete", entity_id: id }); + }); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 93567cdc..80642b0a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@edgeever/desktop", - "version": "2.18.0", + "version": "2.19.0", "desktopName": "EdgeEver", "private": true, "description": "Native EdgeEver desktop client", diff --git a/apps/desktop/src/main/desktop-update-controller.mjs b/apps/desktop/src/main/desktop-update-controller.mjs new file mode 100644 index 00000000..42efdd0e --- /dev/null +++ b/apps/desktop/src/main/desktop-update-controller.mjs @@ -0,0 +1,251 @@ +import { isDeepStrictEqual } from "node:util"; +import { prepareMacUpdate } from "./mac-update-install.mjs"; + +// electron-updater owns one mutable download/install target. Keep checks, +// downloads and package verification serialized around that target. +export const createDesktopUpdateController = ({ + updater, platform, nativeUpdater, fetchWindowsMetadata, verifyWindowsPackage, + quitApplication = () => {}, + onStatus, onError, onDownloaded, beforeInstall, + onStage = () => {}, onCheckStarted = () => {}, onSettled = () => {}, + log = () => {}, +}) => { + let status = { state: "idle", version: null, progress: null }; + let target = null; + let ready = null; + let checkInFlight = null; + let downloadInFlight = null; + let verificationInFlight = null; + let installInFlight = null; + let installing = false; + let quittingForUpdate = false; + let quitRequested = false; + let skipQuitInstall = false; + let nativePreparationStarted = false; + let nativePreparationFailed = false; + const windows = platform === "win32"; + const mac = platform === "darwin"; + const downloadedPath = () => mac ? updater.downloadedUpdateHelper?.file : updater.installerPath; + const publish = (next) => { status = { ...status, ...next }; onStatus({ ...status }); }; + const packageMatches = (candidate) => Boolean(candidate && ( + (!windows || candidate.manifest?.version === candidate.info.version) && + candidate.path && downloadedPath() === candidate.path + )); + const refreshQuitInstall = () => { + // Squirrel must not stage an intermediate background download. The shared + // before-quit hook refreshes the target before normal quit installation. + updater.autoInstallOnAppQuit = !mac && !checkInFlight && !downloadInFlight && + !verificationInFlight && ready === target && packageMatches(ready); + if (updater.autoInstallOnAppQuit) { + // BaseUpdater normally registers this during update-downloaded, before + // our async hash verification completes. Register only after trust is + // established; its quit callback reads autoInstallOnAppQuit again. + updater.addQuitHandler(); + } + }; + const invalidate = () => { + updater.autoInstallOnAppQuit = false; + ready = null; + target = null; + }; + const fail = (error, stage) => { + invalidate(); + publish({ state: "idle", version: null, progress: null }); + onError(error, stage); + }; + + // Downloads are still automatic, but start only after metadata selection + // (and Windows signature verification), never inside checkForUpdates(). + updater.autoDownload = false; + updater.autoInstallOnAppQuit = false; + updater.autoRunAppAfterInstall = true; + updater.on("download-progress", (progress) => { + if (downloadInFlight) { + publish({ progress }); + log("update.download-progress", { percent: progress.percent }); + } + }); + updater.on("error", (error) => { + // check/download promises report their own errors with the correct stage. + if (!checkInFlight && !downloadInFlight) { + installing = false; + fail(error, "install"); + } + }); + updater.on("update-downloaded", (info) => { + const candidate = target; + if (!downloadInFlight || verificationInFlight) { + fail(new Error("Unexpected update download completion"), windows ? "verify-windows-package" : "download"); + return; + } + verificationInFlight = Promise.resolve().then(async () => { + if (!candidate || candidate.info.version !== info?.version) { + throw new Error("Downloaded update does not match the selected target"); + } + if (windows) { + onStage("verify-windows-package"); + if (!candidate.manifest || candidate.manifest.version !== info.version || + !info.downloadedFile || updater.installerPath !== info.downloadedFile) { + throw new Error("Downloaded Windows update has no matching trusted installer"); + } + await verifyWindowsPackage({ path: info.downloadedFile, manifest: candidate.manifest }); + } + if (target !== candidate) throw new Error("Update target changed during package verification"); + candidate.path = info.downloadedFile; + candidate.verified = true; + }); + // The event may precede downloadUpdate()'s promise settlement. + void verificationInFlight.catch(() => {}); + }); + + const download = (reason) => { + if (downloadInFlight) return downloadInFlight; + const candidate = target; + if (!candidate || (windows && !candidate.manifest)) { + return Promise.reject(new Error("Update metadata has not passed the signature gate")); + } + if (ready === candidate && packageMatches(ready)) return Promise.resolve(); + updater.autoInstallOnAppQuit = false; + downloadInFlight = Promise.resolve().then(async () => { + onStage("download"); + await updater.downloadUpdate(); + await verificationInFlight; + if (target !== candidate || !candidate.verified || !packageMatches(candidate)) { + throw new Error("Downloaded package does not match the selected update target"); + } + ready = candidate; + publish({ state: "downloaded", version: candidate.info.version, progress: null }); + log("update.downloaded", { version: candidate.info.version }); + }).catch((error) => { + fail(error, windows && verificationInFlight ? "verify-windows-package" : "download"); + log(windows && verificationInFlight ? "update.windows-package-blocked" : "update.download-failed", { + reason, message: error.message, + }); + }).finally(() => { + downloadInFlight = null; + verificationInFlight = null; + refreshQuitInstall(); + if (!checkInFlight) onSettled(); + if (ready === candidate && !quitRequested) onDownloaded(candidate.info.version); + }); + return downloadInFlight; + }; + + const check = (reason) => { + if (nativePreparationFailed) { + return Promise.reject(new Error("Restart EdgeEver before retrying native macOS update preparation")); + } + if (installing) return Promise.resolve(null); + if (checkInFlight) return checkInFlight; + // In particular, do not change updater.updateInfoAndProvider while a + // Windows package is still being hashed after its download has finished. + if (downloadInFlight) return Promise.resolve(null); + updater.autoInstallOnAppQuit = false; + let stage = "check"; + checkInFlight = Promise.resolve().then(async () => { + onCheckStarted(reason); + const result = await updater.checkForUpdates(); + if (!result?.isUpdateAvailable) { + invalidate(); + publish({ state: "idle", version: null, progress: null }); + log("update.not-available"); + return result; + } + const info = result.updateInfo; + if (ready && isDeepStrictEqual(ready.info, info) && packageMatches(ready)) { + publish({ state: "downloaded", version: ready.info.version, progress: null }); + return result; + } + // Revoke installation of the previous package before awaiting trust of + // a replacement. A failed replacement must never install the old file. + invalidate(); + target = { info, manifest: null, path: null, verified: false }; + publish({ state: "available", version: info.version, progress: null }); + log("update.available", { version: info.version }); + if (windows) { + stage = "verify-windows-metadata"; + onStage(stage); + target.manifest = await fetchWindowsMetadata({ version: info.version, updateInfo: info }); + if (target.manifest.version !== info.version) throw new Error("Windows manifest target mismatch"); + log("update.windows-manifest-verified", { version: info.version, keyId: target.manifest.keyId }); + } + void download(reason); + return result; + }).catch((error) => { + // A failed check leaves the old package on disk, but does not authorize + // installation without a successful check and (on Windows) verification. + fail(error, stage); + log("update.check-failed", { reason, message: error.message }); + throw error; + }).finally(() => { + checkInFlight = null; + refreshQuitInstall(); + if (!downloadInFlight) onSettled(); + }); + return checkInFlight; + }; + + const install = (expectedVersion) => { + if (installInFlight) return installInFlight; + if (!ready || installing) return Promise.resolve({ started: false }); + installInFlight = Promise.resolve().then(async () => { + await check("before-install"); + if (downloadInFlight) await downloadInFlight; + if (!ready || ready !== target || !ready.verified || !packageMatches(ready) || + checkInFlight || downloadInFlight || verificationInFlight || + (expectedVersion && !quitRequested && expectedVersion !== ready.info.version)) { + return { started: false }; + } + installing = true; + log("update.install-confirmed", { version: ready.info.version }); + if (mac) { + onStage("install"); + nativePreparationStarted = true; + await prepareMacUpdate(nativeUpdater); + if (ready !== target || !packageMatches(ready)) { + throw new Error("macOS installation target changed during native preparation"); + } + } + beforeInstall(); + quittingForUpdate = true; + updater.autoRunAppAfterInstall = !quitRequested; + if (mac) { + if (quitRequested) quitApplication(); // Squirrel applies the staged ZIP without relaunching. + else nativeUpdater.quitAndInstall(); + } else { + updater.quitAndInstall(windows, !quitRequested); + } + return { started: true }; + }).catch((error) => { + installing = false; + quittingForUpdate = false; + if (mac && nativePreparationStarted) { + // Electron exposes no cancellation for Squirrel. A timed-out or failed + // operation may still complete later; never let it race a new target. + nativePreparationFailed = true; + fail(new Error(`macOS native update preparation failed; restart EdgeEver before retrying: ${error.message}`), "install"); + } else if (status.state !== "idle") { + fail(error, "install"); + } + return { started: false }; + }).finally(() => { + installInFlight = null; + if (quitRequested && !quittingForUpdate) { + skipQuitInstall = true; + quitApplication(); + } + }); + return installInFlight; + }; + + const handleBeforeQuit = (event) => { + if (skipQuitInstall || quittingForUpdate || (!ready && !installInFlight)) return false; + event.preventDefault(); + quitRequested = true; + // If a check or preparation fails, quit without installing the old package. + if (!installInFlight) void install(); + return true; + }; + + return { check, download, install, handleBeforeQuit, snapshot: () => ({ ...status }) }; +}; diff --git a/apps/desktop/src/main/desktop-update-controller.test.mjs b/apps/desktop/src/main/desktop-update-controller.test.mjs new file mode 100644 index 00000000..f566c4b4 --- /dev/null +++ b/apps/desktop/src/main/desktop-update-controller.test.mjs @@ -0,0 +1,325 @@ +import { createRequire } from "node:module"; +import { EventEmitter } from "node:events"; +import { createHash } from "node:crypto"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { createDesktopUpdateController } from "./desktop-update-controller.mjs"; +import { prepareMacUpdate } from "./mac-update-install.mjs"; + +const require = createRequire(new URL("../../package.json", import.meta.url)); +const { BaseUpdater } = require("electron-updater/out/BaseUpdater.js"); +const { MacUpdater } = require("electron-updater/out/MacUpdater.js"); +const { AppImageUpdater } = require("electron-updater/out/AppImageUpdater.js"); +const cleanup = []; +afterEach(async () => { for (const close of cleanup.splice(0)) await close(); }); +const deferred = () => { + let resolve; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +}; +const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; + +// Use real AppUpdater selection/download bookkeeping and MacUpdater's real +// authenticated loopback proxy. The native Squirrel API and installer process +// are simulated; these tests do not claim OS signing or installation coverage. +const harness = async (platform) => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-desktop-update-")); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + const releases = new Map(); + for (const version of ["2.17.0", "2.18.0", "2.19.0"]) { + const bytes = Buffer.from(`package ${version}`); + const name = platform === "darwin" ? `EdgeEver-${version}-mac-arm64.zip` : `EdgeEver-${version}-linux-x64.AppImage`; + const path = join(directory, name); + await writeFile(path, bytes); + releases.set(version, { path, bytes, info: { version, files: [{ url: name, size: bytes.length, + sha512: createHash("sha512").update(bytes).digest("base64") }] } }); + } + const h = { latest: "2.17.0", checks: 0, downloads: [], installs: [], errors: [], notices: [], + prepared: [], quitFallbacks: 0, relaunches: [], checkError: null, downloadError: null, eventVersion: null, + downloadGate: null, checkGate: null, nativeGate: null, nativeError: null }; + const app = { version: "2.16.1", isPackaged: true, onQuit: (handler) => { h.quitHandler = handler; } }; + const updater = platform === "linux" ? new AppImageUpdater(null, app) : new BaseUpdater(null, app); + updater.logger = null; + if (platform === "linux") updater.forceDevUpdateConfig = true; + const native = new EventEmitter(); + let feed; + let preparedVersion; + native.setFeedURL = (options) => { feed = options; }; + native.getFeedURL = () => feed?.url; + native.checkForUpdates = () => { + void (async () => { + if (h.nativeGate) await h.nativeGate.promise; + if (h.nativeError) throw h.nativeError; + const expectedFeed = feed; + const response = await fetch(expectedFeed.url, { headers: expectedFeed.headers }); + if (!response.ok) throw new Error(`Feed returned ${response.status}`); + const metadata = await response.json(); + const zip = await fetch(metadata.url); + if (!zip.ok) throw new Error(`Archive returned ${zip.status}`); + const bytes = Buffer.from(await zip.arrayBuffer()); + const release = [...releases].find(([, value]) => value.bytes.equals(bytes)); + if (!release) throw new Error("Native updater received an unexpected ZIP"); + preparedVersion = release[0]; + h.prepared.push(preparedVersion); + native.emit("update-downloaded"); + })().catch((error) => native.emit("error", error)); + }; + native.quitAndInstall = () => { + h.relaunches.push(true); + h.installs.push({ version: preparedVersion, path: releases.get(preparedVersion).path }); + updater.closeServerIfExists(); + }; + if (platform === "darwin") { + // Initialize the real MacUpdater methods without loading Electron's native + // module into the test runner. Its global native updater is injected here. + Object.setPrototypeOf(updater, MacUpdater.prototype); + updater.nativeUpdater = native; + updater.squirrelDownloadedUpdate = false; + native.on("error", (error) => updater.emit("error", error)); + native.on("update-downloaded", () => { updater.squirrelDownloadedUpdate = true; }); + cleanup.push(() => { updater.closeServerIfExists(); }); + } + updater.getUpdateInfoAndProvider = async () => { + h.checks++; + if (h.checkGate) await h.checkGate.promise; + if (h.checkError) throw h.checkError; + const info = h.latest ? structuredClone(releases.get(h.latest).info) : { version: "2.16.1" }; + return { info, provider: {} }; + }; + updater.doDownloadUpdate = async ({ updateInfoAndProvider: { info } }) => { + const version = info.version; + h.downloads.push(version); + if (h.downloadGate) await h.downloadGate.promise; + if (h.downloadError) throw h.downloadError; + const path = releases.get(version).path; + updater.downloadedUpdateHelper = { file: path, downloadedFileInfo: { isAdminRightsRequired: false } }; + const event = { ...info, version: h.eventVersion || version, downloadedFile: path }; + if (platform === "darwin") { + return updater.updateDownloaded({ url: new URL(`https://example.invalid/${info.files[0].url}`), info: info.files[0] }, event); + } + updater.dispatchUpdateDownloaded(event); + updater.addQuitHandler(); + return [path]; + }; + if (platform === "linux") { + updater.doInstall = ({ isForceRunAfter }) => { + h.relaunches.push(isForceRunAfter); + const path = updater.installerPath; + h.installs.push({ version: [...releases].find(([, value]) => value.path === path)[0], path }); + return true; + }; + updater.quitAndInstall = (silent, forceRun) => updater.install(silent, forceRun); + } + const controller = createDesktopUpdateController({ + updater, platform, nativeUpdater: native, + fetchWindowsMetadata: () => { throw new Error("Windows trust must not run on another platform"); }, + verifyWindowsPackage: () => { throw new Error("Windows trust must not run on another platform"); }, + onStatus: () => {}, onError: (error, stage) => h.errors.push({ error, stage }), + onDownloaded: (version) => h.notices.push(version), beforeInstall: () => {}, + quitApplication: () => { + if (platform === "darwin" && preparedVersion) { + h.relaunches.push(false); + h.installs.push({ version: preparedVersion, path: releases.get(preparedVersion).path }); + updater.closeServerIfExists(); + } else { + h.quitFallbacks++; + } + }, + }); + const settle = () => controller.download("wait").catch(() => {}); + const ready = async () => { await controller.check("startup"); await settle(); }; + return Object.assign(h, { updater, native, controller, releases, settle, ready }); +}; + +for (const platform of ["darwin", "linux"]) { + describe(`${platform} update target lifecycle (#776)`, () => { + test("replaces a downloaded intermediate release and installs only the latest package", async () => { + const h = await harness(platform); + await h.ready(); + h.latest = "2.19.0"; + await h.controller.check("interval"); + await h.settle(); + expect(h.downloads).toEqual(["2.17.0", "2.19.0"]); + expect(h.prepared).toEqual([]); + expect(await h.controller.install()).toEqual({ started: true }); + expect(h.installs).toEqual([{ version: "2.19.0", path: h.releases.get("2.19.0").path }]); + }); + + test("refreshes the latest target at installation time", async () => { + const h = await harness(platform); + await h.ready(); + h.latest = "2.18.0"; + expect(await h.controller.install()).toEqual({ started: true }); + expect(h.installs.map((entry) => entry.version)).toEqual(["2.18.0"]); + }); + + test("normal quit refreshes the target and installs without relaunching", async () => { + const h = await harness(platform); + await h.ready(); + h.latest = "2.19.0"; + expect(h.controller.handleBeforeQuit({ preventDefault() {} })).toBe(true); + await h.controller.install(); + expect(h.installs.map((entry) => entry.version)).toEqual(["2.19.0"]); + expect(h.relaunches).toEqual([false]); + expect(h.notices).toEqual(["2.17.0"]); + expect(h.controller.handleBeforeQuit({ preventDefault() { throw new Error("quit loop"); } })).toBe(false); + }); + + test("unchanged metadata does not download or prompt twice", async () => { + const h = await harness(platform); + await h.ready(); + await h.controller.check("manual"); + expect(h.checks).toBe(2); + expect(h.downloads).toEqual(["2.17.0"]); + expect(h.notices).toEqual(["2.17.0"]); + }); + + test("a stale dialog approval cannot install a replacement", async () => { + const h = await harness(platform); + await h.ready(); + h.latest = "2.18.0"; + expect(await h.controller.install("2.17.0")).toEqual({ started: false }); + expect(h.installs).toEqual([]); + expect(h.prepared).toEqual([]); + expect(await h.controller.install("2.18.0")).toEqual({ started: true }); + }); + + test("does not change the target while another version is downloading", async () => { + const h = await harness(platform); + await h.ready(); + h.latest = "2.18.0"; + h.downloadGate = deferred(); + await h.controller.check("manual"); + h.latest = "2.19.0"; + await h.controller.check("resume"); + expect(h.checks).toBe(2); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + h.downloadGate.resolve(); + await h.settle(); + expect(h.controller.snapshot().version).toBe("2.18.0"); + }); + + for (const failure of ["check", "download", "event-version"]) { + test(`a ${failure} failure never installs the old package`, async () => { + const h = await harness(platform); + await h.ready(); + h.latest = "2.18.0"; + if (failure === "check") h.checkError = new Error("offline"); + if (failure === "download") h.downloadError = new Error("download failed"); + if (failure === "event-version") h.eventVersion = "2.17.0"; + await h.controller.check("interval").catch(() => {}); + await h.settle(); + expect(await h.controller.install()).toEqual({ started: false }); + expect(h.installs).toEqual([]); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + }); + } + }); +} + +describe("macOS native installation", () => { + test("never stages intermediate background downloads in Squirrel", async () => { + const h = await harness("darwin"); + await h.ready(); + h.latest = "2.18.0"; + await h.controller.check("interval"); + await h.settle(); + expect(h.prepared).toEqual([]); + expect(h.updater.squirrelDownloadedUpdate).toBe(false); + // Even stale bookkeeping must not shortcut preparation of the current ZIP. + h.updater.squirrelDownloadedUpdate = true; + expect(await h.controller.install()).toEqual({ started: true }); + expect(h.prepared).toEqual(["2.18.0"]); + expect(h.installs.map((entry) => entry.version)).toEqual(["2.18.0"]); + }); + + test("normal quit refreshes and prepares the latest version before installation", async () => { + const h = await harness("darwin"); + await h.ready(); + h.latest = "2.19.0"; + let prevented = false; + expect(h.controller.handleBeforeQuit({ preventDefault() { prevented = true; } })).toBe(true); + expect(prevented).toBe(true); + await h.controller.install(); + expect(h.prepared).toEqual(["2.19.0"]); + expect(h.installs.map((entry) => entry.version)).toEqual(["2.19.0"]); + expect(h.quitFallbacks).toBe(0); + expect(h.controller.handleBeforeQuit({ preventDefault() { throw new Error("blocked final quit"); } })).toBe(false); + }); + + test("native signature failure does not install or retain an installation listener", async () => { + const h = await harness("darwin"); + await h.ready(); + h.nativeError = new Error("native code signature rejected"); + expect(await h.controller.install()).toEqual({ started: false }); + expect(h.installs).toEqual([]); + expect(h.native.listenerCount("update-downloaded")).toBe(1); // MacUpdater bookkeeping only. + expect(h.errors[0].stage).toBe("install"); + h.nativeError = null; + h.latest = "2.19.0"; + await expect(h.controller.check("interval")).rejects.toThrow("Restart EdgeEver"); + h.native.emit("update-downloaded"); // Late completion cannot authorize another target. + expect(h.installs).toEqual([]); + expect(h.downloads).toEqual(["2.17.0"]); + }); + + test("a failed check while quitting exits without staging an older package", async () => { + const h = await harness("darwin"); + await h.ready(); + h.checkError = new Error("offline"); + h.controller.handleBeforeQuit({ preventDefault() {} }); + await h.controller.install(); + expect(h.quitFallbacks).toBe(1); + expect(h.prepared).toEqual([]); + expect(h.installs).toEqual([]); + expect(h.controller.handleBeforeQuit({ preventDefault() { throw new Error("quit loop"); } })).toBe(false); + }); + + test("quit waits for ongoing native preparation and does not start a second install", async () => { + const h = await harness("darwin"); + await h.ready(); + h.nativeGate = deferred(); + const install = h.controller.install(); + await flush(); + expect(h.controller.handleBeforeQuit({ preventDefault() {} })).toBe(true); + expect(h.installs).toEqual([]); + h.nativeGate.resolve(); + await install; + expect(h.prepared).toEqual(["2.17.0"]); + expect(h.installs).toHaveLength(1); + }); + + test("a changed native feed cannot complete preparation of another target", async () => { + const native = new EventEmitter(); + let feed = "http://127.0.0.1:1/"; + native.getFeedURL = () => feed; + native.checkForUpdates = () => { + feed = "http://127.0.0.1:2/"; + native.emit("update-downloaded"); + }; + await expect(prepareMacUpdate(native)).rejects.toThrow("feed changed"); + expect(native.listenerCount("update-downloaded")).toBe(0); + expect(native.listenerCount("error")).toBe(0); + }); + + test("native update-not-available fails immediately and removes temporary listeners", async () => { + const native = new EventEmitter(); + native.getFeedURL = () => "http://127.0.0.1:1/"; + native.checkForUpdates = () => native.emit("update-not-available"); + await expect(prepareMacUpdate(native)).rejects.toThrow("not available"); + expect(native.listenerCount("update-downloaded")).toBe(0); + expect(native.listenerCount("error")).toBe(0); + expect(native.listenerCount("update-not-available")).toBe(0); + }); + + test("native timeout removes temporary listeners", async () => { + const native = new EventEmitter(); + native.getFeedURL = () => "http://127.0.0.1:1/"; + native.checkForUpdates = () => {}; + await expect(prepareMacUpdate(native, { timeoutMs: 1 })).rejects.toThrow("timed out"); + expect(native.listenerCount("update-downloaded")).toBe(0); + expect(native.listenerCount("error")).toBe(0); + }); +}); diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs index 3f3baa06..4e81ba34 100644 --- a/apps/desktop/src/main/desktop-update-flow.test.mjs +++ b/apps/desktop/src/main/desktop-update-flow.test.mjs @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; const mainSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8"); +const controllerSource = readFileSync(new URL("./desktop-update-controller.mjs", import.meta.url), "utf8"); const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../../../web/src/components/DesktopUpdateNotice.tsx", import.meta.url), "utf8"); const systemInfoSource = readFileSync(new URL("../../../web/src/components/settings/SystemInfoPanel.tsx", import.meta.url), "utf8"); @@ -17,20 +18,23 @@ describe("desktop update flow", () => { expect(systemInfoSource).toContain('t("systemInfo.desktopDownloadLatest")'); }); - test("downloads updates in the background and relaunches after installation", () => { - expect(mainSource).toContain('autoUpdater.autoDownload = process.platform !== "win32"'); - expect(mainSource).toContain("autoUpdater.autoRunAppAfterInstall = true"); - expect(mainSource).toContain('autoUpdater.quitAndInstall(process.platform === "win32", true)'); - expect(mainSource).toContain("result?.downloadPromise"); - expect(mainSource).toContain("downloadTrustedDesktopUpdate(reason)"); + test("routes every desktop platform through one update controller", () => { + expect(mainSource).toContain("desktopUpdateController = createDesktopUpdateController({"); + expect(mainSource).toContain("platform: process.platform"); + expect(mainSource).not.toContain("windowsUpdateController"); + expect(mainSource).not.toContain('updateState === "downloaded") {'); + expect(mainSource).toContain("desktopUpdateController.check(reason)"); + expect(mainSource).toContain("desktopUpdateController?.install(expectedVersion)"); + expect(mainSource).toContain("desktopUpdateController?.download(reason)"); + expect(controllerSource).toContain("void download(reason)"); + expect(controllerSource).toContain("updater.autoRunAppAfterInstall = true"); }); - test("fails closed around unsigned Windows automatic updates", () => { - expect(mainSource).toContain("fetchTrustedWindowsUpdate({"); - expect(mainSource).toContain("verifyDownloadedWindowsUpdate({"); - expect(mainSource).toContain('autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"'); - expect(mainSource).toContain("windowsDownloadedUpdateVerified = true"); - expect(mainSource).toContain('writeDiagnostic("update.windows-package-blocked"'); + test("provides Windows trust gates and macOS native installation to the shared controller", () => { + expect(mainSource).toContain("fetchTrustedWindowsUpdate({ ...options, fetchImpl: net.fetch })"); + expect(mainSource).toContain("verifyWindowsPackage: verifyDownloadedWindowsUpdate"); + expect(mainSource).toContain("nativeUpdater: nativeAutoUpdater"); + expect(mainSource).toContain("desktopUpdateController?.handleBeforeQuit(event)"); expect(mainSource).toContain('downloadTrustedDesktopUpdate("manual-download")'); }); @@ -60,8 +64,7 @@ describe("desktop update flow", () => { expect(mainSource).toContain('checkForDesktopUpdate("interval", { force: true })'); expect(mainSource).toContain('powerMonitor.on("resume"'); expect(mainSource).toContain('checkForDesktopUpdate("activate")'); - expect(mainSource).toContain("if (updateCheckInFlight) {"); - expect(mainSource).toContain("return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null)"); + expect(controllerSource).toContain("if (checkInFlight) return checkInFlight;"); expect(mainSource).toContain("clearInterval(updateCheckTimer)"); }); @@ -73,7 +76,7 @@ describe("desktop update flow", () => { test("pushes update status and shows a native restart prompt after download", () => { expect(mainSource).toContain('mainWindow.webContents.send("desktop:update-status-changed"'); - expect(mainSource).toContain("promptForDownloadedUpdate(downloadedUpdateVersion)"); + expect(mainSource).toContain("promptForDownloadedUpdate(version)"); expect(mainSource).toContain("title: copy.updateReadyTitle"); expect(preloadSource).toContain('ipcRenderer.on("desktop:update-status-changed"'); expect(noticeSource).toContain("bridge.onUpdateStatus"); @@ -87,7 +90,7 @@ describe("desktop update flow", () => { expect(mainSource).not.toContain("holdAutoRestartUpdate"); expect(mainSource).not.toContain("heldUpdateVersion"); expect(mainSource).not.toContain("update.held-for-instance"); - expect(mainSource).toContain("promptForDownloadedUpdate(downloadedUpdateVersion)"); + expect(mainSource).toContain("promptForDownloadedUpdate(version)"); expect(systemInfoSource).toContain("clientAheadOfInstanceByPlatform"); expect(systemInfoSource).toContain("isClientAheadOfInstance"); }); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index c5e2eb94..5eb6baea 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -1,4 +1,4 @@ -import { app, Notification, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, ClipboardItem, powerMonitor, desktopCapturer, screen, utilityProcess } from "electron"; +import { app, autoUpdater as nativeAutoUpdater, Notification, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, ClipboardItem, powerMonitor, desktopCapturer, screen, utilityProcess } from "electron"; import { createReadStream, existsSync } from "node:fs"; import { appendFile, mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -60,6 +60,7 @@ import { verifyDownloadedWindowsUpdate, } from "./windows-update-trust.mjs"; import electronUpdater from "electron-updater"; +import { createDesktopUpdateController } from "./desktop-update-controller.mjs"; import { createUpdateDiagnostic } from "./update-diagnostics.mjs"; import { createUpdateNetworkDiagnostics } from "./update-network-diagnostics.mjs"; import { normalizeUpdateProgress } from "./update-progress.mjs"; @@ -154,14 +155,11 @@ let isQuitting = false; let updateState = "idle"; let updateError = null; let updateProgress = null; -let updateCheckInFlight = null; -let updateDownloadInFlight = null; +let desktopUpdateController = null; let updateCheckTimer = null; let lastUpdateCheckAt = 0; let downloadedUpdateVersion = null; let promptedUpdateVersion = null; -let trustedWindowsUpdate = null; -let windowsDownloadedUpdateVerified = false; let sidecarScopeKey = "anonymous"; let activeAccountId = null; let shutdownCleanupStarted = false; @@ -1249,43 +1247,11 @@ const publishDesktopUpdateStatus = () => { mainWindow.webContents.send("desktop:update-status-changed", desktopUpdateStatus()); }; -const installDownloadedUpdate = () => { - if ( - updateState !== "downloaded" || - (process.platform === "win32" && !windowsDownloadedUpdateVerified) - ) return { started: false }; - // The normal window close handler hides the app. Mark this as a real quit - // before electron-updater closes windows so installation can proceed. - isQuitting = true; - // Keep Windows updates silent and reuse the registered installation directory. - autoUpdater.quitAndInstall(process.platform === "win32", true); - return { started: true }; -}; +const installDownloadedUpdate = (expectedVersion) => + desktopUpdateController?.install(expectedVersion) ?? Promise.resolve({ started: false }); -const trackDesktopUpdateDownload = (downloadPromise, reason) => { - updateDownloadInFlight = Promise.resolve(downloadPromise) - .catch(async (error) => { - updateState = "idle"; - updateProgress = null; - downloadedUpdateVersion = null; - windowsDownloadedUpdateVerified = false; - refreshTrayMenu(); - recordUpdateError(error, "download"); - publishDesktopUpdateStatus(); - await writeDiagnostic("update.download-failed", { reason, message: error.message }); - }) - .finally(() => { updateDownloadInFlight = null; updateNetworkDiagnostics.stop(); }); - return updateDownloadInFlight; -}; - -const downloadTrustedDesktopUpdate = (reason) => { - if (updateDownloadInFlight) return updateDownloadInFlight; - if (process.platform === "win32" && !trustedWindowsUpdate) { - return Promise.reject(new Error("Windows update metadata has not passed the signature gate")); - } - updateNetworkDiagnostics.setStage("download"); - return trackDesktopUpdateDownload(autoUpdater.downloadUpdate(), reason); -}; +const downloadTrustedDesktopUpdate = (reason) => + desktopUpdateController?.download(reason) ?? Promise.resolve(); const promptForDownloadedUpdate = async (version) => { const promptKey = version || "unknown"; @@ -1304,69 +1270,17 @@ const promptForDownloadedUpdate = async (version) => { noLink: true, }); if (result.response === 0) { - void writeDiagnostic("update.install-confirmed", { version }); - installDownloadedUpdate(); + void installDownloadedUpdate(version); } else { void writeDiagnostic("update.install-deferred", { version }); } }; const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = {}) => { - if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1" || updateState === "downloaded") { - return Promise.resolve(null); - } - if (updateCheckInFlight) { - return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null); - } - if (updateDownloadInFlight) return Promise.resolve(null); - const now = Date.now(); - if (!force && now - lastUpdateCheckAt < updateCheckFocusThrottleMs) return Promise.resolve(null); - lastUpdateCheckAt = now; - updateError = null; - updateNetworkDiagnostics.beginCheck(); - publishDesktopUpdateStatus(); - let diagnosticStage = "check"; - void writeDiagnostic("update.check-started", { reason }); - updateCheckInFlight = autoUpdater.checkForUpdates() - .then(async (result) => { - if (process.platform === "win32" && result?.isUpdateAvailable) { - diagnosticStage = "verify-windows-metadata"; - updateNetworkDiagnostics.setStage(diagnosticStage); - trustedWindowsUpdate = await fetchTrustedWindowsUpdate({ - version: result.updateInfo.version, - updateInfo: result.updateInfo, - fetchImpl: net.fetch, - }); - windowsDownloadedUpdateVerified = false; - void writeDiagnostic("update.windows-manifest-verified", { - version: trustedWindowsUpdate.version, - keyId: trustedWindowsUpdate.keyId, - }); - void downloadTrustedDesktopUpdate(reason); - } - if (result?.downloadPromise) { - updateNetworkDiagnostics.setStage("download"); - trackDesktopUpdateDownload(result.downloadPromise, reason); - } - return result; - }) - .catch(async (error) => { - updateState = "idle"; - updateProgress = null; - downloadedUpdateVersion = null; - trustedWindowsUpdate = null; - windowsDownloadedUpdateVerified = false; - refreshTrayMenu(); - recordUpdateError(error, diagnosticStage); - publishDesktopUpdateStatus(); - await writeDiagnostic("update.check-failed", { reason, message: error.message }); - throw error; - }) - .finally(() => { - updateCheckInFlight = null; - if (!updateDownloadInFlight) updateNetworkDiagnostics.stop(); - }); - return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null); + if (!desktopUpdateController) return Promise.resolve(null); + if (!force && Date.now() - lastUpdateCheckAt < updateCheckFocusThrottleMs) return Promise.resolve(null); + const result = desktopUpdateController.check(reason); + return throwOnError ? result : result.catch(() => null); }; const configureAutoUpdater = () => { @@ -1395,95 +1309,52 @@ const configureAutoUpdater = () => { appImage: process.env.APPIMAGE || null, }); } - autoUpdater.autoDownload = process.platform !== "win32"; - autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"; - autoUpdater.autoRunAppAfterInstall = true; - autoUpdater.on("update-available", (info) => { - updateState = "available"; - updateProgress = null; - downloadedUpdateVersion = info?.version || null; - if (process.platform === "win32") { - trustedWindowsUpdate = null; - windowsDownloadedUpdateVerified = false; - } - refreshTrayMenu(); - publishDesktopUpdateStatus(); - void writeDiagnostic("update.available", { version: info?.version }); - }); - autoUpdater.on("update-not-available", () => { - updateState = "idle"; - updateProgress = null; - downloadedUpdateVersion = null; - trustedWindowsUpdate = null; - windowsDownloadedUpdateVerified = false; - refreshTrayMenu(); - publishDesktopUpdateStatus(); - const diagnosticWritten = writeDiagnostic("update.not-available"); - if (linuxUpdateTestMode) { - void diagnosticWritten.finally(() => setTimeout(() => app.quit(), 100)); - } - }); - autoUpdater.on("download-progress", (progress) => { - updateProgress = normalizeUpdateProgress(progress); - publishDesktopUpdateStatus(); - void writeDiagnostic("update.download-progress", { percent: progress.percent }); - }); - autoUpdater.on("update-downloaded", (info) => { - void (async () => { - if (process.platform === "win32") { - if (!trustedWindowsUpdate || trustedWindowsUpdate.version !== info?.version) { - throw new Error("Downloaded Windows update has no matching trusted manifest"); - } - await verifyDownloadedWindowsUpdate({ - path: info.downloadedFile, - manifest: trustedWindowsUpdate, - }); - windowsDownloadedUpdateVerified = true; - autoUpdater.autoInstallOnAppQuit = true; - } - updateState = "downloaded"; - updateProgress = null; + desktopUpdateController = createDesktopUpdateController({ + updater: autoUpdater, + platform: process.platform, + nativeUpdater: nativeAutoUpdater, + quitApplication: () => app.quit(), + fetchWindowsMetadata: (options) => fetchTrustedWindowsUpdate({ ...options, fetchImpl: net.fetch }), + verifyWindowsPackage: verifyDownloadedWindowsUpdate, + onStatus: (status) => { + updateState = status.state; + downloadedUpdateVersion = status.version; + updateProgress = status.progress ? normalizeUpdateProgress(status.progress) : null; updateError = null; - downloadedUpdateVersion = info?.version || downloadedUpdateVersion; refreshTrayMenu(); publishDesktopUpdateStatus(); - await writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion }); + }, + onError: (error, stage) => { + isQuitting = false; + recordUpdateError(error, stage); + publishDesktopUpdateStatus(); + }, + onCheckStarted: (reason) => { + lastUpdateCheckAt = Date.now(); + updateError = null; + updateNetworkDiagnostics.beginCheck(); + publishDesktopUpdateStatus(); + void writeDiagnostic("update.check-started", { reason }); + }, + onStage: (stage) => updateNetworkDiagnostics.setStage(stage), + onSettled: () => updateNetworkDiagnostics.stop(), + log: (event, details) => { + const written = writeDiagnostic(event, details); + if (linuxUpdateTestMode && event === "update.not-available") { + void written.finally(() => setTimeout(() => app.quit(), 100)); + } + }, + beforeInstall: () => { isQuitting = true; }, + onDownloaded: (version) => { if (linuxUpdateTestMode) { - installDownloadedUpdate(); + void installDownloadedUpdate(version); return; } - await promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => { + void promptForDownloadedUpdate(version).catch((error) => { promptedUpdateVersion = null; void writeDiagnostic("update.prompt-failed", { message: error.message }); }); - })().catch(async (error) => { - if (process.platform !== "win32") { - await writeDiagnostic("update.download-handler-failed", { message: error.message }); - return; - } - autoUpdater.autoInstallOnAppQuit = false; - updateState = "idle"; - updateProgress = null; - downloadedUpdateVersion = null; - windowsDownloadedUpdateVerified = false; - refreshTrayMenu(); - recordUpdateError(error, "verify-windows-package"); - publishDesktopUpdateStatus(); - await writeDiagnostic("update.windows-package-blocked", { message: error.message }); - }); - }); - autoUpdater.on("error", (error) => { - isQuitting = false; - if (updateState !== "downloaded") { - updateState = "idle"; - updateProgress = null; - downloadedUpdateVersion = null; - windowsDownloadedUpdateVerified = false; - } - refreshTrayMenu(); - recordUpdateError(error, updateDownloadInFlight ? "download" : "check"); - publishDesktopUpdateStatus(); - void writeDiagnostic("update.error", { message: error.message }); + }, }); void checkForDesktopUpdate("startup", { force: true }); updateCheckTimer = setInterval(() => { @@ -2598,6 +2469,7 @@ app.on("window-all-closed", () => { }); app.on("before-quit", (event) => { + if (desktopUpdateController?.handleBeforeQuit(event)) return; if (shutdownCleanupStarted) return; event.preventDefault(); shutdownCleanupStarted = true; diff --git a/apps/desktop/src/main/mac-update-install.mjs b/apps/desktop/src/main/mac-update-install.mjs new file mode 100644 index 00000000..649064fd --- /dev/null +++ b/apps/desktop/src/main/mac-update-install.mjs @@ -0,0 +1,28 @@ +// Keep Squirrel out of background downloads. Its staged update can otherwise +// survive a later ZIP download and be installed instead of the selected target. +// Only prepare the current loopback feed once the controller has frozen it. +export const prepareMacUpdate = (nativeUpdater, { timeoutMs = 120_000 } = {}) => new Promise((resolve, reject) => { + const feed = nativeUpdater.getFeedURL(); + let timer; + const cleanup = () => { + clearTimeout(timer); + nativeUpdater.removeListener("update-downloaded", downloaded); + nativeUpdater.removeListener("error", failed); + nativeUpdater.removeListener("update-not-available", unavailable); + }; + const failed = (error) => { cleanup(); reject(error); }; + const unavailable = () => failed(new Error("Native macOS update is not available for the selected package")); + const downloaded = () => { + cleanup(); + if (nativeUpdater.getFeedURL() !== feed) { + reject(new Error("Native macOS update feed changed during preparation")); + } else { + resolve(); + } + }; + nativeUpdater.once("update-downloaded", downloaded); + nativeUpdater.once("error", failed); + nativeUpdater.once("update-not-available", unavailable); + timer = setTimeout(() => failed(new Error("Native macOS update preparation timed out")), timeoutMs); + try { nativeUpdater.checkForUpdates(); } catch (error) { failed(error); } +}); diff --git a/apps/desktop/src/main/renderer-storage-migration.test.mjs b/apps/desktop/src/main/renderer-storage-migration.test.mjs index c0eced38..26ac5638 100644 --- a/apps/desktop/src/main/renderer-storage-migration.test.mjs +++ b/apps/desktop/src/main/renderer-storage-migration.test.mjs @@ -1,9 +1,14 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { IDBFactory } from "fake-indexeddb"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { runInThisContext } from "node:vm"; import { RENDERER_STORAGE_MIGRATION_MARKER, + RENDERER_STORAGE_MIGRATION_VERSION, + exportLegacyRendererStorage, + importRendererStorage, migrateRendererStorageOrigin, rendererStorageMigrationDatabaseNames, } from "./renderer-storage-migration.mjs"; @@ -98,3 +103,587 @@ describe("renderer storage origin migration", () => { } }); }); + +const requestDone = (request) => new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); +}); + +const transactionComplete = (transaction) => new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); +}); + +const createLocalStorage = (initial = []) => { + const entries = new Map(initial); + return { + get length() { return entries.size; }, + key(index) { return Array.from(entries.keys())[index] ?? null; }, + getItem(key) { return entries.has(key) ? entries.get(key) : null; }, + setItem(key, value) { entries.set(key, String(value)); }, + removeItem(key) { entries.delete(key); }, + clear() { entries.clear(); }, + entries() { return Array.from(entries.entries()); }, + }; +}; + +const createOrigin = (localStorageEntries = []) => ({ + indexedDB: new IDBFactory(), + localStorage: createLocalStorage(localStorageEntries), +}); + +const originalGlobals = { + indexedDB: Object.getOwnPropertyDescriptor(globalThis, "indexedDB"), + localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), +}; + +const activateOrigin = (origin) => { + Object.defineProperty(globalThis, "indexedDB", { value: origin.indexedDB, configurable: true, writable: true }); + Object.defineProperty(globalThis, "localStorage", { value: origin.localStorage, configurable: true, writable: true }); +}; + +const restoreGlobals = () => { + for (const [name, descriptor] of Object.entries(originalGlobals)) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; + } +}; + +const openDatabase = (factory, name, version, upgrade) => { + const request = factory.open(name, version); + if (upgrade) request.onupgradeneeded = () => upgrade(request.result); + return requestDone(request); +}; + +const readStore = async (factory, databaseName, storeName) => { + const database = await openDatabase(factory, databaseName); + try { + const transaction = database.transaction(storeName, "readonly"); + const store = transaction.objectStore(storeName); + const [keys, values] = await Promise.all([requestDone(store.getAllKeys()), requestDone(store.getAll())]); + await transactionComplete(transaction); + return keys.map((key, index) => ({ key, value: values[index] })); + } finally { + database.close(); + } +}; + +const seedLegacyNotes = async (factory) => { + const database = await openDatabase(factory, "edgeever-local", 60, (db) => { + const memos = db.createObjectStore("memos", { keyPath: ["scope", "id"] }); + memos.createIndex("notebookId", "notebookId"); + memos.createIndex("tags", "tags", { multiEntry: true }); + const syncQueue = db.createObjectStore("syncQueue", { keyPath: "id" }); + syncQueue.createIndex("id", "id", { unique: true }); + db.createObjectStore("counters", { autoIncrement: true }); + db.createObjectStore("outOfLine"); + }); + const transaction = database.transaction(["memos", "syncQueue", "counters", "outOfLine"], "readwrite"); + transaction.objectStore("memos").put({ scope: "local", id: "memo-1", notebookId: "nb-1", tags: ["a", "b"], title: "First" }); + transaction.objectStore("memos").put({ scope: "local", id: "memo-2", notebookId: "nb-2", tags: [], title: "Second " }); + transaction.objectStore("syncQueue").put({ id: "op-1", memoId: "memo-1", status: "pending" }); + transaction.objectStore("counters").put({ value: 1 }); + transaction.objectStore("counters").put({ value: 2 }); + transaction.objectStore("outOfLine").put({ note: "out of line" }, "custom-key"); + await transactionComplete(transaction); + database.close(); +}; + +const seedLegacyPackages = async (factory) => { + const database = await openDatabase(factory, "edgeever-plugin-packages-v1", 2, (db) => { + const store = db.createObjectStore("packageVersions", { keyPath: ["pluginId", "version"] }); + store.createIndex("pluginId", "pluginId"); + }); + const transaction = database.transaction("packageVersions", "readwrite"); + transaction.objectStore("packageVersions").put({ pluginId: "com.example.alpha", version: "1.0.0", mainJs: "main()", stylesCss: null }); + await transactionComplete(transaction); + database.close(); +}; + +const seedLegacySecrets = async (factory, secrets, { withKey = true } = {}) => { + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + const database = await openDatabase(factory, "edgeever-plugin-secrets-v1", 1, (db) => { + db.createObjectStore("keys"); + db.createObjectStore("secrets", { keyPath: "id" }); + }); + const records = await Promise.all(secrets.map(async ([id, value]) => { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value)); + return { id, iv, ciphertext }; + })); + const transaction = database.transaction(["keys", "secrets"], "readwrite"); + if (withKey) transaction.objectStore("keys").put(key, "device-master-key"); + for (const record of records) transaction.objectStore("secrets").put(record); + await transactionComplete(transaction); + database.close(); +}; + +const readPluginSecrets = async (factory) => { + const database = await openDatabase(factory, "edgeever-plugin-secrets-v1"); + try { + const transaction = database.transaction(["keys", "secrets"], "readonly"); + const key = await requestDone(transaction.objectStore("keys").get("device-master-key")); + const records = await requestDone(transaction.objectStore("secrets").getAll()); + await transactionComplete(transaction); + const values = {}; + for (const record of records) { + values[record.id] = new TextDecoder().decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv: record.iv }, key, record.ciphertext)); + } + return { key, records, values }; + } finally { + database.close(); + } +}; + +const databaseNames = async (factory) => (await factory.databases()).map((entry) => entry.name).sort(); + +const seedFullLegacyOrigin = async () => { + const legacy = createOrigin([["theme", "dark"], ["edgeever:session", "{\"user\":\"u1\"}"]]); + await seedLegacyNotes(legacy.indexedDB); + await seedLegacyPackages(legacy.indexedDB); + await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"], ["com.example.beta:token", "beta-secret"]]); + return legacy; +}; + +// Mirrors how Electron moves the snapshot: the export result crosses IPC via +// structured clone and the import argument is embedded as JSON source text. +const transferSnapshot = (snapshot) => JSON.parse(JSON.stringify(snapshot)); + +describe("exportLegacyRendererStorage", () => { + afterEach(restoreGlobals); + + test("returns an empty snapshot for an origin without stored data", async () => { + activateOrigin(createOrigin()); + expect(await exportLegacyRendererStorage()).toEqual({ + version: 1, + localStorageEntries: [], + databases: [], + pluginSecrets: [], + }); + }); + + test("captures schemas, records, decrypted plugin secrets and localStorage", async () => { + const legacy = await seedFullLegacyOrigin(); + await openDatabase(legacy.indexedDB, "unrelated-database", 1, (db) => db.createObjectStore("items")).then((db) => db.close()); + activateOrigin(legacy); + + const snapshot = await exportLegacyRendererStorage(); + + expect(snapshot.version).toBe(1); + expect(snapshot.localStorageEntries).toEqual([["theme", "dark"], ["edgeever:session", "{\"user\":\"u1\"}"]]); + expect(snapshot.databases.map((database) => database.name)).toEqual(["edgeever-local", "edgeever-plugin-packages-v1"]); + const notes = snapshot.databases[0]; + expect(notes.version).toBe(60); + const memos = notes.stores.find((store) => store.name === "memos"); + expect(memos.keyPath).toEqual(["scope", "id"]); + expect(memos.autoIncrement).toBe(false); + expect(memos.indexes).toEqual(expect.arrayContaining([ + { name: "notebookId", keyPath: "notebookId", multiEntry: false, unique: false }, + { name: "tags", keyPath: "tags", multiEntry: true, unique: false }, + ])); + expect(memos.records).toHaveLength(2); + expect(memos.records[0].key).toEqual(["local", "memo-1"]); + const counters = notes.stores.find((store) => store.name === "counters"); + expect(counters.autoIncrement).toBe(true); + expect(counters.keyPath).toBeNull(); + expect(counters.records).toEqual([{ key: 1, value: { value: 1 } }, { key: 2, value: { value: 2 } }]); + expect(notes.stores.find((store) => store.name === "outOfLine").records).toEqual([ + { key: "custom-key", value: { note: "out of line" } }, + ]); + expect(snapshot.pluginSecrets).toEqual([ + { id: "com.example.alpha:token", value: "alpha-secret" }, + { id: "com.example.beta:token", value: "beta-secret" }, + ]); + }); + + test("leaves the legacy origin untouched", async () => { + const legacy = await seedFullLegacyOrigin(); + activateOrigin(legacy); + await exportLegacyRendererStorage(); + + expect(await databaseNames(legacy.indexedDB)).toEqual([ + "edgeever-local", + "edgeever-plugin-packages-v1", + "edgeever-plugin-secrets-v1", + ]); + expect(await readStore(legacy.indexedDB, "edgeever-local", "memos")).toHaveLength(2); + expect((await readPluginSecrets(legacy.indexedDB)).values["com.example.alpha:token"]).toBe("alpha-secret"); + expect(legacy.localStorage.entries()).toHaveLength(2); + }); + + test("fails instead of dropping secrets when the device key is missing", async () => { + const legacy = createOrigin(); + await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"]], { withKey: false }); + activateOrigin(legacy); + + await expect(exportLegacyRendererStorage()).rejects.toThrow("Plugin secret key is missing"); + }); + + test("fails when a stored secret cannot be decrypted", async () => { + const legacy = createOrigin(); + await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"]]); + const database = await openDatabase(legacy.indexedDB, "edgeever-plugin-secrets-v1"); + const transaction = database.transaction("secrets", "readwrite"); + transaction.objectStore("secrets").put({ id: "com.example.alpha:token", iv: new Uint8Array(12), ciphertext: new Uint8Array(32).buffer }); + await transactionComplete(transaction); + database.close(); + activateOrigin(legacy); + + await expect(exportLegacyRendererStorage()).rejects.toThrow(); + }); + + test("tolerates a secret key without secrets and a secret database without stores", async () => { + const withKeyOnly = createOrigin(); + await seedLegacySecrets(withKeyOnly.indexedDB, []); + activateOrigin(withKeyOnly); + expect((await exportLegacyRendererStorage()).pluginSecrets).toEqual([]); + + const withoutStores = createOrigin(); + await openDatabase(withoutStores.indexedDB, "edgeever-plugin-secrets-v1", 1).then((db) => db.close()); + activateOrigin(withoutStores); + expect((await exportLegacyRendererStorage()).pluginSecrets).toEqual([]); + }); +}); + +describe("importRendererStorage", () => { + afterEach(restoreGlobals); + + test("rejects missing or unsupported snapshots before touching storage", async () => { + const target = createOrigin([["keep", "me"]]); + activateOrigin(target); + await expect(importRendererStorage(null)).rejects.toThrow("Unsupported renderer storage migration snapshot"); + await expect(importRendererStorage({ version: 2, localStorageEntries: [], databases: [], pluginSecrets: [] })) + .rejects.toThrow("Unsupported renderer storage migration snapshot"); + expect(target.localStorage.entries()).toEqual([["keep", "me"]]); + }); + + test("restores every record, schema and secret into the new origin", async () => { + const legacy = await seedFullLegacyOrigin(); + activateOrigin(legacy); + const snapshot = transferSnapshot(await exportLegacyRendererStorage()); + + const target = createOrigin([["stale", "value"]]); + activateOrigin(target); + const counts = await importRendererStorage(snapshot); + + expect(counts).toEqual({ localStorageEntries: 2, databases: 2, pluginSecrets: 2 }); + expect(target.localStorage.entries()).toEqual([["theme", "dark"], ["edgeever:session", "{\"user\":\"u1\"}"]]); + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "memos")); + expect(await readStore(target.indexedDB, "edgeever-local", "syncQueue")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "syncQueue")); + expect(await readStore(target.indexedDB, "edgeever-local", "counters")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "counters")); + expect(await readStore(target.indexedDB, "edgeever-local", "outOfLine")).toEqual([{ key: "custom-key", value: { note: "out of line" } }]); + expect(await readStore(target.indexedDB, "edgeever-plugin-packages-v1", "packageVersions")).toHaveLength(1); + + const database = await openDatabase(target.indexedDB, "edgeever-local"); + try { + expect(database.version).toBe(60); + const transaction = database.transaction(["memos", "syncQueue", "counters"], "readwrite"); + const memos = transaction.objectStore("memos"); + expect(memos.index("tags").multiEntry).toBe(true); + expect(transaction.objectStore("syncQueue").index("id").unique).toBe(true); + expect(await requestDone(memos.index("tags").getAll("b"))).toHaveLength(1); + expect(await requestDone(transaction.objectStore("counters").add({ value: 3 }))).toBe(3); + await transactionComplete(transaction); + } finally { + database.close(); + } + + const secrets = await readPluginSecrets(target.indexedDB); + expect(secrets.values).toEqual({ + "com.example.alpha:token": "alpha-secret", + "com.example.beta:token": "beta-secret", + }); + expect(secrets.key.extractable).toBe(false); + for (const record of secrets.records) { + expect(record.iv).toHaveLength(12); + expect(new TextDecoder().decode(record.ciphertext)).not.toContain("secret"); + } + }); + + test("is idempotent: importing the same snapshot twice does not duplicate records", async () => { + const legacy = await seedFullLegacyOrigin(); + activateOrigin(legacy); + const snapshot = transferSnapshot(await exportLegacyRendererStorage()); + + const target = createOrigin(); + activateOrigin(target); + await importRendererStorage(snapshot); + await importRendererStorage(snapshot); + + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toHaveLength(2); + expect(await readStore(target.indexedDB, "edgeever-local", "counters")).toEqual([ + { key: 1, value: { value: 1 } }, + { key: 2, value: { value: 2 } }, + ]); + expect(Object.keys((await readPluginSecrets(target.indexedDB)).values)).toHaveLength(2); + expect(target.localStorage.entries()).toHaveLength(2); + }); + + test("replaces stale target data and removes stale plugin secrets when none are migrated", async () => { + const target = createOrigin(); + await seedLegacyNotes(target.indexedDB); + await seedLegacySecrets(target.indexedDB, [["com.example.stale:token", "stale"]]); + activateOrigin(target); + + const counts = await importRendererStorage({ + version: 1, + localStorageEntries: [["valid", "yes"], ["number", 1], [2, "key"], ["null", null]], + databases: [{ + name: "edgeever-local", + version: 60, + stores: [{ name: "memos", keyPath: ["scope", "id"], autoIncrement: false, indexes: [], records: [ + { key: ["local", "memo-9"], value: { scope: "local", id: "memo-9" } }, + ] }], + }, { + name: "edgeever-plugin-packages-v1", + version: 2, + stores: [], + }], + pluginSecrets: [], + }); + + expect(counts).toEqual({ localStorageEntries: 4, databases: 2, pluginSecrets: 0 }); + expect(target.localStorage.entries()).toEqual([["valid", "yes"]]); + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual([ + { key: ["local", "memo-9"], value: { scope: "local", id: "memo-9" } }, + ]); + const database = await openDatabase(target.indexedDB, "edgeever-local"); + expect(Array.from(database.objectStoreNames)).toEqual(["memos"]); + database.close(); + const packages = await openDatabase(target.indexedDB, "edgeever-plugin-packages-v1"); + expect(packages.version).toBe(2); + expect(Array.from(packages.objectStoreNames)).toEqual([]); + packages.close(); + expect(await databaseNames(target.indexedDB)).toEqual(["edgeever-local", "edgeever-plugin-packages-v1"]); + }); + + test("rejects when an open connection blocks the reset and succeeds once it is released", async () => { + const legacy = await seedFullLegacyOrigin(); + activateOrigin(legacy); + const snapshot = transferSnapshot(await exportLegacyRendererStorage()); + + const target = createOrigin(); + await seedLegacyNotes(target.indexedDB); + const blocker = await openDatabase(target.indexedDB, "edgeever-local"); + activateOrigin(target); + + await expect(importRendererStorage(snapshot)).rejects.toThrow("Reset of edgeever-local was blocked"); + blocker.close(); + + expect(await importRendererStorage(snapshot)).toEqual({ localStorageEntries: 2, databases: 2, pluginSecrets: 2 }); + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "memos")); + expect(Object.keys((await readPluginSecrets(target.indexedDB)).values)).toHaveLength(2); + }); +}); + +const createEvaluatingWindow = (origins, log) => () => { + let destroyed = false; + return { + async loadURL(url) { + log.push(["load", url]); + if (!origins[url]) throw new Error(`Cannot load ${url}`); + activateOrigin(origins[url]); + }, + destroy() { destroyed = true; log.push(["destroy"]); }, + isDestroyed() { return destroyed; }, + webContents: { + async executeJavaScript(code, userGesture) { + log.push(["execute", code, userGesture]); + // Runs the injected script in the global scope, like the renderer would. + return structuredClone(await runInThisContext(code)); + }, + }, + }; +}; + +describe("migrateRendererStorageOrigin end to end", () => { + let directory; + let markerPath; + const legacyBridgeUrl = "file:///bundle/desktop-storage-bridge.html"; + const targetBridgeUrl = "edgeever-app://app/desktop-storage-bridge.html"; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "edgeever-origin-migration-e2e-")); + markerPath = join(directory, RENDERER_STORAGE_MIGRATION_MARKER); + }); + + afterEach(async () => { + restoreGlobals(); + await rm(directory, { recursive: true, force: true }); + }); + + test("moves all data to the new origin, keeps the legacy copy and writes the marker", async () => { + const legacy = await seedFullLegacyOrigin(); + const target = createOrigin(); + const log = []; + + const result = await migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, log), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + }); + + expect(result).toEqual({ state: "migrated", counts: { localStorageEntries: 2, databases: 2, pluginSecrets: 2 } }); + expect(log.map(([kind, value]) => (kind === "execute" ? kind : value ? `${kind}:${value}` : kind))).toEqual([ + `load:${legacyBridgeUrl}`, + "execute", + `load:${targetBridgeUrl}`, + "execute", + "destroy", + ]); + const importCode = log.filter(([kind]) => kind === "execute")[1][1]; + expect(importCode).not.toContain(""); + expect(log.filter(([kind]) => kind === "execute").every(([, , userGesture]) => userGesture === true)).toBe(true); + + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "memos")); + expect((await readStore(target.indexedDB, "edgeever-local", "memos"))[1].value.title).toBe("Second "); + expect((await readPluginSecrets(target.indexedDB)).values["com.example.beta:token"]).toBe("beta-secret"); + expect(target.localStorage.entries()).toEqual(legacy.localStorage.entries()); + expect(await databaseNames(legacy.indexedDB)).toHaveLength(3); + + const marker = JSON.parse(await readFile(markerPath, "utf8")); + expect(marker.version).toBe(RENDERER_STORAGE_MIGRATION_VERSION); + expect(Number.isNaN(Date.parse(marker.migratedAt))).toBe(false); + expect(await Bun.file(`${markerPath}.tmp`).exists()).toBe(false); + + const again = await migrateRendererStorageOrigin({ + createWindow: () => { throw new Error("should not create a window"); }, + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + }); + expect(again).toEqual({ state: "already-migrated" }); + }); + + test("an interrupted export leaves no marker and no target data, and a retry completes", async () => { + const legacy = createOrigin([["theme", "dark"]]); + await seedLegacyNotes(legacy.indexedDB); + await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"]], { withKey: false }); + const target = createOrigin([["target-only", "kept"]]); + const log = []; + + await expect(migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, log), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + })).rejects.toThrow("Plugin secret key is missing"); + + expect(log.at(-1)).toEqual(["destroy"]); + expect(log.some(([kind, url]) => kind === "load" && url === targetBridgeUrl)).toBe(false); + expect(await Bun.file(markerPath).exists()).toBe(false); + expect(await databaseNames(target.indexedDB)).toEqual([]); + expect(target.localStorage.entries()).toEqual([["target-only", "kept"]]); + + const database = await openDatabase(legacy.indexedDB, "edgeever-plugin-secrets-v1"); + const transaction = database.transaction("secrets", "readwrite"); + transaction.objectStore("secrets").clear(); + await transactionComplete(transaction); + database.close(); + + const retry = await migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, []), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + }); + expect(retry.state).toBe("migrated"); + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toHaveLength(2); + expect(await Bun.file(markerPath).exists()).toBe(true); + }); + + test("a failed import followed by a retry yields exactly one copy of each record", async () => { + const legacy = await seedFullLegacyOrigin(); + const target = createOrigin(); + await seedLegacyNotes(target.indexedDB); + const blocker = await openDatabase(target.indexedDB, "edgeever-local"); + const origins = { [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }; + + await expect(migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow(origins, []), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + })).rejects.toThrow("was blocked"); + expect(await Bun.file(markerPath).exists()).toBe(false); + blocker.close(); + + expect((await migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow(origins, []), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + })).state).toBe("migrated"); + expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toHaveLength(2); + expect(await readStore(target.indexedDB, "edgeever-local", "counters")).toHaveLength(2); + expect(Object.keys((await readPluginSecrets(target.indexedDB)).values)).toHaveLength(2); + }); + + test("a leftover temporary marker from an interrupted run does not count as migrated", async () => { + await writeFile(`${markerPath}.tmp`, "{\"partial\":"); + const legacy = await seedFullLegacyOrigin(); + const target = createOrigin(); + + const result = await migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, []), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + }); + + expect(result.state).toBe("migrated"); + expect(JSON.parse(await readFile(markerPath, "utf8")).counts.databases).toBe(2); + expect(await Bun.file(`${markerPath}.tmp`).exists()).toBe(false); + }); + + test("does not write a marker when it cannot be persisted", async () => { + const missingDirectoryMarker = join(directory, "missing", RENDERER_STORAGE_MIGRATION_MARKER); + const log = []; + await expect(migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: createOrigin(), [targetBridgeUrl]: createOrigin() }, log), + legacyBridgeUrl, + targetBridgeUrl, + markerPath: missingDirectoryMarker, + })).rejects.toThrow(); + expect(await Bun.file(missingDirectoryMarker).exists()).toBe(false); + expect(log.at(-1)).toEqual(["destroy"]); + }); + + test("destroys the window when a bridge page fails to load", async () => { + const log = []; + await expect(migrateRendererStorageOrigin({ + createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: createOrigin() }, log), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + })).rejects.toThrow(`Cannot load ${targetBridgeUrl}`); + expect(log.at(-1)).toEqual(["destroy"]); + expect(await Bun.file(markerPath).exists()).toBe(false); + }); + + test("propagates window creation failures and skips destroying an already destroyed window", async () => { + await expect(migrateRendererStorageOrigin({ + createWindow: () => { throw new Error("no window"); }, + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + })).rejects.toThrow("no window"); + + let destroyCalls = 0; + await expect(migrateRendererStorageOrigin({ + createWindow: () => ({ + async loadURL() { throw new Error("renderer gone"); }, + destroy() { destroyCalls += 1; }, + isDestroyed() { return true; }, + webContents: { async executeJavaScript() { return null; } }, + }), + legacyBridgeUrl, + targetBridgeUrl, + markerPath, + })).rejects.toThrow("renderer gone"); + expect(destroyCalls).toBe(0); + expect(await Bun.file(markerPath).exists()).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/windows-update-controller.test.mjs b/apps/desktop/src/main/windows-update-controller.test.mjs new file mode 100644 index 00000000..293d50cb --- /dev/null +++ b/apps/desktop/src/main/windows-update-controller.test.mjs @@ -0,0 +1,329 @@ +import { createRequire } from "node:module"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { createDesktopUpdateController } from "./desktop-update-controller.mjs"; +import { WINDOWS_UPDATE_KEY_ID, verifyWindowsUpdateMetadata, verifyDownloadedWindowsUpdate } from "./windows-update-trust.mjs"; + +// Exercise the installed electron-updater's real check, download promise and +// quit-handler behavior. Replace only network/package acquisition and process +// spawning; no Electron app or Windows installer is launched by these tests. +const require = createRequire(new URL("../../package.json", import.meta.url)); +const { BaseUpdater } = require("electron-updater/out/BaseUpdater.js"); + +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; +const directories = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +const harness = async () => { + const directory = await mkdtemp(join(tmpdir(), "edgeever-update-target-")); + directories.push(directory); + const keys = generateKeyPairSync("ed25519"); + const releases = new Map(); + for (const version of ["2.17.0", "2.18.0", "2.19.0"]) { + const bytes = Buffer.from(`installer ${version}`); + const name = `EdgeEver-${version}-windows-x64.exe`; + const path = join(directory, name); + await writeFile(path, bytes); + const manifest = { + schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, version, platform: "win32", arch: "x64", + releaseDate: "2026-11-03T00:00:00.000Z", + file: { + name, size: bytes.length, + sha512: createHash("sha512").update(bytes).digest("base64"), + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }; + const info = { version, files: [{ url: name, size: bytes.length, sha512: manifest.file.sha512 }] }; + const manifestBytes = Buffer.from(JSON.stringify(manifest)); + const signatureBytes = Buffer.from(JSON.stringify({ schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, + signature: sign(null, manifestBytes, keys.privateKey).toString("base64") })); + releases.set(version, { path, info, manifest, manifestBytes, signatureBytes }); + } + const h = { latest: "2.17.0", checks: 0, downloads: [], installs: [], errors: [], notices: [], statuses: [], + checkGate: null, metadataGate: null, downloadGate: null, hashGate: null, + checkError: null, metadataError: null, downloadError: null, eventVersion: null, packagePath: null, + corruptSignature: false, corruptMetadata: false }; + const updater = new BaseUpdater(null, { + version: "2.16.1", isPackaged: true, + onQuit: (handler) => { h.quitHandler = handler; }, + }); + updater.logger = null; + updater.getUpdateInfoAndProvider = async () => { + h.checks++; + if (h.checkGate) await h.checkGate.promise; + if (h.checkError) throw h.checkError; + if (!h.latest) return { info: { version: "2.16.1" }, provider: {} }; + const info = structuredClone(releases.get(h.latest).info); + if (h.corruptMetadata) info.files[0].sha512 = "invalid"; + updater.selected = info; + return { info, provider: {} }; + }; + updater.doDownloadUpdate = async () => { + const version = updater.selected.version; + h.downloads.push(version); + if (h.downloadGate) await h.downloadGate.promise; + if (h.downloadError) throw h.downloadError; + updater.downloadedUpdateHelper = { + file: h.packagePath || releases.get(version).path, + downloadedFileInfo: { isAdminRightsRequired: false }, + }; + updater.dispatchUpdateDownloaded({ version: h.eventVersion || version, downloadedFile: updater.installerPath }); + updater.addQuitHandler(); + return [updater.installerPath]; + }; + updater.doInstall = ({ isSilent, isForceRunAfter }) => { + h.installs.push({ version: updater.selected.version, path: updater.installerPath, silent: isSilent, forceRun: isForceRunAfter }); + return true; + }; + updater.quitAndInstall = (silent, forceRun) => updater.install(silent, forceRun); + const controller = createDesktopUpdateController({ + updater, platform: "win32", + fetchWindowsMetadata: async ({ version, updateInfo }) => { + if (h.metadataGate) await h.metadataGate.promise; + if (h.metadataError) throw h.metadataError; + const release = releases.get(version); + return verifyWindowsUpdateMetadata({ ...release, expectedVersion: version, updateInfo, + signatureBytes: h.corruptSignature ? Buffer.from(JSON.stringify({ + schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, signature: Buffer.alloc(64).toString("base64"), + })) : release.signatureBytes, + trustedPublicKeys: { [WINDOWS_UPDATE_KEY_ID]: keys.publicKey } }); + }, + verifyWindowsPackage: async (options) => { + if (h.hashGate) await h.hashGate.promise; + await verifyDownloadedWindowsUpdate(options); + }, + onStatus: (status) => h.statuses.push(status), + onError: (error, stage) => h.errors.push({ error, stage }), + onDownloaded: (version) => h.notices.push(version), + beforeInstall: () => {}, + }); + // Await the download and hash work without timers or polling the filesystem. + const settle = async () => { await controller.download("test-wait").catch(() => {}); await flush(); }; + const ready = async () => { await controller.check("startup"); await settle(); }; + return Object.assign(h, { updater, controller, releases, settle, ready }); +}; + +describe("desktop update target lifecycle (#776)", () => { + test("replaces a downloaded intermediate version with the newest release", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.19.0"; + await h.controller.check("interval"); + await h.settle(); + expect(h.downloads).toEqual(["2.17.0", "2.19.0"]); + expect(h.controller.snapshot().version).toBe("2.19.0"); + expect(await h.controller.install()).toEqual({ started: true }); + expect(h.installs).toEqual([{ version: "2.19.0", path: h.releases.get("2.19.0").path, silent: true, forceRun: true }]); + }); + + test("checks again at installation time and installs the latest directly", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.18.0"; + expect(await h.controller.install()).toEqual({ started: true }); + expect(h.downloads).toEqual(["2.17.0", "2.18.0"]); + expect(h.installs.map((entry) => entry.version)).toEqual(["2.18.0"]); + }); + + test("normal Windows quit refreshes the installer and does not relaunch", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.19.0"; + expect(h.controller.handleBeforeQuit({ preventDefault() {} })).toBe(true); + await h.controller.install(); + expect(h.installs).toEqual([{ version: "2.19.0", path: h.releases.get("2.19.0").path, silent: true, forceRun: false }]); + expect(h.notices).toEqual(["2.17.0"]); + }); + + test("an unchanged release does not download or prompt again", async () => { + const h = await harness(); + await h.ready(); + await h.controller.check("manual"); + expect(h.checks).toBe(2); + expect(h.downloads).toEqual(["2.17.0"]); + expect(h.notices).toEqual(["2.17.0"]); + expect(h.updater.autoInstallOnAppQuit).toBe(true); + }); + + test("an old native dialog cannot approve installing a replacement version", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.18.0"; + expect(await h.controller.install("2.17.0")).toEqual({ started: false }); + expect(h.installs).toEqual([]); + expect(h.notices).toEqual(["2.17.0", "2.18.0"]); + expect(await h.controller.install("2.18.0")).toEqual({ started: true }); + }); + + test("blocks quit installation during a check and coalesces manual installation requests", async () => { + const h = await harness(); + await h.ready(); + h.checkGate = deferred(); + const check = h.controller.check("interval"); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + const first = h.controller.install(); + const second = h.controller.install(); + expect(first).toBe(second); + await flush(); + expect(h.installs).toEqual([]); + h.checkGate.resolve(); + await check; + await first; + expect(h.checks).toBe(2); + expect(h.installs).toHaveLength(1); + }); + + test("serializes checks and installation around asynchronous Windows hashing", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.18.0"; + h.hashGate = deferred(); + await h.controller.check("manual"); + await flush(); + h.latest = "2.19.0"; + await h.controller.check("resume"); + expect(h.checks).toBe(2); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + h.quitHandler(0); + expect(h.installs).toEqual([]); + expect(await h.controller.install()).toEqual({ started: false }); + h.hashGate.resolve(); + await h.settle(); + expect(h.controller.snapshot()).toMatchObject({ state: "downloaded", version: "2.18.0" }); + expect(h.updater.autoInstallOnAppQuit).toBe(true); + }); + + for (const failure of ["check", "metadata", "download", "signature", "metadata-digest", "package-digest", "event-version"]) { + test(`a replacement ${failure} failure never installs the old or unverified package`, async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.18.0"; + if (failure === "check") h.checkError = new Error("offline"); + if (failure === "metadata") h.metadataError = new Error("metadata unavailable"); + if (failure === "download") h.downloadError = new Error("download failed"); + if (failure === "signature") h.corruptSignature = true; + if (failure === "metadata-digest") h.corruptMetadata = true; + if (failure === "package-digest") await writeFile(h.releases.get(h.latest).path, "tampered! 2.18.0"); + if (failure === "event-version") h.eventVersion = "2.17.0"; + await h.controller.check("interval").catch(() => {}); + await h.settle(); + expect(await h.controller.install()).toEqual({ started: false }); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + expect(h.installs).toEqual([]); + expect(h.errors.length).toBeGreaterThan(0); + expect(h.controller.snapshot().state).toBe("idle"); + }); + } + + test("does not download a replacement until its signature gate completes", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.18.0"; + h.metadataGate = deferred(); + const check = h.controller.check("manual"); + expect(h.controller.check("resume")).toBe(check); + await flush(); + expect(h.downloads).toEqual(["2.17.0"]); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + expect(await h.controller.install()).toEqual({ started: false }); + h.metadataGate.resolve(); + await check; + await h.settle(); + expect(h.downloads).toEqual(["2.17.0", "2.18.0"]); + }); + + test("does not replace the updater target during a pending download", async () => { + const h = await harness(); + await h.ready(); + h.latest = "2.18.0"; + h.downloadGate = deferred(); + await h.controller.check("manual"); + h.latest = "2.19.0"; + await h.controller.check("resume"); + expect(h.checks).toBe(2); + expect(h.updater.selected.version).toBe("2.18.0"); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + h.downloadGate.resolve(); + await h.settle(); + expect(h.controller.snapshot().version).toBe("2.18.0"); + }); + + test("a changed digest at the same version cannot reuse the old verified target", async () => { + const h = await harness(); + await h.ready(); + h.corruptMetadata = true; + await expect(h.controller.check("interval")).rejects.toThrow("does not match the signed manifest"); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + expect(await h.controller.install()).toEqual({ started: false }); + expect(h.installs).toEqual([]); + }); + + test("a withdrawn release revokes installation of the downloaded package", async () => { + const h = await harness(); + await h.ready(); + h.latest = null; + await h.controller.check("interval"); + expect(h.controller.snapshot().state).toBe("idle"); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + expect(await h.controller.install()).toEqual({ started: false }); + }); + + test("registers the real updater quit hook only after hashing and revokes it during replacement", async () => { + const h = await harness(); + h.hashGate = deferred(); + await h.controller.check("startup"); + await flush(); + expect(h.quitHandler).toBeUndefined(); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + h.hashGate.resolve(); + await h.settle(); + expect(typeof h.quitHandler).toBe("function"); + h.latest = "2.18.0"; + h.metadataGate = deferred(); + const check = h.controller.check("interval"); + h.quitHandler(0); + expect(h.installs).toEqual([]); + h.metadataGate.resolve(); + await check; + await h.settle(); + h.quitHandler(0); + expect(h.installs).toEqual([{ version: "2.18.0", path: h.releases.get("2.18.0").path, silent: true, forceRun: false }]); + }); + + test("a failed check can recover and reauthorize a verified cached package", async () => { + const h = await harness(); + await h.ready(); + h.checkError = new Error("offline"); + await expect(h.controller.check("interval")).rejects.toThrow("offline"); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + h.checkError = null; + await h.ready(); + expect(h.controller.snapshot().state).toBe("downloaded"); + expect(await h.controller.install()).toEqual({ started: true }); + }); + + test("requires the installed Windows file to be the verified file", async () => { + const h = await harness(); + await h.ready(); + h.updater.downloadedUpdateHelper.file = h.releases.get("2.18.0").path; + h.metadataGate = deferred(); + const install = h.controller.install(); + await flush(); + expect(h.updater.autoInstallOnAppQuit).toBe(false); + expect(h.installs).toEqual([]); + h.metadataError = new Error("cannot authorize replacement"); + h.metadataGate.resolve(); + expect(await install).toEqual({ started: false }); + }); + +}); diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index b39171a0..abc42dbc 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -8,5 +8,5 @@ // CI_BUILD_NUMBER into the archive when present. // Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0). // Build 49 is reserved for the 1.74.0 App Store submission. -MARKETING_VERSION = 2.18.0 +MARKETING_VERSION = 2.19.0 CURRENT_PROJECT_VERSION = 49 diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index ae86a003..d1b53a3d 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -812,7 +812,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 2.18.0; + MARKETING_VERSION = 2.19.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -902,7 +902,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 2.18.0; + MARKETING_VERSION = 2.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 37840c91..c9ad2972 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "EdgeEver", "slug": "edgeever", "scheme": "edgeever", - "version": "2.18.0", + "version": "2.19.0", "runtimeVersion": { "policy": "appVersion" }, @@ -32,7 +32,7 @@ }, "android": { "package": "org.edgeever.mobile", - "versionCode": 256, + "versionCode": 257, "adaptiveIcon": { "backgroundColor": "#16A06E", "backgroundImage": "./assets/icon.png", diff --git a/apps/web/src/components/EditorOutline.tsx b/apps/web/src/components/EditorOutline.tsx index 54a920d2..2fec81d6 100644 --- a/apps/web/src/components/EditorOutline.tsx +++ b/apps/web/src/components/EditorOutline.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type Ref, type RefObject } from "react"; import { createPortal } from "react-dom"; import type { Transaction } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; @@ -21,6 +21,7 @@ type EditorOutlineProps = { collapsed: boolean; shortcutLabel: string; onCollapsedChange: (collapsed: boolean) => void; + contentRef?: Ref; }; const OUTLINE_INDENT = 12; @@ -53,7 +54,7 @@ const sameOutlineItems = (left: OutlineItem[], right: OutlineItem[]) => return other?.level === item.level && other.pos === item.pos && other.text === item.text; }); -export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContainer, collapsedAnchor, collapsed, shortcutLabel, onCollapsedChange }: EditorOutlineProps) => { +export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContainer, collapsedAnchor, collapsed, shortcutLabel, onCollapsedChange, contentRef }: EditorOutlineProps) => { const { t } = useTranslation(); const [items, setItems] = useState([]); const [activePos, setActivePos] = useState(null); @@ -377,7 +378,7 @@ export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContaine {!collapsed && ( -