diff --git a/docs/adr/2026_07_multiple_remote_onboarding.md b/docs/adr/2026_07_multiple_remote_onboarding.md index 3ad81596..db468c0d 100644 --- a/docs/adr/2026_07_multiple_remote_onboarding.md +++ b/docs/adr/2026_07_multiple_remote_onboarding.md @@ -77,7 +77,7 @@ Commonlib unit tests cover preserving existing profiles, opaque-ID insertion, ge Self-hosted LiveSync unit tests cover preserving modern Setup URI profiles and their active selection, retaining legacy Setup URI and QR migration, adding CouchDB and Object Storage profiles beside an existing profile, independent P2P selection, fresh P2P selection as both main and P2P remote, and cancellation without mutation. -The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and command reopening. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate. +The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and reopening from the Setup pane. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate. ## Consequences diff --git a/docs/adr/2026_07_release_notes_and_database_compatibility.md b/docs/adr/2026_07_release_notes_and_database_compatibility.md index 15b21c1e..a444e40d 100644 --- a/docs/adr/2026_07_release_notes_and_database_compatibility.md +++ b/docs/adr/2026_07_release_notes_and_database_compatibility.md @@ -60,7 +60,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex ### Onboarding activation and initialisation -- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice and the permanent command instead of opening a competing dialogue automatically. +- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice, and allow the wizard to be reopened from the Setup pane instead of opening a competing dialogue automatically. - For new-device onboarding, reserve Rebuild before enabling and saving the accepted settings. - For an unconfigured existing device, reserve Fetch before enabling and saving imported or manually confirmed settings. - Suspend the current runtime after the flag has been written, apply the accepted settings through the scheduler's preparation callback, and request restart only after that callback succeeds. diff --git a/docs/p2p.md b/docs/p2p.md index 4e7902d9..ff47468a 100644 --- a/docs/p2p.md +++ b/docs/p2p.md @@ -48,7 +48,7 @@ A TURN provider cannot read LiveSync's encrypted Vault contents, but it can obse The **P2P Status** pane is the current Obsidian interface for P2P connections. -- The command **Self-hosted LiveSync: P2P Sync : Open P2P Status** remains available from the command palette. +- After a P2P configuration exists, the command **Self-hosted LiveSync: P2P Sync : Open P2P Status** is available from the command palette. - The P2P ribbon icon appears only after a P2P configuration exists. - LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it. - Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed. diff --git a/docs/settings.md b/docs/settings.md index 16af6883..b371d330 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -40,7 +40,7 @@ Internal database or settings compatibility reviews use a separate safety dialog This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync. -An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action, and **Open onboarding wizard** remains available from the command palette after that Notice closes. +An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**. Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan. diff --git a/docs/setup_p2p.md b/docs/setup_p2p.md index 0bf049ec..fb13a9c1 100644 --- a/docs/setup_p2p.md +++ b/docs/setup_p2p.md @@ -34,7 +34,7 @@ Before starting: ![P2P local database confirmation on the first device](../images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png) 7. Keep optional features disabled until ordinary note synchronisation works. -8. Open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. After a P2P profile exists, the P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected. +8. After saving the P2P profile, open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. The P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected. ![First P2P device connected to the signalling relay](../images/p2p-setup/guide-p2p-setup-first-device-connected.png) diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 5d031336..0c9057ba 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -64,6 +64,8 @@ export const liveSyncProvisionalEnglishMessages = { "This file has unresolved conflicts.": "This file has unresolved conflicts.", "This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.": "This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", + "Sync now": "Sync now", + "Apply pending changes now": "Apply pending changes now", } as const; export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages; diff --git a/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts new file mode 100644 index 00000000..e09efe85 --- /dev/null +++ b/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({ + addIcon: vi.fn(), + diff_match_patch: class DiffMatchPatch {}, + normalizePath: vi.fn((path: string) => path), + Notice: class Notice {}, + parseYaml: vi.fn(), + Platform: {}, +})); +vi.mock("./PluginDialogModal.ts", () => ({ + PluginDialogModal: class PluginDialogModal {}, +})); +vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ + JsonResolveModal: class JsonResolveModal {}, +})); +vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({ + ConflictResolveModal: class ConflictResolveModal {}, +})); +vi.mock("@/features/LiveSyncCommands.ts", () => ({ + LiveSyncCommands: class LiveSyncCommands { + core!: { services: unknown }; + get services() { + return this.core.services; + } + }, +})); +vi.mock("@/common/types.ts", () => ({ + ICXHeader: "ix:", + PERIODIC_PLUGIN_SWEEP: 60, +})); +vi.mock("@/common/utils.ts", () => ({ + EVEN: Symbol("even"), + disposeMemoObject: vi.fn(), + isCustomisationSyncMetadata: vi.fn(), + isPluginMetadata: vi.fn(), + memoIfNotExist: vi.fn(), + memoObject: vi.fn(), + retrieveMemoObject: vi.fn(), + scheduleTask: vi.fn(), +})); +vi.mock("@/common/PeriodicProcessor.ts", () => ({ + PeriodicProcessor: class PeriodicProcessor {}, +})); +vi.mock("@/common/events.ts", () => ({ + EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync", + eventHub: { + onEvent: vi.fn(), + }, +})); +vi.mock("@/common/translation", () => ({ + $msg: vi.fn((message: string) => message), +})); +vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ + getObsidianCommunityPluginManager: vi.fn(), +})); + +import { ConfigSync } from "./CmdConfigSync"; + +describe("ConfigSync commands", () => { + it("shows the Customisation Sync command only whilst the feature is enabled", () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + usePluginSync: false, + }; + const showPluginSyncModal = vi.fn(); + const configSync = Object.create(ConfigSync.prototype) as ConfigSync; + Object.assign(configSync, { + core: { + settings, + services: { + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + }, + }, + addRibbonIcon: vi.fn(() => ({ + addClass: vi.fn(), + })), + showPluginSyncModal, + }); + + configSync.onload(); + + const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex"); + expect(command?.checkCallback?.(true)).toBe(false); + + settings.usePluginSync = true; + expect(command?.checkCallback?.(true)).toBe(true); + expect(command?.checkCallback?.(false)).toBe(true); + expect(showPluginSyncModal).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.ts b/src/features/ConfigSync/CmdConfigSync.ts index f5ef2c80..b4a40841 100644 --- a/src/features/ConfigSync/CmdConfigSync.ts +++ b/src/features/ConfigSync/CmdConfigSync.ts @@ -454,8 +454,14 @@ export class ConfigSync extends LiveSyncCommands { this.services.API.addCommand({ id: "livesync-plugin-dialog-ex", name: "Show customization sync dialog", - callback: () => { - this.showPluginSyncModal(); + checkCallback: (checking) => { + if (!this.isThisModuleEnabled()) { + return false; + } + if (!checking) { + this.showPluginSyncModal(); + } + return true; }, }); this.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => { diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.ts index 6d1147aa..92826376 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.ts @@ -54,10 +54,7 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import type { LiveSyncCore } from "@/main.ts"; import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; -import { - configureHiddenFileSyncMode, - type ConfigureHiddenFileSyncResult, -} from "./configureHiddenFileSyncMode.ts"; +import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts"; type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; @@ -109,37 +106,45 @@ export class HiddenFileSync extends LiveSyncCommands { this.services.API.addCommand({ id: "livesync-sync-internal", name: "(re)initialise hidden files between storage and database", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.initialiseInternalFileSync("safe", true); } + return true; }, }); this.services.API.addCommand({ id: "livesync-scaninternal-storage", name: "Scan hidden file changes on the storage", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.scanAllStorageChanges(true); } + return true; }, }); this.services.API.addCommand({ id: "livesync-scaninternal-database", name: "Scan hidden file changes on the local database", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.scanAllDatabaseChanges(true); } + return true; }, }); this.services.API.addCommand({ id: "livesync-internal-scan-offline-changes", name: "Scan and apply all offline hidden-file changes", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.applyOfflineChanges(true); } + return true; }, }); eventHub.onEvent(EVENT_SETTING_SAVED, () => { @@ -191,12 +196,16 @@ export class HiddenFileSync extends LiveSyncCommands { } isReady() { - if (!this._isMainReady) return false; + if (!this._isMainReady()) return false; if (this._isMainSuspended()) return false; if (!this.isThisModuleEnabled()) return false; return true; } + private isManualCommandAvailable() { + return this.settings.useAdvancedMode && this.isReady() && this._isDatabaseReady(); + } + async performStartupScan(showNotice: boolean) { await this.applyOfflineChanges(showNotice); } diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts index 66d00a29..7e7685d2 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts @@ -8,13 +8,16 @@ vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ vi.mock("@/features/LiveSyncCommands.ts", () => ({ LiveSyncCommands: class LiveSyncCommands { plugin!: { app: unknown }; - core!: { services: unknown }; + core!: { services: unknown; settings: unknown }; get app() { return this.plugin.app; } get services() { return this.core.services; } + get settings() { + return this.core.settings; + } }, })); vi.mock("./configureHiddenFileSyncMode.ts", () => ({ @@ -25,6 +28,66 @@ import { HiddenFileSync } from "./CmdHiddenFileSync.ts"; import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; describe("HiddenFileSync configuration-change notices", () => { + it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + syncInternalFiles: false, + useAdvancedMode: false, + }; + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + core: { + settings, + services: { + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + }, + }, + _isMainReady: vi.fn(() => true), + _isMainSuspended: vi.fn(() => false), + _isDatabaseReady: vi.fn(() => true), + }); + + hiddenFileSync.onload(); + + const commandIds = [ + "livesync-sync-internal", + "livesync-scaninternal-storage", + "livesync-scaninternal-database", + "livesync-internal-scan-offline-changes", + ]; + for (const commandId of commandIds) { + const command = commands.find(({ id }) => id === commandId); + expect(command?.checkCallback?.(true)).toBe(false); + } + + settings.syncInternalFiles = true; + settings.useAdvancedMode = true; + for (const commandId of commandIds) { + const command = commands.find(({ id }) => id === commandId); + expect(command?.checkCallback?.(true)).toBe(true); + } + }); + + it("does not report Hidden File Sync as ready before the main runtime is ready", () => { + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + core: { + settings: { + syncInternalFiles: true, + }, + }, + _isMainReady: vi.fn(() => false), + _isMainSuspended: vi.fn(() => false), + }); + + expect(hiddenFileSync.isReady()).toBe(false); + }); + it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => { const noticeGroups = { setItem: vi.fn(), diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts index 05a32e8c..37a9c7e5 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts @@ -4,6 +4,8 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, + REMOTE_COUCHDB, + REMOTE_P2P, type DocumentID, type EntryDoc, type EntryLeaf, @@ -38,16 +40,28 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands { id: "analyse-database", name: "Analyse Database Usage (advanced)", icon: "database-search", - callback: async () => { - await this.analyseDatabase(); + checkCallback: (checking) => { + if (!this.settings.useAdvancedMode || !this._isDatabaseReady()) return false; + if (!checking) { + void this.analyseDatabase(); + } + return true; }, }); this.plugin.addCommand({ id: "gc-v3", name: "Garbage Collection V3 (advanced, beta)", icon: "trash-2", - callback: async () => { - await this.gcv3(); + checkCallback: (checking) => { + const isApplicableRemote = + this.settings.remoteType === REMOTE_COUCHDB || this.settings.remoteType === REMOTE_P2P; + if (!this.settings.useEdgeCaseMode || !this._isDatabaseReady() || !isApplicableRemote) { + return false; + } + if (!checking) { + void this.gcv3(); + } + return true; }, }); eventHub.onEvent(EVENT_ANALYSE_DB_USAGE, () => this.analyseDatabase()); diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts index 6bcef72a..19be24ee 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts @@ -1,5 +1,31 @@ import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +vi.mock("octagonal-wheels/number", () => ({ + sizeToHumanReadable: vi.fn((value: number) => `${value} B`), +})); +vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({ + serialized: vi.fn((_key: string, task: () => unknown) => task()), +})); +vi.mock("octagonal-wheels/collection", () => ({ + arrayToChunkedArray: vi.fn((values: unknown[]) => [values]), +})); +vi.mock("@/features/LiveSyncCommands", () => ({ + LiveSyncCommands: class LiveSyncCommands { + core!: { settings: unknown }; + get settings() { + return this.core.settings; + } + }, +})); +vi.mock("@/common/events", () => ({ + EVENT_ANALYSE_DB_USAGE: "analyse", + EVENT_REQUEST_PERFORM_GC_V3: "gc", + eventHub: { + onEvent: vi.fn(), + }, +})); +import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte"; import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites"; function createPrerequisites(settingsOverride: Partial = {}) { @@ -22,6 +48,49 @@ function createPrerequisites(settingsOverride: Partial } describe("LocalDatabaseMaintenance prerequisites", () => { + it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings: { + useAdvancedMode: boolean; + useEdgeCaseMode: boolean; + remoteType: string; + } = { + useAdvancedMode: false, + useEdgeCaseMode: false, + remoteType: REMOTE_COUCHDB, + }; + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + Object.assign(maintenance, { + plugin: { + addCommand: vi.fn((command) => commands.push(command)), + }, + core: { + settings, + }, + _isDatabaseReady: vi.fn(() => true), + }); + + maintenance.onload(); + + const analyse = commands.find(({ id }) => id === "analyse-database"); + const garbageCollect = commands.find(({ id }) => id === "gc-v3"); + expect(analyse?.checkCallback?.(true)).toBe(false); + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + + settings.useAdvancedMode = true; + expect(analyse?.checkCallback?.(true)).toBe(true); + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + + settings.useEdgeCaseMode = true; + expect(garbageCollect?.checkCallback?.(true)).toBe(true); + + settings.remoteType = REMOTE_MINIO; + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + }); + it("asks to disable on-demand chunk fetching before maintenance actions", async () => { const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites(); diff --git a/src/modules/essential/ModuleBasicMenu.ts b/src/modules/essential/ModuleBasicMenu.ts index 2c67ab34..5eb6d235 100644 --- a/src/modules/essential/ModuleBasicMenu.ts +++ b/src/modules/essential/ModuleBasicMenu.ts @@ -2,13 +2,14 @@ import type { LiveSyncCore } from "@/main"; import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { fireAndForget } from "octagonal-wheels/promises"; import { AbstractModule } from "@/modules/AbstractModule"; +import { $msg } from "@/common/translation"; // Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes. // However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version. export class ModuleBasicMenu extends AbstractModule { _everyOnloadStart(): Promise { this.addCommand({ id: "livesync-replicate", - name: "Replicate now", + name: $msg("Sync now"), callback: async () => { await this.services.replication.replicate(); }, @@ -56,14 +57,18 @@ export class ModuleBasicMenu extends AbstractModule { this.addCommand({ id: "livesync-scan-files", name: "Scan storage and database again", - callback: async () => { - await this.services.vault.scanVault(true); + checkCallback: (checking) => { + if (!this.settings.useAdvancedMode) return false; + if (!checking) { + fireAndForget(() => this.services.vault.scanVault(true)); + } + return true; }, }); this.addCommand({ id: "livesync-runbatch", - name: "Run pended batch processes", + name: $msg("Apply pending changes now"), callback: async () => { await this.services.fileProcessing.commitPendingFileEvents(); }, @@ -73,8 +78,12 @@ export class ModuleBasicMenu extends AbstractModule { this.addCommand({ id: "livesync-abortsync", name: "Abort synchronization immediately", - callback: () => { - this.core.replicator.terminateSync(); + checkCallback: (checking) => { + if (!this.settings.useAdvancedMode) return false; + if (!checking) { + this.core.replicator.terminateSync(); + } + return true; }, }); return Promise.resolve(true); diff --git a/src/modules/essential/ModuleBasicMenu.unit.spec.ts b/src/modules/essential/ModuleBasicMenu.unit.spec.ts new file mode 100644 index 00000000..10a175e7 --- /dev/null +++ b/src/modules/essential/ModuleBasicMenu.unit.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Command } from "@/deps"; +import { ModuleBasicMenu } from "./ModuleBasicMenu"; + +type RegisteredCommand = Command & { + checkCallback?: (checking: boolean) => boolean | void; +}; + +function createFixture() { + const commands: RegisteredCommand[] = []; + const settings = { + liveSync: false, + useAdvancedMode: false, + enableDebugTools: false, + }; + const services = { + API: { + addLog: vi.fn(), + addCommand: vi.fn((command: RegisteredCommand) => { + commands.push(command); + return command; + }), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + replication: { + replicate: vi.fn(async () => undefined), + }, + vault: { + getActiveFilePath: vi.fn((): string | null => "note.md"), + scanVault: vi.fn(async () => undefined), + }, + control: { + applySettings: vi.fn(async () => undefined), + }, + setting: { + saveSettingData: vi.fn(async () => undefined), + }, + appLifecycle: { + isSuspended: vi.fn(() => false), + setSuspended: vi.fn(), + }, + fileProcessing: { + commitPendingFileEvents: vi.fn(async () => true), + }, + UI: { + promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true), + }, + path: { + path2id: vi.fn(async () => "f:note"), + }, + }; + const core = { + settings, + _services: services, + services, + localDatabase: { + getDBEntry: vi.fn(async () => false), + localDatabase: { + get: vi.fn(async () => ({ + _id: "f:note", + _rev: "2-current", + _conflicts: [], + path: "note.md", + ctime: 100, + mtime: 200, + size: 12, + type: "plain", + children: ["h:private-chunk-id"], + eden: {}, + })), + }, + getDBEntryMeta: vi.fn(async () => ({ + _id: "f:note", + _rev: "2-current", + _conflicts: [], + path: "note.md", + ctime: 100, + mtime: 200, + size: 12, + type: "plain", + datatype: "plain", + data: "", + children: ["h:private-chunk-id"], + eden: {}, + })), + allDocsRaw: vi.fn(async () => ({ + rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }], + })), + }, + storageAccess: { + isExistsIncludeHidden: vi.fn(async () => true), + statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })), + }, + replicator: { + terminateSync: vi.fn(), + }, + }; + const module = new ModuleBasicMenu(core as never); + + return { + commands, + core, + module, + services, + settings, + getCommand(id: string) { + const command = commands.find((candidate) => candidate.id === id); + expect(command, `command ${id}`).toBeDefined(); + return command!; + }, + }; +} + +describe("ModuleBasicMenu command palette", () => { + it("uses clear user-facing names without changing the established command IDs", async () => { + const fixture = createFixture(); + + await fixture.module._everyOnloadStart(); + + expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now"); + expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now"); + }); + + it("keeps maintenance commands out of the normal palette", async () => { + const fixture = createFixture(); + + await fixture.module._everyOnloadStart(); + + expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false); + expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false); + + fixture.settings.useAdvancedMode = true; + expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true); + expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true); + }); + +}); diff --git a/src/modules/features/SettingDialogue/PaneSetup.ts b/src/modules/features/SettingDialogue/PaneSetup.ts index 866d403b..1fd66675 100644 --- a/src/modules/features/SettingDialogue/PaneSetup.ts +++ b/src/modules/features/SettingDialogue/PaneSetup.ts @@ -12,7 +12,7 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts import type { PageFunctions } from "./SettingPane.ts"; import { visibleOnly } from "./SettingPane.ts"; import { request } from "@/deps.ts"; -import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts"; +import { SetupManager } from "@/modules/features/SetupManager.ts"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; import { createCoreSettingsAfterFullReset, @@ -40,8 +40,7 @@ export function paneSetup( .addButton((text) => { text.setButtonText($msg("Rerun Wizard")).onClick(async () => { const setupManager = this.core.getModule(SetupManager); - await setupManager.onOnboard(UserMode.ExistingUser); - // await this.plugin.moduleSetupObsidian.onBoardingWizard(true); + await setupManager.startOnBoarding(); }); }); diff --git a/src/serviceFeatures/setupObsidian/qrCode.ts b/src/serviceFeatures/setupObsidian/qrCode.ts index 6498c1fb..844719c2 100644 --- a/src/serviceFeatures/setupObsidian/qrCode.ts +++ b/src/serviceFeatures/setupObsidian/qrCode.ts @@ -65,7 +65,11 @@ export function useSetupQRCodeFeature(host: NecessaryServices<"API" | "UI" | "se host.services.API.addCommand({ id: "livesync-setting-qr", name: "Show settings as a QR code", - callback: () => fireAndForget(encodeSetupSettingsAsQR(host)), + checkCallback: (checking) => { + if (!host.services.setting.currentSettings().isConfigured) return false; + if (!checking) fireAndForget(encodeSetupSettingsAsQR(host)); + return true; + }, }); host.services.context.events.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => encodeSetupSettingsAsQR(host)) diff --git a/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts index 26f7884d..61244f7c 100644 --- a/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts @@ -114,4 +114,44 @@ describe("setupObsidian/qrCode", () => { ); expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_SHOW_SETUP_QR, expect.any(Function)); }); + + it("keeps the QR command out of the palette until setup is complete", async () => { + const addHandler = vi.fn(); + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { isConfigured: false }; + const host = { + services: { + context: createServiceContext(), + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => settings), + }, + UI: { + confirm: { + confirmWithMessage: vi.fn(), + }, + }, + }, + } as any; + + useSetupQRCodeFeature(host); + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + const command = commands.find((candidate) => candidate.id === "livesync-setting-qr")!; + expect(command.checkCallback?.(true)).toBe(false); + + settings.isConfigured = true; + expect(command.checkCallback?.(true)).toBe(true); + }); }); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts index b12ae1da..d745a563 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts @@ -47,11 +47,6 @@ export function useSetupManagerHandlersFeature( setupManager: SetupManager ) { host.services.appLifecycle.onLoaded.addHandler(() => { - host.services.API.addCommand({ - id: "livesync-open-onboarding", - name: "Open onboarding wizard", - callback: () => fireAndForget(() => openOnboarding(setupManager)), - }); host.services.API.addCommand({ id: "livesync-opensetupuri", name: "Use the copied setup URI (Formerly Open setup URI)", diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts index a5493495..bdae06ca 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts @@ -109,7 +109,7 @@ describe("setupObsidian/setupManagerHandlers", () => { expect(preventDefault).toHaveBeenCalledOnce(); }); - it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => { + it("keeps onboarding out of the command palette while wiring the setup URI command and events", async () => { const addHandler = vi.fn(); const addCommand = vi.fn(); const events = { onEvent: vi.fn() }; @@ -147,10 +147,9 @@ describe("setupObsidian/setupManagerHandlers", () => { const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; await loadedHandler(); - expect(addCommand).toHaveBeenCalledWith( + expect(addCommand).not.toHaveBeenCalledWith( expect.objectContaining({ id: "livesync-open-onboarding", - name: "Open onboarding wizard", }) ); expect(addCommand).toHaveBeenCalledWith( diff --git a/src/serviceFeatures/setupObsidian/setupUri.ts b/src/serviceFeatures/setupObsidian/setupUri.ts index 9a8d8c65..a4fe4aec 100644 --- a/src/serviceFeatures/setupObsidian/setupUri.ts +++ b/src/serviceFeatures/setupObsidian/setupUri.ts @@ -50,19 +50,33 @@ export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setti host.services.API.addCommand({ id: "livesync-copysetupuri", name: "Copy settings as a new setup URI", - callback: () => fireAndForget(copySetupURI(host, log)), + checkCallback: (checking) => { + if (!host.services.setting.currentSettings().isConfigured) return false; + if (!checking) fireAndForget(copySetupURI(host, log)); + return true; + }, }); host.services.API.addCommand({ id: "livesync-copysetupuri-short", name: "Copy settings as a new setup URI (With customization sync)", - callback: () => fireAndForget(copySetupURI(host, log, false)), + checkCallback: (checking) => { + const settings = host.services.setting.currentSettings(); + if (!settings.isConfigured || !settings.usePluginSync) return false; + if (!checking) fireAndForget(copySetupURI(host, log, false)); + return true; + }, }); host.services.API.addCommand({ id: "livesync-copysetupurifull", name: "Copy settings as a new setup URI (Full)", - callback: () => fireAndForget(copySetupURIFull(host, log)), + checkCallback: (checking) => { + const settings = host.services.setting.currentSettings(); + if (!settings.isConfigured || !settings.useAdvancedMode) return false; + if (!checking) fireAndForget(copySetupURIFull(host, log)); + return true; + }, }); host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => diff --git a/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts index 91b280a0..27ec04ef 100644 --- a/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts @@ -156,4 +156,57 @@ describe("setupObsidian/setupUri", () => { expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupurifull" })); expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_COPY_SETUP_URI, expect.any(Function)); }); + + it("shows Setup URI variants only when their configuration level is relevant", async () => { + const addHandler = vi.fn(); + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + isConfigured: false, + usePluginSync: false, + useAdvancedMode: false, + }; + const host = { + services: { + context: createServiceContext(), + API: { + addCommand: vi.fn((command) => commands.push(command)), + addLog: vi.fn(), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => settings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard: vi.fn(() => true), + }, + }, + } as any; + + useSetupURIFeature(host); + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + const command = (id: string) => commands.find((candidate) => candidate.id === id)!; + expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(false); + + settings.isConfigured = true; + expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(true); + expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(false); + expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(false); + + settings.usePluginSync = true; + settings.useAdvancedMode = true; + expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(true); + expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(true); + }); }); diff --git a/src/serviceFeatures/useP2PReplicatorUI.ts b/src/serviceFeatures/useP2PReplicatorUI.ts index 418c41fa..a30f6182 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -67,7 +67,12 @@ export function useP2PReplicatorUI( showWindow: (type: string) => Promise; showWindowOnRight?: (type: string) => Promise; registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void; - addCommand: (command: { id: string; name: string; callback: () => void }) => unknown; + addCommand: (command: { + id: string; + name: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean | void; + }) => unknown; addRibbonIcon: ( icon: string, title: string, @@ -146,8 +151,12 @@ export function useP2PReplicatorUI( api.addCommand({ id: "open-p2p-server-status", name: "P2P Sync : Open P2P Status", - callback: () => { - void openStatusPane(); + checkCallback: (checking) => { + if (!hasP2PConfiguration(host.services.setting.currentSettings())) return false; + if (!checking) { + void openStatusPane(); + } + return true; }, }); host.services.API.addCommand({ @@ -155,11 +164,15 @@ export function useP2PReplicatorUI( name: "Replicate P2P to default peer", checkCallback: (isChecking: boolean) => { const settings = host.services.setting.currentSettings(); - if (isChecking) { - if (settings.remoteType == REMOTE_P2P) return false; - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(settings) && + settings.remoteType !== REMOTE_P2P && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + runOpenReplication(); } - runOpenReplication(); + return true; }, }); host.services.API.addCommand({ @@ -167,11 +180,15 @@ export function useP2PReplicatorUI( name: "Replicate now by P2P", checkCallback: (isChecking: boolean) => { const settings = host.services.setting.currentSettings(); - if (isChecking) { - if (settings.remoteType == REMOTE_P2P) return false; - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(settings) && + settings.remoteType !== REMOTE_P2P && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + runOpenReplication(); } - runOpenReplication(); + return true; }, }); @@ -179,10 +196,14 @@ export function useP2PReplicatorUI( id: "p2p-sync-targets", name: "P2P: Sync with targets", checkCallback: (isChecking: boolean) => { - if (isChecking) { - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(host.services.setting.currentSettings()) && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + void replicator.replicator?.replicateFromCommand(true); } - void replicator.replicator?.replicateFromCommand(true); + return true; }, }); diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts index eaaf8950..b37e3944 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -85,7 +85,12 @@ describe("useP2PReplicatorUI commands", () => { onSettingLoaded: { addHandler: vi.fn() }, onLayoutReady: { addHandler: vi.fn() }, }, - setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + P2P_Enabled: true, + })), + }, replicator: { runFiniteReplicationActivity }, }, } as any; @@ -143,7 +148,11 @@ describe("useP2PReplicatorUI commands", () => { }); it("retains only the current P2P status command and routes existing open requests to it", async () => { - const commands: Array<{ id: string; callback?: () => void }> = []; + const commands: Array<{ + id: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; let initialise: (() => Promise) | undefined; const showWindow = vi.fn(async () => undefined); const showWindowOnRight = vi.fn(async () => undefined); @@ -183,12 +192,90 @@ describe("useP2PReplicatorUI commands", () => { expect(commands.map((command) => command.id)).not.toContain("open-p2p-replicator"); expect(commands.map((command) => command.id)).toContain("open-p2p-server-status"); + expect(commands.find((command) => command.id === "open-p2p-server-status")?.checkCallback?.(true)).toBe(false); eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P); await vi.waitFor(() => expect(showWindowOnRight).toHaveBeenCalledWith("p2p-status")); expect(showWindow).not.toHaveBeenCalledWith("p2p"); }); + it("shows P2P commands only when a P2P configuration exists and their runtime prerequisites are met", async () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + let initialise: (() => Promise) | undefined; + let settings: Record = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + }; + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + showWindowOnRight: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn((command) => commands.push(command)), + addRibbonIcon: vi.fn(), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => settings), + onSettingSaved: { addHandler: vi.fn() }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + const p2p = { + replicator: { + server: { isServing: true }, + openReplication: vi.fn(), + replicateFromCommand: vi.fn(), + }, + } as any; + + useP2PReplicatorUI(host, {} as any, p2p); + await initialise?.(); + + for (const commandId of [ + "open-p2p-server-status", + "replicate-now-by-p2p-default-peer", + "replicate-now-by-p2p", + "p2p-sync-targets", + ]) { + expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(false); + } + + settings = { + ...settings, + remoteConfigurations: { + peer: { + id: "peer", + name: "Peer", + uri: "sls+p2p://room?passphrase=secret", + isEncrypted: false, + }, + }, + }; + for (const commandId of [ + "open-p2p-server-status", + "replicate-now-by-p2p-default-peer", + "replicate-now-by-p2p", + "p2p-sync-targets", + ]) { + expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(true); + } + }); + it("does not open the P2P status pane automatically when the workspace becomes ready", async () => { let layoutReady: (() => Promise) | undefined; const showWindow = vi.fn(async () => undefined); diff --git a/styles.css b/styles.css index 3f315599..273fe736 100644 --- a/styles.css +++ b/styles.css @@ -355,6 +355,10 @@ body { justify-content: center; } +body:not(.is-mobile):has(.sls-setting) .notice:has(.sls-onboarding-invitation-action) { + margin-right: 96px; +} + .sls-review-harness { box-sizing: border-box; max-width: 100%; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index e0069e12..06b05ac2 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -81,7 +81,7 @@ The underlying `test:e2e:obsidian:` scripts remain available for an im `test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change. -`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. +`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from **Self-hosted LiveSync settings** → **Setup** on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts index 82818632..8367fb82 100644 --- a/test/e2e-obsidian/scripts/onboarding-invitation.ts +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -26,7 +26,10 @@ type UnconfiguredStartupEvidence = { }; type ObsidianTestApp = { - commands?: { executeCommandById(commandId: string): boolean }; + setting?: { + open(): void; + openTabById(tabId: string): void; + }; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; @@ -151,14 +154,38 @@ async function captureAndCloseIntro(filename: string, mobile: boolean): Promise< return screenshot; } -async function openPermanentCommand(): Promise { - const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { - return await page.evaluate( - (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, - "obsidian-livesync:livesync-open-onboarding" - ); +async function openOnboardingFromSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs }); + + const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }), + }); + await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await onboardingSetting + .getByRole("button", { name: "Rerun Wizard", exact: true }) + .click({ timeout: uiTimeoutMs }); + await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function closeSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const settingsContainer = page.locator(".modal-container").filter({ + has: page.locator(".sls-setting"), + }); + await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); + await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); - if (!opened) throw new Error("The permanent onboarding command was not registered."); } async function main(): Promise { @@ -190,8 +217,9 @@ async function main(): Promise { console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`); const desktopInvitation = await captureDesktopInvitation(); - await openPermanentCommand(); - const commandIntro = await captureAndCloseIntro("onboarding-intro-command-desktop.png", false); + await openOnboardingFromSettings(); + const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false); + await closeSettings(); const mobileInvitation = await captureAndSelectMobileInvitation(); const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true); @@ -200,7 +228,7 @@ async function main(): Promise { desktopInvitation, mobileInvitation, mobileIntro, - commandIntro, + settingsIntro, ].join(", ")}` ); } finally { diff --git a/updates.md b/updates.md index 4b3bd275..27f20885 100644 --- a/updates.md +++ b/updates.md @@ -14,6 +14,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved +- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. - P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. - First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.