mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2025-12-13 17:55:56 +00:00
Merge branch 'svelteui'
This commit is contained in:
@@ -32,6 +32,7 @@ import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { getPlatformName } from "../../lib/src/PlatformAPIs/obsidian/Environment.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { TrysteroReplicator } from "../../lib/src/replication/trystero/TrysteroReplicator.ts";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../lib/src/common/types.ts";
|
||||
|
||||
export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase, CommandShim {
|
||||
storeP2PStatusLine = reactiveSource("");
|
||||
@@ -93,14 +94,10 @@ export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase
|
||||
}
|
||||
|
||||
getConfig(key: string) {
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const dbKey = `${vaultName}-${key}`;
|
||||
return localStorage.getItem(dbKey);
|
||||
return this.services.config.getSmallConfig(key);
|
||||
}
|
||||
setConfig(key: string, value: string) {
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const dbKey = `${vaultName}-${key}`;
|
||||
localStorage.setItem(dbKey, value);
|
||||
return this.services.config.setSmallConfig(key, value);
|
||||
}
|
||||
enableBroadcastCastings() {
|
||||
return this?._replicatorInstance?.enableBroadcastChanges();
|
||||
@@ -125,7 +122,8 @@ export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase
|
||||
if (!this.settings.P2P_AppID) {
|
||||
this.settings.P2P_AppID = P2P_DEFAULT_SETTINGS.P2P_AppID;
|
||||
}
|
||||
const getInitialDeviceName = () => this.getConfig("p2p_device_name") || this.services.vault.getVaultName();
|
||||
const getInitialDeviceName = () =>
|
||||
this.getConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
||||
|
||||
const getSettings = () => this.settings;
|
||||
const store = () => this.simpleStore();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import { $msg as _msg } from "../../../lib/src/common/i18n";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types";
|
||||
|
||||
interface Props {
|
||||
plugin: PluginShim;
|
||||
@@ -35,7 +36,7 @@
|
||||
// const vaultName = service.vault.getVaultName();
|
||||
// const dbKey = `${vaultName}-p2p-device-name`;
|
||||
|
||||
const initialDeviceName = cmdSync.getConfig("p2p_device_name") ?? plugin.services.vault.getVaultName();
|
||||
const initialDeviceName = cmdSync.getConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? plugin.services.vault.getVaultName();
|
||||
let deviceName = $state<string>(initialDeviceName);
|
||||
|
||||
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
||||
@@ -84,7 +85,7 @@
|
||||
P2P_AutoBroadcast: eAutoBroadcast,
|
||||
};
|
||||
plugin.settings = newSettings;
|
||||
cmdSync.setConfig("p2p_device_name", eDeviceName);
|
||||
cmdSync.setConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
||||
deviceName = eDeviceName;
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
@@ -250,6 +251,9 @@
|
||||
};
|
||||
cmdSync.setConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
});
|
||||
let isObsidian = $derived.by(() => {
|
||||
return plugin.services.API.getPlatform() === "obsidian";
|
||||
});
|
||||
</script>
|
||||
|
||||
<article>
|
||||
@@ -265,95 +269,105 @@
|
||||
{/each}
|
||||
</details>
|
||||
<h2>Connection Settings</h2>
|
||||
<details bind:open={isSettingOpened}>
|
||||
<summary>{eRelay}</summary>
|
||||
<table class="settings">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th> Enable P2P Replicator </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isP2PEnabledModified }}>
|
||||
<input type="checkbox" bind:checked={eP2PEnabled} />
|
||||
</label>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<th> Relay settings </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRelayModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="wss://exp-relay.vrtmrz.net, wss://xxxxx"
|
||||
bind:value={eRelay}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button onclick={() => useDefaultRelay()}> Use vrtmrz's relay </button>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Room ID </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRoomIdModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="anything-you-like"
|
||||
bind:value={eRoomId}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
<button onclick={() => chooseRandom()}> Use Random Number </button>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
This can isolate your connections between devices. Use the same Room ID for the same
|
||||
devices.</small
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Password </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isPasswordModified }}>
|
||||
<input type="password" placeholder="password" bind:value={ePassword} />
|
||||
</label>
|
||||
<span>
|
||||
<small> This password is used to encrypt the connection. Use something long enough. </small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> This device name </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isDeviceNameModified }}>
|
||||
<input type="text" placeholder="iphone-16" bind:value={eDeviceName} autocomplete="off" />
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
Device name to identify the device. Please use shorter one for the stable peer
|
||||
detection, i.e., "iphone-16" or "macbook-2021".
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Auto Connect </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoStartModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoStart} />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Start change-broadcasting on Connect </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoBroadcastModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoBroadcast} />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- <tr>
|
||||
{#if isObsidian}
|
||||
You can configure in the Obsidian Plugin Settings.
|
||||
{:else}
|
||||
<details bind:open={isSettingOpened}>
|
||||
<summary>{eRelay}</summary>
|
||||
<table class="settings">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th> Enable P2P Replicator </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isP2PEnabledModified }}>
|
||||
<input type="checkbox" bind:checked={eP2PEnabled} />
|
||||
</label>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<th> Relay settings </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRelayModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="wss://exp-relay.vrtmrz.net, wss://xxxxx"
|
||||
bind:value={eRelay}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button onclick={() => useDefaultRelay()}> Use vrtmrz's relay </button>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Room ID </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRoomIdModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="anything-you-like"
|
||||
bind:value={eRoomId}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
<button onclick={() => chooseRandom()}> Use Random Number </button>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
This can isolate your connections between devices. Use the same Room ID for the same
|
||||
devices.</small
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Password </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isPasswordModified }}>
|
||||
<input type="password" placeholder="password" bind:value={ePassword} />
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
This password is used to encrypt the connection. Use something long enough.
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> This device name </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isDeviceNameModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="iphone-16"
|
||||
bind:value={eDeviceName}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
Device name to identify the device. Please use shorter one for the stable peer
|
||||
detection, i.e., "iphone-16" or "macbook-2021".
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Auto Connect </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoStartModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoStart} />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Start change-broadcasting on Connect </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoBroadcastModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoBroadcast} />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- <tr>
|
||||
<th> Auto Accepting </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoAcceptModified }}>
|
||||
@@ -361,11 +375,12 @@
|
||||
</label>
|
||||
</td>
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}>Save and Apply</button>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>Revert changes</button>
|
||||
</details>
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}>Save and Apply</button>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>Revert changes</button>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<h2>Signaling Server Connection</h2>
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 8fee5ee0b7...08b43da7fb
11
src/main.ts
11
src/main.ts
@@ -34,6 +34,7 @@ import { ModuleObsidianSettings } from "./modules/features/ModuleObsidianSetting
|
||||
import { ModuleRedFlag } from "./modules/coreFeatures/ModuleRedFlag.ts";
|
||||
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
|
||||
import { ModuleSetupObsidian } from "./modules/features/ModuleSetupObsidian.ts";
|
||||
import { SetupManager } from "./modules/features/SetupManager.ts";
|
||||
import type { StorageAccess } from "./modules/interfaces/StorageAccess.ts";
|
||||
import type { Confirm } from "./lib/src/interfaces/Confirm.ts";
|
||||
import type { Rebuilder } from "./modules/interfaces/DatabaseRebuilder.ts";
|
||||
@@ -107,7 +108,7 @@ export default class ObsidianLiveSyncPlugin
|
||||
/**
|
||||
* The service hub for managing all services.
|
||||
*/
|
||||
_services: InjectableServiceHub = new ObsidianServiceHub();
|
||||
_services: InjectableServiceHub = new ObsidianServiceHub(this);
|
||||
get services() {
|
||||
return this._services;
|
||||
}
|
||||
@@ -176,7 +177,15 @@ export default class ObsidianLiveSyncPlugin
|
||||
new ModuleDev(this, this),
|
||||
new ModuleReplicateTest(this, this),
|
||||
new ModuleIntegratedTest(this, this),
|
||||
new SetupManager(this, this),
|
||||
] as (IObsidianModule | AbstractModule)[];
|
||||
|
||||
getModule<T extends IObsidianModule>(constructor: new (...args: any[]) => T): T {
|
||||
for (const module of this.modules) {
|
||||
if (module.constructor === constructor) return module as T;
|
||||
}
|
||||
throw new Error(`Module ${constructor} not found or not loaded.`);
|
||||
}
|
||||
// injected = injectModules(this, [...this.modules, ...this.addOns] as ICoreModule[]);
|
||||
// <-- Module System
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
|
||||
}
|
||||
}
|
||||
|
||||
async informOptionalFeatures() {
|
||||
await this.core.services.UI.showMarkdownDialog(
|
||||
"All optional features are disabled",
|
||||
`Customisation Sync and Hidden File Sync will all be disabled.
|
||||
Please enable them from the settings screen after setup is complete.`,
|
||||
["OK"]
|
||||
);
|
||||
}
|
||||
async askUsingOptionalFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
@@ -56,11 +64,12 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
|
||||
await this.services.remote.tryResetDatabase();
|
||||
await this.services.remote.markLocked();
|
||||
await delay(500);
|
||||
await this.askUsingOptionalFeature({ enableOverwrite: true });
|
||||
// await this.askUsingOptionalFeature({ enableOverwrite: true });
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true);
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true, true);
|
||||
await this.informOptionalFeatures();
|
||||
}
|
||||
$rebuildRemote(): Promise<void> {
|
||||
return this.rebuildRemote();
|
||||
@@ -79,11 +88,12 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
|
||||
await this.services.remote.markLocked();
|
||||
await delay(500);
|
||||
// We do not have any other devices' data, so we do not need to ask for overwriting.
|
||||
await this.askUsingOptionalFeature({ enableOverwrite: false });
|
||||
// await this.askUsingOptionalFeature({ enableOverwrite: false });
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true);
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true, true);
|
||||
await this.informOptionalFeatures();
|
||||
}
|
||||
|
||||
$rebuildEverything(): Promise<void> {
|
||||
@@ -200,7 +210,9 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllFromRemote(true);
|
||||
await this.resumeReflectingDatabase();
|
||||
await this.askUsingOptionalFeature({ enableFetch: true });
|
||||
await this.informOptionalFeatures();
|
||||
// No longer enable
|
||||
// await this.askUsingOptionalFeature({ enableFetch: true });
|
||||
}
|
||||
async fetchLocalWithRebuild() {
|
||||
return await this.fetchLocal(true);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { normalizePath } from "../../deps.ts";
|
||||
import {
|
||||
FLAGMD_REDFLAG,
|
||||
FLAGMD_REDFLAG2,
|
||||
FLAGMD_REDFLAG2_HR,
|
||||
FLAGMD_REDFLAG3,
|
||||
FLAGMD_REDFLAG3_HR,
|
||||
FlagFilesHumanReadable,
|
||||
FlagFilesOriginal,
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { SvelteDialogManager } from "../features/SetupWizard/ObsidianSvelteDialog.ts";
|
||||
import FetchEverything from "../features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
|
||||
export class ModuleRedFlag extends AbstractModule {
|
||||
async isFlagFileExist(path: string) {
|
||||
@@ -33,165 +34,284 @@ export class ModuleRedFlag extends AbstractModule {
|
||||
}
|
||||
}
|
||||
|
||||
isRedFlagRaised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG);
|
||||
isRedFlag2Raised = async () =>
|
||||
(await this.isFlagFileExist(FLAGMD_REDFLAG2)) || (await this.isFlagFileExist(FLAGMD_REDFLAG2_HR));
|
||||
isRedFlag3Raised = async () =>
|
||||
(await this.isFlagFileExist(FLAGMD_REDFLAG3)) || (await this.isFlagFileExist(FLAGMD_REDFLAG3_HR));
|
||||
isSuspendFlagActive = async () => await this.isFlagFileExist(FlagFilesOriginal.SUSPEND_ALL);
|
||||
isRebuildFlagActive = async () =>
|
||||
(await this.isFlagFileExist(FlagFilesOriginal.REBUILD_ALL)) ||
|
||||
(await this.isFlagFileExist(FlagFilesHumanReadable.REBUILD_ALL));
|
||||
isFetchAllFlagActive = async () =>
|
||||
(await this.isFlagFileExist(FlagFilesOriginal.FETCH_ALL)) ||
|
||||
(await this.isFlagFileExist(FlagFilesHumanReadable.FETCH_ALL));
|
||||
|
||||
async deleteRedFlag2() {
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG2);
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG2_HR);
|
||||
async cleanupRebuildFlag() {
|
||||
await this.deleteFlagFile(FlagFilesOriginal.REBUILD_ALL);
|
||||
await this.deleteFlagFile(FlagFilesHumanReadable.REBUILD_ALL);
|
||||
}
|
||||
|
||||
async deleteRedFlag3() {
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG3);
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG3_HR);
|
||||
async cleanupFetchAllFlag() {
|
||||
await this.deleteFlagFile(FlagFilesOriginal.FETCH_ALL);
|
||||
await this.deleteFlagFile(FlagFilesHumanReadable.FETCH_ALL);
|
||||
}
|
||||
dialogManager = new SvelteDialogManager(this.core);
|
||||
|
||||
/**
|
||||
* Adjust setting to remote if needed.
|
||||
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
*/
|
||||
async adjustSettingToRemoteIfNeeded(extra: { preventFetchingConfig: boolean }, config: ObsidianLiveSyncSettings) {
|
||||
if (extra && extra.preventFetchingConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote configuration fetched and applied.
|
||||
if (await this.adjustSettingToRemote(config)) {
|
||||
config = this.core.settings;
|
||||
} else {
|
||||
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
console.debug(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote configuration.
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
* @returns updated configuration if applied, otherwise null.
|
||||
*/
|
||||
async adjustSettingToRemote(config: ObsidianLiveSyncSettings) {
|
||||
// Fetch remote configuration unless prevented.
|
||||
const SKIP_FETCH = "Skip and proceed";
|
||||
const RETRY_FETCH = "Retry (recommended)";
|
||||
let canProceed = false;
|
||||
do {
|
||||
const remoteTweaks = await this.services.tweakValue.fetchRemotePreferred(config);
|
||||
if (!remoteTweaks) {
|
||||
const choice = await this.core.confirm.askSelectStringDialogue(
|
||||
"Could not fetch remote configuration. What do you want to do?",
|
||||
[SKIP_FETCH, RETRY_FETCH] as const,
|
||||
{
|
||||
defaultAction: RETRY_FETCH,
|
||||
timeout: 0,
|
||||
title: "Fetch Remote Configuration Failed",
|
||||
}
|
||||
);
|
||||
if (choice === SKIP_FETCH) {
|
||||
canProceed = true;
|
||||
}
|
||||
} else {
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return (config as any)[key] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
this._log(
|
||||
"Remote configuration matches local configuration. No changes applied.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
} else {
|
||||
await this.core.confirm.askSelectStringDialogue(
|
||||
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
||||
["OK"] as const,
|
||||
{
|
||||
defaultAction: "OK",
|
||||
timeout: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...Object.fromEntries(differentItems),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
this.core.settings = config;
|
||||
await this.core.services.setting.saveSettingData();
|
||||
this._log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
return this.core.settings;
|
||||
}
|
||||
} while (!canProceed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process vault initialisation with suspending file watching and sync.
|
||||
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
|
||||
* @param keepSuspending whether to keep suspending file watching after the process.
|
||||
* @returns result of the process, or false if error occurs.
|
||||
*/
|
||||
async processVaultInitialisation(proc: () => Promise<boolean>, keepSuspending = false) {
|
||||
try {
|
||||
// Disable batch saving and file watching during initialisation.
|
||||
this.settings.batchSave = false;
|
||||
await this.services.setting.suspendAllSync();
|
||||
await this.services.setting.suspendExtraSync();
|
||||
this.settings.suspendFileWatching = true;
|
||||
await this.saveSettings();
|
||||
try {
|
||||
const result = await proc();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
this._log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log("Error during vault initialisation.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
} finally {
|
||||
if (!keepSuspending) {
|
||||
// Re-enable file watching after initialisation.
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the rebuild everything scheduled operation.
|
||||
* @returns true if can be continued, false if app restart is needed.
|
||||
*/
|
||||
async onRebuildEverythingScheduled() {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(RebuildEverything);
|
||||
if (method === "cancelled") {
|
||||
// Clean up the flag file and restart the app.
|
||||
this._log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await this.cleanupRebuildFlag();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { extra } = method;
|
||||
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
await this.cleanupRebuildFlag();
|
||||
this._log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handle the fetch all scheduled operation.
|
||||
* @returns true if can be continued, false if app restart is needed.
|
||||
*/
|
||||
async onFetchAllScheduled() {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(FetchEverything);
|
||||
if (method === "cancelled") {
|
||||
this._log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
// Clean up the flag file and restart the app.
|
||||
await this.cleanupFetchAllFlag();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { vault, extra } = method;
|
||||
|
||||
const mapVaultStateToAction = {
|
||||
identical: {
|
||||
// If both are identical, no need to make local files/chunks before sync,
|
||||
// Just for the efficiency, chunks should be made before sync.
|
||||
makeLocalChunkBeforeSync: true,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
independent: {
|
||||
// If both are independent, nothing needs to be made before sync.
|
||||
// Respect the remote state.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
unbalanced: {
|
||||
// If both are unbalanced, local files should be made before sync to avoid data loss.
|
||||
// Then, chunks should be made before sync for the efficiency, but also the metadata made and should be detected as conflicting.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: true,
|
||||
},
|
||||
cancelled: {
|
||||
// Cancelled case, not actually used.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
|
||||
// Okay, proceed to fetch everything.
|
||||
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = mapVaultStateToAction[vault];
|
||||
this._log(
|
||||
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
|
||||
await this.cleanupFetchAllFlag();
|
||||
this._log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async onSuspendAllScheduled() {
|
||||
this._log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE);
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
this._log(
|
||||
"All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.settings.writeLogToTheFile = true;
|
||||
await this.core.services.setting.saveSettingData();
|
||||
return Promise.resolve(false);
|
||||
}, true);
|
||||
}
|
||||
|
||||
async verifyAndUnlockSuspension() {
|
||||
if (!this.settings.suspendFileWatching) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) != "yes"
|
||||
) {
|
||||
// TODO: Confirm actually proceed to next process.
|
||||
return true;
|
||||
}
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async processFlagFilesOnStartup(): Promise<boolean> {
|
||||
const isFlagSuspensionActive = await this.isSuspendFlagActive();
|
||||
const isFlagRebuildActive = await this.isRebuildFlagActive();
|
||||
const isFlagFetchAllActive = await this.isFetchAllFlagActive();
|
||||
// TODO: Address the case when both flags are active (very unlikely though).
|
||||
// if(isFlagFetchAllActive && isFlagRebuildActive) {
|
||||
// const message = "Rebuild everything and Fetch everything flags are both detected.";
|
||||
// await this.core.confirm.askSelectStringDialogue(
|
||||
// "Both Rebuild Everything and Fetch Everything flags are detected. Please remove one of them and restart the app.",
|
||||
// ["OK"] as const,)
|
||||
if (isFlagFetchAllActive) {
|
||||
const res = await this.onFetchAllScheduled();
|
||||
if (res) {
|
||||
return await this.verifyAndUnlockSuspension();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isFlagRebuildActive) {
|
||||
const res = await this.onRebuildEverythingScheduled();
|
||||
if (res) {
|
||||
return await this.verifyAndUnlockSuspension();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isFlagSuspensionActive) {
|
||||
const res = await this.onSuspendAllScheduled();
|
||||
return res;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnLayoutReady(): Promise<boolean> {
|
||||
try {
|
||||
const isRedFlagRaised = await this.isRedFlagRaised();
|
||||
const isRedFlag2Raised = await this.isRedFlag2Raised();
|
||||
const isRedFlag3Raised = await this.isRedFlag3Raised();
|
||||
|
||||
if (isRedFlagRaised || isRedFlag2Raised || isRedFlag3Raised) {
|
||||
if (isRedFlag2Raised) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Rebuild everything has been scheduled! Are you sure to rebuild everything?",
|
||||
{ defaultOption: "Yes", timeout: 0 }
|
||||
)) !== "yes"
|
||||
) {
|
||||
await this.deleteRedFlag2();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isRedFlag3Raised) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Fetch again has been scheduled! Are you sure?", {
|
||||
defaultOption: "Yes",
|
||||
timeout: 0,
|
||||
})) !== "yes"
|
||||
) {
|
||||
await this.deleteRedFlag3();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.settings.batchSave = false;
|
||||
await this.services.setting.suspendAllSync();
|
||||
await this.services.setting.suspendExtraSync();
|
||||
this.settings.suspendFileWatching = true;
|
||||
await this.saveSettings();
|
||||
if (isRedFlag2Raised) {
|
||||
this._log(
|
||||
`${FLAGMD_REDFLAG2} or ${FLAGMD_REDFLAG2_HR} has been detected! Self-hosted LiveSync suspends all sync and rebuild everything.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
await this.deleteRedFlag2();
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
} else if (isRedFlag3Raised) {
|
||||
this._log(
|
||||
`${FLAGMD_REDFLAG3} or ${FLAGMD_REDFLAG3_HR} has been detected! Self-hosted LiveSync will discard the local database and fetch everything from the remote once again.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const method1 = $msg("RedFlag.Fetch.Method.FetchSafer");
|
||||
const method2 = $msg("RedFlag.Fetch.Method.FetchSmoother");
|
||||
const method3 = $msg("RedFlag.Fetch.Method.FetchTraditional");
|
||||
|
||||
const methods = [method1, method2, method3] as const;
|
||||
const chunkMode = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("RedFlag.Fetch.Method.Desc"),
|
||||
methods,
|
||||
{
|
||||
defaultAction: method1,
|
||||
timeout: 0,
|
||||
title: $msg("RedFlag.Fetch.Method.Title"),
|
||||
}
|
||||
);
|
||||
let makeLocalChunkBeforeSync = false;
|
||||
let makeLocalFilesBeforeSync = false;
|
||||
if (chunkMode === method1) {
|
||||
makeLocalFilesBeforeSync = true;
|
||||
} else if (chunkMode === method2) {
|
||||
makeLocalChunkBeforeSync = true;
|
||||
} else if (chunkMode === method3) {
|
||||
// Do nothing.
|
||||
} else {
|
||||
this._log("Cancelled the fetch operation", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
const optionFetchRemoteConf = $msg("RedFlag.FetchRemoteConfig.Buttons.Fetch");
|
||||
const optionCancel = $msg("RedFlag.FetchRemoteConfig.Buttons.Cancel");
|
||||
const fetchRemote = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("RedFlag.FetchRemoteConfig.Message"),
|
||||
[optionFetchRemoteConf, optionCancel],
|
||||
{
|
||||
defaultAction: optionFetchRemoteConf,
|
||||
timeout: 0,
|
||||
title: $msg("RedFlag.FetchRemoteConfig.Title"),
|
||||
}
|
||||
);
|
||||
if (fetchRemote === optionFetchRemoteConf) {
|
||||
this._log("Fetching remote configuration", LOG_LEVEL_NOTICE);
|
||||
const newSettings = JSON.parse(JSON.stringify(this.core.settings)) as ObsidianLiveSyncSettings;
|
||||
const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
|
||||
if (remoteConfig) {
|
||||
this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
|
||||
const mergedSettings = {
|
||||
...this.core.settings,
|
||||
...remoteConfig,
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
this._log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
this.core.settings = mergedSettings;
|
||||
} else {
|
||||
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
|
||||
|
||||
await this.deleteRedFlag3();
|
||||
if (this.settings.suspendFileWatching) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(
|
||||
"Your content of files will be synchronised gradually. Please wait for the completion.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Case of FLAGMD_REDFLAG.
|
||||
this.settings.writeLogToTheFile = true;
|
||||
// await this.plugin.openDatabase();
|
||||
const warningMessage =
|
||||
"The red flag is raised! The whole initialize steps are skipped, and any file changes are not captured.";
|
||||
this._log(warningMessage, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
const flagProcessResult = await this.processFlagFilesOnStartup();
|
||||
return flagProcessResult;
|
||||
} catch (ex) {
|
||||
this._log("Something went wrong on FlagFile Handling", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
EVENT_REQUEST_OPEN_P2P,
|
||||
EVENT_REQUEST_OPEN_SETTING_WIZARD,
|
||||
EVENT_REQUEST_OPEN_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
EVENT_REQUEST_RUN_DOCTOR,
|
||||
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
||||
eventHub,
|
||||
@@ -16,6 +15,7 @@ import { isMetaEntry } from "../../lib/src/common/types.ts";
|
||||
import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "../../lib/src/common/utils.ts";
|
||||
import { countCompromisedChunks } from "../../lib/src/pouchdb/negotiation.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { SetupManager } from "../features/SetupManager.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
@@ -66,6 +66,9 @@ export class ModuleMigration extends AbstractModule {
|
||||
}
|
||||
|
||||
async initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
return await manager.startOnBoarding();
|
||||
/*
|
||||
const message = $msg("moduleMigration.msgInitialSetup", {
|
||||
URI_DOC: $msg("moduleMigration.docUri"),
|
||||
});
|
||||
@@ -83,6 +86,7 @@ export class ModuleMigration extends AbstractModule {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
*/
|
||||
}
|
||||
|
||||
async askAgainForSetupURI() {
|
||||
@@ -326,8 +330,11 @@ export class ModuleMigration extends AbstractModule {
|
||||
await this.migrateDisableBulkSend();
|
||||
}
|
||||
if (!this.settings.isConfigured) {
|
||||
// Case sensitivity
|
||||
if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
|
||||
// if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
|
||||
// this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
// return false;
|
||||
// }
|
||||
if (!(await this.initialMessage())) {
|
||||
this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
|
||||
compression: boolean,
|
||||
customHeaders: Record<string, string>,
|
||||
useRequestAPI: boolean,
|
||||
getPBKDF2Salt: () => Promise<Uint8Array>
|
||||
getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>
|
||||
): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> {
|
||||
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
|
||||
if (uri.toLowerCase() != uri) return "Remote URI and database name could not contain capital letters.";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
type ObsidianLiveSyncSettings,
|
||||
SALT_OF_PASSPHRASE,
|
||||
SETTING_KEY_P2P_DEVICE_NAME,
|
||||
} from "../../lib/src/common/types";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { $msg, setLang } from "../../lib/src/common/i18n.ts";
|
||||
@@ -111,6 +112,11 @@ export class ModuleObsidianSettings extends AbstractObsidianModule {
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
const settings = { ...this.settings };
|
||||
settings.deviceAndVaultName = "";
|
||||
if (settings.P2P_DevicePeerName && settings.P2P_DevicePeerName.trim() !== "") {
|
||||
console.log("Saving device peer name to small config");
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, settings.P2P_DevicePeerName.trim());
|
||||
settings.P2P_DevicePeerName = "";
|
||||
}
|
||||
if (this.usedPassphrase == "" && !(await this.getPassphrase(settings))) {
|
||||
this._log("Failed to retrieve passphrase. data.json contains unencrypted items!", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
KeyIndexOfSettings,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { configURIBase, configURIBaseQR } from "../../common/types.ts";
|
||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE } from "../../lib/src/common/types.ts";
|
||||
import { configURIBase } from "../../common/types.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
||||
import {
|
||||
EVENT_REQUEST_COPY_SETUP_URI,
|
||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
EVENT_REQUEST_SHOW_SETUP_QR,
|
||||
eventHub,
|
||||
} from "../../common/events.ts";
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { decodeAnyArray, encodeAnyArray } from "../../common/utils.ts";
|
||||
import qrcode from "qrcode-generator";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
|
||||
import { encryptString, decryptString } from "@/lib/src/encryption/stringEncryption.ts";
|
||||
// import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import {
|
||||
encodeQR,
|
||||
encodeSettingsToQRCodeData,
|
||||
encodeSettingsToSetupURI,
|
||||
OutputFormat,
|
||||
} from "../../lib/src/API/processSetting.ts";
|
||||
import { SetupManager, UserMode } from "./SetupManager.ts";
|
||||
|
||||
export class ModuleSetupObsidian extends AbstractObsidianModule {
|
||||
private _setupManager!: SetupManager;
|
||||
private _everyOnload(): Promise<boolean> {
|
||||
this._setupManager = this.plugin.getModule(SetupManager);
|
||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
||||
if (conf.settings) {
|
||||
await this.setupWizard(conf.settings);
|
||||
await this._setupManager.onUseSetupURI(
|
||||
UserMode.Unknown,
|
||||
`${configURIBase}${encodeURIComponent(conf.settings)}`
|
||||
);
|
||||
} else if (conf.settingsQR) {
|
||||
await this.decodeQR(conf.settingsQR);
|
||||
await this._setupManager.decodeQR(conf.settingsQR);
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
@@ -59,293 +63,134 @@ export class ModuleSetupObsidian extends AbstractObsidianModule {
|
||||
name: "Use the copied setup URI (Formerly Open setup URI)",
|
||||
callback: () => fireAndForget(this.command_openSetupURI()),
|
||||
});
|
||||
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
|
||||
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
|
||||
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () =>
|
||||
fireAndForget(() => {
|
||||
return this._setupManager.onP2PManualSetup(UserMode.Update, this.settings, false);
|
||||
})
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async encodeQR() {
|
||||
const settingArr = [];
|
||||
const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][];
|
||||
for (const [settingKey, index] of fullIndexes) {
|
||||
const settingValue = this.settings[settingKey];
|
||||
if (index < 0) {
|
||||
// This setting should be ignored.
|
||||
continue;
|
||||
}
|
||||
settingArr[index] = settingValue;
|
||||
}
|
||||
const w = encodeAnyArray(settingArr);
|
||||
const qr = qrcode(0, "L");
|
||||
const uri = `${configURIBaseQR}${encodeURIComponent(w)}`;
|
||||
qr.addData(uri);
|
||||
qr.make();
|
||||
const img = qr.createSvgTag(3);
|
||||
const msg = $msg("Setup.QRCode", { qr_image: img });
|
||||
const settingString = encodeSettingsToQRCodeData(this.settings);
|
||||
const codeSVG = encodeQR(settingString, OutputFormat.SVG);
|
||||
const msg = $msg("Setup.QRCode", { qr_image: codeSVG });
|
||||
await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK");
|
||||
return await Promise.resolve(w);
|
||||
return await Promise.resolve(codeSVG);
|
||||
}
|
||||
async decodeQR(qr: string) {
|
||||
const settingArr = decodeAnyArray(qr);
|
||||
// console.warn(settingArr);
|
||||
const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][];
|
||||
const newSettings = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
|
||||
for (const [settingKey, index] of fullIndexes) {
|
||||
if (index < 0) {
|
||||
// This setting should be ignored.
|
||||
continue;
|
||||
}
|
||||
if (index >= settingArr.length) {
|
||||
// Possibly a new setting added.
|
||||
continue;
|
||||
}
|
||||
const settingValue = settingArr[index];
|
||||
//@ts-ignore
|
||||
newSettings[settingKey] = settingValue;
|
||||
}
|
||||
await this.applySettingWizard(this.settings, newSettings, "QR Code");
|
||||
|
||||
async askEncryptingPassphrase(): Promise<string | false> {
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
return encryptingPassphrase;
|
||||
}
|
||||
|
||||
async command_copySetupURI(stripExtra = true) {
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
||||
if (encryptingPassphrase === false) return;
|
||||
const encryptedURI = await encodeSettingsToSetupURI(
|
||||
this.settings,
|
||||
encryptingPassphrase,
|
||||
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
|
||||
true
|
||||
);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const setting = {
|
||||
...this.settings,
|
||||
configPassphraseStore: "",
|
||||
encryptedCouchDBConnection: "",
|
||||
encryptedPassphrase: "",
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
if (stripExtra) {
|
||||
delete setting.pluginSyncExtendedSetting;
|
||||
if (await this.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
const keys = Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[];
|
||||
for (const k of keys) {
|
||||
if (
|
||||
JSON.stringify(k in setting ? setting[k] : "") ==
|
||||
JSON.stringify(k in DEFAULT_SETTINGS ? DEFAULT_SETTINGS[k] : "*")
|
||||
) {
|
||||
delete setting[k];
|
||||
}
|
||||
}
|
||||
const encryptedSetting = encodeURIComponent(await encryptString(JSON.stringify(setting), encryptingPassphrase));
|
||||
const uri = `${configURIBase}${encryptedSetting} `;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
// await navigator.clipboard.writeText(encryptedURI);
|
||||
}
|
||||
|
||||
async command_copySetupURIFull() {
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
||||
if (encryptingPassphrase === false) return;
|
||||
const setting = {
|
||||
...this.settings,
|
||||
configPassphraseStore: "",
|
||||
encryptedCouchDBConnection: "",
|
||||
encryptedPassphrase: "",
|
||||
};
|
||||
const encryptedSetting = encodeURIComponent(await encryptString(JSON.stringify(setting), encryptingPassphrase));
|
||||
const uri = `${configURIBase}${encryptedSetting} `;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
const encryptedURI = await encodeSettingsToSetupURI(this.settings, encryptingPassphrase, [], false);
|
||||
await navigator.clipboard.writeText(encryptedURI);
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
|
||||
async command_copySetupURIWithSync() {
|
||||
await this.command_copySetupURI(false);
|
||||
}
|
||||
async command_openSetupURI() {
|
||||
const setupURI = await this.core.confirm.askString("Easy setup", "Set up URI", `${configURIBase} aaaaa`);
|
||||
if (setupURI === false) return;
|
||||
if (!setupURI.startsWith(`${configURIBase}`)) {
|
||||
this._log("Set up URI looks wrong.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const config = decodeURIComponent(setupURI.substring(configURIBase.length));
|
||||
await this.setupWizard(config);
|
||||
}
|
||||
async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
||||
const buttons = {
|
||||
fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
|
||||
no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
|
||||
} as const;
|
||||
const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("Setup.FetchRemoteConf.Message"),
|
||||
Object.values(buttons),
|
||||
{ defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
|
||||
);
|
||||
if (fetchRemoteConf == buttons.no) {
|
||||
return tryingSettings;
|
||||
}
|
||||
|
||||
const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
||||
const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
|
||||
if (remoteConfig) {
|
||||
this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
|
||||
const resultSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...tryingSettings,
|
||||
...remoteConfig,
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
return resultSettings;
|
||||
} else {
|
||||
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...tryingSettings,
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
}
|
||||
}
|
||||
async askPerformDoctor(
|
||||
tryingSettings: ObsidianLiveSyncSettings
|
||||
): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
|
||||
const buttons = {
|
||||
yes: $msg("Setup.Doctor.Buttons.Yes"),
|
||||
no: $msg("Setup.Doctor.Buttons.No"),
|
||||
} as const;
|
||||
const performDoctor = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("Setup.Doctor.Message"),
|
||||
Object.values(buttons),
|
||||
{ defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
|
||||
);
|
||||
if (performDoctor == buttons.no) {
|
||||
return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
||||
}
|
||||
|
||||
const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
||||
const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
|
||||
localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
|
||||
remoteRebuild: RebuildOptions.SkipEvenIfRequired,
|
||||
activateReason: "New settings from URI",
|
||||
});
|
||||
if (isModified) {
|
||||
this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
|
||||
return {
|
||||
settings,
|
||||
shouldRebuild,
|
||||
isModified,
|
||||
};
|
||||
} else {
|
||||
this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
|
||||
return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
||||
}
|
||||
await this._setupManager.onUseSetupURI(UserMode.Unknown);
|
||||
}
|
||||
|
||||
async applySettingWizard(
|
||||
oldConf: ObsidianLiveSyncSettings,
|
||||
newConf: ObsidianLiveSyncSettings,
|
||||
method = "Setup URI"
|
||||
) {
|
||||
const result = await this.core.confirm.askYesNoDialog(
|
||||
"Importing Configuration from the " + method + ". Are you sure to proceed ? ",
|
||||
{}
|
||||
);
|
||||
if (result == "yes") {
|
||||
let newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings;
|
||||
this.core.replicator.closeReplication();
|
||||
this.settings.suspendFileWatching = true;
|
||||
newSettingW = await this.askSyncWithRemoteConfig(newSettingW);
|
||||
const { settings, shouldRebuild, isModified } = await this.askPerformDoctor(newSettingW);
|
||||
if (isModified) {
|
||||
newSettingW = settings;
|
||||
}
|
||||
// Back into the default method once.
|
||||
newSettingW.configPassphraseStore = "";
|
||||
newSettingW.encryptedPassphrase = "";
|
||||
newSettingW.encryptedCouchDBConnection = "";
|
||||
newSettingW.additionalSuffixOfDatabaseName = `${"appId" in this.app ? this.app.appId : ""} `;
|
||||
const setupJustImport = $msg("Setup.Apply.Buttons.OnlyApply");
|
||||
const setupAsNew = $msg("Setup.Apply.Buttons.ApplyAndFetch");
|
||||
const setupAsMerge = $msg("Setup.Apply.Buttons.ApplyAndMerge");
|
||||
const setupAgain = $msg("Setup.Apply.Buttons.ApplyAndRebuild");
|
||||
const setupCancel = $msg("Setup.Apply.Buttons.Cancel");
|
||||
newSettingW.syncInternalFiles = false;
|
||||
newSettingW.usePluginSync = false;
|
||||
newSettingW.isConfigured = true;
|
||||
// Migrate completely obsoleted configuration.
|
||||
if (!newSettingW.useIndexedDBAdapter) {
|
||||
newSettingW.useIndexedDBAdapter = true;
|
||||
}
|
||||
const warn = shouldRebuild ? $msg("Setup.Apply.WarningRebuildRecommended") : "";
|
||||
const message = $msg("Setup.Apply.Message", {
|
||||
method,
|
||||
warn,
|
||||
});
|
||||
// TODO: Where to implement these?
|
||||
|
||||
// async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
||||
// const buttons = {
|
||||
// fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
|
||||
// no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
|
||||
// } as const;
|
||||
// const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
|
||||
// $msg("Setup.FetchRemoteConf.Message"),
|
||||
// Object.values(buttons),
|
||||
// { defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
|
||||
// );
|
||||
// if (fetchRemoteConf == buttons.no) {
|
||||
// return tryingSettings;
|
||||
// }
|
||||
|
||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
||||
// const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
|
||||
// if (remoteConfig) {
|
||||
// this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
|
||||
// const resultSettings = {
|
||||
// ...DEFAULT_SETTINGS,
|
||||
// ...tryingSettings,
|
||||
// ...remoteConfig,
|
||||
// } satisfies ObsidianLiveSyncSettings;
|
||||
// return resultSettings;
|
||||
// } else {
|
||||
// this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
// return {
|
||||
// ...DEFAULT_SETTINGS,
|
||||
// ...tryingSettings,
|
||||
// } satisfies ObsidianLiveSyncSettings;
|
||||
// }
|
||||
// }
|
||||
// async askPerformDoctor(
|
||||
// tryingSettings: ObsidianLiveSyncSettings
|
||||
// ): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
|
||||
// const buttons = {
|
||||
// yes: $msg("Setup.Doctor.Buttons.Yes"),
|
||||
// no: $msg("Setup.Doctor.Buttons.No"),
|
||||
// } as const;
|
||||
// const performDoctor = await this.core.confirm.askSelectStringDialogue(
|
||||
// $msg("Setup.Doctor.Message"),
|
||||
// Object.values(buttons),
|
||||
// { defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
|
||||
// );
|
||||
// if (performDoctor == buttons.no) {
|
||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
||||
// }
|
||||
|
||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
||||
// const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
|
||||
// localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
|
||||
// remoteRebuild: RebuildOptions.SkipEvenIfRequired,
|
||||
// activateReason: "New settings from URI",
|
||||
// });
|
||||
// if (isModified) {
|
||||
// this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
|
||||
// return {
|
||||
// settings,
|
||||
// shouldRebuild,
|
||||
// isModified,
|
||||
// };
|
||||
// } else {
|
||||
// this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
|
||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
||||
// }
|
||||
// }
|
||||
|
||||
const setupType = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[setupAsNew, setupAsMerge, setupAgain, setupJustImport, setupCancel],
|
||||
{ defaultAction: setupAsNew, title: $msg("Setup.Apply.Title", { method }), timeout: 0 }
|
||||
);
|
||||
if (setupType == setupJustImport) {
|
||||
this.core.settings = newSettingW;
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
} else if (setupType == setupAsNew) {
|
||||
this.core.settings = newSettingW;
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
} else if (setupType == setupAsMerge) {
|
||||
this.core.settings = newSettingW;
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
await this.core.rebuilder.$fetchLocal(true);
|
||||
} else if (setupType == setupAgain) {
|
||||
const confirm =
|
||||
"This operation will rebuild all databases with files on this device. Any files on the remote database not synced here will be lost.";
|
||||
if (
|
||||
(await this.core.confirm.askSelectStringDialogue(
|
||||
"Are you sure you want to do this?",
|
||||
["Cancel", confirm],
|
||||
{ defaultAction: "Cancel" }
|
||||
)) != confirm
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.core.settings = newSettingW;
|
||||
await this.core.saveSettings();
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
} else {
|
||||
// Explicitly cancel the operation or the dialog was closed.
|
||||
this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
this.core.settings = oldConf;
|
||||
return;
|
||||
}
|
||||
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
this.core.settings = oldConf;
|
||||
return;
|
||||
}
|
||||
}
|
||||
async setupWizard(confString: string) {
|
||||
try {
|
||||
const oldConf = JSON.parse(JSON.stringify(this.settings));
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Passphrase",
|
||||
"The passphrase to decrypt your setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const newConf = await JSON.parse(await decryptString(confString, encryptingPassphrase));
|
||||
if (newConf) {
|
||||
await this.applySettingWizard(oldConf, newConf);
|
||||
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this._log("Cancelled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log("Couldn't parse or decrypt configuration uri.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.handleOnLoaded(this._everyOnload.bind(this));
|
||||
}
|
||||
|
||||
15
src/modules/features/SettingDialogue/InfoPanel.svelte
Normal file
15
src/modules/features/SettingDialogue/InfoPanel.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Info Panel to display key-value information from the port
|
||||
* Mostly used in the Setting Dialogue
|
||||
*/
|
||||
import { type SveltePanelProps } from "./SveltePanel";
|
||||
import InfoTable from "@lib/UI/components/InfoTable.svelte";
|
||||
type Props = SveltePanelProps<{
|
||||
info: Record<string, any>;
|
||||
}>;
|
||||
const { port }: Props = $props();
|
||||
const info = $derived.by(() => $port?.info ?? {});
|
||||
</script>
|
||||
|
||||
<InfoTable {info} />
|
||||
@@ -143,6 +143,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
pluginConfig.jwtKid = redact(pluginConfig.jwtKid);
|
||||
pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders);
|
||||
pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders);
|
||||
pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential);
|
||||
pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername);
|
||||
pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`;
|
||||
const endpoint = pluginConfig.endpoint;
|
||||
if (endpoint == "") {
|
||||
pluginConfig.endpoint = "Not configured or AWS";
|
||||
@@ -170,11 +173,13 @@ ${stringifyYaml({
|
||||
...pluginConfig,
|
||||
})}`;
|
||||
console.log(msgConfig);
|
||||
await navigator.clipboard.writeText(msgConfig);
|
||||
Logger(
|
||||
`Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
if ((await this.services.UI.promptCopyToClipboard("Generated report", msgConfig)) == true) {
|
||||
// await navigator.clipboard.writeText(msgConfig);
|
||||
// Logger(
|
||||
// `Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`,
|
||||
// LOG_LEVEL_NOTICE
|
||||
// );
|
||||
}
|
||||
})
|
||||
);
|
||||
new Setting(paneEl).autoWireToggle("writeLogToTheFile");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LocalDatabaseMaintenance } from "../../../features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { FLAGMD_REDFLAG, FLAGMD_REDFLAG2_HR, FLAGMD_REDFLAG3_HR } from "../../../lib/src/common/types.ts";
|
||||
import { FlagFilesHumanReadable, FLAGMD_REDFLAG } from "../../../lib/src/common/types.ts";
|
||||
import { fireAndForget } from "../../../lib/src/common/utils.ts";
|
||||
import { LiveSyncCouchDBReplicator } from "../../../lib/src/replication/couchdb/LiveSyncReplicator.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -98,6 +98,35 @@ export function paneMaintenance(
|
||||
);
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Reset Synchronisation information").then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
.setName("Reset Synchronisation on This Device")
|
||||
.setDesc("Restore or reconstruct local database from remote.")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Schedule and Restart")
|
||||
.setCta()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.plugin.storageAccess.writeFileAuto(FlagFilesHumanReadable.FETCH_ALL, "");
|
||||
this.services.appLifecycle.performRestart();
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
.setName("Overwrite Server Data with This Device's Files")
|
||||
.setDesc("Rebuild local and remote database with local files.")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Schedule and Restart")
|
||||
.setCta()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.plugin.storageAccess.writeFileAuto(FlagFilesHumanReadable.REBUILD_ALL, "");
|
||||
this.services.appLifecycle.performRestart();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Syncing", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
.setName("Resend")
|
||||
@@ -244,69 +273,7 @@ export function paneMaintenance(
|
||||
);
|
||||
}
|
||||
);
|
||||
void addPanel(paneEl, "Rebuilding Operations (Local)").then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
.setName("Fetch from remote")
|
||||
.setDesc("Restore or reconstruct local database from remote.")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Fetch")
|
||||
.setWarning()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.plugin.storageAccess.writeFileAuto(FLAGMD_REDFLAG3_HR, "");
|
||||
this.services.appLifecycle.performRestart();
|
||||
})
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Fetch w/o restarting")
|
||||
.setWarning()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.rebuildDB("localOnly");
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName("Fetch rebuilt DB (Save local documents before)")
|
||||
.setDesc("Restore or reconstruct local database from remote database but use local chunks.")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Save and Fetch")
|
||||
.setWarning()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.rebuildDB("localOnlyWithChunks");
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnCouchDB);
|
||||
});
|
||||
|
||||
void addPanel(paneEl, "Total Overhaul", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
.setName("Rebuild everything")
|
||||
.setDesc("Rebuild local and remote database with local files.")
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Rebuild")
|
||||
.setWarning()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.plugin.storageAccess.writeFileAuto(FLAGMD_REDFLAG2_HR, "");
|
||||
this.services.appLifecycle.performRestart();
|
||||
})
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Rebuild w/o restarting")
|
||||
.setWarning()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.rebuildDB("rebuildBothByThisDevice");
|
||||
})
|
||||
);
|
||||
});
|
||||
void addPanel(paneEl, "Rebuilding Operations (Remote Only)", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
.setName("Perform cleanup")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { DEFAULT_SETTINGS } from "../../../lib/src/common/types.ts";
|
||||
import { request } from "obsidian";
|
||||
import { SetupManager, UserMode } from "../SetupManager.ts";
|
||||
export function paneSetup(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
@@ -30,11 +31,13 @@ export function paneSetup(
|
||||
});
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName($msg("obsidianLiveSyncSettingTab.nameManualSetup"))
|
||||
.setDesc($msg("obsidianLiveSyncSettingTab.descManualSetup"))
|
||||
.setName("Rerun Onboarding Wizard")
|
||||
.setDesc("Rerun the onboarding wizard to set up Self-hosted LiveSync again.")
|
||||
.addButton((text) => {
|
||||
text.setButtonText($msg("obsidianLiveSyncSettingTab.btnStart")).onClick(async () => {
|
||||
await this.enableMinimalSetup();
|
||||
text.setButtonText("Rerun Wizard").onClick(async () => {
|
||||
const setupManager = this.plugin.getModule(SetupManager);
|
||||
await setupManager.onOnboard(UserMode.ExistingUser);
|
||||
// await this.plugin.moduleSetupObsidian.onBoardingWizard(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
54
src/modules/features/SettingDialogue/SveltePanel.ts
Normal file
54
src/modules/features/SettingDialogue/SveltePanel.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { mount, type Component, unmount } from "svelte";
|
||||
import { type Writable, writable, get } from "svelte/store";
|
||||
|
||||
/**
|
||||
* Props passed to Svelte panels, containing a writable port
|
||||
* to communicate with the panel
|
||||
*/
|
||||
export type SveltePanelProps<T = any> = {
|
||||
port: Writable<T | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A class to manage a Svelte panel within Obsidian
|
||||
* Especially useful for settings panels
|
||||
*/
|
||||
export class SveltePanel<T = any> {
|
||||
private _mountedComponent: ReturnType<typeof mount>;
|
||||
private _componentValue = writable<T | undefined>(undefined);
|
||||
/**
|
||||
* Creates a Svelte panel instance
|
||||
* @param component Component to mount
|
||||
* @param mountTo HTMLElement to mount the component to
|
||||
* @param valueStore Optional writable store to bind to the component's port, if not provided a new one will be created
|
||||
* @returns The SveltePanel instance
|
||||
*/
|
||||
constructor(component: Component<SveltePanelProps<T>>, mountTo: HTMLElement, valueStore?: Writable<T>) {
|
||||
this._componentValue = valueStore ?? writable<T | undefined>(undefined);
|
||||
this._mountedComponent = mount(component, {
|
||||
target: mountTo,
|
||||
props: {
|
||||
port: this._componentValue,
|
||||
},
|
||||
});
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Destroys the Svelte panel instance by unmounting the component
|
||||
*/
|
||||
destroy() {
|
||||
if (this._mountedComponent) {
|
||||
void unmount(this._mountedComponent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the current value of the component's port
|
||||
*/
|
||||
get componentValue() {
|
||||
return get(this._componentValue);
|
||||
}
|
||||
set componentValue(value: T | undefined) {
|
||||
this._componentValue.set(value);
|
||||
}
|
||||
}
|
||||
78
src/modules/features/SettingDialogue/settingUtils.ts
Normal file
78
src/modules/features/SettingDialogue/settingUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { escapeStringToHTML } from "octagonal-wheels/string";
|
||||
import { E2EEAlgorithmNames, type ObsidianLiveSyncSettings } from "../../../lib/src/common/types";
|
||||
import {
|
||||
pickCouchDBSyncSettings,
|
||||
pickBucketSyncSettings,
|
||||
pickP2PSyncSettings,
|
||||
pickEncryptionSettings,
|
||||
} from "../../../lib/src/common/utils";
|
||||
import { getConfig, type AllSettingItemKey } from "./settingConstants";
|
||||
|
||||
/**
|
||||
* Generates a summary of P2P configuration settings
|
||||
* @param setting Settings object
|
||||
* @param additional Additional summary information to include
|
||||
* @param showAdvanced Whether to include advanced settings
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function getP2PConfigSummary(
|
||||
setting: ObsidianLiveSyncSettings,
|
||||
additional: Record<string, string> = {},
|
||||
showAdvanced = false
|
||||
) {
|
||||
const settingTable: Partial<ObsidianLiveSyncSettings> = pickP2PSyncSettings(setting);
|
||||
return { ...getSummaryFromPartialSettings({ ...settingTable }, showAdvanced), ...additional };
|
||||
}
|
||||
/**
|
||||
* Generates a summary of Object Storage configuration settings
|
||||
* @param setting Settings object
|
||||
* @param showAdvanced Whether to include advanced settings
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function getBucketConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
|
||||
const settingTable: Partial<ObsidianLiveSyncSettings> = pickBucketSyncSettings(setting);
|
||||
return getSummaryFromPartialSettings(settingTable, showAdvanced);
|
||||
}
|
||||
/**
|
||||
* Generates a summary of CouchDB configuration settings
|
||||
* @param setting Settings object
|
||||
* @param showAdvanced Whether to include advanced settings
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function getCouchDBConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
|
||||
const settingTable: Partial<ObsidianLiveSyncSettings> = pickCouchDBSyncSettings(setting);
|
||||
return getSummaryFromPartialSettings(settingTable, showAdvanced);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a summary of E2EE configuration settings
|
||||
* @param setting Settings object
|
||||
* @param showAdvanced Whether to include advanced settings
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
|
||||
const settingTable: Partial<ObsidianLiveSyncSettings> = pickEncryptionSettings(setting);
|
||||
return getSummaryFromPartialSettings(settingTable, showAdvanced);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts partial settings into a summary object
|
||||
* @param setting Partial settings object
|
||||
* @param showAdvanced Whether to include advanced settings
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncSettings>, showAdvanced = false) {
|
||||
const outputSummary: Record<string, string> = {};
|
||||
for (const key of Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]) {
|
||||
const config = getConfig(key as AllSettingItemKey);
|
||||
if (!config) continue;
|
||||
if (config.isAdvanced && !showAdvanced) continue;
|
||||
const value =
|
||||
key != "E2EEAlgorithm"
|
||||
? `${setting[key]}`
|
||||
: E2EEAlgorithmNames[`${setting[key]}` as keyof typeof E2EEAlgorithmNames];
|
||||
const displayValue = config.isHidden ? "•".repeat(value.length) : escapeStringToHTML(value);
|
||||
outputSummary[config.name] = displayValue;
|
||||
}
|
||||
return outputSummary;
|
||||
}
|
||||
274
src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts
Normal file
274
src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { requestToCouchDBWithCredentials } from "../../../common/utils";
|
||||
import { $msg } from "../../../lib/src/common/i18n";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "../../../lib/src/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "../../../lib/src/common/types";
|
||||
import { fireAndForget, parseHeaderValues } from "../../../lib/src/common/utils";
|
||||
import { isCloudantURI } from "../../../lib/src/pouchdb/utils_couchdb";
|
||||
import { generateCredentialObject } from "../../../lib/src/replication/httplib";
|
||||
|
||||
export const checkConfig = async (
|
||||
checkResultDiv: HTMLDivElement | undefined,
|
||||
editingSettings: ObsidianLiveSyncSettings
|
||||
) => {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
|
||||
let isSuccessful = true;
|
||||
const emptyDiv = createDiv();
|
||||
emptyDiv.innerHTML = "<span></span>";
|
||||
checkResultDiv?.replaceChildren(...[emptyDiv]);
|
||||
const addResult = (msg: string, classes?: string[]) => {
|
||||
const tmpDiv = createDiv();
|
||||
tmpDiv.addClass("ob-btn-config-fix");
|
||||
if (classes) {
|
||||
tmpDiv.addClasses(classes);
|
||||
}
|
||||
tmpDiv.innerHTML = `${msg}`;
|
||||
checkResultDiv?.appendChild(tmpDiv);
|
||||
};
|
||||
try {
|
||||
if (isCloudantURI(editingSettings.couchDB_URI)) {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCannotUseCloudant"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
// Tip: Add log for cloudant as Logger($msg("obsidianLiveSyncSettingTab.logServerConfigurationCheck"));
|
||||
const customHeaders = parseHeaderValues(editingSettings.couchDB_CustomHeaders);
|
||||
const credential = generateCredentialObject(editingSettings);
|
||||
const r = await requestToCouchDBWithCredentials(
|
||||
editingSettings.couchDB_URI,
|
||||
credential,
|
||||
window.origin,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseConfig = r.json;
|
||||
|
||||
const addConfigFixButton = (title: string, key: string, value: string) => {
|
||||
if (!checkResultDiv) return;
|
||||
const tmpDiv = createDiv();
|
||||
tmpDiv.addClass("ob-btn-config-fix");
|
||||
tmpDiv.innerHTML = `<label>${title}</label><button>${$msg("obsidianLiveSyncSettingTab.btnFix")}</button>`;
|
||||
const x = checkResultDiv.appendChild(tmpDiv);
|
||||
x.querySelector("button")?.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
|
||||
const res = await requestToCouchDBWithCredentials(
|
||||
editingSettings.couchDB_URI,
|
||||
credential,
|
||||
undefined,
|
||||
key,
|
||||
value,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
if (res.status == 200) {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigUpdated", { title }), LOG_LEVEL_NOTICE);
|
||||
checkResultDiv.removeChild(x);
|
||||
await checkConfig(checkResultDiv, editingSettings);
|
||||
} else {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigFail", { title }), LOG_LEVEL_NOTICE);
|
||||
Logger(res.text, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgNotice"), ["ob-btn-config-head"]);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]);
|
||||
|
||||
const serverBanner = r.headers["server"] ?? r.headers["Server"] ?? "unknown";
|
||||
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
||||
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
||||
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
||||
const versionParts = `${versionStr}.0.0.0`.split(".");
|
||||
// Compare version string with the target version.
|
||||
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
|
||||
function isGreaterThanOrEqual(version: string) {
|
||||
const targetParts = version.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Admin check
|
||||
// for database creation and deletion
|
||||
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.warnNoAdmin"));
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||
}
|
||||
if (isGreaterThanOrEqual("3.2.0")) {
|
||||
// HTTP user-authorization check
|
||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUser"));
|
||||
addConfigFixButton(
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUser"),
|
||||
"chttpd/require_valid_user",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUser"));
|
||||
}
|
||||
} else {
|
||||
if (responseConfig?.chttpd_auth?.require_valid_user != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUserAuth"));
|
||||
addConfigFixButton(
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth"),
|
||||
"chttpd_auth/require_valid_user",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUserAuth"));
|
||||
}
|
||||
}
|
||||
// HTTPD check
|
||||
// Check Authentication header
|
||||
if (!responseConfig?.httpd["WWW-Authenticate"]) {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errMissingWwwAuth"));
|
||||
addConfigFixButton(
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetWwwAuth"),
|
||||
"httpd/WWW-Authenticate",
|
||||
'Basic realm="couchdb"'
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||
}
|
||||
if (isGreaterThanOrEqual("3.2.0")) {
|
||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
||||
addConfigFixButton(
|
||||
$msg("obsidianLiveSyncSettingTab.msgEnableCorsChttpd"),
|
||||
"chttpd/enable_cors",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okEnableCorsChttpd"));
|
||||
}
|
||||
} else {
|
||||
if (responseConfig?.httpd?.enable_cors != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errEnableCors"));
|
||||
addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgEnableCors"), "httpd/enable_cors", "true");
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okEnableCors"));
|
||||
}
|
||||
}
|
||||
// If the server is not cloudant, configure request size
|
||||
if (!isCloudantURI(editingSettings.couchDB_URI)) {
|
||||
// REQUEST SIZE
|
||||
if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errMaxRequestSize"));
|
||||
addConfigFixButton(
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetMaxRequestSize"),
|
||||
"chttpd/max_http_request_size",
|
||||
"4294967296"
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okMaxRequestSize"));
|
||||
}
|
||||
if (Number(responseConfig?.couchdb?.max_document_size ?? 0) < 50000000) {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errMaxDocumentSize"));
|
||||
addConfigFixButton(
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetMaxDocSize"),
|
||||
"couchdb/max_document_size",
|
||||
"50000000"
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okMaxDocumentSize"));
|
||||
}
|
||||
}
|
||||
// CORS check
|
||||
// checking connectivity for mobile
|
||||
if (responseConfig?.cors?.credentials != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsCredentials"));
|
||||
addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgSetCorsCredentials"), "cors/credentials", "true");
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okCorsCredentials"));
|
||||
}
|
||||
const ConfiguredOrigins = ((responseConfig?.cors?.origins ?? "") + "").split(",");
|
||||
if (
|
||||
responseConfig?.cors?.origins == "*" ||
|
||||
(ConfiguredOrigins.indexOf("app://obsidian.md") !== -1 &&
|
||||
ConfiguredOrigins.indexOf("capacitor://localhost") !== -1 &&
|
||||
ConfiguredOrigins.indexOf("http://localhost") !== -1)
|
||||
) {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okCorsOrigins"));
|
||||
} else {
|
||||
const fixedValue = [
|
||||
...new Set([
|
||||
...ConfiguredOrigins.map((e) => e.trim()),
|
||||
"app://obsidian.md",
|
||||
"capacitor://localhost",
|
||||
"http://localhost",
|
||||
]),
|
||||
].join(",");
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsOrigins"));
|
||||
addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgSetCorsOrigins"), "cors/origins", fixedValue);
|
||||
isSuccessful = false;
|
||||
}
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgConnectionCheck"), ["ob-btn-config-head"]);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgCurrentOrigin", { origin: window.location.origin }));
|
||||
|
||||
// Request header check
|
||||
const origins = ["app://obsidian.md", "capacitor://localhost", "http://localhost"];
|
||||
for (const org of origins) {
|
||||
const rr = await requestToCouchDBWithCredentials(
|
||||
editingSettings.couchDB_URI,
|
||||
credential,
|
||||
org,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseHeaders = Object.fromEntries(
|
||||
Object.entries(rr.headers).map((e) => {
|
||||
e[0] = `${e[0]}`.toLowerCase();
|
||||
return e;
|
||||
})
|
||||
);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||
isSuccessful = false;
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin"));
|
||||
}
|
||||
if (responseHeaders["access-control-allow-origin"] != org) {
|
||||
addResult(
|
||||
$msg("obsidianLiveSyncSettingTab.warnCorsOriginUnmatched", {
|
||||
from: origin,
|
||||
to: responseHeaders["access-control-allow-origin"],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okCorsOriginMatched"));
|
||||
}
|
||||
}
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]);
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"), LOG_LEVEL_INFO);
|
||||
} catch (ex: any) {
|
||||
if (ex?.status == 401) {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errAccessForbidden"));
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errCannotContinueTest"));
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"), LOG_LEVEL_INFO);
|
||||
} else {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigFailed"), LOG_LEVEL_NOTICE);
|
||||
Logger(ex);
|
||||
isSuccessful = false;
|
||||
}
|
||||
}
|
||||
return isSuccessful;
|
||||
};
|
||||
378
src/modules/features/SetupManager.ts
Normal file
378
src/modules/features/SetupManager.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { SvelteDialogManager } from "./SetupWizard/ObsidianSvelteDialog.ts";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
||||
import ScanQRCode from "./SetupWizard/dialogs/ScanQRCode.svelte";
|
||||
import UseSetupURI from "./SetupWizard/dialogs/UseSetupURI.svelte";
|
||||
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 "../../lib/src/API/processSetting.ts";
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
*/
|
||||
export const enum UserMode {
|
||||
/**
|
||||
* New User Mode - for users who are new to the plugin
|
||||
*/
|
||||
NewUser = "new-user",
|
||||
/**
|
||||
* Existing User Mode - for users who have used the plugin before, or just configuring again
|
||||
*/
|
||||
ExistingUser = "existing-user",
|
||||
/**
|
||||
* Unknown User Mode - for cases where the user mode is not determined
|
||||
*/
|
||||
Unknown = "unknown",
|
||||
/**
|
||||
* Update User Mode - for users who are updating configuration. May be `existing-user` as well, but possibly they want to treat it differently.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
Update = "unknown", // Alias for Unknown for better readability
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Manager to handle onboarding and configuration setup
|
||||
*/
|
||||
export class SetupManager extends AbstractObsidianModule {
|
||||
/**
|
||||
* Dialog manager for handling Svelte dialogs
|
||||
*/
|
||||
private dialogManager: SvelteDialogManager = new SvelteDialogManager(this.plugin);
|
||||
|
||||
/**
|
||||
* Starts the onboarding process
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
*/
|
||||
async startOnBoarding(): Promise<boolean> {
|
||||
const isUserNewOrExisting = await this.dialogManager.openWithExplicitCancel(Intro);
|
||||
if (isUserNewOrExisting === "new-user") {
|
||||
await this.onOnboard(UserMode.NewUser);
|
||||
} else if (isUserNewOrExisting === "existing-user") {
|
||||
await this.onOnboard(UserMode.ExistingUser);
|
||||
} else if (isUserNewOrExisting === "cancelled") {
|
||||
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the onboarding process based on user mode
|
||||
* @param userMode
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
*/
|
||||
async onOnboard(userMode: UserMode): Promise<boolean> {
|
||||
const originalSetting = userMode === UserMode.NewUser ? DEFAULT_SETTINGS : this.core.settings;
|
||||
if (userMode === UserMode.NewUser) {
|
||||
//Ask how to apply initial setup
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodNewUser);
|
||||
if (method === "use-setup-uri") {
|
||||
await this.onUseSetupURI(userMode);
|
||||
} else if (method === "configure-manually") {
|
||||
await this.onConfigureManually(originalSetting, userMode);
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else if (userMode === UserMode.ExistingUser) {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodExisting);
|
||||
if (method === "use-setup-uri") {
|
||||
await this.onUseSetupURI(userMode);
|
||||
} else if (method === "configure-manually") {
|
||||
await this.onConfigureManually(originalSetting, userMode);
|
||||
} else if (method === "scan-qr-code") {
|
||||
await this.onPromptQRCodeInstruction();
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles setup using a setup URI
|
||||
* @param userMode
|
||||
* @param setupURI
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
*/
|
||||
async onUseSetupURI(userMode: UserMode, setupURI: string = ""): Promise<boolean> {
|
||||
const newSetting = await this.dialogManager.openWithExplicitCancel(UseSetupURI, setupURI);
|
||||
if (newSetting === "cancelled") {
|
||||
this._log("Setup URI dialog cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for CouchDB
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @param activate Whether to activate the CouchDB as remote type
|
||||
* @returns Promise that resolves to true if setup completed successfully, false otherwise
|
||||
*/
|
||||
async onCouchDBManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
|
||||
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, originalSetting);
|
||||
if (couchConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...baseSetting, ...couchConf } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for S3-compatible bucket
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @param activate Whether to activate the Bucket as remote type
|
||||
* @returns Promise that resolves to true if setup completed successfully, false otherwise
|
||||
*/
|
||||
async onBucketManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const bucketConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteBucket, currentSetting);
|
||||
if (bucketConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...bucketConf } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for P2P
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @param activate Whether to activate the P2P as remote type (as P2P Only setup)
|
||||
* @returns Promise that resolves to true if setup completed successfully, false otherwise
|
||||
*/
|
||||
async onP2PManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSetting);
|
||||
if (p2pConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_P2P;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles only E2EE configuration
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @returns
|
||||
*/
|
||||
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, currentSetting);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await false;
|
||||
}
|
||||
const newSetting = {
|
||||
...currentSetting,
|
||||
...e2eeConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual configuration flow (E2EE + select server)
|
||||
* @param originalSetting
|
||||
* @param userMode
|
||||
* @returns
|
||||
*/
|
||||
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, originalSetting);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const currentSetting = {
|
||||
...originalSetting,
|
||||
...e2eeConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
return await this.onSelectServer(currentSetting, userMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles server selection during manual configuration
|
||||
* @param currentSetting
|
||||
* @param userMode
|
||||
* @returns
|
||||
*/
|
||||
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(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);
|
||||
}
|
||||
}
|
||||
// Should not reach here.
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Confirms and applies settings obtained from the wizard
|
||||
* @param newConf
|
||||
* @param _userMode
|
||||
* @param activate Whether to activate the remote type in the new settings
|
||||
* @param extra Extra function to run before applying settings
|
||||
* @returns Promise that resolves to true if settings applied successfully, false otherwise
|
||||
*/
|
||||
async onConfirmApplySettingsFromWizard(
|
||||
newConf: ObsidianLiveSyncSettings,
|
||||
_userMode: UserMode,
|
||||
activate: boolean = true,
|
||||
extra: () => void = () => {}
|
||||
): Promise<boolean> {
|
||||
let userMode = _userMode;
|
||||
if (userMode === UserMode.Unknown) {
|
||||
if (isObjectDifferent(this.settings, newConf, true) === false) {
|
||||
this._log("No changes in settings detected. Skipping applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
const patch = generatePatchObj(this.settings, newConf);
|
||||
console.log(`Changes:`);
|
||||
console.dir(patch);
|
||||
if (!activate) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Setting Applied", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
// Check virtual changes
|
||||
const original = { ...this.settings, P2P_DevicePeerName: "" } as ObsidianLiveSyncSettings;
|
||||
const modified = { ...newConf, P2P_DevicePeerName: "" } as ObsidianLiveSyncSettings;
|
||||
const isOnlyVirtualChange = isObjectDifferent(original, modified, true) === false;
|
||||
if (isOnlyVirtualChange) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
} else {
|
||||
const userModeResult = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
|
||||
if (userModeResult === "new-user") {
|
||||
userMode = UserMode.NewUser;
|
||||
} else if (userModeResult === "existing-user") {
|
||||
userMode = UserMode.ExistingUser;
|
||||
} else if (userModeResult === "compatible-existing-user") {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
} else if (userModeResult === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
|
||||
const confirm = await this.dialogManager.openWithExplicitCancel(component);
|
||||
if (confirm === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (confirm) {
|
||||
extra();
|
||||
await this.applySetting(newConf, userMode);
|
||||
if (userMode === UserMode.NewUser) {
|
||||
// For new users, schedule a rebuild everything.
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
} else {
|
||||
// For existing users, schedule a fetch.
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
}
|
||||
}
|
||||
// Settings applied, but may require rebuild to take effect.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user with QR code scanning instructions
|
||||
* @returns Promise that resolves to false as QR code instruction dialog does not yield settings directly
|
||||
*/
|
||||
|
||||
async onPromptQRCodeInstruction(): Promise<boolean> {
|
||||
const qrResult = await this.dialogManager.open(ScanQRCode);
|
||||
this._log("QR Code dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
// Result is not used, but log it for debugging.
|
||||
this._log(`QR Code result: ${qrResult}`, LOG_LEVEL_VERBOSE);
|
||||
// QR Code instruction dialog never yields settings directly.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes settings from a QR code string and applies them
|
||||
* @param qr QR code string containing encoded settings
|
||||
* @returns Promise that resolves to true if settings applied successfully, false otherwise
|
||||
*/
|
||||
async decodeQR(qr: string) {
|
||||
const newSettings = decodeSettingsFromQRCodeData(qr);
|
||||
return await this.onConfirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the new settings to the core settings and saves them
|
||||
* @param newConf
|
||||
* @param userMode
|
||||
* @returns Promise that resolves to true if settings applied successfully, false otherwise
|
||||
*/
|
||||
async applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode) {
|
||||
const newSetting = {
|
||||
...this.core.settings,
|
||||
...newConf,
|
||||
};
|
||||
this.core.settings = newSetting;
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.services.setting.saveSettingData();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
141
src/modules/features/SetupWizard/ObsidianSvelteDialog.ts
Normal file
141
src/modules/features/SetupWizard/ObsidianSvelteDialog.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { eventHub, EVENT_PLUGIN_UNLOADED } from "@/common/events";
|
||||
import { Modal } from "@/deps";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
import { mount, unmount } from "svelte";
|
||||
import DialogHost from "@lib/UI/DialogHost.svelte";
|
||||
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
type DialogControlBase,
|
||||
type DialogSvelteComponentBaseProps,
|
||||
type ComponentHasResult,
|
||||
setupDialogContext,
|
||||
getDialogContext,
|
||||
type SvelteDialogManagerBase,
|
||||
} from "@/lib/src/UI/svelteDialog.ts";
|
||||
|
||||
export type DialogSvelteComponentProps = DialogSvelteComponentBaseProps & {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
services: ObsidianLiveSyncPlugin["services"];
|
||||
};
|
||||
|
||||
export type DialogControls<T = any, U = any> = DialogControlBase<T, U> & {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
services: ObsidianLiveSyncPlugin["services"];
|
||||
};
|
||||
|
||||
export type DialogMessageProps = Record<string, any>;
|
||||
// type DialogSvelteComponent<T extends DialogSvelteComponentProps = DialogSvelteComponentProps> = Component<SvelteComponent<T>,any>;
|
||||
|
||||
export class SvelteDialog<T, U> extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
mountedComponent?: ReturnType<typeof mount>;
|
||||
component: ComponentHasResult<T, U>;
|
||||
result?: T;
|
||||
initialData?: U;
|
||||
title: string = "Obsidian LiveSync - Setup Wizard";
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, component: ComponentHasResult<T, U>, initialData?: U) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.component = component;
|
||||
this.initialData = initialData;
|
||||
}
|
||||
resolveResult() {
|
||||
this.resultPromiseWithResolvers?.resolve(this.result);
|
||||
this.resultPromiseWithResolvers = undefined;
|
||||
}
|
||||
resultPromiseWithResolvers?: PromiseWithResolvers<T | undefined>;
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const dialog = this;
|
||||
|
||||
if (this.resultPromiseWithResolvers) {
|
||||
this.resultPromiseWithResolvers.reject("Dialog opened again");
|
||||
}
|
||||
const pr = promiseWithResolvers<any>();
|
||||
eventHub.once(EVENT_PLUGIN_UNLOADED, () => {
|
||||
if (this.resultPromiseWithResolvers === pr) {
|
||||
pr.reject("Plugin unloaded");
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
this.resultPromiseWithResolvers = pr;
|
||||
this.mountedComponent = mount(DialogHost, {
|
||||
target: contentEl,
|
||||
props: {
|
||||
onSetupContext: (props: DialogSvelteComponentBaseProps) => {
|
||||
setupDialogContext({
|
||||
...props,
|
||||
plugin: this.plugin,
|
||||
services: this.plugin.services,
|
||||
});
|
||||
},
|
||||
setTitle: (title: string) => {
|
||||
dialog.setTitle(title);
|
||||
},
|
||||
closeDialog: () => {
|
||||
dialog.close();
|
||||
},
|
||||
setResult: (result: T) => {
|
||||
this.result = result;
|
||||
},
|
||||
getInitialData: () => this.initialData,
|
||||
mountComponent: this.component,
|
||||
},
|
||||
});
|
||||
}
|
||||
waitForClose(): Promise<T | undefined> {
|
||||
if (!this.resultPromiseWithResolvers) {
|
||||
throw new Error("Dialog not opened yet");
|
||||
}
|
||||
return this.resultPromiseWithResolvers.promise;
|
||||
}
|
||||
onClose() {
|
||||
this.resolveResult();
|
||||
fireAndForget(async () => {
|
||||
if (this.mountedComponent) {
|
||||
await unmount(this.mountedComponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSvelteDialog<T, U>(
|
||||
plugin: ObsidianLiveSyncPlugin,
|
||||
component: ComponentHasResult<T, U>,
|
||||
initialData?: U
|
||||
): Promise<T | undefined> {
|
||||
const dialog = new SvelteDialog<T, U>(plugin, component, initialData);
|
||||
dialog.open();
|
||||
|
||||
return await dialog.waitForClose();
|
||||
}
|
||||
|
||||
export class SvelteDialogManager implements SvelteDialogManagerBase {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
constructor(plugin: ObsidianLiveSyncPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
async open<T, U>(component: ComponentHasResult<T, U>, initialData?: U): Promise<T | undefined> {
|
||||
return await openSvelteDialog<T, U>(this.plugin, component, initialData);
|
||||
}
|
||||
async openWithExplicitCancel<T, U>(component: ComponentHasResult<T, U>, initialData?: U): Promise<T> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ret = await openSvelteDialog<T, U>(this.plugin, component, initialData);
|
||||
if (ret !== undefined) {
|
||||
return ret;
|
||||
}
|
||||
if (this.plugin.services.appLifecycle.hasUnloaded()) {
|
||||
throw new Error("Operation cancelled due to app shutdown.");
|
||||
}
|
||||
Logger("Please select 'Cancel' explicitly to cancel this operation.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
throw new Error("Operation Forcibly cancelled by user.");
|
||||
}
|
||||
}
|
||||
|
||||
export function getObsidianDialogContext<T = any>(): DialogControls<T> {
|
||||
return getDialogContext<T>() as DialogControls<T>;
|
||||
}
|
||||
154
src/modules/features/SetupWizard/dialogs/FetchEverything.svelte
Normal file
154
src/modules/features/SetupWizard/dialogs/FetchEverything.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_IDENTICAL = "identical";
|
||||
const TYPE_INDEPENDENT = "independent";
|
||||
const TYPE_UNBALANCED = "unbalanced";
|
||||
const TYPE_CANCEL = "cancelled";
|
||||
|
||||
const TYPE_BACKUP_DONE = "backup_done";
|
||||
const TYPE_BACKUP_SKIPPED = "backup_skipped";
|
||||
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
|
||||
|
||||
type ResultTypeVault =
|
||||
| typeof TYPE_IDENTICAL
|
||||
| typeof TYPE_INDEPENDENT
|
||||
| typeof TYPE_UNBALANCED
|
||||
| typeof TYPE_CANCEL;
|
||||
type ResultTypeBackup =
|
||||
| typeof TYPE_BACKUP_DONE
|
||||
| typeof TYPE_BACKUP_SKIPPED
|
||||
| typeof TYPE_UNABLE_TO_BACKUP
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
type ResultTypeExtra = {
|
||||
preventFetchingConfig: boolean;
|
||||
};
|
||||
type ResultType =
|
||||
| {
|
||||
vault: ResultTypeVault;
|
||||
backup: ResultTypeBackup;
|
||||
extra: ResultTypeExtra;
|
||||
}
|
||||
| typeof TYPE_CANCEL;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let vaultType = $state<ResultTypeVault>(TYPE_CANCEL);
|
||||
let backupType = $state<ResultTypeBackup>(TYPE_CANCEL);
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
(vaultType === TYPE_IDENTICAL || vaultType === TYPE_INDEPENDENT || vaultType === TYPE_UNBALANCED) &&
|
||||
(backupType === TYPE_BACKUP_DONE || backupType === TYPE_BACKUP_SKIPPED)
|
||||
);
|
||||
});
|
||||
let preventFetchingConfig = $state(false);
|
||||
|
||||
function commit() {
|
||||
setResult({
|
||||
vault: vaultType,
|
||||
backup: backupType,
|
||||
extra: {
|
||||
preventFetchingConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Reset Synchronisation on This Device" />
|
||||
<Guidance
|
||||
>This will rebuild the local database on this device using the most recent data from the server. This action is
|
||||
designed to resolve synchronisation inconsistencies and restore correct functionality.</Guidance
|
||||
>
|
||||
<Guidance important title="⚠️ Important Notice">
|
||||
<strong
|
||||
>If you have unsynchronised changes in your Vault on this device, they will likely diverge from the server's
|
||||
versions after the reset. This may result in a large number of file conflicts.</strong
|
||||
><br />
|
||||
Furthermore, if conflicts are already present in the server data, they will be synchronised to this device as they are,
|
||||
and you will need to resolve them locally.
|
||||
</Guidance>
|
||||
<hr />
|
||||
<Instruction>
|
||||
<Question
|
||||
><strong>To minimise the creation of new conflicts</strong>, please select the option that best describes the
|
||||
current state of your Vault. The application will then check your files in the most appropriate way based on
|
||||
your selection.</Question
|
||||
>
|
||||
<Options>
|
||||
<Option
|
||||
selectedValue={TYPE_IDENTICAL}
|
||||
title="The files in this Vault are almost identical to the server's."
|
||||
bind:value={vaultType}
|
||||
>
|
||||
(e.g., immediately after restoring on another computer, or having recovered from a backup)
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_INDEPENDENT}
|
||||
title="This Vault is empty, or contains only new files that are not on the server."
|
||||
bind:value={vaultType}
|
||||
>
|
||||
(e.g., setting up for the first time on a new smartphone, starting from a clean slate)
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_UNBALANCED}
|
||||
title="There may be differences between the files in this Vault and the server."
|
||||
bind:value={vaultType}
|
||||
>
|
||||
(e.g., after editing many files whilst offline)
|
||||
<InfoNote info>
|
||||
In this scenario, Self-hosted LiveSync will recreate metadata for every file and deliberately generate
|
||||
conflicts. Where the file content is identical, these conflicts will be resolved automatically.
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<hr />
|
||||
<Instruction>
|
||||
<Question>Have you created a backup before proceeding?</Question>
|
||||
<InfoNote>
|
||||
We recommend that you copy your Vault folder to a safe location. This will provide a safeguard in case a large
|
||||
number of conflicts arise, or if you accidentally synchronise with an incorrect destination.
|
||||
</InfoNote>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_BACKUP_DONE} title="I have created a backup of my Vault." bind:value={backupType} />
|
||||
<Option
|
||||
selectedValue={TYPE_BACKUP_SKIPPED}
|
||||
title="I understand the risks and will proceed without a backup."
|
||||
bind:value={backupType}
|
||||
/>
|
||||
<Option
|
||||
selectedValue={TYPE_UNABLE_TO_BACKUP}
|
||||
title="I am unable to create a backup of my Vault."
|
||||
bind:value={backupType}
|
||||
>
|
||||
<InfoNote error visible={backupType === TYPE_UNABLE_TO_BACKUP}>
|
||||
<strong
|
||||
>It is strongly advised to create a backup before proceeding. Continuing without a backup may lead
|
||||
to data loss.
|
||||
</strong>
|
||||
<br />
|
||||
If you understand the risks and still wish to proceed, select so.
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<Instruction>
|
||||
<ExtraItems title="Advanced">
|
||||
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Reset and Resume Synchronisation" important disabled={!canProceed} commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
|
||||
</UserDecisions>
|
||||
55
src/modules/features/SetupWizard/dialogs/Intro.svelte
Normal file
55
src/modules/features/SetupWizard/dialogs/Intro.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_NEW_USER = "new-user";
|
||||
const TYPE_EXISTING_USER = "existing-user";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_NEW_USER | typeof TYPE_EXISTING_USER | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_NEW_USER) {
|
||||
return "Yes, I want to set up a new synchronisation";
|
||||
} else if (userType === TYPE_EXISTING_USER) {
|
||||
return "Yes, I want to add this device to my existing synchronisation";
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_NEW_USER || userType === TYPE_EXISTING_USER;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Welcome to Self-hosted LiveSync" />
|
||||
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance>
|
||||
<Instruction>
|
||||
<Question>First, please select the option that best describes your current situation.</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_NEW_USER} title="I am setting this up for the first time" bind:value={userType}>
|
||||
(Select this if you are configuring this device as the first synchronisation device.) This option is
|
||||
suitable if you are new to LiveSync and want to set it up from scratch.
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_EXISTING_USER}
|
||||
title="I am adding a device to an existing synchronisation setup"
|
||||
bind:value={userType}
|
||||
>
|
||||
(Select this if you are already using synchronisation on another computer or smartphone.) This option is
|
||||
suitable if you are new to LiveSync and want to set it up from scratch.
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
const TYPE_EXISTING = "existing-user";
|
||||
const TYPE_NEW = "new-user";
|
||||
const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_EXISTING | typeof TYPE_NEW | typeof TYPE_COMPATIBLE_EXISTING | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_EXISTING || userType === TYPE_NEW || userType === TYPE_COMPATIBLE_EXISTING;
|
||||
});
|
||||
const proceedMessage = $derived.by(() => {
|
||||
if (userType === TYPE_NEW) {
|
||||
return "Proceed to the next step.";
|
||||
} else if (userType === TYPE_EXISTING) {
|
||||
return "Proceed to the next step.";
|
||||
} else if (userType === TYPE_COMPATIBLE_EXISTING) {
|
||||
return "Apply the settings";
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Mostly Complete: Decision Required" />
|
||||
<Guidance>
|
||||
The connection to the server has been configured successfully. As the next step, <strong
|
||||
>the local database, that is to say the synchronisation information, must be reconstituted.</strong
|
||||
>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select your situation.</Question>
|
||||
<Option title="I am setting up a new server for the first time / I want to reset my existing server." bind:value={userType} selectedValue={TYPE_NEW}>
|
||||
<InfoNote>
|
||||
Selecting this option will result in the current data on this device being used to initialise the server.
|
||||
Any existing data on the server will be completely overwritten.
|
||||
</InfoNote>
|
||||
</Option>
|
||||
<Option
|
||||
title="My remote server is already set up. I want to join this device."
|
||||
bind:value={userType}
|
||||
selectedValue={TYPE_EXISTING}
|
||||
>
|
||||
<InfoNote>
|
||||
Selecting this option will result in this device joining the existing server. You need to fetching the
|
||||
existing synchronisation data from the server to this device.
|
||||
</InfoNote>
|
||||
</Option>
|
||||
<Option
|
||||
title="The remote is already set up, and the configuration is compatible (or got compatible by this operation)."
|
||||
bind:value={userType}
|
||||
selectedValue={TYPE_COMPATIBLE_EXISTING}
|
||||
>
|
||||
<InfoNote warning>
|
||||
Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is
|
||||
compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you
|
||||
are doing.
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedMessage} important={true} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_APPLY = "apply";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
|
||||
<Guidance>
|
||||
<p>
|
||||
The connection to the server has been configured successfully. As the next step, <strong
|
||||
>the latest synchronisation data will be downloaded from the server to this device.</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>PLEASE NOTE</strong>
|
||||
<br />
|
||||
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
|
||||
files in this vault, conflicts may occur with the server data.
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
38
src/modules/features/SetupWizard/dialogs/OutroNewUser.svelte
Normal file
38
src/modules/features/SetupWizard/dialogs/OutroNewUser.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_APPLY = "apply";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
// let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Setup Complete: Preparing to Initialise Server" />
|
||||
<Guidance>
|
||||
<p>
|
||||
The connection to the server has been configured successfully. As the next step, <strong
|
||||
>the synchronisation data on the server will be built based on the current data on this device.</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>IMPORTANT</strong>
|
||||
<br />
|
||||
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that
|
||||
any unintended data currently on the server will be completely overwritten.
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select the button below to restart and proceed to the final confirmation.</Question>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Restart and Initialise Server" important={true} commit={() => setResult(TYPE_APPLY)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Panel to check and fix CouchDB configuration issues
|
||||
*/
|
||||
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
|
||||
import Decision from "../../../../lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "../../../../lib/src/UI/components/UserDecisions.svelte";
|
||||
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
|
||||
type Props = {
|
||||
trialRemoteSetting: ObsidianLiveSyncSettings;
|
||||
};
|
||||
const { trialRemoteSetting }: Props = $props();
|
||||
let detectedIssues = $state<ConfigCheckResult[]>([]);
|
||||
async function testAndFixSettings() {
|
||||
detectedIssues = [];
|
||||
try {
|
||||
const fixResults = await checkConfig(trialRemoteSetting);
|
||||
console.dir(fixResults);
|
||||
detectedIssues = fixResults;
|
||||
} catch (e) {
|
||||
console.error("Error during testAndFixSettings:", e);
|
||||
detectedIssues.push({ message: `Error during testAndFixSettings: ${e}`, result: "error", classes: [] });
|
||||
}
|
||||
}
|
||||
function isErrorResult(result: ConfigCheckResult): result is ResultError | ResultErrorMessage {
|
||||
return "result" in result && result.result === "error";
|
||||
}
|
||||
function isFixableError(result: ConfigCheckResult): result is ResultError {
|
||||
return isErrorResult(result) && "fix" in result && typeof result.fix === "function";
|
||||
}
|
||||
function isSuccessResult(result: ConfigCheckResult): result is { message: string; result: "ok"; value?: any } {
|
||||
return "result" in result && result.result === "ok";
|
||||
}
|
||||
let processing = $state(false);
|
||||
async function fixIssue(issue: ResultError) {
|
||||
try {
|
||||
processing = true;
|
||||
await issue.fix();
|
||||
} catch (e) {
|
||||
console.error("Error during fixIssue:", e);
|
||||
}
|
||||
await testAndFixSettings();
|
||||
processing = false;
|
||||
}
|
||||
const errorIssueCount = $derived.by(() => {
|
||||
return detectedIssues.filter((issue) => isErrorResult(issue)).length;
|
||||
});
|
||||
const isAllSuccess = $derived.by(() => {
|
||||
return !(errorIssueCount > 0 && detectedIssues.length > 0);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{#snippet result(issue: ConfigCheckResult)}
|
||||
<div class="check-result {isErrorResult(issue) ? 'error' : isSuccessResult(issue) ? 'success' : ''}">
|
||||
<div class="message">
|
||||
{issue.message}
|
||||
</div>
|
||||
{#if isFixableError(issue)}
|
||||
<div class="operations">
|
||||
<button onclick={() => fixIssue(issue)} class="mod-cta" disabled={processing}>Fix</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<UserDecisions>
|
||||
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} />
|
||||
</UserDecisions>
|
||||
<div class="check-results">
|
||||
<details open={!isAllSuccess}>
|
||||
<summary>
|
||||
{#if detectedIssues.length === 0}
|
||||
No checks have been performed yet.
|
||||
{:else if isAllSuccess}
|
||||
All checks passed successfully!
|
||||
{:else}
|
||||
{errorIssueCount} issue(s) detected!
|
||||
{/if}
|
||||
</summary>
|
||||
{#if detectedIssues.length > 0}
|
||||
<h3>Issue detection log:</h3>
|
||||
{#each detectedIssues as issue}
|
||||
{@render result(issue)}
|
||||
{/each}
|
||||
{/if}
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Make .check-result a CSS Grid: let .message expand and keep .operations at minimum width, aligned to the right */
|
||||
.check-results {
|
||||
/* Adjust spacing as required */
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.check-result {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto; /* message takes remaining space, operations use minimum width */
|
||||
align-items: center; /* vertically centre align */
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 0rem 0.5rem;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
border-left: 0.5em solid var(--interactive-accent);
|
||||
margin-bottom: 0.25lh;
|
||||
}
|
||||
.check-result.error {
|
||||
border-left: 0.5em solid var(--text-error);
|
||||
}
|
||||
.check-result.success {
|
||||
border-left: 0.5em solid var(--text-success);
|
||||
}
|
||||
|
||||
.check-result .message {
|
||||
/* Wrap long messages */
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.check-result .operations {
|
||||
/* Centre the button(s) vertically and align to the right */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* For small screens: move .operations below and stack vertically */
|
||||
@media (max-width: 520px) {
|
||||
.check-result {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
.check-result .operations {
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_CANCEL = "cancelled";
|
||||
|
||||
const TYPE_BACKUP_DONE = "backup_done";
|
||||
const TYPE_BACKUP_SKIPPED = "backup_skipped";
|
||||
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
|
||||
|
||||
type ResultTypeBackup =
|
||||
| typeof TYPE_BACKUP_DONE
|
||||
| typeof TYPE_BACKUP_SKIPPED
|
||||
| typeof TYPE_UNABLE_TO_BACKUP
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
type ResultTypeExtra = {
|
||||
preventFetchingConfig: boolean;
|
||||
};
|
||||
type ResultType =
|
||||
| {
|
||||
backup: ResultTypeBackup;
|
||||
extra: ResultTypeExtra;
|
||||
}
|
||||
| typeof TYPE_CANCEL;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
|
||||
let backupType = $state<ResultTypeBackup>(TYPE_CANCEL);
|
||||
let confirmationCheck1 = $state(false);
|
||||
let confirmationCheck2 = $state(false);
|
||||
let confirmationCheck3 = $state(false);
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
(backupType === TYPE_BACKUP_DONE || backupType === TYPE_BACKUP_SKIPPED) &&
|
||||
confirmationCheck1 &&
|
||||
confirmationCheck2 &&
|
||||
confirmationCheck3
|
||||
);
|
||||
});
|
||||
let preventFetchingConfig = $state(false);
|
||||
|
||||
function commit() {
|
||||
setResult({
|
||||
backup: backupType,
|
||||
extra: {
|
||||
preventFetchingConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Final Confirmation: Overwrite Server Data with This Device's Files" />
|
||||
<Guidance
|
||||
>This procedure will first delete all existing synchronisation data from the server. Following this, the server data
|
||||
will be completely rebuilt, using the current state of your Vault on this device (including its local database) as
|
||||
<strong>the single, authoritative master copy</strong>.</Guidance
|
||||
>
|
||||
<InfoNote>
|
||||
You should perform this operation only in exceptional circumstances, such as when the server data is completely
|
||||
corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually
|
||||
large in comparison to the Vault size.
|
||||
</InfoNote>
|
||||
<Guidance important title="⚠️ Please Confirm the Following">
|
||||
<Check
|
||||
title="I understand that all changes made on other smartphones or computers possibly could be lost."
|
||||
bind:value={confirmationCheck1}
|
||||
>
|
||||
<InfoNote>There is a way to resolve this on other devices.</InfoNote>
|
||||
<InfoNote>Of course, we can back up the data before proceeding.</InfoNote>
|
||||
</Check>
|
||||
<Check
|
||||
title="I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
|
||||
bind:value={confirmationCheck2}
|
||||
>
|
||||
<InfoNote>by resetting the remote, you will be informed on other devices.</InfoNote>
|
||||
</Check>
|
||||
<Check title="I understand that this action is irreversible once performed." bind:value={confirmationCheck3} />
|
||||
</Guidance>
|
||||
<hr />
|
||||
<Instruction>
|
||||
<Question>Have you created a backup before proceeding?</Question>
|
||||
<InfoNote warning>
|
||||
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe
|
||||
location.
|
||||
</InfoNote>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_BACKUP_DONE} title="I have created a backup of my Vault." bind:value={backupType} />
|
||||
<Option
|
||||
selectedValue={TYPE_BACKUP_SKIPPED}
|
||||
title="I understand the risks and will proceed without a backup."
|
||||
bind:value={backupType}
|
||||
/>
|
||||
<Option
|
||||
selectedValue={TYPE_UNABLE_TO_BACKUP}
|
||||
title="I am unable to create a backup of my Vaults."
|
||||
bind:value={backupType}
|
||||
>
|
||||
<InfoNote error visible={backupType === TYPE_UNABLE_TO_BACKUP}>
|
||||
<strong
|
||||
>You should create a new synchronisation destination and rebuild your data there. <br /> After that,
|
||||
synchronise to a brand new vault on each other device with the new remote one by one.</strong
|
||||
>
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<Instruction>
|
||||
<ExtraItems title="Advanced">
|
||||
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="I Understand, Overwrite Server" important disabled={!canProceed} commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
|
||||
</UserDecisions>
|
||||
28
src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte
Normal file
28
src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_CLOSE = "close";
|
||||
type ResultType = typeof TYPE_CLOSE;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Scan QR Code" />
|
||||
<Guidance>Please follow the steps below to import settings from your existing device.</Guidance>
|
||||
<Instruction>
|
||||
<!-- <Question>How would you like to configure the connection to your server?</Question> -->
|
||||
<ol>
|
||||
<li>On this device, please keep this Vault open.</li>
|
||||
<li>On the source device, open Obsidian.</li>
|
||||
<li>On the source device, from the command palette, run the 'Show settings as a QR code' command.</li>
|
||||
<li>On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.</li>
|
||||
</ol>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Close this dialog" important={true} commit={() => setResult(TYPE_CLOSE)} />
|
||||
</UserDecisions>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_USE_SETUP_URI = "use-setup-uri";
|
||||
const TYPE_SCAN_QR_CODE = "scan-qr-code";
|
||||
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_SCAN_QR_CODE | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
|
||||
return "I know my server details, let me enter them";
|
||||
} else if (userType === TYPE_SCAN_QR_CODE) {
|
||||
return "Scan the QR code displayed on an active device using this device's camera.";
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_USE_SETUP_URI || userType === TYPE_CONFIGURE_MANUALLY || userType === TYPE_SCAN_QR_CODE;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Device Setup Method" />
|
||||
<Guidance>You are adding this device to an existing synchronisation setup.</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select a method to import the settings from another device.</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
|
||||
Paste the Setup URI generated from one of your active devices.
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_SCAN_QR_CODE} title="Scan a QR Code (Recommended for mobile)" bind:value={userType}>
|
||||
Scan the QR code displayed on an active device using this device's camera.
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_CONFIGURE_MANUALLY}
|
||||
title="Enter the server information manually"
|
||||
bind:value={userType}
|
||||
>
|
||||
Configure the same server information as your other devices again, manually, very advanced users only.
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_USE_SETUP_URI = "use-setup-uri";
|
||||
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
|
||||
return "I know my server details, let me enter them";
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_USE_SETUP_URI || userType === TYPE_CONFIGURE_MANUALLY;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Connection Method" />
|
||||
<Guidance>We will now proceed with the server configuration.</Guidance>
|
||||
<Instruction>
|
||||
<Question>How would you like to configure the connection to your server?</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
|
||||
A Setup URI is a single string of text containing your server address and authentication details. Using a
|
||||
URI, if one was generated by your server installation script, provides a simple and secure configuration.
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_CONFIGURE_MANUALLY}
|
||||
title="Enter the server information manually"
|
||||
bind:value={userType}
|
||||
>
|
||||
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
56
src/modules/features/SetupWizard/dialogs/SetupRemote.svelte
Normal file
56
src/modules/features/SetupWizard/dialogs/SetupRemote.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_COUCHDB = "couchdb";
|
||||
const TYPE_BUCKET = "bucket";
|
||||
const TYPE_P2P = "p2p";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_COUCHDB) {
|
||||
return "Continue to CouchDB setup";
|
||||
} else if (userType === TYPE_BUCKET) {
|
||||
return "Continue to S3/MinIO/R2 setup";
|
||||
} else if (userType === TYPE_P2P) {
|
||||
return "Continue to Peer-to-Peer only setup";
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Enter Server Information" />
|
||||
<Instruction>
|
||||
<Question>Please select the type of server to which you are connecting.</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
|
||||
This is the most suitable synchronisation method for the design. All functions are available. You must have
|
||||
set up a CouchDB instance.
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_BUCKET} title="S3/MinIO/R2 Object Storage" bind:value={userType}>
|
||||
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
|
||||
This is an experimental feature enabling direct synchronisation between devices. No server is required, but
|
||||
both devices must be online at the same time for synchronisation to occur, and some features may be limited.
|
||||
Internet connection is only required to signalling (detecting peers) and not for data transfer.
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
RemoteTypes,
|
||||
} from "../../../../lib/src/common/types";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { getObsidianDialogContext } from "../ObsidianSvelteDialog";
|
||||
import { copyTo, pickBucketSyncSettings } from "../../../../lib/src/common/utils";
|
||||
|
||||
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<BucketSyncSetting>({ ...default_setting });
|
||||
|
||||
type ResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
type Props = GuestDialogProps<ResultType, BucketSyncSetting>;
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
}
|
||||
});
|
||||
let error = $state("");
|
||||
const context = getObsidianDialogContext();
|
||||
const isEndpointSecure = $derived.by(() => {
|
||||
return syncSetting.endpoint.trim().toLowerCase().startsWith("https://");
|
||||
});
|
||||
const isEndpointInsecure = $derived.by(() => {
|
||||
return syncSetting.endpoint.trim().toLowerCase().startsWith("http://");
|
||||
});
|
||||
const isEndpointSupplied = $derived.by(() => {
|
||||
return isEndpointInsecure || isEndpointSecure;
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
syncSetting.accessKey.trim() !== "" &&
|
||||
syncSetting.secretKey.trim() !== "" &&
|
||||
syncSetting.bucket.trim() !== "" &&
|
||||
syncSetting.endpoint.trim() !== "" &&
|
||||
syncSetting.region.trim() !== "" &&
|
||||
isEndpointSupplied
|
||||
);
|
||||
});
|
||||
|
||||
function generateSetting() {
|
||||
const connSetting: BucketSyncSetting = {
|
||||
...syncSetting,
|
||||
};
|
||||
const trialSettings: BucketSyncSetting = {
|
||||
...connSetting,
|
||||
};
|
||||
|
||||
const trialRemoteSetting: ObsidianLiveSyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...PREFERRED_JOURNAL_SYNC,
|
||||
remoteType: RemoteTypes.REMOTE_MINIO,
|
||||
...trialSettings,
|
||||
};
|
||||
return trialRemoteSetting;
|
||||
}
|
||||
|
||||
let processing = $state(false);
|
||||
async function checkConnection() {
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return "Failed to create replicator instance.";
|
||||
}
|
||||
try {
|
||||
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
|
||||
if (result) {
|
||||
return "";
|
||||
} else {
|
||||
return "Failed to connect to the server. Please check your settings.";
|
||||
}
|
||||
} catch (e) {
|
||||
return `Failed to connect to the server: ${e}`;
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
if (!error) {
|
||||
const setting = generateSetting();
|
||||
setResult(pickBucketSyncSettings(setting));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
const setting = pickBucketSyncSettings(generateSetting());
|
||||
setResult(setting);
|
||||
}
|
||||
function cancel() {
|
||||
setResult(TYPE_CANCELLED);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="S3/MinIO/R2 Configuration" />
|
||||
<Guidance>Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.</Guidance>
|
||||
<InputRow label="Endpoint URL">
|
||||
<input
|
||||
type="text"
|
||||
name="s3-endpoint"
|
||||
placeholder="https://s3.amazonaws.com"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
pattern="^https?://.+"
|
||||
bind:value={syncSetting.endpoint}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isEndpointInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
|
||||
|
||||
<InputRow label="Access Key ID">
|
||||
<input
|
||||
type="text"
|
||||
name="s3-access-key-id"
|
||||
placeholder="Enter your Access Key ID"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
bind:value={syncSetting.accessKey}
|
||||
/>
|
||||
</InputRow>
|
||||
|
||||
<InputRow label="Secret Access Key">
|
||||
<Password
|
||||
name="s3-secret-access-key"
|
||||
placeholder="Enter your Secret Access Key"
|
||||
required
|
||||
bind:value={syncSetting.secretKey}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Bucket Name">
|
||||
<input
|
||||
type="text"
|
||||
name="s3-bucket-name"
|
||||
placeholder="Enter your Bucket Name"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
bind:value={syncSetting.bucket}
|
||||
/></InputRow
|
||||
>
|
||||
<InputRow label="Region">
|
||||
<input
|
||||
type="text"
|
||||
name="s3-region"
|
||||
placeholder="Enter your Region (e.g., us-east-1, auto for R2)"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.region}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Use Path-Style Access">
|
||||
<input type="checkbox" name="s3-use-path-style" bind:checked={syncSetting.forcePathStyle} />
|
||||
</InputRow>
|
||||
|
||||
<InputRow label="Folder Prefix">
|
||||
<input
|
||||
type="text"
|
||||
name="s3-folder-prefix"
|
||||
placeholder="Enter a folder prefix (optional)"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.bucketPrefix}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here.
|
||||
Otherwise, leave it blank to store data at the root of the bucket.
|
||||
</InfoNote>
|
||||
<InputRow label="Use internal API">
|
||||
<input type="checkbox" name="s3-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate
|
||||
with the S3 server. Not compliant with web standards, but works. Note that this might break in future Obsidian
|
||||
versions.
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title="Advanced Settings">
|
||||
<InputRow label="Custom Headers">
|
||||
<textarea
|
||||
name="bucket-custom-headers"
|
||||
placeholder="e.g., x-example-header: value\n another-header: value2"
|
||||
bind:value={syncSetting.bucketCustomHeaders}
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote error visible={error !== ""}>
|
||||
{error}
|
||||
</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
<Decision title="Continue anyway" commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_SETTING_CLOUDANT,
|
||||
PREFERRED_SETTING_SELF_HOSTED,
|
||||
RemoteTypes,
|
||||
type CouchDBConnection,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../../../lib/src/common/types";
|
||||
import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
|
||||
|
||||
import { getObsidianDialogContext } from "../ObsidianSvelteDialog";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { copyTo, pickCouchDBSyncSettings } from "../../../../lib/src/common/utils";
|
||||
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
|
||||
|
||||
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
|
||||
type ResultType = typeof TYPE_CANCELLED | CouchDBConnection;
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type Props = GuestDialogProps<ResultType, CouchDBConnection>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let error = $state("");
|
||||
const context = getObsidianDialogContext();
|
||||
|
||||
function generateSetting() {
|
||||
const connSetting: CouchDBConnection = {
|
||||
...syncSetting,
|
||||
};
|
||||
const trialSettings: CouchDBConnection = {
|
||||
...connSetting,
|
||||
// ...encryptionSettings,
|
||||
};
|
||||
const preferredSetting = isCloudantURI(syncSetting.couchDB_URI)
|
||||
? PREFERRED_SETTING_CLOUDANT
|
||||
: PREFERRED_SETTING_SELF_HOSTED;
|
||||
const trialRemoteSetting: ObsidianLiveSyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...preferredSetting,
|
||||
remoteType: RemoteTypes.REMOTE_COUCHDB,
|
||||
...trialSettings,
|
||||
};
|
||||
return trialRemoteSetting;
|
||||
}
|
||||
let processing = $state(false);
|
||||
async function checkConnection() {
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return "Failed to create replicator instance.";
|
||||
}
|
||||
try {
|
||||
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
|
||||
if (result) {
|
||||
return "";
|
||||
} else {
|
||||
return "Failed to connect to the server. Please check your settings.";
|
||||
}
|
||||
} catch (e) {
|
||||
return `Failed to connect to the server: ${e}`;
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
if (!error) {
|
||||
const setting = generateSetting();
|
||||
setResult(pickCouchDBSyncSettings(setting));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
const setting = pickCouchDBSyncSettings(generateSetting());
|
||||
setResult(setting);
|
||||
}
|
||||
function cancel() {
|
||||
setResult(TYPE_CANCELLED);
|
||||
}
|
||||
|
||||
// const isURICloudant = $derived.by(() => {
|
||||
// return syncSetting.couchDB_URI && isCloudantURI(syncSetting.couchDB_URI);
|
||||
// });
|
||||
// const isURISelfHosted = $derived.by(() => {
|
||||
// return syncSetting.couchDB_URI && !isCloudantURI(syncSetting.couchDB_URI);
|
||||
// });
|
||||
// const isURISecure = $derived.by(() => {
|
||||
// return syncSetting.couchDB_URI && syncSetting.couchDB_URI.startsWith("https://");
|
||||
// });
|
||||
const isURIInsecure = $derived.by(() => {
|
||||
return !!(syncSetting.couchDB_URI && syncSetting.couchDB_URI.startsWith("http://"));
|
||||
});
|
||||
const isUseJWT = $derived.by(() => {
|
||||
return syncSetting.useJWT;
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
syncSetting.couchDB_URI.trim().length > 0 &&
|
||||
syncSetting.couchDB_USER.trim().length > 0 &&
|
||||
syncSetting.couchDB_PASSWORD.trim().length > 0 &&
|
||||
syncSetting.couchDB_DBNAME.trim().length > 0 &&
|
||||
(isUseJWT ? syncSetting.jwtKey.trim().length > 0 : true)
|
||||
);
|
||||
});
|
||||
const testSettings = $derived.by(() => {
|
||||
return generateSetting();
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="CouchDB Configuration" />
|
||||
<Guidance>Please enter the CouchDB server information below.</Guidance>
|
||||
<InputRow label="URL">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-url"
|
||||
placeholder="https://example.com"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.couchDB_URI}
|
||||
required
|
||||
pattern="^https?://.+"
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
|
||||
<InputRow label="Username">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-username"
|
||||
placeholder="Enter your username"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
bind:value={syncSetting.couchDB_USER}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Password">
|
||||
<Password
|
||||
name="couchdb-password"
|
||||
placeholder="Enter your password"
|
||||
bind:value={syncSetting.couchDB_PASSWORD}
|
||||
required
|
||||
/>
|
||||
</InputRow>
|
||||
|
||||
<InputRow label="Database Name">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-database"
|
||||
placeholder="Enter your database name"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
pattern="^[a-z0-9][a-z0-9_]*$"
|
||||
bind:value={syncSetting.couchDB_DBNAME}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an
|
||||
underscore (_).
|
||||
</InfoNote>
|
||||
<InputRow label="Use Internal API">
|
||||
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate
|
||||
with the CouchDB server. Not compliant with web standards, but works. Note that this might break in future Obsidian
|
||||
versions.
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title="Advanced Settings">
|
||||
<InputRow label="Custom Headers">
|
||||
<textarea
|
||||
name="couchdb-custom-headers"
|
||||
placeholder="e.g., x-example-header: value\n another-header: value2"
|
||||
bind:value={syncSetting.couchDB_CustomHeaders}
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
</ExtraItems>
|
||||
<ExtraItems title="Experimental Settings">
|
||||
<InputRow label="Use JWT Authentication">
|
||||
<input type="checkbox" name="couchdb-use-jwt" bind:checked={syncSetting.useJWT} />
|
||||
</InputRow>
|
||||
<InputRow label="JWT Algorithm">
|
||||
<select bind:value={syncSetting.jwtAlgorithm} disabled={!isUseJWT}>
|
||||
<option value="HS256">HS256</option>
|
||||
<option value="HS512">HS512</option>
|
||||
<option value="ES256">ES256</option>
|
||||
<option value="ES512">ES512</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Expiration Duration (seconds)">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-exp-duration"
|
||||
placeholder="0"
|
||||
bind:value={() => `${syncSetting.jwtExpDuration}`, (v) => (syncSetting.jwtExpDuration = parseInt(v) || 0)}
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Key">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-key"
|
||||
placeholder="Enter your JWT secret or private key"
|
||||
bind:value={syncSetting.jwtKey}
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Key ID (kid)">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-kid"
|
||||
placeholder="Enter your JWT Key ID (optional)"
|
||||
bind:value={syncSetting.jwtKid}
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Subject (sub)">
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-sub"
|
||||
placeholder="Enter your JWT Subject (optional)"
|
||||
bind:value={syncSetting.jwtSub}
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning>
|
||||
JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens.
|
||||
Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the
|
||||
server's configuration. Incidentally, I have not verified it very thoroughly.
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
|
||||
<hr />
|
||||
|
||||
<InfoNote error visible={error !== ""}>
|
||||
{error}
|
||||
</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
<Decision title="Continue anyway" commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
123
src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte
Normal file
123
src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
E2EEAlgorithmNames,
|
||||
E2EEAlgorithms,
|
||||
type EncryptionSettings,
|
||||
} from "../../../../lib/src/common/types";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "../../../../lib/src/common/utils";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_CANCELLED | EncryptionSettings;
|
||||
type Props = GuestDialogProps<ResultType, EncryptionSettings>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let default_encryption: EncryptionSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "",
|
||||
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
|
||||
usePathObfuscation: true,
|
||||
} as EncryptionSettings;
|
||||
|
||||
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
|
||||
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, encryptionSettings);
|
||||
}
|
||||
}
|
||||
});
|
||||
let e2eeValid = $derived.by(() => {
|
||||
if (!encryptionSettings.encrypt) return true;
|
||||
return encryptionSettings.passphrase.trim().length >= 1;
|
||||
});
|
||||
|
||||
function commit() {
|
||||
setResult(pickEncryptionSettings(encryptionSettings));
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="End-to-End Encryption" />
|
||||
<Guidance>Please configure your end-to-end encryption settings.</Guidance>
|
||||
<InputRow label="End-to-End Encryption">
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
<Password
|
||||
name="e2ee-passphrase"
|
||||
placeholder="Enter your passphrase"
|
||||
bind:value={encryptionSettings.passphrase}
|
||||
disabled={!encryptionSettings.encrypt}
|
||||
required={encryptionSettings.encrypt}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote title="Strongly Recommended">
|
||||
Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote
|
||||
server. This means that even if someone gains access to the server, they won't be able to read your data without the
|
||||
passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices.
|
||||
<br />
|
||||
Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch
|
||||
to other methods and connect to a remote server in the future.
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
This setting must be the same even when connecting to multiple synchronisation destinations.
|
||||
</InfoNote>
|
||||
<InputRow label="Obfuscate Properties">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={encryptionSettings.usePathObfuscation}
|
||||
disabled={!encryptionSettings.encrypt}
|
||||
/>
|
||||
</InputRow>
|
||||
|
||||
<InfoNote>
|
||||
Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of
|
||||
security by making it harder to identify the structure and names of your files and folders on the remote server.
|
||||
This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your
|
||||
data.
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title="Advanced">
|
||||
<InputRow label="Encryption Algorithm">
|
||||
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
|
||||
{#each Object.values(E2EEAlgorithms) as alg}
|
||||
<option value={alg}>{E2EEAlgorithmNames[alg] ?? alg}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
In most cases, you should stick with the default algorithm ({E2EEAlgorithmNames[
|
||||
DEFAULT_SETTINGS.E2EEAlgorithm
|
||||
]}), This setting is only required if you have an existing Vault encrypted in a different format.
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
Changing the encryption algorithm will prevent access to any data previously encrypted with a different
|
||||
algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your
|
||||
data.
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote warning>
|
||||
<p>
|
||||
Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process
|
||||
actually commences. This is a security measure designed to protect your data.
|
||||
</p>
|
||||
<p>
|
||||
Therefore, we ask that you exercise extreme caution when configuring server information manually. If an
|
||||
incorrect passphrase is entered, the data on the server will become corrupted. <br /><br />
|
||||
Please understand that this is intended behaviour.
|
||||
</p>
|
||||
</InfoNote>
|
||||
|
||||
<UserDecisions>
|
||||
<Decision title="Proceed" important disabled={!e2eeValid} commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
306
src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte
Normal file
306
src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte
Normal file
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
// import { delay } from "octagonal-wheels/promises";
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
import { PouchDB } from "../../../../lib/src/pouchdb/pouchdb-browser";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
PREFERRED_BASE,
|
||||
RemoteTypes,
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PConnectionInfo,
|
||||
type P2PSyncSetting,
|
||||
} from "../../../../lib/src/common/types";
|
||||
|
||||
import { TrysteroReplicator } from "../../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "../../../../lib/src/replication/trystero/types";
|
||||
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "../../../../lib/src/common/utils";
|
||||
import { getObsidianDialogContext } from "../ObsidianSvelteDialog";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../../lib/src/common/types";
|
||||
import ExtraItems from "../../../../lib/src/UI/components/ExtraItems.svelte";
|
||||
|
||||
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
|
||||
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
|
||||
|
||||
const context = getObsidianDialogContext();
|
||||
let error = $state("");
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type SettingInfo = P2PConnectionInfo;
|
||||
type ResultType = typeof TYPE_CANCELLED | SettingInfo;
|
||||
type Props = GuestDialogProps<ResultType, P2PSyncSetting>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
if (context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME)) {
|
||||
syncSetting.P2P_DevicePeerName = context.services.config.getSmallConfig(
|
||||
SETTING_KEY_P2P_DEVICE_NAME
|
||||
) as string;
|
||||
} else {
|
||||
syncSetting.P2P_DevicePeerName = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
function generateSetting() {
|
||||
const connSetting: P2PSyncSetting = {
|
||||
// remoteType: ",
|
||||
...P2P_DEFAULT_SETTINGS,
|
||||
...syncSetting,
|
||||
P2P_Enabled: true,
|
||||
};
|
||||
const trialSettings: P2PSyncSetting = {
|
||||
...connSetting,
|
||||
};
|
||||
const trialRemoteSetting: ObsidianLiveSyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...PREFERRED_BASE,
|
||||
remoteType: RemoteTypes.REMOTE_P2P,
|
||||
...trialSettings,
|
||||
};
|
||||
return trialRemoteSetting;
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
processing = true;
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const map = new Map<string, string>();
|
||||
const store = {
|
||||
get: (key: string) => {
|
||||
return Promise.resolve(map.get(key) || null);
|
||||
},
|
||||
set: (key: string, value: any) => {
|
||||
map.set(key, value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
delete: (key: string) => {
|
||||
map.delete(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
keys: () => {
|
||||
return Promise.resolve(Array.from(map.keys()));
|
||||
},
|
||||
} as SimpleStore<any>;
|
||||
|
||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||
const env: ReplicatorHostEnv = {
|
||||
settings: trialRemoteSetting,
|
||||
processReplicatedDocs: async (docs: any[]) => {
|
||||
return;
|
||||
},
|
||||
confirm: context.plugin.confirm,
|
||||
db: dummyPouch,
|
||||
simpleStore: store,
|
||||
deviceName: syncSetting.P2P_DevicePeerName || "unnamed-device",
|
||||
platform: "setup-wizard",
|
||||
};
|
||||
const replicator = new TrysteroReplicator(env);
|
||||
try {
|
||||
await replicator.setOnSetup();
|
||||
await replicator.allowReconnection();
|
||||
await replicator.open();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// await delay(1000);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
|
||||
if (replicator.knownAdvertisements.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// context.holdingSettings = trialRemoteSetting;
|
||||
|
||||
if (replicator.knownAdvertisements.length === 0) {
|
||||
return "Your settings seem correct, but no other peers were found.";
|
||||
}
|
||||
return "";
|
||||
} catch (e) {
|
||||
return `Failed to connect to other peers: ${e}`;
|
||||
} finally {
|
||||
try {
|
||||
replicator.close();
|
||||
dummyPouch.destroy();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
function setDefaultRelay() {
|
||||
syncSetting.P2P_relays = P2P_DEFAULT_SETTINGS.P2P_relays;
|
||||
}
|
||||
|
||||
let processing = $state(false);
|
||||
function generateDefaultGroupId() {
|
||||
const randomValues = new Uint16Array(4);
|
||||
crypto.getRandomValues(randomValues);
|
||||
const MAX_UINT16 = 65536;
|
||||
const a = Math.floor((randomValues[0] / MAX_UINT16) * 1000);
|
||||
const b = Math.floor((randomValues[1] / MAX_UINT16) * 1000);
|
||||
const c = Math.floor((randomValues[2] / MAX_UINT16) * 1000);
|
||||
const d_range = 36 * 36 * 36;
|
||||
const d = Math.floor((randomValues[3] / MAX_UINT16) * d_range);
|
||||
syncSetting.P2P_roomID = `${a.toString().padStart(3, "0")}-${b
|
||||
.toString()
|
||||
.padStart(3, "0")}-${c.toString().padStart(3, "0")}-${d.toString(36).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
if (!error) {
|
||||
const setting = generateSetting();
|
||||
setResult(pickP2PSyncSettings(setting));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
const setting = pickP2PSyncSettings(generateSetting());
|
||||
setResult(setting);
|
||||
}
|
||||
function cancel() {
|
||||
setResult(TYPE_CANCELLED);
|
||||
}
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
syncSetting.P2P_relays.trim() !== "" &&
|
||||
syncSetting.P2P_roomID.trim() !== "" &&
|
||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||
(syncSetting.P2P_DevicePeerName ?? "").trim() !== ""
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="P2P Configuration" />
|
||||
<Guidance>Please enter the Peer-to-Peer Synchronisation information below.</Guidance>
|
||||
<InputRow label="Enabled">
|
||||
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
|
||||
</InputRow>
|
||||
<InputRow label="Relay URL">
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-relay-url"
|
||||
placeholder="Enter the Relay URL)"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_relays}
|
||||
/>
|
||||
<button class="button" onclick={() => setDefaultRelay()}>Use vrtmrz's relay</button>
|
||||
</InputRow>
|
||||
<InputRow label="Group ID">
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-room-id"
|
||||
placeholder="123-456-789-abc"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_roomID}
|
||||
/>
|
||||
<button class="button" onclick={() => generateDefaultGroupId()}>Generate Random ID</button>
|
||||
</InputRow>
|
||||
<InputRow label="Passphrase">
|
||||
<Password name="p2p-password" placeholder="Enter your passphrase" bind:value={syncSetting.P2P_passphrase} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
The Group ID and passphrase are used to identify your group of devices. Make sure to use the same Group ID and
|
||||
passphrase on all devices you want to synchronise.<br />
|
||||
Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.
|
||||
</InfoNote>
|
||||
<InputRow label="Device Peer ID">
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-device-peer-id"
|
||||
placeholder="main-iphone16"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_DevicePeerName}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Auto Start P2P Connection">
|
||||
<input type="checkbox" name="p2p-auto-start" bind:checked={syncSetting.P2P_AutoStart} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
|
||||
launches.
|
||||
</InfoNote>
|
||||
<InputRow label="Auto Broadcast Changes">
|
||||
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without
|
||||
requiring manual intervention. This requests peers to fetch this device's changes.
|
||||
</InfoNote>
|
||||
<ExtraItems title="Advanced Settings">
|
||||
<InfoNote>
|
||||
TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P
|
||||
connections. In most cases, you can leave these fields blank.
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
|
||||
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
|
||||
server provider before using their services. Also your `network administrator` too. You should consider setting
|
||||
up your own TURN server for your FQDN, if possible.
|
||||
</InfoNote>
|
||||
<InputRow label="TURN Server URLs (comma-separated)">
|
||||
<textarea
|
||||
name="p2p-turn-servers"
|
||||
placeholder="turn:turn.example.com:3478,turn:turn.example.com:443"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_turnServers}
|
||||
rows="5"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
<InputRow label="TURN Username">
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-turn-username"
|
||||
placeholder="Enter TURN username"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_turnUsername}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="TURN Credential">
|
||||
<Password
|
||||
name="p2p-turn-credential"
|
||||
placeholder="Enter TURN credential"
|
||||
bind:value={syncSetting.P2P_turnCredential}
|
||||
/>
|
||||
</InputRow>
|
||||
</ExtraItems>
|
||||
<InfoNote error visible={error !== ""}>
|
||||
{error}
|
||||
</InfoNote>
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
<Decision title="Continue anyway" commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
96
src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte
Normal file
96
src/modules/features/SetupWizard/dialogs/UseSetupURI.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { configURIBase } from "../../../../common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { decryptString } from "../../../../lib/src/encryption/stringEncryption.ts";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog.ts";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
type Props = GuestDialogProps<ResultType, string>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
|
||||
let setupURI = $state("");
|
||||
let passphrase = $state("");
|
||||
let error = $state("");
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialURI = getInitialData();
|
||||
if (initialURI) {
|
||||
setupURI = initialURI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const seemsValid = $derived.by(() => setupURI.startsWith(configURIBase));
|
||||
async function processSetupURI() {
|
||||
error = "";
|
||||
if (!seemsValid) return;
|
||||
if (!passphrase) {
|
||||
error = "Passphrase is required.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const settingPieces = setupURI.substring(configURIBase.length);
|
||||
const encodedConfig = decodeURIComponent(settingPieces);
|
||||
const newConf = (await JSON.parse(
|
||||
await decryptString(encodedConfig, passphrase)
|
||||
)) as ObsidianLiveSyncSettings;
|
||||
setResult(newConf);
|
||||
// Logger("Settings imported successfully", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
} catch (e) {
|
||||
error = "Failed to parse Setup-URI.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
async function canProceed() {
|
||||
return (await processSetupURI()) ?? false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Enter Setup URI" />
|
||||
<Guidance
|
||||
>Please enter the Setup URI that was generated during server installation or on another device, along with the vault
|
||||
passphrase.<br />
|
||||
Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.</Guidance
|
||||
>
|
||||
|
||||
<InputRow label="Setup-URI">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="obsidian://setuplivesync?settings=...."
|
||||
bind:value={setupURI}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote visible={seemsValid}>The Setup-URI is valid and ready to use.</InfoNote>
|
||||
<InfoNote warning visible={!seemsValid && setupURI.trim() != ""}>
|
||||
The Setup-URI does not appear to be valid. Please check that you have copied it correctly.
|
||||
</InfoNote>
|
||||
<InputRow label="Passphrase">
|
||||
<Password placeholder="Enter your passphrase" bind:value={passphrase} required />
|
||||
</InputRow>
|
||||
<InfoNote error visible={error.trim() != ""}>
|
||||
{error}
|
||||
</InfoNote>
|
||||
|
||||
<UserDecisions>
|
||||
<Decision
|
||||
title="Test Settings and Continue"
|
||||
important={true}
|
||||
disabled={!canProceed}
|
||||
commit={() => processSetupURI()}
|
||||
/>
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
293
src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts
Normal file
293
src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { requestToCouchDBWithCredentials } from "../../../../common/utils";
|
||||
import { $msg } from "../../../../lib/src/common/i18n";
|
||||
import { Logger } from "../../../../lib/src/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
|
||||
import { parseHeaderValues } from "../../../../lib/src/common/utils";
|
||||
import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
|
||||
import { generateCredentialObject } from "../../../../lib/src/replication/httplib";
|
||||
export type ResultMessage = { message: string; classes: string[] };
|
||||
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
||||
export type ResultOk = { message: string; result: "ok"; value?: any };
|
||||
export type ResultError = { message: string; result: "error"; value: any; fixMessage: string; fix(): Promise<void> };
|
||||
export type ConfigCheckResult = ResultOk | ResultError | ResultMessage | ResultErrorMessage;
|
||||
/**
|
||||
* Compares two version strings to determine if the baseVersion is greater than or equal to the version.
|
||||
* @param baseVersion a.b.c format
|
||||
* @param version a.b.c format
|
||||
* @returns true if baseVersion is greater than or equal to version, false otherwise
|
||||
*/
|
||||
function isGreaterThanOrEqual(baseVersion: string, version: string) {
|
||||
const versionParts = `${baseVersion}.0.0.0`.split(".");
|
||||
const targetParts = version.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the remote CouchDB setting with the given key and value.
|
||||
* @param setting Connection settings
|
||||
* @param key setting key to update
|
||||
* @param value setting value to update
|
||||
* @returns true if the update was successful, false otherwise
|
||||
*/
|
||||
async function updateRemoteSetting(setting: ObsidianLiveSyncSettings, key: string, value: any) {
|
||||
const customHeaders = parseHeaderValues(setting.couchDB_CustomHeaders);
|
||||
const credential = generateCredentialObject(setting);
|
||||
const res = await requestToCouchDBWithCredentials(
|
||||
setting.couchDB_URI,
|
||||
credential,
|
||||
undefined,
|
||||
key,
|
||||
value,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
if (res.status == 200) {
|
||||
return true;
|
||||
} else {
|
||||
return res.text || "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the CouchDB configuration and returns the results.
|
||||
* @param editingSettings
|
||||
* @returns Array of ConfigCheckResult
|
||||
*/
|
||||
export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => {
|
||||
const result = [] as ConfigCheckResult[];
|
||||
const addMessage = (msg: string, classes: string[] = []) => {
|
||||
result.push({ message: msg, classes });
|
||||
};
|
||||
const addSuccess = (msg: string, value?: any) => {
|
||||
result.push({ message: msg, result: "ok", value });
|
||||
};
|
||||
const _addError = (message: string, fixMessage: string, fix: () => Promise<void>, value?: any) => {
|
||||
result.push({ message, result: "error", fixMessage, fix, value });
|
||||
};
|
||||
const addErrorMessage = (msg: string, classes: string[] = []) => {
|
||||
result.push({ message: msg, result: "error", classes });
|
||||
};
|
||||
|
||||
const addError = (message: string, fixMessage: string, key: string, expected: any) => {
|
||||
_addError(message, fixMessage, async () => {
|
||||
await updateRemoteSetting(editingSettings, key, expected);
|
||||
});
|
||||
};
|
||||
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"));
|
||||
|
||||
try {
|
||||
if (isCloudantURI(editingSettings.couchDB_URI)) {
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.logCannotUseCloudant"));
|
||||
return result;
|
||||
}
|
||||
// Tip: Add log for cloudant as Logger($msg("obsidianLiveSyncSettingTab.logServerConfigurationCheck"));
|
||||
const customHeaders = parseHeaderValues(editingSettings.couchDB_CustomHeaders);
|
||||
const credential = generateCredentialObject(editingSettings);
|
||||
const r = await requestToCouchDBWithCredentials(
|
||||
editingSettings.couchDB_URI,
|
||||
credential,
|
||||
window.origin,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseConfig = r.json;
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgNotice"), ["ob-btn-config-head"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]);
|
||||
|
||||
const serverBanner = r.headers["server"] ?? r.headers["Server"] ?? "unknown";
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
||||
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
||||
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
||||
|
||||
// Compare version string with the target version.
|
||||
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
|
||||
|
||||
// Admin check
|
||||
// for database creation and deletion
|
||||
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.warnNoAdmin"));
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||
}
|
||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
||||
// HTTP user-authorization check
|
||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errRequireValidUser"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUser"),
|
||||
"chttpd/require_valid_user",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okRequireValidUser"));
|
||||
}
|
||||
} else {
|
||||
if (responseConfig?.chttpd_auth?.require_valid_user != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errRequireValidUserAuth"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth"),
|
||||
"chttpd_auth/require_valid_user",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okRequireValidUserAuth"));
|
||||
}
|
||||
}
|
||||
// HTTPD check
|
||||
// Check Authentication header
|
||||
if (!responseConfig?.httpd["WWW-Authenticate"]) {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errMissingWwwAuth"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetWwwAuth"),
|
||||
"httpd/WWW-Authenticate",
|
||||
'Basic realm="couchdb"'
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||
}
|
||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgEnableCorsChttpd"),
|
||||
"chttpd/enable_cors",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okEnableCorsChttpd"));
|
||||
}
|
||||
} else {
|
||||
if (responseConfig?.httpd?.enable_cors != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errEnableCors"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgEnableCors"),
|
||||
"httpd/enable_cors",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okEnableCors"));
|
||||
}
|
||||
}
|
||||
// If the server is not cloudant, configure request size
|
||||
if (!isCloudantURI(editingSettings.couchDB_URI)) {
|
||||
// REQUEST SIZE
|
||||
if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errMaxRequestSize"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetMaxRequestSize"),
|
||||
"chttpd/max_http_request_size",
|
||||
"4294967296"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okMaxRequestSize"));
|
||||
}
|
||||
if (Number(responseConfig?.couchdb?.max_document_size ?? 0) < 50000000) {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errMaxDocumentSize"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetMaxDocSize"),
|
||||
"couchdb/max_document_size",
|
||||
"50000000"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okMaxDocumentSize"));
|
||||
}
|
||||
}
|
||||
// CORS check
|
||||
// checking connectivity for mobile
|
||||
if (responseConfig?.cors?.credentials != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errCorsCredentials"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetCorsCredentials"),
|
||||
"cors/credentials",
|
||||
"true"
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsCredentials"));
|
||||
}
|
||||
const ConfiguredOrigins = ((responseConfig?.cors?.origins ?? "") + "").split(",");
|
||||
if (
|
||||
responseConfig?.cors?.origins == "*" ||
|
||||
(ConfiguredOrigins.indexOf("app://obsidian.md") !== -1 &&
|
||||
ConfiguredOrigins.indexOf("capacitor://localhost") !== -1 &&
|
||||
ConfiguredOrigins.indexOf("http://localhost") !== -1)
|
||||
) {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsOrigins"));
|
||||
} else {
|
||||
const fixedValue = [
|
||||
...new Set([
|
||||
...ConfiguredOrigins.map((e) => e.trim()),
|
||||
"app://obsidian.md",
|
||||
"capacitor://localhost",
|
||||
"http://localhost",
|
||||
]),
|
||||
].join(",");
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errCorsOrigins"),
|
||||
$msg("obsidianLiveSyncSettingTab.msgSetCorsOrigins"),
|
||||
"cors/origins",
|
||||
fixedValue
|
||||
);
|
||||
}
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgConnectionCheck"), ["ob-btn-config-head"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgCurrentOrigin", { origin: window.location.origin }));
|
||||
|
||||
// Request header check
|
||||
const origins = ["app://obsidian.md", "capacitor://localhost", "http://localhost"];
|
||||
for (const org of origins) {
|
||||
const rr = await requestToCouchDBWithCredentials(
|
||||
editingSettings.couchDB_URI,
|
||||
credential,
|
||||
org,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseHeaders = Object.fromEntries(
|
||||
Object.entries(rr.headers).map((e) => {
|
||||
e[0] = `${e[0]}`.toLowerCase();
|
||||
return e;
|
||||
})
|
||||
);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin"));
|
||||
}
|
||||
if (responseHeaders["access-control-allow-origin"] != org) {
|
||||
addErrorMessage(
|
||||
$msg("obsidianLiveSyncSettingTab.warnCorsOriginUnmatched", {
|
||||
from: origin,
|
||||
to: responseHeaders["access-control-allow-origin"],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsOriginMatched"));
|
||||
}
|
||||
}
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"));
|
||||
} catch (ex: any) {
|
||||
if (ex?.status == 401) {
|
||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errAccessForbidden"));
|
||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCannotContinueTest"));
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"));
|
||||
} else {
|
||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigFailed"));
|
||||
Logger(ex);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -14,10 +14,17 @@ import {
|
||||
InjectableVaultService,
|
||||
} from "../../lib/src/services/InjectableServices.ts";
|
||||
import { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import { ConfigServiceBrowserCompat } from "../../lib/src/services/Services.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../../main.ts";
|
||||
import { ObsidianUIService } from "./ObsidianUIService.ts";
|
||||
// All Services will be migrated to be based on Plain Services, not Injectable Services.
|
||||
// This is a migration step.
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService {}
|
||||
export class ObsidianAPIService extends InjectableAPIService {
|
||||
getPlatform(): string {
|
||||
return "obsidian";
|
||||
}
|
||||
}
|
||||
export class ObsidianPathService extends InjectablePathService {}
|
||||
export class ObsidianDatabaseService extends InjectableDatabaseService {}
|
||||
|
||||
@@ -42,6 +49,8 @@ export class ObsidianVaultService extends InjectableVaultService {}
|
||||
// InjectableTestService
|
||||
export class ObsidianTestService extends InjectableTestService {}
|
||||
|
||||
export class ObsidianConfigService extends ConfigServiceBrowserCompat {}
|
||||
|
||||
// InjectableServiceHub
|
||||
|
||||
export class ObsidianServiceHub extends InjectableServiceHub {
|
||||
@@ -73,4 +82,14 @@ export class ObsidianServiceHub extends InjectableServiceHub {
|
||||
);
|
||||
protected _vault: ObsidianVaultService = new ObsidianVaultService(this._serviceBackend, this._throughHole);
|
||||
protected _test: ObsidianTestService = new ObsidianTestService(this._serviceBackend, this._throughHole);
|
||||
|
||||
private _plugin: ObsidianLiveSyncPlugin;
|
||||
constructor(plugin: ObsidianLiveSyncPlugin) {
|
||||
const config = new ObsidianConfigService();
|
||||
super({
|
||||
ui: new ObsidianUIService(plugin),
|
||||
config: config,
|
||||
});
|
||||
this._plugin = plugin;
|
||||
}
|
||||
}
|
||||
|
||||
40
src/modules/services/ObsidianUIService.ts
Normal file
40
src/modules/services/ObsidianUIService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { UIService } from "../../lib/src/services/Services";
|
||||
import type ObsidianLiveSyncPlugin from "../../main";
|
||||
import { SvelteDialogManager } from "../features/SetupWizard/ObsidianSvelteDialog";
|
||||
import DialogueToCopy from "../../lib/src/UI/dialogues/DialogueToCopy.svelte";
|
||||
|
||||
export class ObsidianUIService extends UIService {
|
||||
private _dialogManager: SvelteDialogManager;
|
||||
private _plugin: ObsidianLiveSyncPlugin;
|
||||
get dialogManager() {
|
||||
return this._dialogManager;
|
||||
}
|
||||
constructor(plugin: ObsidianLiveSyncPlugin) {
|
||||
super();
|
||||
this._dialogManager = new SvelteDialogManager(plugin);
|
||||
this._plugin = plugin;
|
||||
}
|
||||
async promptCopyToClipboard(title: string, value: string): Promise<boolean> {
|
||||
const param = {
|
||||
title: title,
|
||||
dataToCopy: value,
|
||||
};
|
||||
const result = await this._dialogManager.open(DialogueToCopy, param);
|
||||
if (result !== "ok") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
showMarkdownDialog<T extends string[]>(
|
||||
title: string,
|
||||
contentMD: string,
|
||||
buttons: T,
|
||||
defaultAction?: (typeof buttons)[number]
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
return this._plugin.confirm.askSelectStringDialogue(contentMD, buttons, {
|
||||
title,
|
||||
defaultAction: defaultAction ?? buttons[0],
|
||||
timeout: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user