diff --git a/package.json b/package.json index 9734ed1..38bbbfd 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@tsconfig/svelte": "^5.0.8", "@types/deno": "^2.5.0", "@types/diff-match-patch": "^1.0.36", + "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.13", "@types/pouchdb": "^6.4.2", "@types/pouchdb-adapter-http": "^6.1.6", @@ -134,6 +135,7 @@ "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", + "markdown-it": "^14.1.1", "minimatch": "^10.2.2", "node-datachannel": "^0.32.1", "octagonal-wheels": "^0.1.45", diff --git a/src/apps/webapp/bootstrap.ts b/src/apps/webapp/bootstrap.ts index f186128..2450285 100644 --- a/src/apps/webapp/bootstrap.ts +++ b/src/apps/webapp/bootstrap.ts @@ -42,7 +42,7 @@ async function renderHistoryList(): Promise { const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); listEl.innerHTML = ""; - emptyEl.style.display = items.length > 0 ? "none" : "block"; + emptyEl.classList.toggle("is-hidden", items.length > 0); for (const item of items) { const row = document.createElement("div"); @@ -82,7 +82,7 @@ async function startWithHandle(handle: FileSystemDirectoryHandle): Promise await app.initialize(); const selectorEl = getRequiredElement("vault-selector"); - selectorEl.style.display = "none"; + selectorEl.classList.add("is-hidden"); } async function startWithHistory(item: VaultHistoryItem): Promise { diff --git a/src/apps/webapp/main.ts b/src/apps/webapp/main.ts index 5892bd9..3cd4adf 100644 --- a/src/apps/webapp/main.ts +++ b/src/apps/webapp/main.ts @@ -13,6 +13,9 @@ import type { InjectableSettingService } from "@lib/services/implements/injectab import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner"; import { useRedFlagFeatures } from "@/serviceFeatures/redFlag"; import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize"; +import { useSetupQRCodeFeature } from "@lib/serviceFeatures/setupObsidian/qrCode"; +import { useSetupURIFeature } from "@lib/serviceFeatures/setupObsidian/setupUri"; +import { SetupManager } from "@/modules/features/SetupManager"; // import { ModuleObsidianSettingsAsMarkdown } from "@/modules/features/ModuleObsidianSettingAsMarkdown"; // import { ModuleObsidianMenu } from "@/modules/essentialObsidian/ModuleObsidianMenu"; @@ -112,12 +115,15 @@ class LiveSyncWebApp { // new ModuleReplicateTest(this, core), // new ModuleIntegratedTest(this, core), // new SetupManager(core), + new SetupManager(core), // this should be moved to core? ], () => [], // No add-ons (core) => { useOfflineScanner(core); useRedFlagFeatures(core); useCheckRemoteSize(core); + useSetupQRCodeFeature(core); + useSetupURIFeature(core); } ); diff --git a/src/apps/webapp/vaultSelector.ts b/src/apps/webapp/vaultSelector.ts index 2935376..79764a9 100644 --- a/src/apps/webapp/vaultSelector.ts +++ b/src/apps/webapp/vaultSelector.ts @@ -62,10 +62,7 @@ export class VaultHistoryStore { }); } - private async withStore( - mode: IDBTransactionMode, - task: (store: IDBObjectStore) => Promise - ): Promise { + private async withStore(mode: IDBTransactionMode, task: (store: IDBObjectStore) => Promise): Promise { const db = await this.openHandleDB(); try { const tx = db.transaction([HANDLE_STORE_NAME], mode); @@ -141,7 +138,9 @@ export class VaultHistoryStore { await this.requestAsPromise(store.put(item, makeVaultKey(item.id))); await this.requestAsPromise(store.put(item.id, LAST_USED_KEY)); - const merged = [...existing.filter((v) => v.id !== item.id), item].sort((a, b) => b.lastUsedAt - a.lastUsedAt); + const merged = [...existing.filter((v) => v.id !== item.id), item].sort( + (a, b) => b.lastUsedAt - a.lastUsedAt + ); const stale = merged.slice(MAX_HISTORY_COUNT); for (const old of stale) { await this.requestAsPromise(store.delete(makeVaultKey(old.id))); diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index 5b42608..48daae6 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -22,9 +22,7 @@ export default defineConfig({ index: path.resolve(__dirname, "index.html"), webapp: path.resolve(__dirname, "webapp.html"), }, - external:[ - "crypto" - ] + external: ["crypto"], }, }, define: { diff --git a/src/apps/webapp/webapp.css b/src/apps/webapp/webapp.css new file mode 100644 index 0000000..69bedda --- /dev/null +++ b/src/apps/webapp/webapp.css @@ -0,0 +1,369 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.container { + background: white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + padding: 40px; + max-width: 700px; + width: 100%; +} + +h1 { + color: #333; + margin-bottom: 10px; + font-size: 28px; +} + +.subtitle { + color: #666; + margin-bottom: 24px; + font-size: 14px; +} + +#status { + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + font-size: 14px; + font-weight: 500; +} + +#status.error { + background: #fee; + color: #c33; + border: 1px solid #fcc; +} + +#status.warning { + background: #ffeaa7; + color: #d63031; + border: 1px solid #fdcb6e; +} + +#status.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +#status.info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.vault-selector { + border: 1px solid #e6e9f2; + border-radius: 8px; + padding: 16px; + background: #fbfcff; + margin-bottom: 22px; +} + +.vault-selector h2 { + font-size: 18px; + margin-bottom: 8px; + color: #333; +} + +.vault-selector p { + color: #555; + font-size: 14px; + margin-bottom: 12px; +} + +.vault-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.vault-item { + border: 1px solid #d9deee; + border-radius: 8px; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: #fff; +} + +.vault-item-info { + min-width: 0; +} + +.vault-item-name { + font-weight: 600; + color: #1f2a44; + word-break: break-word; +} + +.vault-item-meta { + margin-top: 2px; + font-size: 12px; + color: #63708f; +} + +button { + border: none; + border-radius: 6px; + padding: 8px 12px; + background: #2f5ae5; + color: #fff; + cursor: pointer; + font-weight: 600; + white-space: nowrap; +} + +button:hover { + background: #1e4ad6; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.empty-note { + font-size: 13px; + color: #6c757d; + margin-bottom: 8px; +} + +.empty-note.is-hidden, +.vault-selector.is-hidden { + display: none; +} + +.info-section { + margin-top: 20px; + padding: 20px; + background: #f8f9fa; + border-radius: 8px; +} + +.info-section h2 { + font-size: 18px; + margin-bottom: 12px; + color: #333; +} + +.info-section ul { + list-style: none; + padding-left: 0; +} + +.info-section li { + padding: 7px 0; + color: #666; + font-size: 14px; +} + +.info-section li::before { + content: "•"; + color: #667eea; + font-weight: bold; + display: inline-block; + width: 1em; + margin-left: -1em; + padding-right: 0.5em; +} + +code { + background: #e9ecef; + padding: 2px 6px; + border-radius: 4px; + font-family: "Courier New", monospace; + font-size: 13px; +} + +.footer { + margin-top: 24px; + text-align: center; + color: #999; + font-size: 12px; +} + +.footer a { + color: #667eea; + text-decoration: none; +} + +.footer a:hover { + text-decoration: underline; +} + +body.livesync-log-visible { + min-height: 100vh; + padding-bottom: 42vh; +} + +#livesync-log-panel { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 42vh; + z-index: 900; + display: flex; + flex-direction: column; + background: #0f172a; + border-top: 1px solid #334155; +} + +.livesync-log-header { + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: #e2e8f0; + background: #111827; + border-bottom: 1px solid #334155; +} + +#livesync-log-viewport { + flex: 1; + overflow: auto; + padding: 8px 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.4; + color: #e2e8f0; + white-space: pre-wrap; + word-break: break-word; +} + +.livesync-log-line { + margin-bottom: 2px; +} + +#livesync-command-bar { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1000; + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 40vw; + padding: 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.livesync-command-button { + border: 1px solid #ddd; + border-radius: 8px; + padding: 6px 10px; + background: #fff; + color: #111827; + cursor: pointer; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + font-weight: 500; +} + +.livesync-command-button:hover:not(:disabled) { + background: #f3f4f6; +} + +.livesync-command-button.is-disabled { + opacity: 0.55; +} + +#livesync-window-root { + position: fixed; + top: 16px; + left: 16px; + right: 16px; + bottom: calc(42vh + 16px); + z-index: 850; + display: flex; + flex-direction: column; + border-radius: 10px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + overflow: hidden; +} + +#livesync-window-tabs { + display: flex; + gap: 6px; + padding: 8px; + background: #f3f4f6; + border-bottom: 1px solid #e5e7eb; +} + +#livesync-window-body { + position: relative; + flex: 1; + overflow: auto; + padding: 10px; +} + +.livesync-window-tab { + border: 1px solid #d1d5db; + background: #fff; + color: #111827; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; +} + +.livesync-window-tab.is-active { + background: #e0e7ff; + border-color: #818cf8; +} + +.livesync-window-panel { + display: none; + width: 100%; + height: 100%; + overflow: auto; +} + +.livesync-window-panel.is-active { + display: block; +} + +@media (max-width: 600px) { + .container { + padding: 28px 18px; + } + + h1 { + font-size: 24px; + } + + .vault-item { + flex-direction: column; + align-items: stretch; + } + + #livesync-command-bar { + max-width: calc(100vw - 24px); + right: 12px; + left: 12px; + bottom: 12px; + } +} diff --git a/src/apps/webapp/webapp.html b/src/apps/webapp/webapp.html index 5692f73..7512066 100644 --- a/src/apps/webapp/webapp.html +++ b/src/apps/webapp/webapp.html @@ -4,229 +4,7 @@ Self-hosted LiveSync WebApp - +
diff --git a/src/lib b/src/lib index 9145013..94e44e8 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 9145013efa054f6e6f388bff9f405ad42eb18b92 +Subproject commit 94e44e8a03b4d079f874c09b9ec60e43b7d35c58 diff --git a/src/main.ts b/src/main.ts index 78df81e..b1f9374 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,7 +29,6 @@ import type { ServiceModules } from "./types.ts"; import { setNoticeClass } from "@lib/mock_and_interop/wrapper.ts"; import type { ObsidianServiceContext } from "./lib/src/services/implements/obsidian/ObsidianServiceContext.ts"; import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts"; -import { ModuleSetupObsidian } from "./modules/features/ModuleSetupObsidian.ts"; import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts"; import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts"; import { SetupManager } from "./modules/features/SetupManager.ts"; @@ -38,6 +37,10 @@ import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts import { useOfflineScanner } from "./lib/src/serviceFeatures/offlineScanner.ts"; import { useCheckRemoteSize } from "./lib/src/serviceFeatures/checkRemoteSize.ts"; import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts"; +import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts"; +import { useSetupQRCodeFeature } from "@lib/serviceFeatures/setupObsidian/qrCode"; +import { useSetupURIFeature } from "@lib/serviceFeatures/setupObsidian/setupUri"; +import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts"; export type LiveSyncCore = LiveSyncBaseCore; export default class ObsidianLiveSyncPlugin extends Plugin { core: LiveSyncCore; @@ -147,7 +150,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { new ModuleObsidianEvents(this, core), new ModuleObsidianSettingDialogue(this, core), new ModuleObsidianMenu(core), - new ModuleSetupObsidian(core), new ModuleObsidianSettingsAsMarkdown(core), new ModuleLog(this, core), new ModuleObsidianDocumentHistory(this, core), @@ -174,6 +176,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const featuresInitialiser = enableI18nFeature; const curriedFeature = () => featuresInitialiser(core); core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); + const setupManager = core.getModule(SetupManager); + useSetupProtocolFeature(core, setupManager); + useSetupQRCodeFeature(core); + useSetupURIFeature(core); + useSetupManagerHandlersFeature(core, setupManager); useOfflineScanner(core); useRedFlagFeatures(core); useCheckRemoteSize(core); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts new file mode 100644 index 0000000..4eb9e42 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts @@ -0,0 +1,34 @@ +import { type SetupManager, UserMode } from "@/modules/features/SetupManager"; +import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; +import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@lib/events/coreEvents"; +import { eventHub } from "@lib/hub/hub"; +import { fireAndForget } from "@lib/common/utils"; +import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; + +export async function openSetupURI(setupManager: SetupManager) { + await setupManager.onUseSetupURI(UserMode.Unknown); +} + +export async function openP2PSettings(host: SetupFeatureHost, setupManager: SetupManager) { + return await setupManager.onP2PManualSetup(UserMode.Update, host.services.setting.currentSettings(), false); +} + +export function useSetupManagerHandlersFeature( + host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>, + setupManager: SetupManager +) { + host.services.appLifecycle.onLoaded.addHandler(() => { + host.services.API.addCommand({ + id: "livesync-opensetupuri", + name: "Use the copied setup URI (Formerly Open setup URI)", + callback: () => fireAndForget(openSetupURI(setupManager)), + }); + + eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => openSetupURI(setupManager))); + eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () => + fireAndForget(() => openP2PSettings(host, setupManager)) + ); + + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts new file mode 100644 index 0000000..6611194 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { eventHub } from "@lib/hub/hub"; +import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@lib/events/coreEvents"; +import { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers"; + +vi.mock("@/modules/features/SetupManager", () => { + return { + UserMode: { + Unknown: "unknown", + Update: "unknown", + }, + }; +}); + +describe("setupObsidian/setupManagerHandlers", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("openSetupURI should delegate to SetupManager.onUseSetupURI", async () => { + const setupManager = { + onUseSetupURI: vi.fn(async () => true), + } as any; + + await openSetupURI(setupManager); + expect(setupManager.onUseSetupURI).toHaveBeenCalledWith("unknown"); + }); + + it("openP2PSettings should delegate to SetupManager.onP2PManualSetup", async () => { + const settings = { x: 1 }; + const host = { + services: { + setting: { + currentSettings: vi.fn(() => settings), + }, + }, + } as any; + const setupManager = { + onP2PManualSetup: vi.fn(async () => true), + } as any; + + await openP2PSettings(host, setupManager); + expect(setupManager.onP2PManualSetup).toHaveBeenCalledWith("unknown", settings, false); + }); + + it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => { + const addHandler = vi.fn(); + const addCommand = vi.fn(); + const onEventSpy = vi.spyOn(eventHub, "onEvent"); + + const host = { + services: { + API: { + addCommand, + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => ({ x: 1 })), + }, + }, + } as any; + const setupManager = { + onUseSetupURI: vi.fn(async () => true), + onP2PManualSetup: vi.fn(async () => true), + } as any; + + useSetupManagerHandlersFeature(host, setupManager); + expect(addHandler).toHaveBeenCalledTimes(1); + + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + expect(addCommand).toHaveBeenCalledWith( + expect.objectContaining({ + id: "livesync-opensetupuri", + name: "Use the copied setup URI (Formerly Open setup URI)", + }) + ); + expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_SETUP_URI, expect.any(Function)); + expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_P2P_SETTINGS, expect.any(Function)); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/setupProtocol.ts b/src/serviceFeatures/setupObsidian/setupProtocol.ts new file mode 100644 index 0000000..5310fbf --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupProtocol.ts @@ -0,0 +1,37 @@ +import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/types"; +import type { LogFunction } from "@lib/services/lib/logUtils"; +import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; +import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; +import { configURIBase } from "@/common/types"; +import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; +import { type SetupManager, UserMode } from "@/modules/features/SetupManager"; + +async function handleSetupProtocol(setupManager: SetupManager, conf: Record) { + if (conf.settings) { + await setupManager.onUseSetupURI(UserMode.Unknown, `${configURIBase}${encodeURIComponent(conf.settings)}`); + } else if (conf.settingsQR) { + await setupManager.decodeQR(conf.settingsQR); + } +} + +export function registerSetupProtocolHandler(host: SetupFeatureHost, log: LogFunction, setupManager: SetupManager) { + try { + host.services.API.registerProtocolHandler("setuplivesync", async (conf) => { + await handleSetupProtocol(setupManager, conf); + }); + } catch (e) { + log("Failed to register protocol handler. This feature may not work in some environments.", LOG_LEVEL_NOTICE); + log(e, LOG_LEVEL_VERBOSE); + } +} + +export function useSetupProtocolFeature( + host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>, + setupManager: SetupManager +) { + const log = createInstanceLogFunction("SF:SetupProtocol", host.services.API); + host.services.appLifecycle.onLoaded.addHandler(() => { + registerSetupProtocolHandler(host, log, setupManager); + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/setupObsidian/setupProtocol.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupProtocol.unit.spec.ts new file mode 100644 index 0000000..03d56e5 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupProtocol.unit.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupProtocol"; + +vi.mock("@/common/types", () => { + return { + configURIBase: "mock-config://", + }; +}); + +vi.mock("@/modules/features/SetupManager", () => { + return { + UserMode: { + Unknown: "unknown", + Update: "unknown", + }, + }; +}); + +describe("setupObsidian/setupProtocol", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("registerSetupProtocolHandler should route settings payload to onUseSetupURI", async () => { + let protocolHandler: ((params: Record) => Promise) | undefined; + const host = { + services: { + API: { + registerProtocolHandler: vi.fn( + (_action: string, handler: (params: Record) => Promise) => { + protocolHandler = handler; + } + ), + }, + }, + } as any; + const log = vi.fn(); + const setupManager = { + onUseSetupURI: vi.fn(async () => true), + decodeQR: vi.fn(async () => true), + } as any; + + registerSetupProtocolHandler(host, log, setupManager); + expect(host.services.API.registerProtocolHandler).toHaveBeenCalledWith("setuplivesync", expect.any(Function)); + + await protocolHandler!({ settings: "a b" }); + expect(setupManager.onUseSetupURI).toHaveBeenCalledWith( + "unknown", + `mock-config://${encodeURIComponent("a b")}` + ); + expect(setupManager.decodeQR).not.toHaveBeenCalled(); + }); + + it("registerSetupProtocolHandler should route settingsQR payload to decodeQR", async () => { + let protocolHandler: ((params: Record) => Promise) | undefined; + const host = { + services: { + API: { + registerProtocolHandler: vi.fn( + (_action: string, handler: (params: Record) => Promise) => { + protocolHandler = handler; + } + ), + }, + }, + } as any; + const log = vi.fn(); + const setupManager = { + onUseSetupURI: vi.fn(async () => true), + decodeQR: vi.fn(async () => true), + } as any; + + registerSetupProtocolHandler(host, log, setupManager); + await protocolHandler!({ settingsQR: "qr-data" }); + + expect(setupManager.decodeQR).toHaveBeenCalledWith("qr-data"); + expect(setupManager.onUseSetupURI).not.toHaveBeenCalled(); + }); + + it("registerSetupProtocolHandler should log and continue when registration throws", () => { + const host = { + services: { + API: { + registerProtocolHandler: vi.fn(() => { + throw new Error("register failed"); + }), + }, + }, + } as any; + const log = vi.fn(); + const setupManager = { + onUseSetupURI: vi.fn(), + decodeQR: vi.fn(), + } as any; + + registerSetupProtocolHandler(host, log, setupManager); + + expect(log).toHaveBeenCalledTimes(2); + }); + + it("useSetupProtocolFeature should register onLoaded handler", async () => { + const addHandler = vi.fn(); + const registerProtocolHandler = vi.fn(); + const host = { + services: { + API: { + addLog: vi.fn(), + registerProtocolHandler, + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + }, + } as any; + const setupManager = { + onUseSetupURI: vi.fn(), + decodeQR: vi.fn(), + } as any; + + useSetupProtocolFeature(host, setupManager); + expect(addHandler).toHaveBeenCalledTimes(1); + + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + expect(registerProtocolHandler).toHaveBeenCalledWith("setuplivesync", expect.any(Function)); + }); +});