Merge main into shared runtime foundations

Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
vorotamoroz committed 2026-10-30 15:36:57 +00:00
commit bd05dde39c
83 files changed
+6313 -1507

No files matched your search

@@ -35,6 +35,8 @@ export function createGeneralSettingSpecGroups({
},
{ key: "showStatusOnStatusbar", control: { type: "toggle" } },
{ key: "hideFileWarningNotice", control: { type: "toggle" } },
{ key: "syncGuardEnabled", control: { type: "toggle" } },
{ key: "syncGuardMinimumFiles", control: { type: "number", min: 1 } },
{
key: "networkWarningStyle",
control: {
@@ -1,6 +1,7 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SYNC_GUARD_SETTINGS, type SyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
import type { PageFunctions } from "./SettingPane.ts";
@@ -152,6 +153,7 @@ type SettingsTabOptions = {
};
function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
const syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
const core = {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
@@ -160,6 +162,10 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
services: {
setting: {
deviceValues: new MemoryValueStore(),
syncGuardSettings,
setSyncGuardSetting: vi.fn(async (key: keyof SyncGuardSettings, value: boolean | number) => {
Object.assign(syncGuardSettings, { [key]: value });
}),
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
},
@@ -179,8 +185,8 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
_editingSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
initialSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
});
return tab;
}
@@ -335,6 +341,8 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
]);
expect(
@@ -345,6 +353,39 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]);
});
it("persists declarative SyncGuard controls through Obsidian's local settings service", async () => {
const tab = createSettingsTab();
const setting = tab.services.setting as typeof tab.services.setting & {
setSyncGuardSetting: ReturnType<typeof vi.fn>;
};
const initialCoreSettings = { ...tab.core.settings };
await tab.setControlValue("syncGuardEnabled", true);
await tab.setControlValue("syncGuardMinimumFiles", 6);
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(1, "syncGuardEnabled", true);
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(2, "syncGuardMinimumFiles", 6);
expect(tab.getControlValue("syncGuardEnabled")).toBe(true);
expect(tab.getControlValue("syncGuardMinimumFiles")).toBe(6);
expect(tab.core.settings).toEqual(initialCoreSettings);
expect(setting.saveSettingData).not.toHaveBeenCalled();
});
it("refreshes active settings when local SyncGuard preferences change elsewhere", async () => {
const tab = createSettingsTab();
tab.isShown = true;
const refresh = vi.spyOn(tab, "requestPageRefresh").mockImplementation(() => undefined);
const setting = tab.services.setting as typeof tab.services.setting & { syncGuardSettings: SyncGuardSettings };
setting.syncGuardSettings.syncGuardEnabled = true;
setting.syncGuardSettings.syncGuardMinimumFiles = 13;
await tab.requestReload();
expect(tab.editingSettings.syncGuardEnabled).toBe(true);
expect(tab.editingSettings.syncGuardMinimumFiles).toBe(13);
expect(refresh).toHaveBeenCalledOnce();
});
it("omits the old Setup child page and keeps standard General and Advanced pages native", () => {
const tab = createSettingsTab();
const pages = collectPages(tab.getSettingDefinitions());
@@ -77,6 +77,11 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
import {
getSyncGuardSettings,
isSyncGuardSettingKey,
setSyncGuardSetting,
} from "@/modules/services/ObsidianSettingService.ts";
// For creating a document
// const toc = new Set<string>();
@@ -138,6 +143,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (
this.isReconstructionRequiredSetting(k) ||
this.isConfigurationEncryptionSetting(k) ||
isSyncGuardSettingKey(k) ||
k in OnDialogSettingsDefault
) {
// //@ts-ignore
@@ -157,6 +163,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
);
await this.saveConfigurationEncryptionIfDirty(changedKeys);
for (const key of changedKeys.filter(isSyncGuardSettingKey)) {
await setSyncGuardSetting(this.services.setting, key, this.editingSettings[key]);
this.copySettingValue(this.initialSettings, this.editingSettings, key);
}
this.applySetting(changedKeys);
this.reloadAllSettings();
}
@@ -177,6 +187,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
for (const k of appliedKeys) {
if (this.isConfigurationEncryptionSetting(k)) continue;
if (isSyncGuardSettingKey(k)) {
await setSyncGuardSetting(this.services.setting, k, this.editingSettings[k]);
this.copySettingValue(this.initialSettings, this.editingSettings, k);
continue;
}
if (k in OnDialogSettingsDefault) {
await this.saveLocalSetting(k as keyof OnDialogSettings);
this.copySettingValue(this.initialSettings, this.editingSettings, k);
@@ -307,7 +322,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
ret.configPassphrase = this.localConfigurationPassphrase;
ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret;
return { ...ret, ...getSyncGuardSettings(this.services.setting) };
}
computeAllLocalSettings(): Partial<OnDialogSettings> {
const syncMode = this.editingSettings?.liveSync
@@ -375,7 +390,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
return;
}
const localSetting = this.reloadAllLocalSettings();
if (key in this.core.settings) {
if (isSyncGuardSettingKey(key) || key in this.core.settings) {
if (key in localSetting) {
this.copySettingValue(this.initialSettings, localSetting, key);
this.copySettingValue(this.editingSettings, localSetting, key);
@@ -567,25 +582,25 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
const nativeTabIsShown =
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
if (this.isShown || nativeTabIsShown) {
const newConf = this.core.settings;
const keys = Object.keys(newConf) as (keyof ObsidianLiveSyncSettings)[];
const newConf = { ...this.core.settings, ...getSyncGuardSettings(this.services.setting) };
const keys = Object.keys(newConf) as AllSettingItemKey[];
let hasLoaded = false;
let catalogueVisibilityChanged = false;
for (const k of keys) {
if (isObjectDifferent(newConf[k], this.initialSettings?.[k])) {
if (isObjectDifferent(Reflect.get(newConf, k), this.initialSettings?.[k])) {
// Something has changed
if (this.isDirty(k as AllSettingItemKey)) {
if (this.isDirty(k)) {
// And modified.
this.core.confirm.askInPopup(
`config-reloaded-${k}`,
$msg("obsidianLiveSyncSettingTab.msgSettingModified", {
setting: getConfName(k as AllSettingItemKey),
setting: getConfName(k),
}),
(anchor) => {
anchor.text = $msg("obsidianLiveSyncSettingTab.optionHere");
anchor.addEventListener("click", () => {
this.refreshSetting(k as AllSettingItemKey);
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
this.refreshSetting(k);
if (this.changesPageCatalogue(k)) {
this.requestCatalogueRefresh();
} else {
this.requestPageRefresh();
@@ -595,12 +610,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
);
} else {
// not modified
this.refreshSetting(k as AllSettingItemKey);
this.refreshSetting(k);
if (k in OnDialogSettingsDefault) {
continue;
}
hasLoaded = true;
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
if (this.changesPageCatalogue(k)) {
catalogueVisibilityChanged = true;
}
}
@@ -142,6 +142,8 @@ describe("General setting specifications", () => {
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
@@ -1,25 +1,50 @@
export {
AllSettingDefault,
OnDialogSettingsDefault,
SettingInformation,
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
export type {
AllSettings,
AllSettingItemKey,
AllStringItemKey,
AllNumericItemKey,
AllBooleanItemKey,
OnDialogSettings,
ValueOf,
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
export { OnDialogSettingsDefault } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
export type { OnDialogSettings } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
import {
AllSettingDefault as CommonlibAllSettingDefault,
SettingInformation as CommonlibSettingInformation,
getConfig as getCommonlibConfig,
getConfName as getCommonlibConfName,
type AllSettingItemKey,
type AllSettings as CommonlibAllSettings,
type AllSettingItemKey as CommonlibAllSettingItemKey,
type AllStringItemKey as CommonlibAllStringItemKey,
type AllNumericItemKey as CommonlibAllNumericItemKey,
type AllBooleanItemKey as CommonlibAllBooleanItemKey,
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
import type { ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import { translateLiveSyncMessage } from "@/common/translation";
import {
DEFAULT_SYNC_GUARD_SETTINGS,
isSyncGuardSettingKey,
type SyncGuardSettingKey,
type SyncGuardSettings,
} from "@/modules/services/ObsidianSettingService";
export type AllSettings = Omit<CommonlibAllSettings, SyncGuardSettingKey> & SyncGuardSettings;
export type AllStringItemKey = CommonlibAllStringItemKey;
export type AllNumericItemKey = Exclude<CommonlibAllNumericItemKey, SyncGuardSettingKey> | "syncGuardMinimumFiles";
export type AllBooleanItemKey = Exclude<CommonlibAllBooleanItemKey, SyncGuardSettingKey> | "syncGuardEnabled";
export type AllSettingItemKey = Exclude<CommonlibAllSettingItemKey, SyncGuardSettingKey> | SyncGuardSettingKey;
export type ValueOf<T extends AllSettingItemKey> = AllSettings[T];
const syncGuardSettingInformation: Record<SyncGuardSettingKey, ConfigurationItem> = {
syncGuardEnabled: {
name: "Enable SyncGuard",
desc: "Enable device-local SyncGuard protection during synchronisation.",
},
syncGuardMinimumFiles: {
name: "SyncGuard minimum files",
desc: "Minimum number of Vault files required for SyncGuard protection to apply.",
},
};
export const AllSettingDefault = { ...CommonlibAllSettingDefault, ...DEFAULT_SYNC_GUARD_SETTINGS };
export const SettingInformation: Partial<Record<AllSettingItemKey, ConfigurationItem>> = {
...CommonlibSettingInformation,
...syncGuardSettingInformation,
};
// Commonlib defaults `translate` to its English-only translator, so every caller which omits
// it silently renders English regardless of `displayLanguage`. Default it to the LiveSync
@@ -28,10 +53,12 @@ import { translateLiveSyncMessage } from "@/common/translation";
/** `getConfig` with the LiveSync catalogue applied by default. */
export function getConfig(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key];
return getCommonlibConfig(key, translate);
}
/** `getConfName` with the LiveSync catalogue applied by default. See `getConfig`. */
export function getConfName(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key].name;
return getCommonlibConfName(key, translate);
}