Compare commits

..
Author SHA1 Message Date
vorotamoroz 9c59cd15d1 Limit 1.0.3 release notes to the plug-in 2026-08-03 12:24:28 +00:00
github-actions[bot] d672ff04c3 Releasing 1.0.3 2026-08-03 12:07:33 +00:00
vorotamorozandGitHub cd5f5aa740 Merge pull request #1068 from vrtmrz/fix/commonlib-history-and-chunk-fetch
Adopt Commonlib revision and chunk-fetch fixes
2026-08-03 20:54:31 +09:00
vorotamoroz 34d8fe5d31 chore: pin Commonlib 0.1.2 2026-08-03 11:19:40 +00:00
vorotamoroz d15df7367f fix: adopt Commonlib history and chunk fetch fixes 2026-08-03 10:12:25 +00:00
vorotamorozandGitHub 36b2ea7096 Merge pull request #1066 from vrtmrz/fix/cli-log-handler-boundary
Fix CLI start-up with the Commonlib headless log handler
2026-08-03 15:47:35 +09:00
22 changed files with 292 additions and 513 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.2",
"version": "1.0.3",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.2",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.2",
"version": "1.0.3",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.2",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -4775,9 +4775,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
"integrity": "sha512-rdzEubzLStioanE67pps2XSGZ2UGyMIyeEoKsdPztQLLW8wM05PWApOPbJujIydHcDr2hFanbDFe89Lk7Mn4XQ==",
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.2.tgz",
"integrity": "sha512-o4nHy8d4wxfUR6CcOv50vQnVc+vFZDZTI612/DPBJY3ObGm0749IobdJJ04l6iBjmVkNaB9nnaSsl7BWHuuY+A==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -15924,7 +15924,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.2-cli",
"version": "1.0.3-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -15949,7 +15949,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.2-webapp",
"version": "1.0.3-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.52"
},
@@ -15961,7 +15961,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.2-webpeer",
"version": "1.0.3-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.52"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.2",
"version": "1.0.3",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.2",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
+2 -3
View File
@@ -15,13 +15,13 @@ import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/co
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useJournalSyncFeature } from "@vrtmrz/livesync-commonlib/journal-sync";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { AbstractModule } from "./modules/AbstractModule";
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
@@ -139,6 +139,7 @@ export class LiveSyncBaseCore<
public registerModules(extraModules: AbstractModule[] = []) {
this._registerModule(new ModuleLiveSyncMain(this));
this._registerModule(new ModuleConflictChecker(this));
this._registerModule(new ModuleReplicatorMinIO(this));
this._registerModule(new ModuleReplicatorCouchDB(this));
this._registerModule(new ModuleReplicator(this));
this._registerModule(new ModuleConflictResolver(this));
@@ -278,8 +279,6 @@ export class LiveSyncBaseCore<
usePrepareDatabaseForUse(this);
// Migration to multiple remote configurations
useRemoteConfigurationMigration(this);
// Journal providers are selected through the composed remote-provider registry.
useJournalSyncFeature(this);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.2-cli",
"version": "1.0.3-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.2-webapp",
"version": "1.0.3-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.2-webpeer",
"version": "1.0.3-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+4 -4
View File
@@ -1,6 +1,5 @@
import { fireAndForget } from "octagonal-wheels/promises";
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "@/modules/AbstractModule";
@@ -9,7 +8,8 @@ import type { LiveSyncCore } from "@/main";
export class ModuleReplicatorCouchDB extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (!defaultRemoteProviderRegistry.isRemoteTypeInFamily(settings.remoteType, "couchdb")) {
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
return Promise.resolve(false);
}
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_everyAfterResumeProcess(): Promise<boolean> {
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
if (defaultRemoteProviderRegistry.isRemoteTypeInFamily(this.settings.remoteType, "couchdb")) {
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
const LiveSyncEnabled = this.settings.liveSync;
const continuous = LiveSyncEnabled;
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
@@ -1,11 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
function createModule(
settings: { liveSync: boolean; syncOnStart: boolean; remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO },
isReplicationReady = true
) {
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
const openReplication = vi.fn(async () => true);
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const services = {
@@ -34,7 +30,7 @@ function createModule(
_services: services,
services,
settings: {
remoteType: REMOTE_COUCHDB,
remoteType: "",
...settings,
},
replicator: { openReplication },
@@ -90,18 +86,4 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
expect(openReplication).not.toHaveBeenCalled();
});
it("does not start CouchDB replication for a registered Journal provider", async () => {
const { module, openReplication } = createModule({
liveSync: true,
syncOnStart: true,
remoteType: REMOTE_MINIO,
});
await expect(module._anyNewReplicator()).resolves.toBe(false);
await module._everyAfterResumeProcess();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(openReplication).not.toHaveBeenCalled();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
export class ModuleReplicatorMinIO extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (settings.remoteType == REMOTE_MINIO) {
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
}
return Promise.resolve(false);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
}
}
@@ -1,4 +1,7 @@
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
@@ -18,12 +21,18 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
import {
activateRemoteConfiguration,
createRemoteConfigurationId,
defaultRemoteProviderRegistry,
suggestRemoteConfigurationName,
type BuiltInRemoteConfiguration,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -34,6 +43,10 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
}
return workObj;
}
function createRemoteConfigurationId(): string {
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function cloneRemoteConfigurations(
configs: Record<string, RemoteConfiguration> | undefined
): Record<string, RemoteConfiguration> {
@@ -41,10 +54,13 @@ function cloneRemoteConfigurations(
}
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
const type = defaultRemoteProviderRegistry.typeForRemoteType(settings.remoteType);
if (!type) throw new Error(`Unsupported remote type: ${settings.remoteType}`);
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
return defaultRemoteProviderRegistry.serialise(configuration);
if (settings.remoteType === REMOTE_MINIO) {
return ConnectionStringParser.serialize({ type: "s3", settings });
}
if (settings.remoteType === REMOTE_P2P) {
return ConnectionStringParser.serialize({ type: "p2p", settings });
}
return ConnectionStringParser.serialize({ type: "couchdb", settings });
}
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
@@ -55,6 +71,21 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
return button;
}
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
if (parsed.type === "couchdb") {
try {
const url = new URL(parsed.settings.couchDB_URI);
return `CouchDB ${url.host}`;
} catch {
return "Imported CouchDB";
}
}
if (parsed.type === "s3") {
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
}
export function paneRemoteConfig(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -158,10 +189,48 @@ export function paneRemoteConfig(
};
const runRemoteSetup = async (
baseSettings: ObsidianLiveSyncSettings,
type?: BuiltInRemoteConfiguration["type"]
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
): Promise<ObsidianLiveSyncSettings | false> => {
const setupManager = this.core.getModule(SetupManager);
return await setupManager.configureRemoteForSettings(baseSettings, type);
const dialogManager = setupManager.dialogManager;
let targetRemoteType = remoteType;
if (targetRemoteType === undefined) {
const method = await dialogManager.openWithExplicitCancel(SetupRemote);
if (method === "cancelled") {
return false;
}
targetRemoteType =
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
}
if (targetRemoteType === REMOTE_MINIO) {
const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings);
if (bucketConf === "cancelled" || typeof bucketConf !== "object") {
return false;
}
return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO };
}
if (targetRemoteType === REMOTE_P2P) {
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
return false;
}
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: baseSettings,
mode: "settings",
});
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
}
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
};
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
...DEFAULT_SETTINGS,
@@ -213,9 +282,9 @@ export function paneRemoteConfig(
return;
}
let parsed: BuiltInRemoteConfiguration;
let parsed: RemoteConfigurationResult;
try {
parsed = defaultRemoteProviderRegistry.parse(trimmedURI);
parsed = ConnectionStringParser.parse(trimmedURI);
} catch (ex) {
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
@@ -233,7 +302,7 @@ export function paneRemoteConfig(
configs[id] = {
id,
name: name.trim() || defaultName,
uri: defaultRemoteProviderRegistry.serialise(parsed),
uri: ConnectionStringParser.serialize(parsed),
isEncrypted: false,
};
this.editingSettings.remoteConfigurations = configs;
@@ -268,9 +337,9 @@ export function paneRemoteConfig(
row.addButton((btn) =>
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
let parsed: BuiltInRemoteConfiguration;
let parsed: RemoteConfigurationResult;
try {
parsed = defaultRemoteProviderRegistry.parse(config.uri);
parsed = ConnectionStringParser.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for editing!`,
@@ -280,9 +349,16 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
Object.assign(workSettings, parsed.settings);
const nextSettings = await runRemoteSetup(workSettings, parsed.type);
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
if (!nextSettings) {
return;
}
@@ -371,9 +447,9 @@ export function paneRemoteConfig(
.addSeparator()
.addItem((item) => {
item.setTitle("📡 Fetch remote settings").onClick(async () => {
let parsed: BuiltInRemoteConfiguration;
let parsed: RemoteConfigurationResult;
try {
parsed = defaultRemoteProviderRegistry.parse(config.uri);
parsed = ConnectionStringParser.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
@@ -383,7 +459,14 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
Object.assign(workSettings, parsed.settings);
const newTweaks =
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
workSettings
+82 -87
View File
@@ -1,15 +1,15 @@
import {
type BucketSyncSetting,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import {
defaultRemoteProviderRegistry,
upsertRemoteConfigurationInPlace,
type BuiltInRemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
@@ -20,6 +20,9 @@ import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -28,8 +31,11 @@ import type {
OutroExistingUserResultType,
OutroNewUserResultType,
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteInitialData,
SetupRemoteP2PResultType,
SetupRemoteResultType,
UseSetupURIResultType,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
@@ -38,8 +44,6 @@ import {
applySettingsWithScheduledInitialisation,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { builtInRemoteSetupRegistry } from "./SetupWizard/builtInRemoteSetupProviders.ts";
import type { RemoteSetupIntent } from "./SetupWizard/RemoteSetupRegistry.ts";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
return {
@@ -75,8 +79,6 @@ export const enum UserMode {
* Setup Manager to handle onboarding and configuration setup
*/
export class SetupManager extends AbstractModule {
readonly remoteSetupRegistry = builtInRemoteSetupRegistry;
// /**
// * Dialog manager for handling Svelte dialogs
// */
@@ -155,76 +157,6 @@ export class SetupManager extends AbstractModule {
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
}
private setupIntent(userMode: UserMode): RemoteSetupIntent {
if (userMode === UserMode.NewUser) return "create-or-connect";
if (userMode === UserMode.ExistingUser) return "connect-existing";
return "settings";
}
private async selectRemoteSetupProvider(): Promise<BuiltInRemoteConfiguration["type"] | false> {
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType, SetupRemoteInitialData>(
SetupRemote,
this.remoteSetupRegistry.choices()
);
return method === "cancelled" ? false : method;
}
/**
* Opens a provider-specific dialogue without applying or storing the resulting settings.
*
* The settings pane uses this boundary to prepare a remote profile in its own edit buffer.
*/
async configureRemoteForSettings(
currentSetting: ObsidianLiveSyncSettings,
type?: BuiltInRemoteConfiguration["type"]
): Promise<ObsidianLiveSyncSettings | false> {
const selectedType = type ?? (await this.selectRemoteSetupProvider());
if (selectedType === false) return false;
const configuration = await this.remoteSetupRegistry.open(selectedType, {
dialogManager: this.dialogManager,
intent: "settings",
settings: currentSetting,
});
if (configuration === "cancelled") return false;
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
return newSetting;
}
private async onRemoteManualSetup(
type: BuiltInRemoteConfiguration["type"],
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate: boolean
): Promise<boolean> {
const configuration = await this.remoteSetupRegistry.open(type, {
dialogManager: this.dialogManager,
intent: this.setupIntent(userMode),
settings: currentSetting,
});
if (configuration === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
if (activate) {
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
} else {
Object.assign(newSetting, configuration.settings);
}
const activateForP2P = defaultRemoteProviderRegistry.supportsActivationRole(type, "p2p");
upsertRemoteConfigurationInPlace(newSetting, type, {
id: activateForP2P ? newSetting.P2P_ActiveRemoteConfigurationId || undefined : undefined,
activate,
activateForP2P,
});
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for CouchDB
* @param userMode
@@ -237,7 +169,31 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("couchdb", userMode, currentSetting, activate);
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: currentSetting,
mode:
userMode === UserMode.NewUser
? "create-or-connect"
: userMode === UserMode.ExistingUser
? "connect-existing"
: "settings",
});
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...couchConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
@@ -252,7 +208,23 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
const bucketConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteBucketResultType,
BucketSyncSetting
>(SetupRemoteBucket, currentSetting);
if (bucketConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...bucketConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
@@ -267,7 +239,24 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("p2p", userMode, currentSetting, activate);
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
currentSetting
);
if (p2pConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...p2pConf,
} as ObsidianLiveSyncSettings;
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
activate,
activateForP2P: true,
});
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
@@ -321,15 +310,21 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const method = await this.selectRemoteSetupProvider();
if (method === false) {
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
if (method === "couchdb") {
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
} else if (method === "bucket") {
return await this.onBucketManualSetup(userMode, currentSetting, true);
} else if (method === "p2p") {
return await this.onP2PManualSetup(userMode, currentSetting, true);
} else if (method === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
if (userMode !== UserMode.Unknown) {
return await this.onOnboard(userMode);
}
return false;
}
return await this.onRemoteManualSetup(method, userMode, currentSetting, true);
// Should not reach here.
return false;
}
/**
* Confirms and applies settings obtained from the wizard
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
@@ -422,40 +421,6 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
});
it("uses the registered provider choices when configuring a Settings edit buffer", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const bucketSettings = {
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("s3").mockResolvedValueOnce(bucketSettings);
const nextSettings = await manager.configureRemoteForSettings(setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ type: "couchdb" }),
expect.objectContaining({ type: "s3" }),
expect.objectContaining({ type: "p2p" }),
])
);
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
2,
expect.anything(),
setting.currentSettings()
);
expect(nextSettings).toEqual(expect.objectContaining({ ...bucketSettings, remoteType: REMOTE_MINIO }));
});
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -1,84 +0,0 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
BuiltInRemoteConfiguration,
RemoteProviderConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
export type RemoteSetupIntent = "create-or-connect" | "connect-existing" | "settings";
export interface RemoteSetupDialogManager {
openWithExplicitCancel<TResult, TInitial = TResult>(
component: ComponentHasResult<TResult, TInitial>,
initialData?: TInitial
): Promise<TResult>;
}
export interface RemoteSetupContext {
readonly dialogManager: RemoteSetupDialogManager;
readonly intent: RemoteSetupIntent;
readonly settings: ObsidianLiveSyncSettings;
}
export interface RemoteSetupChoice<TType extends string = string> {
readonly description: string;
readonly proceedTitle: string;
readonly title: string;
readonly type: TType;
}
export interface RemoteSetupProviderDescriptor<
TConfiguration extends RemoteProviderConfiguration = RemoteProviderConfiguration,
> {
readonly type: TConfiguration["type"];
choice(): Omit<RemoteSetupChoice<TConfiguration["type"]>, "type">;
open(context: RemoteSetupContext): Promise<TConfiguration | "cancelled">;
}
/**
* Host-side presentation for the remote providers compiled into this plug-in.
*
* Connection semantics remain in Commonlib's remote provider registry. This registry only owns
* the Obsidian-specific choice text and setup dialogue for each provider.
*/
export class RemoteSetupRegistry<TConfiguration extends RemoteProviderConfiguration = BuiltInRemoteConfiguration> {
private readonly providersByType = new Map<string, RemoteSetupProviderDescriptor<TConfiguration>>();
private frozen = false;
register<TProviderConfiguration extends TConfiguration>(
descriptor: RemoteSetupProviderDescriptor<TProviderConfiguration>
): this {
if (this.frozen) throw new Error("The remote setup registry is frozen");
if (this.providersByType.has(descriptor.type)) {
throw new Error(`Remote setup provider '${descriptor.type}' is already registered`);
}
this.providersByType.set(descriptor.type, descriptor);
return this;
}
freeze(): this {
this.frozen = true;
return this;
}
isFrozen(): boolean {
return this.frozen;
}
choices(): RemoteSetupChoice<TConfiguration["type"]>[] {
return [...this.providersByType.values()].map((provider) => ({
...provider.choice(),
type: provider.type,
}));
}
has(type: string): type is TConfiguration["type"] {
return this.providersByType.has(type);
}
open(type: TConfiguration["type"], context: RemoteSetupContext): Promise<TConfiguration | "cancelled"> {
const provider = this.providersByType.get(type);
if (!provider) throw new Error(`Unsupported remote setup provider: ${type}`);
return provider.open(context);
}
}
@@ -1,69 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
type TestConfiguration =
| { type: "alpha"; settings: { value: string } }
| { type: "beta"; settings: { enabled: boolean } };
function alphaProvider(
open: RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>>["open"] = async () => ({
type: "alpha",
settings: { value: "configured" },
})
): RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>> {
return {
type: "alpha",
choice: () => ({
title: "Alpha",
description: "Alpha description",
proceedTitle: "Configure Alpha",
}),
open,
};
}
describe("RemoteSetupRegistry", () => {
it("preserves registration order and dispatches setup through the selected provider", async () => {
const openAlpha = vi.fn(alphaProvider().open);
const registry = new RemoteSetupRegistry<TestConfiguration>()
.register(alphaProvider(openAlpha))
.register({
type: "beta",
choice: () => ({
title: "Beta",
description: "Beta description",
proceedTitle: "Configure Beta",
}),
open: async () => ({ type: "beta", settings: { enabled: true } }),
})
.freeze();
const context = {
dialogManager: { openWithExplicitCancel: vi.fn() },
intent: "settings" as const,
settings: {} as never,
};
expect(registry.choices().map((choice) => choice.type)).toEqual(["alpha", "beta"]);
await expect(registry.open("alpha", context)).resolves.toEqual({
type: "alpha",
settings: { value: "configured" },
});
expect(openAlpha).toHaveBeenCalledWith(context);
});
it("rejects duplicate and late registrations", () => {
const registry = new RemoteSetupRegistry<TestConfiguration>().register(alphaProvider());
expect(() => registry.register(alphaProvider())).toThrow("already registered");
registry.freeze();
expect(registry.isFrozen()).toBe(true);
expect(() =>
registry.register({
type: "beta",
choice: () => ({ title: "Beta", description: "", proceedTitle: "" }),
open: async () => "cancelled",
})
).toThrow("frozen");
});
});
@@ -1,102 +0,0 @@
import type { BucketSyncSetting, P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { $msg as translateMessage } from "@/common/translation";
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteBucketResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
SetupRemoteP2PResultType,
} from "./dialogs/setupDialogTypes";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
type ConfigurationOf<TType extends BuiltInRemoteConfiguration["type"]> = Extract<
BuiltInRemoteConfiguration,
{ type: TType }
>;
function assertSemanticProvider(type: BuiltInRemoteConfiguration["type"]): void {
const isRegistered = defaultRemoteProviderRegistry.providerSummaries().some((provider) => provider.type === type);
if (!isRegistered) throw new Error(`Remote setup provider '${type}' has no Commonlib provider`);
}
export function useCouchDBRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"couchdb">> = {
type: "couchdb",
choice: () => ({
title: "CouchDB",
description: translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc"),
proceedTitle: translateMessage("Continue to CouchDB setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, { settings, mode: intent });
return result === "cancelled" ? result : { type: "couchdb", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useS3RemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"s3">> = {
type: "s3",
choice: () => ({
title: translateMessage("Ui.SetupWizard.SetupRemote.BucketOption"),
description: translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc"),
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket"),
}),
open: async ({ dialogManager, settings }) => {
const result = await dialogManager.openWithExplicitCancel<SetupRemoteBucketResultType, BucketSyncSetting>(
SetupRemoteBucket,
settings
);
return result === "cancelled" ? result : { type: "s3", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useP2PRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"p2p">> = {
type: "p2p",
choice: () => ({
title: translateMessage("Ui.SetupWizard.SetupRemote.P2POption"),
description: translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
),
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P"),
}),
open: async ({ dialogManager, settings }) => {
const result = await dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
settings
);
return result === "cancelled" ? result : { type: "p2p", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
useCouchDBRemoteSetup(registry);
useS3RemoteSetup(registry);
useP2PRemoteSetup(registry);
return registry;
}
export const builtInRemoteSetupRegistry = createBuiltInRemoteSetupRegistry().freeze();
@@ -7,33 +7,58 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import { onMount } from "svelte";
import { type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { TYPE_CANCELLED, type SetupRemoteInitialData, type SetupRemoteResultType } from "./setupDialogTypes";
import {
TYPE_COUCHDB,
TYPE_BUCKET,
TYPE_P2P,
TYPE_CANCELLED,
type SetupRemoteResultType,
} from "./setupDialogTypes";
type Props = GuestDialogProps<SetupRemoteResultType, SetupRemoteInitialData>;
const { setResult, getInitialData }: Props = $props();
let choices = $state<SetupRemoteInitialData>([]);
type Props = {
setResult: (result: SetupRemoteResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
onMount(() => {
choices = getInitialData?.() ?? [];
let proceedTitle = $derived.by(() => {
if (userType === TYPE_COUCHDB) {
return translateMessage("Continue to CouchDB setup");
} else if (userType === TYPE_BUCKET) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
} else if (userType === TYPE_P2P) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
} else {
return translateMessage("Please select an option to proceed");
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
});
const selectedChoice = $derived(choices.find((choice) => choice.type === userType));
const proceedTitle = $derived(
selectedChoice?.proceedTitle ?? translateMessage("Please select an option to proceed")
);
const canProceed = $derived(selectedChoice !== undefined);
</script>
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
<Instruction>
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
<Options>
{#each choices as choice (choice.type)}
<Option selectedValue={choice.type} title={choice.title} bind:value={userType}>
{choice.description}
</Option>
{/each}
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
</Option>
<Option
selectedValue={TYPE_BUCKET}
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
bind:value={userType}
>
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
</Option>
<Option
selectedValue={TYPE_P2P}
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
bind:value={userType}
>
{translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
)}
</Option>
</Options>
</Instruction>
<UserDecisions>
@@ -5,8 +5,6 @@ import type {
ObsidianLiveSyncSettings,
P2PConnectionInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
export const TYPE_IDENTICAL = "identical";
export const TYPE_INDEPENDENT = "independent";
@@ -40,7 +38,7 @@ export const TYPE_CLOSE = "close";
// SetupRemote
export const TYPE_COUCHDB = "couchdb";
export const TYPE_BUCKET = "s3";
export const TYPE_BUCKET = "bucket";
export const TYPE_P2P = "p2p";
export type ResultTypeVault =
@@ -95,8 +93,7 @@ export type SelectMethodExistingResultType =
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType = BuiltInRemoteConfiguration["type"] | typeof TYPE_CANCELLED;
export type SetupRemoteInitialData = RemoteSetupChoice<BuiltInRemoteConfiguration["type"]>[];
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
+9 -6
View File
@@ -147,9 +147,14 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
: req instanceof Request && typeof req.method === "string"
? req.method
: "GET";
const suppliedBody = opts?.body ?? (req instanceof Request ? req.body : undefined);
if (suppliedBody !== undefined && suppliedBody !== null) {
body = typeof suppliedBody === "string" ? suppliedBody : await new Response(suppliedBody).arrayBuffer();
if (typeof req !== "string") {
if (opts?.body) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (req.body) {
body = await new Response(req.body).arrayBuffer();
}
} else {
body = opts?.body as string;
}
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
@@ -187,9 +192,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
contentType: contentType,
};
const r = await requestUrl({ ...requestParam, throw: false });
const responseHasNoBody =
method.toUpperCase() === "HEAD" || r.status === 204 || r.status === 205 || r.status === 304;
return new Response(responseHasNoBody ? null : r.arrayBuffer, {
return new Response(r.arrayBuffer, {
headers: r.headers,
status: r.status,
statusText: `${r.status}`,
@@ -4,17 +4,16 @@ const mocks = vi.hoisted(() => ({
platform: {
isMobile: false,
},
requestUrl: vi.fn(),
}));
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: mocks.requestUrl,
requestUrl: vi.fn(),
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: mocks.requestUrl,
requestUrl: vi.fn(),
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
@@ -66,41 +65,3 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
describe("ObsidianAPIService.nativeFetch", () => {
it("normalises a typed-array body when the request URL is a string", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new Uint8Array([9, 8, 7]).buffer,
headers: { etag: '"created"' },
status: 201,
});
const source = new Uint8Array([0, 1, 2, 3, 4]);
const body = source.subarray(1, 4);
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
body: body as unknown as BodyInit,
headers: { "Content-Type": "application/octet-stream" },
method: "PUT",
});
expect(response.status).toBe(201);
const request = mocks.requestUrl.mock.calls[0][0] as { body?: unknown };
expect(request.body).toBeInstanceOf(ArrayBuffer);
expect([...new Uint8Array(request.body as ArrayBuffer)]).toEqual([1, 2, 3]);
});
it("constructs a bodyless response for a successful DELETE", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new ArrayBuffer(0),
headers: {},
status: 204,
});
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
method: "DELETE",
});
expect(response.status).toBe(204);
expect(await response.text()).toBe("");
});
});
+8 -3
View File
@@ -12,11 +12,16 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
### P2P and experimental browser applications
## 1.0.3
#### Improved
3rd August, 2026
- WebPeer now includes a separate P2P connection check which prepares a disposable encrypted Setup URI and QR code locally, tests desktop and mobile devices in separate random rooms, can reuse the unchanged QR and a recorded baseline for an optional second device in the same room, and displays browser-side WebRTC diagnostic totals without presenting them as note-synchronisation results.
### Synchronisation and storage
#### Fixed
- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions.
- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771).
### Interface
+2 -1
View File
@@ -14,5 +14,6 @@
"1.0.0-rc.1": "1.7.2",
"1.0.0": "1.7.2",
"1.0.1": "1.7.2",
"1.0.2": "1.7.2"
"1.0.2": "1.7.2",
"1.0.3": "1.7.2"
}