mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-30 16:33:01 +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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
tags:
|
tags:
|
||||||
- "*.*.*-cli"
|
- "*.*.*-cli"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -41,14 +43,32 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r '.version' manifest.json)
|
VERSION=$(jq -r '.version' manifest.json)
|
||||||
EPOCH=$(date +%s)
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
TAG="${VERSION}-${EPOCH}-cli"
|
|
||||||
IMAGE="ghcr.io/${{ github.repository_owner }}/livesync-cli"
|
IMAGE="ghcr.io/${{ github.repository_owner }}/livesync-cli"
|
||||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
|
# Build tag list based on the event and git ref
|
||||||
echo "full=${IMAGE}:${TAG}" >> $GITHUB_OUTPUT
|
TAGS=""
|
||||||
echo "version=${IMAGE}:${VERSION}-cli" >> $GITHUB_OUTPUT
|
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
echo "latest=${IMAGE}:latest" >> $GITHUB_OUTPUT
|
# 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
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -92,10 +112,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: src/apps/cli/Dockerfile
|
file: src/apps/cli/Dockerfile
|
||||||
push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
push: ${{ steps.meta.outputs.push }}
|
||||||
tags: |
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
${{ steps.meta.outputs.full }}
|
|
||||||
${{ steps.meta.outputs.version }}
|
|
||||||
${{ steps.meta.outputs.latest }}
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -488,6 +488,11 @@ Automatically Sync all files when opening Obsidian.
|
|||||||
Setting key: syncAfterMerge
|
Setting key: syncAfterMerge
|
||||||
Sync automatically after merging files
|
Sync automatically after merging files
|
||||||
|
|
||||||
|
#### Keep replication active in the background
|
||||||
|
|
||||||
|
Setting key: keepReplicationActiveInBackground
|
||||||
|
Desktop only; uses more battery and network.
|
||||||
|
|
||||||
### 3. Update thinning
|
### 3. Update thinning
|
||||||
|
|
||||||
#### Batch database update
|
#### 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 { 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 { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
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 { fireAndForget } from "octagonal-wheels/promises";
|
||||||
import { type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
import { type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||||
import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
@@ -138,12 +138,38 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
|||||||
|
|
||||||
await this.services.fileProcessing.commitPendingFileEvents();
|
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) {
|
if (isHidden) {
|
||||||
await this.services.appLifecycle.onSuspending();
|
if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending();
|
||||||
} else {
|
} else {
|
||||||
// suspend all temporary.
|
// suspend all temporary.
|
||||||
if (this.services.appLifecycle.isSuspended()) return;
|
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.onResuming();
|
||||||
await this.services.appLifecycle.onResumed();
|
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("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync });
|
||||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync });
|
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync });
|
||||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncAfterMerge", { 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(
|
void addPanel(
|
||||||
|
|||||||
Reference in New Issue
Block a user