diff --git a/.github/workflows/cli-docker.yml b/.github/workflows/cli-docker.yml index a47e58c..9bbd6bd 100644 --- a/.github/workflows/cli-docker.yml +++ b/.github/workflows/cli-docker.yml @@ -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 diff --git a/docs/settings.md b/docs/settings.md index e0f2ca5..3727346 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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 diff --git a/src/modules/essentialObsidian/ModuleObsidianEvents.ts b/src/modules/essentialObsidian/ModuleObsidianEvents.ts index 638b960..263e862 100644 --- a/src/modules/essentialObsidian/ModuleObsidianEvents.ts +++ b/src/modules/essentialObsidian/ModuleObsidianEvents.ts @@ -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(); } diff --git a/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts b/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts new file mode 100644 index 0000000..ed3b005 --- /dev/null +++ b/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts @@ -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; + 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); + }); +}); diff --git a/src/modules/features/SettingDialogue/PaneSyncSettings.ts b/src/modules/features/SettingDialogue/PaneSyncSettings.ts index 394c7c1..294b318 100644 --- a/src/modules/features/SettingDialogue/PaneSyncSettings.ts +++ b/src/modules/features/SettingDialogue/PaneSyncSettings.ts @@ -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(