import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts"; import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "@/common/events.js"; import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; import { scheduleTask } from "octagonal-wheels/concurrency/task"; import type { TFile } from "@/deps.ts"; import { fireAndForget } from "octagonal-wheels/promises"; import { type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive"; import { collectingChunks, pluginScanningCount, hiddenFilesEventCount, hiddenFilesProcessingCount, } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores"; import type { LiveSyncCore } from "@/main.ts"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; type MutableCommandDefinition = { callback?: () => void; }; type InternalCommandRegistry = { commands?: Record; executeCommandById(commandId: string): unknown; }; type AppWithInternalCommands = { commands?: InternalCommandRegistry; }; type CodeMirrorAdapter = { commands: { save: () => void }; }; export class ModuleObsidianEvents extends AbstractObsidianModule { _everyOnloadStart(): Promise { // this.registerEvent(this.app.workspace.on("editor-change", )); this.plugin.registerEvent( this.app.vault.on("rename", (file, oldPath) => { eventHub.emitEvent(EVENT_FILE_RENAMED, { newPath: file.path as FilePathWithPrefix, old: oldPath as FilePathWithPrefix, }); }) ); this.plugin.registerEvent( this.app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED)) ); return Promise.resolve(true); } __performAppReload() { this.services.appLifecycle.performRestart(); } initialCallback: (() => void) | undefined = undefined; swapSaveCommand() { this._log("Modifying callback of the save command", LOG_LEVEL_VERBOSE); const commandRegistry = (this.app as unknown as AppWithInternalCommands).commands; const saveCommandDefinition = commandRegistry?.commands?.["editor:save-file"]; const save = saveCommandDefinition?.callback; if (saveCommandDefinition && typeof save === "function") { this.initialCallback = save; saveCommandDefinition.callback = () => { scheduleTask("syncOnEditorSave", 250, () => { if (this.services.control.hasUnloaded()) { this._log("Unload and remove the handler.", LOG_LEVEL_VERBOSE); saveCommandDefinition.callback = this.initialCallback; this.initialCallback = undefined; } else { if (this.settings.syncOnEditorSave) { this._log("Sync on Editor Save.", LOG_LEVEL_VERBOSE); fireAndForget(() => this.services.replication.replicateByEvent()); } } }); save(); }; } const codeMirrorAdapter = (compatGlobal as typeof compatGlobal & { CodeMirrorAdapter?: CodeMirrorAdapter }) .CodeMirrorAdapter; if (!codeMirrorAdapter) { this._log("CodeMirrorAdapter is not available"); return; } codeMirrorAdapter.commands.save = () => { void commandRegistry?.executeCommandById("editor:save-file"); // _this.app.performCommand('editor:save-file'); }; } registerWatchEvents() { this.setHasFocus = this.setHasFocus.bind(this); this.watchWindowVisibility = this.watchWindowVisibility.bind(this); this.watchWorkspaceOpen = this.watchWorkspaceOpen.bind(this); this.watchOnline = this.watchOnline.bind(this); // Already bound // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration. this.plugin.registerEvent(this.app.workspace.on("file-open", this.watchWorkspaceOpen)); // Already bound // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration. this.plugin.registerDomEvent(activeDocument, "visibilitychange", this.watchWindowVisibility); this.plugin.registerDomEvent(compatGlobal, "focus", () => this.setHasFocus(true)); this.plugin.registerDomEvent(compatGlobal, "blur", () => this.setHasFocus(false)); // Already bound // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration. this.plugin.registerDomEvent(compatGlobal, "online", this.watchOnline); // Already bound // eslint-disable-next-line @typescript-eslint/unbound-method -- The handler is bound above before registration. this.plugin.registerDomEvent(compatGlobal, "offline", this.watchOnline); } hasFocus = true; isLastHidden = false; private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown; private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible"; private get boundedActivityCounts(): ReactiveSource[] { const replicator = this.services.replicator as typeof this.services.replicator & { boundedLocalApplicationActivityCount: ReactiveSource; }; return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount]; } private hasBoundedActivity() { return this.boundedActivityCounts.some((count) => count.value > 0); } private keepReplicationActiveInBackground() { return ( this.settings.keepReplicationActiveInBackground && (this.settings.liveSync || this.settings.periodicReplication) && !this.services.API.isMobile() ); } private async applyDeferredBoundedActivityLifecycle() { if (this.hasBoundedActivity()) { this.deferLifecycleUntilBoundedActivityEnds(); return; } const deferredLifecycle = this.deferredBoundedLifecycle; this.deferredBoundedLifecycle = undefined; const keepActiveInBackground = this.keepReplicationActiveInBackground(); if (deferredLifecycle === "suspend-if-hidden" && activeWindow.document.hidden) { if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending(); return; } if ( deferredLifecycle === "restart-continuous-if-visible" && !activeWindow.document.hidden && keepActiveInBackground && this.settings.liveSync ) { await this.services.appLifecycle.onSuspending(); await this.services.appLifecycle.onResuming(); await this.services.appLifecycle.onResumed(); } } private deferLifecycleUntilBoundedActivityEnds() { if (this.boundedActivityEndHandler) return; const counts = this.boundedActivityCounts; const handler = () => { if (this.hasBoundedActivity()) return; for (const count of counts) count.offChanged(handler); this.boundedActivityEndHandler = undefined; fireAndForget(() => this.applyDeferredBoundedActivityLifecycle()); }; this.boundedActivityEndHandler = handler; for (const count of counts) count.onChanged(handler); } setHasFocus(hasFocus: boolean) { this.hasFocus = hasFocus; this.watchWindowVisibility(); } watchWindowVisibility() { scheduleTask("watch-window-visibility", 100, () => fireAndForget(() => this.watchWindowVisibilityAsync())); } watchOnline() { scheduleTask("watch-online", 500, () => fireAndForget(() => this.watchOnlineAsync())); } async watchOnlineAsync() { // If some files were failed to retrieve, scan files again. // TODO:FIXME AT V0.17.31, this logic has been disabled. if (compatGlobal.navigator.onLine && this.localDatabase.needScanning) { this.localDatabase.needScanning = false; await this.services.vault.scanVault(); } } async watchWindowVisibilityAsync() { if (this.settings.suspendFileWatching) { if ( this.settings.isConfigured && this.services.appLifecycle.isReady() && this.hasBoundedActivity() ) { const isHidden = activeWindow.document.hidden; this.isLastHidden = isHidden; this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined; this.deferLifecycleUntilBoundedActivityEnds(); } return; } if (!this.settings.isConfigured) return; if (!this.services.appLifecycle.isReady()) return; if (this.isLastHidden && !this.hasFocus) { // NO OP while non-focused after made hidden; return; } const isHidden = activeWindow.document.hidden; if (this.isLastHidden === isHidden) { return; } const boundedActivityInProgress = this.hasBoundedActivity(); if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") { this.isLastHidden = false; this.deferredBoundedLifecycle = undefined; return; } this.isLastHidden = isHidden; await this.services.fileProcessing.commitPendingFileEvents(); // Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the // window is hidden, instead of suspending on hide. On hide we skip the suspend for both // modes (LiveSync's continuous replication and Periodic's timer both stall otherwise); // becoming visible reopens normally, and for LiveSync additionally forces a teardown first // (see the resume branch) so a stalled continuous channel is always replaced. const keepActiveInBackground = this.keepReplicationActiveInBackground(); if (isHidden) { if (boundedActivityInProgress && !keepActiveInBackground) { this.deferredBoundedLifecycle = "suspend-if-hidden"; this.deferLifecycleUntilBoundedActivityEnds(); } else if (!keepActiveInBackground) { await this.services.appLifecycle.onSuspending(); } } else { // suspend all temporary. if (this.services.appLifecycle.isSuspended()) return; if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) { this.deferredBoundedLifecycle = "restart-continuous-if-visible"; this.deferLifecycleUntilBoundedActivityEnds(); return; } // Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB // emits paused/retry while the replicator keeps its AbortController set, so the reopen // below would no-op on exactly the channel that needs replacing. Force a teardown first // so becoming visible always re-establishes a fresh channel (restoring the default's // reset-on-visibility). Periodic mode has no such channel — its timer just resumes via // the normal path below — so this teardown is gated on liveSync to avoid needlessly // bouncing it. The teardown's closeReplication() aborts synchronously while the reopen is // deferred (fireAndForget + awaited isReplicationReady/initializeDatabaseForReplication), // so the aborted continuousReplication run (and its shareRunningResult lock) unwinds in // microtasks before the reopen runs: it neither double-opens nor gets swallowed by the // still-registered shared run. if (keepActiveInBackground && this.settings.liveSync) { await this.services.appLifecycle.onSuspending(); } // Resume is not gated on focus in this branch, but note the top-of-handler check // (isLastHidden && !hasFocus) still defers the whole handler when the window becomes // visible again while unfocused; in that case recovery happens on the next focus. await this.services.appLifecycle.onResuming(); await this.services.appLifecycle.onResumed(); } } watchWorkspaceOpen(file: TFile | null) { if (this.settings.suspendFileWatching) return; if (!this.settings.isConfigured) return; if (!this.services.appLifecycle.isReady()) return; if (!file) return; scheduleTask("watch-workspace-open", 500, () => fireAndForget(() => this.watchWorkspaceOpenAsync(file))); } async watchWorkspaceOpenAsync(file: TFile) { if (this.settings.suspendFileWatching) return; if (!this.settings.isConfigured) return; if (!this.services.appLifecycle.isReady()) return; await this.services.fileProcessing.commitPendingFileEvents(); if (file == null) { return; } if (this.settings.syncOnFileOpen && !this.services.appLifecycle.isSuspended()) { await this.services.replication.replicateByEvent(); } await this.services.conflict.queueCheckForIfOpen(file.path as FilePathWithPrefix); } _everyOnLayoutReady(): Promise { this.swapSaveCommand(); this.registerWatchEvents(); return Promise.resolve(true); } private _askReload(message?: string) { if (this.services.appLifecycle.isReloadingScheduled()) { this._log(`Reloading is already scheduled`, LOG_LEVEL_VERBOSE); return; } scheduleTask("configReload", 250, async () => { const RESTART_NOW = "Yes, restart immediately"; const RESTART_AFTER_STABLE = "Yes, schedule a restart after stabilisation"; const RETRY_LATER = "No, Leave it to me"; const ret = await this.core.confirm.askSelectStringDialogue( message || "Do you want to restart and reload Obsidian now?", [RESTART_AFTER_STABLE, RESTART_NOW, RETRY_LATER], { defaultAction: RETRY_LATER } ); if (ret == RESTART_NOW) { this.__performAppReload(); } else if (ret == RESTART_AFTER_STABLE) { this.services.appLifecycle.scheduleRestart(); } }); } // Process counting for app reload scheduling _totalProcessingCount?: ReactiveSource = undefined; private _scheduleAppReload() { if (!this._totalProcessingCount) { const __tick = reactiveSource(0); this._totalProcessingCount = reactive(() => { const dbCount = this.services.replication.databaseQueueCount.value; const replicationCount = this.services.replication.replicationResultCount.value; const storageApplyingCount = this.services.replication.storageApplyingCount.value; const chunkCount = collectingChunks.value; const pluginScanCount = pluginScanningCount.value; const hiddenFilesCount = hiddenFilesEventCount.value + hiddenFilesProcessingCount.value; const conflictProcessCount = this.services.conflict.conflictProcessQueueCount.value; // Now no longer `pendingFileEventCount` and `processingFileEventCount` is used // const e = this.core.pendingFileEventCount.value; // const proc = this.core.processingFileEventCount.value; const e = 0; const proc = 0; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Reading the tick establishes the reactive polling dependency. const __ = __tick.value; return ( dbCount + replicationCount + storageApplyingCount + chunkCount + pluginScanCount + hiddenFilesCount + conflictProcessCount + e + proc ); }); this.plugin.registerInterval( compatGlobal.setInterval(() => { __tick.value++; }, 1000) ); let stableCheck = 3; this._totalProcessingCount.onChanged((e) => { if (e.value == 0) { if (stableCheck-- <= 0) { this.__performAppReload(); } this._log( `Obsidian will be restarted soon! (Within ${stableCheck} seconds)`, LOG_LEVEL_NOTICE, "restart-notice" ); } else { stableCheck = 3; } }); } } _isReloadingScheduled(): boolean { return this._totalProcessingCount !== undefined; } override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this)); services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this)); services.appLifecycle.askRestart.setHandler(this._askReload.bind(this)); services.appLifecycle.scheduleRestart.setHandler(this._scheduleAppReload.bind(this)); services.appLifecycle.isReloadingScheduled.setHandler(this._isReloadingScheduled.bind(this)); } }