Files
obsidian-livesync/src/modules/essentialObsidian/ModuleObsidianEvents.ts
T
Ouyang Xingyuan 0df1f57f3b fix: keep replicated document application active
Why:
- Large downloaded batches can outlive the finite remote operation and be suspended before every document reaches the Vault.
- The v1.0 activity contract requires local application to remain distinct from remote-operation reporting.

Changes:
- Add an Obsidian-owned local application activity boundary which reuses Wake Lock policy without incrementing remote activity.
- Retain one boundary through queue processing and the final recovery snapshot, releasing and reacquiring it around suspension.
- Log final snapshot failures, preserve processing results, and cover the lifecycle with focused tests and documentation.
2026-07-31 11:06:18 +08:00

392 lines
18 KiB
TypeScript

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<string, MutableCommandDefinition | undefined>;
executeCommandById(commandId: string): unknown;
};
type AppWithInternalCommands = {
commands?: InternalCommandRegistry;
};
type CodeMirrorAdapter = {
commands: { save: () => void };
};
export class ModuleObsidianEvents extends AbstractObsidianModule {
_everyOnloadStart(): Promise<boolean> {
// 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<number>[] {
const replicator = this.services.replicator as typeof this.services.replicator & {
boundedLocalApplicationActivityCount: ReactiveSource<number>;
};
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<boolean> {
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<number> = 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));
}
}