mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-11 14:15:46 +00:00
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.
392 lines
18 KiB
TypeScript
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));
|
|
}
|
|
}
|