v0.25.23.beta1

### Fixed (This should be backported to 0.25.22 if the beta phase is prolonged)

- No longer larger files will not create a chunks during preparing `Reset Synchronisation on This Device`.

### Behaviour changes

- Setup wizard is now more `goal-oriented`. Brand-new screens are introduced.
- `Fetch everything` and `Rebuild everything` is now `Reset Synchronisation on This Device` and `Overwrite Server Data with This Device's Files`.
- Remote configuration and E2EE settings are now separated to each modal dialogue.
- Peer-to-Peer settings is also separated into its own modal dialogue.
- Setup-URI, and Report for the Issue are now not copied to clipboard automatically. Instead, there are copy dialogue and buttons to copy them explicitly.
- No longer optional features are introduced during the setup or `Reset Synchronisation on This Device`, `Overwrite Server Data with This Device's Files`.
- We cannot preform `Fetch everything` and `Rebuild everything` (Removed, so the old name) without restarting Obsidian now.

### Miscellaneous

- Setup QR Code generation is separated into a src/lib/src/API/processSetting.ts file. Please use it as a subrepository if you want to generate QR codes in your own application.
- Setup-URI is also separated into a src/lib/src/API/processSetting.ts
- Some direct access to web-APIs are now wrapped into the services layer.

### Dependency updates

- Many dependencies are updated. Please see `package.json`.
- As upgrading TypeScript, Fixed many UInt8Array<ArrayBuffer> and Uint8Array type mismatches.
This commit is contained in:
vorotamoroz
2025-10-22 13:56:15 +01:00
parent 5a93066870
commit f5315aacb8
42 changed files with 6546 additions and 2261 deletions

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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}

View File

@@ -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}

View 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>

View File

@@ -0,0 +1,255 @@
<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";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
const context = getObsidianDialogContext();
let error = $state("");
let devicePeerId = $state("");
const TYPE_CANCELLED = "cancelled";
type SettingInfo = {
info: P2PConnectionInfo;
devicePeerId: string;
};
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)) {
devicePeerId = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) as string;
}
}
});
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: devicePeerId || "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({
info: pickP2PSyncSettings(setting),
devicePeerId: devicePeerId,
});
return;
}
} catch (e) {
error = `Error during connection test: ${e}`;
return;
}
}
function commit() {
const setting = pickP2PSyncSettings(generateSetting());
setResult({
info: setting,
devicePeerId: devicePeerId,
});
}
function cancel() {
setResult(TYPE_CANCELLED);
}
const canProceed = $derived.by(() => {
return (
syncSetting.P2P_relays.trim() !== "" &&
syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" &&
devicePeerId.trim() !== ""
);
});
</script>
<DialogHeader title="P2P Configuration" />
<Guidance>Please enter the Peer-to-Peer Synchronisation information below.</Guidance>
<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={devicePeerId}
/>
</InputRow>
<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}

View 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>

View 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;
};