mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-14 10:20:15 +00:00
Merge remote-tracking branch 'origin/main' into fix_953
This commit is contained in:
@@ -8,6 +8,8 @@ name: Build and Push CLI Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*.*.*-cli"
|
||||
workflow_dispatch:
|
||||
@@ -41,14 +43,32 @@ jobs:
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' manifest.json)
|
||||
EPOCH=$(date +%s)
|
||||
TAG="${VERSION}-${EPOCH}-cli"
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
IMAGE="ghcr.io/${{ github.repository_owner }}/livesync-cli"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
|
||||
echo "full=${IMAGE}:${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "version=${IMAGE}:${VERSION}-cli" >> $GITHUB_OUTPUT
|
||||
echo "latest=${IMAGE}:latest" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build tag list based on the event and git ref
|
||||
TAGS=""
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
# Stable release builds
|
||||
TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"
|
||||
elif [[ "${{ github.ref }}" == refs/heads/main ]]; then
|
||||
# Bleeding-edge / nightly builds
|
||||
TAGS="${IMAGE}:edge,${IMAGE}:${VERSION}-dev-sha-${SHORT_SHA}-cli"
|
||||
else
|
||||
# Other branches / manual run fallback
|
||||
TAGS="${IMAGE}:${VERSION}-dev-sha-${SHORT_SHA}-cli"
|
||||
fi
|
||||
|
||||
# Determine if the image should be pushed
|
||||
PUSH="true"
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
if [[ "${{ inputs.dry_run }}" == "true" ]]; then
|
||||
PUSH="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "push=${PUSH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -92,10 +112,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: src/apps/cli/Dockerfile
|
||||
push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.full }}
|
||||
${{ steps.meta.outputs.version }}
|
||||
${{ steps.meta.outputs.latest }}
|
||||
push: ${{ steps.meta.outputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -488,6 +488,11 @@ Automatically Sync all files when opening Obsidian.
|
||||
Setting key: syncAfterMerge
|
||||
Sync automatically after merging files
|
||||
|
||||
#### Keep replication active in the background
|
||||
|
||||
Setting key: keepReplicationActiveInBackground
|
||||
Desktop only; uses more battery and network.
|
||||
|
||||
### 3. Update thinning
|
||||
|
||||
#### Batch database update
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AbstractObsidianModule } from "../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 type { TFile } from "../../deps.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
@@ -138,12 +138,38 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
|
||||
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.settings.keepReplicationActiveInBackground &&
|
||||
(this.settings.liveSync || this.settings.periodicReplication) &&
|
||||
!this.services.API.isMobile();
|
||||
|
||||
if (isHidden) {
|
||||
await this.services.appLifecycle.onSuspending();
|
||||
if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending();
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
// Do not block resume by focus state here; visibility recovery should be enough.
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@lib/common/types";
|
||||
|
||||
type SetupOptions = {
|
||||
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
||||
hidden: boolean;
|
||||
isLastHidden?: boolean;
|
||||
hasFocus?: boolean;
|
||||
isSuspended?: boolean;
|
||||
// Platform is read via services.API.isMobile(); default desktop (false) so the feature applies.
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
function setup(opts: SetupOptions) {
|
||||
const appLifecycle = {
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => opts.isSuspended ?? false),
|
||||
onSuspending: vi.fn(async () => true),
|
||||
onResuming: vi.fn(async () => true),
|
||||
onResumed: vi.fn(async () => true),
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
isMobile: vi.fn(() => opts.isMobile ?? false),
|
||||
},
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
isConfigured: true,
|
||||
...opts.settings,
|
||||
},
|
||||
} as any;
|
||||
Object.defineProperty(core, "services", { get: () => core._services });
|
||||
|
||||
const module = new ModuleObsidianEvents({} as any, core);
|
||||
module.isLastHidden = opts.isLastHidden ?? false;
|
||||
module.hasFocus = opts.hasFocus ?? true;
|
||||
|
||||
// The handler reads `activeWindow.document.hidden`.
|
||||
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
|
||||
|
||||
return { module, appLifecycle, fileProcessing };
|
||||
}
|
||||
|
||||
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
|
||||
afterEach(() => {
|
||||
// The handler reads a global `activeWindow`; clear it so it doesn't leak into sibling spec
|
||||
// files running in the same worker.
|
||||
delete (globalThis as any).activeWindow;
|
||||
});
|
||||
|
||||
it("does NOT suspend on hide when enabled in LiveSync mode on the desktop app", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
hidden: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suspends on hide by default (setting off)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: true },
|
||||
hidden: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
hidden: false,
|
||||
isLastHidden: true, // hidden -> visible transition
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
// Decision-logic only: on visible + enabled + LiveSync the handler calls onSuspending (the
|
||||
// forced teardown) before onResuming. The actual stalled-channel replacement is exercised by
|
||||
// the manual integration test, not here.
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
appLifecycle.onResuming.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("does not force a teardown on becoming visible by default (setting off)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: true },
|
||||
hidden: false,
|
||||
isLastHidden: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not apply in On-Events mode even if the flag is set (no scope leak)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: {
|
||||
keepReplicationActiveInBackground: true,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
},
|
||||
hidden: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT suspend on hide when enabled in Periodic mode (the periodic timer also stalls otherwise)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: {
|
||||
keepReplicationActiveInBackground: true,
|
||||
liveSync: false,
|
||||
periodicReplication: true,
|
||||
},
|
||||
hidden: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT force a teardown on becoming visible in Periodic mode (only the continuous channel can stall)", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: {
|
||||
keepReplicationActiveInBackground: true,
|
||||
liveSync: false,
|
||||
periodicReplication: true,
|
||||
},
|
||||
hidden: false,
|
||||
isLastHidden: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
// The teardown is gated on liveSync: a periodic timer doesn't go half-open, so bouncing it
|
||||
// on every restore would be needless churn. Resume still runs normally.
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not apply on mobile even if the flag is set", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
hidden: true,
|
||||
isMobile: true,
|
||||
});
|
||||
await module.watchWindowVisibilityAsync();
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -189,6 +189,16 @@ export function paneSyncSettings(
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync });
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync });
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync });
|
||||
// Desktop app only, and only for the sync modes that keep a background replication channel
|
||||
// (LiveSync and Periodic). Ignored on mobile, where suspending preserves battery. The
|
||||
// visibility predicate mirrors the runtime guard in ModuleObsidianEvents.
|
||||
if (!this.services.API.isMobile()) {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("keepReplicationActiveInBackground", {
|
||||
onUpdate: visibleOnly(
|
||||
() => this.isConfiguredAs("syncMode", "LIVESYNC") || this.isConfiguredAs("syncMode", "PERIODIC")
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
|
||||
Reference in New Issue
Block a user