mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-05 15:26:41 +03:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18bf31a01b | ||
|
|
33250c6961 | ||
|
|
a36fe6c36e | ||
|
|
92a5e6e145 | ||
|
|
be6da86a3a | ||
|
|
8bb2d5038d | ||
|
|
9d25a00c1d | ||
|
|
277337af40 | ||
|
|
da3e2c983f | ||
|
|
185ec2c392 | ||
|
|
b1d7f3b622 | ||
|
|
697ebe8272 | ||
|
|
c27dbbd572 | ||
|
|
83087f159e | ||
|
|
d12d0f8ef7 | ||
|
|
e9b2477bf3 | ||
|
|
9c59cd15d1 | ||
|
|
d672ff04c3 | ||
|
|
cd5f5aa740 | ||
|
|
34d8fe5d31 | ||
|
|
96a69c85bd | ||
|
|
d15df7367f | ||
|
|
de190e2b23 | ||
|
|
36b2ea7096 | ||
|
|
19f0b3d145 | ||
|
|
3ba9feaac7 | ||
|
|
42f37753b8 | ||
|
|
ec1fc4a97e | ||
|
|
146e25907d | ||
|
|
c97346e262 | ||
|
|
48c398948a | ||
|
|
e32e3f545e | ||
|
|
0aed7f8556 | ||
|
|
50a51bf07e | ||
|
|
1570136703 | ||
|
|
5b6bbc8118 | ||
|
|
54f0262b3f | ||
|
|
7d33613017 |
5
.github/workflows/finalise-release.yml
vendored
5
.github/workflows/finalise-release.yml
vendored
@@ -137,8 +137,9 @@ jobs:
|
||||
echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
|
||||
elif [[ "${PRERELEASE}" == "true" ]]; then
|
||||
echo "Publish the draft initially as a pre-release without replacing the latest stable release."
|
||||
echo "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
echo "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
|
||||
echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before its manifest reaches the default branch."
|
||||
echo "Confirm the release is no longer a pre-release, then merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
echo "This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release."
|
||||
echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate."
|
||||
else
|
||||
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
|
||||
|
||||
@@ -23,6 +23,7 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling de
|
||||
- No central data-storage server is required, but a signalling relay is still required for peer discovery.
|
||||
- At least one device containing the required data must be online while another device synchronises.
|
||||
- Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md).
|
||||
- If you would like to see how P2P setup and connectivity behave on your devices, try the guided connection check in the experimental [WebPeer browser utility](https://vrtmrz.github.io/obsidian-livesync/webpeer/). It uses an empty Vault and does not test note synchronisation. See the [WebPeer documentation](src/apps/webpeer/README.md) for its scope and limitations.
|
||||
|
||||
This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private.
|
||||
|
||||
|
||||
6
devs.md
6
devs.md
@@ -250,7 +250,7 @@ export class ModuleExample extends AbstractObsidianModule {
|
||||
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
|
||||
- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action.
|
||||
- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation.
|
||||
- After a staged stable version passes BRAT validation, merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. Promote the GitHub Release only after the default branch contains the exact stable metadata; publish stable CLI tags through a separate maintainer gate.
|
||||
- After a staged stable version passes BRAT validation, remove the GitHub pre-release designation and make the exact release the latest stable release first. Confirm that promotion, then merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release. Publish stable CLI tags through a separate maintainer gate.
|
||||
- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version.
|
||||
|
||||
## Release Notes
|
||||
@@ -276,7 +276,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request.
|
||||
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
|
||||
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
|
||||
- After a stable version passes, mark its release pull request ready and merge the exact release commit into the selected base branch with a merge commit. Integrate that exact commit through the reviewed branch chain into the repository's default branch. Only after the default branch contains the matching stable metadata, remove the GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- After a stable version passes, remove the GitHub pre-release designation and make that exact release the latest stable release. Confirm that the release is no longer a pre-release, then mark its release pull request ready, merge the exact release commit into the selected base branch with a merge commit, and integrate that exact commit through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
|
||||
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
|
||||
@@ -307,7 +307,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
8. Update the PR state message to describe the published pre-release and state that merging remains on hold.
|
||||
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
|
||||
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
|
||||
11. After a stable version passes, mark the release PR ready and merge the exact release commit into the selected base branch. Integrate that commit through the reviewed branch chain into the repository's default branch. Once the default branch contains the matching stable metadata, remove the pre-release designation, make the exact release the latest stable release, and publish stable CLI tags through a separate maintainer gate if selected.
|
||||
11. After a stable version passes, remove the pre-release designation and make the exact release the latest stable release. Confirm that promotion, then mark the release PR ready, merge the exact release commit into the selected base branch, and integrate it through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata, then publish stable CLI tags through a separate maintainer gate if selected.
|
||||
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
103
docs/adr/2026_07_p2p_connection_check.md
Normal file
103
docs/adr/2026_07_p2p_connection_check.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Architectural Decision Record: Browser-assisted P2P Connection Check
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — WebPeer provides a disposable, browser-observed connection check with a clean one-device path and an optional same-room second-device attempt.
|
||||
|
||||
## Context
|
||||
|
||||
P2P availability depends on the browser, device, signalling relay, NAT, firewall, and current network. A Setup URI can make a trial easier, but a connection observed between a browser and one device does not prove that two other devices can connect to each other, and a WebRTC connection does not prove that LiveSync documents can be synchronised correctly.
|
||||
|
||||
Showing this experiment inside the ordinary LiveSync interface would mix a temporary connectivity check with the state of a real Vault. It would also make a browser-owned diagnostic result appear to be a plug-in-owned synchronisation result.
|
||||
|
||||
The Commonlib diagnostic counters are cumulative within one JavaScript realm. A negotiation may create more than one `RTCPeerConnection`, a failed attempt may later be followed by a successful retry, and closing a successful connection is not a failure. The counters therefore cannot be interpreted as peer counts or as mutually exclusive outcomes.
|
||||
|
||||
## Decision
|
||||
|
||||
WebPeer provides a separate page labelled 'P2P connection check'. It acts as a temporary reference peer and creates a fresh, random P2P configuration locally in the browser. The page displays an encrypted Setup URI, a QR code containing that URI, and the separate Setup URI passphrase.
|
||||
|
||||
The initial implementation is part of the WebPeer production build and is served over HTTPS or from `localhost`. A downloaded copy of `check.html` alone is not a supported standalone application because the build uses separate module assets and origin-scoped browser storage.
|
||||
|
||||
Each check has these boundaries:
|
||||
|
||||
| Participant | Responsibility |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Browser page | Joins the generated room as the diagnostic reference peer and displays its own diagnostic counters. |
|
||||
| Desktop or mobile LiveSync | Opens the generated Setup URI in a dedicated empty Vault and attempts the ordinary P2P connection. |
|
||||
| User | Uses an empty Vault for every device and chooses either a fresh room or the explicitly less isolated same-room second-device attempt. |
|
||||
|
||||
The generated device configuration:
|
||||
|
||||
- uses a dedicated P2P application identifier so that test rooms do not overlap ordinary LiveSync rooms;
|
||||
- uses random room, P2P, Vault-encryption, and Setup URI secrets;
|
||||
- enables P2P auto-start so that a dedicated empty test Vault attempts the connection after setup;
|
||||
- does not enable automatic broadcasting, peer acceptance, watching, or replication;
|
||||
- omits device-local names from the Setup URI; and
|
||||
- enables diagnostic WebRTC wrapping only on the browser reference peer.
|
||||
|
||||
The browser joins the room only after the user explicitly starts monitoring. Preparing the Setup URI and QR code does not contact the signalling relay. The page must remain open while monitoring.
|
||||
|
||||
The primary path checks one target device. The user selects either desktop or mobile before generating the configuration. Starting a fresh check reloads the page, creates a new room, and resets the page-scoped diagnostic baseline; this remains the clearest way to compare devices.
|
||||
|
||||
After the first connection succeeds, the page can scroll back to the existing QR code without generating new credentials. It also offers an optional same-room second-device attempt. This keeps the browser and first device connected, records the current diagnostic totals and active `RTCPeerConnection` identifiers as a local baseline, and reuses the exact Setup URI for another empty Vault. It reports an additional connection only when both the successful-connection total and the number of simultaneous active connections increase, with an active connection identifier absent from the baseline. A reconnect which replaces the first connection can therefore increase negotiation counters without satisfying the additional-connection result.
|
||||
|
||||
The same-room result remains a convenience rather than an isolated comparison. Its totals are still cumulative, a connection is not a device identity, it depends on the first device remaining connected, and unusual reconnect timing can be harder to interpret than a fresh room. The interface keeps the fresh-check action available and explains this limitation beside the additional result.
|
||||
|
||||
The page displays the following Commonlib diagnostic totals:
|
||||
|
||||
- new connection states;
|
||||
- successful connection states;
|
||||
- failed connection states; and
|
||||
- closed connection states.
|
||||
|
||||
A successful total greater than zero proves that this browser and the target device established at least one WebRTC connection in the generated room. That successful result remains valid if a later closed or failed total increases. A failed total without a successful total describes a failed attempt, not a final verdict, because the transport may retry. If no success has been observed after the documented observation period, the result is inconclusive for P2P on the current network. The page recommends CouchDB for predictable synchronisation when repeated checks on the intended networks do not connect.
|
||||
|
||||
The page does not claim to verify document transfer, conflict handling, sustained connectivity, background execution, or a direct desktop-to-mobile path. An optional final test can use two disposable empty Vaults and a note round trip directly between the user's devices. That direct test is more representative, but its result cannot be observed reliably by the browser page without a separate reporting protocol.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
All credentials are generated with Web Crypto in the browser. The Setup URI is encrypted, its passphrase is shown separately, and neither value is uploaded merely by preparing the check. Their read-only fields disable browser autocomplete and spell-check; copying either value to the system clipboard remains an explicit user action. Starting monitoring uses the configured signalling relay, which is the existing public relay by default, and exposes the usual P2P signalling metadata to that service. WebRTC and optional relay or TURN behaviour retain their existing Commonlib security and privacy properties.
|
||||
|
||||
The production page does not accept a relay override from a deployed origin. A `relay` query parameter is honoured only when the page itself is served from a loopback host, and only for `ws:` or `wss:` URLs. This narrow boundary allows the real-Obsidian E2E scenario to use the local Compose relay without making an arbitrary relay query part of the hosted user workflow.
|
||||
|
||||
The generated configuration is disposable. It must be used only in a dedicated empty Vault, must not replace a production Vault's settings, and must not be retained as the credentials for a later production setup. The browser page stores no ordinary Vault files for this workflow, although browser-local database infrastructure is opened to satisfy the existing WebPeer runtime contract.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Display the result in the LiveSync plug-in
|
||||
|
||||
The observation belongs to the browser reference peer and does not prove LiveSync document synchronisation. Keeping the result beside the QR code makes the evidence and its owner explicit.
|
||||
|
||||
### Use browser, desktop, and mobile simultaneously as the primary comparison
|
||||
|
||||
The diagnostic totals are cumulative and do not attribute an `RTCPeerConnection` to a named target device. Separate random rooms provide a clearer result. Same-room reuse is retained only as an explicit convenience, with a counter baseline and another simultaneous active connection required for its result.
|
||||
|
||||
### Treat any failure increment as a final failure
|
||||
|
||||
Negotiation can fail and then retry successfully. A later successful increment takes precedence over earlier failure increments.
|
||||
|
||||
### Treat browser success as proof of device-to-device synchronisation
|
||||
|
||||
Browser-to-device success is a useful preflight check, but it does not exercise the exact desktop-to-mobile network path or a LiveSync note round trip.
|
||||
|
||||
### Put the Setup URI passphrase in the QR code
|
||||
|
||||
Keeping the encrypted URI and passphrase separate preserves the existing Setup URI boundary and avoids turning one captured QR code into the complete configuration secret.
|
||||
|
||||
### Distribute one standalone downloaded HTML file
|
||||
|
||||
WebPeer already depends on a compiled module graph, origin-scoped storage, and secure browser capabilities. A hosted production build reuses those boundaries and can be updated consistently; a single self-contained file would require a separate bundling and trust model.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests decode each generated Setup URI and verify its isolation identifier, random credentials, remote-profile selection, automatic-start policy, absence of a device-local name, and browser-only diagnostic setting. Pure outcome tests cover waiting, failed attempts, later success, and closure after success.
|
||||
|
||||
The WebPeer browser smoke test verifies that the production page can prepare a desktop or mobile check, render the QR code, display a separately formatted passphrase, expose zeroed counters without contacting the signalling relay, and return to the unchanged QR code. Pure tests verify that a reconnect which replaces the first connection does not satisfy the same-room additional-connection rule. The focused real-Obsidian E2E test serves the production page from loopback, starts the local Compose Nostr relay, applies the browser-generated Setup URI through visible onboarding in one empty Vault, records the same-room baseline, reuses the unchanged URI in a second empty Vault, and requires the browser to observe another simultaneous active connection before retaining a result screenshot. Existing Commonlib tests remain authoritative for diagnostic WebRTC wrapping, Trystero negotiation, and P2P lifecycle behaviour.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users can assess whether P2P is plausible on each intended device and network before relying on it.
|
||||
- Results remain visibly scoped to connectivity rather than synchronisation correctness.
|
||||
- A fresh test requires a fresh page session and disposable empty Vault; same-room reuse requires another empty Vault and retains cumulative counters.
|
||||
- CouchDB remains the recommended predictable option when representative P2P checks repeatedly fail.
|
||||
- Direct device-to-device note transfer remains a separate, optional verification step.
|
||||
@@ -528,7 +528,19 @@ Sync automatically after merging files
|
||||
Setting key: keepReplicationActiveInBackground
|
||||
Desktop only; uses more battery and network. This setting applies to continuous and periodic replication.
|
||||
|
||||
Finite remote operations, including one-shot replication, P2P peer discovery and selection, rebuilds, fetches, and remote chunk fetching, request best-effort screen-awake protection automatically and do not require this setting. That protection does not guarantee execution while Obsidian is hidden or while the operating system suspends the device.
|
||||
#### Allow sleep during synchronisation
|
||||
|
||||
Setting key: allowSleepDuringSynchronisation
|
||||
|
||||
Allow the operating system to sleep while finite synchronisation operations are in progress. This option applies on every platform and is disabled by default. When it is disabled, finite operations request best-effort screen-awake protection.
|
||||
|
||||
#### Allow sleep during synchronisation on the desktop
|
||||
|
||||
Setting key: allowSleepDuringSynchronisationOnDesktop
|
||||
|
||||
Desktop only. Allow the operating system to sleep during finite synchronisation operations even when the general option is disabled. This option is enabled by default, so periodic or event-driven synchronisation does not repeatedly prevent automatic desktop sleep. Disable it to retain best-effort screen-awake protection on desktop.
|
||||
|
||||
Setup URIs preserve both sleep preferences. Older Setup URIs which do not contain them use the defaults described above. The preferences cover finite remote operations, including one-shot replication, P2P peer discovery and selection, rebuilds, fetches, remote chunk fetching, and applying downloaded documents to the Vault. They do not control whether continuous replication remains active while Obsidian is hidden, and allowing sleep does not force the operating system to suspend the device.
|
||||
|
||||
### 3. Update thinning
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
96
package-lock.json
generated
96
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.4",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -1887,9 +1887,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1968,9 +1968,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4775,9 +4775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
|
||||
"integrity": "sha512-rdzEubzLStioanE67pps2XSGZ2UGyMIyeEoKsdPztQLLW8wM05PWApOPbJujIydHcDr2hFanbDFe89Lk7Mn4XQ==",
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.4.tgz",
|
||||
"integrity": "sha512-qQst1QCZEgfxwpdjnjFmXE3JNy4ATKhN8ihJSUUho4mdWC1O4zyRRhmQjg0GHu5hQPZokufr2mrP4Hur8niDNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -4896,9 +4896,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@wdio/config/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5264,9 +5264,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5734,9 +5734,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5986,9 +5986,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -7673,9 +7673,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7770,9 +7770,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7836,9 +7836,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8013,9 +8013,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8215,9 +8215,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9052,9 +9052,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/globby/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12668,9 +12668,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.2-cli",
|
||||
"version": "1.0.5-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.2-webapp",
|
||||
"version": "1.0.5-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.2-webpeer",
|
||||
"version": "1.0.5-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -69,6 +69,9 @@
|
||||
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
|
||||
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
|
||||
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
|
||||
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
|
||||
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
|
||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||
@@ -174,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.4",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -67,6 +67,10 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `--vault <path>` / `-V <path>`: (daemon/mirror only) Path to the vault directory containing `.md` files.
|
||||
- Allows the PouchDB database directory and the actual vault directory to be different locations.
|
||||
- For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`.
|
||||
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
|
||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||
- All remaining commands leave the settings file unchanged by default.
|
||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -333,6 +337,7 @@ Options:
|
||||
--debug, -d Enable debug logging (includes verbose)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
|
||||
--write-settings Write setting changes after a successful command
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface CLIOptions {
|
||||
verbose?: boolean;
|
||||
debug?: boolean;
|
||||
force?: boolean;
|
||||
writeSettings?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
|
||||
55
src/apps/cli/main.log-handler.unit.spec.ts
Normal file
55
src/apps/cli/main.log-handler.unit.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setLogHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./services/NodeServiceHub", () => ({
|
||||
NodeServiceContext: class {},
|
||||
NodeServiceHub: class {
|
||||
API = {
|
||||
addLog: {
|
||||
setHandler: mocks.setLogHandler,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
import { main } from "./main";
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
readStdin: vi.fn(async () => ""),
|
||||
prompt: vi.fn(async () => ""),
|
||||
writeStdout: vi.fn(),
|
||||
writeStderr: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("CLI log handler", () => {
|
||||
const originalArgv = process.argv.slice();
|
||||
let databasePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
databasePath = await mkdtemp(join(tmpdir(), "livesync-cli-log-handler-"));
|
||||
mocks.setLogHandler.mockReset();
|
||||
mocks.setLogHandler.mockImplementation(() => {
|
||||
throw new Error("__LOG_HANDLER_CONFIGURED__");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.argv = originalArgv.slice();
|
||||
await rm(databasePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("replaces the default Headless API log handler", async () => {
|
||||
process.argv = ["node", "livesync-cli", databasePath, "remote-ls"];
|
||||
|
||||
await expect(main(createStandardIoMock())).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
|
||||
expect(mocks.setLogHandler).toHaveBeenCalledWith(expect.any(Function), true);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,20 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
import { createDefaultCliSettings } from "./cliSettingsDefaults";
|
||||
import {
|
||||
applyStoredSetting,
|
||||
changedSettingKeys,
|
||||
CLI_RUNTIME_ONLY_SETTING_KEYS,
|
||||
cloneSettings,
|
||||
isSettingsWriteCommand,
|
||||
preserveStoredSetting,
|
||||
reconcileDurableSettings,
|
||||
settingsEqual,
|
||||
} from "./settingsPersistence";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -92,6 +102,7 @@ Options:
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to the vault directory containing .md files
|
||||
(defaults to database-path; allows separate PouchDB and vault dirs)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--write-settings Write setting changes after a successful command
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
@@ -141,6 +152,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
let verbose = false;
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let writeSettings = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
@@ -197,6 +209,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
case "-f":
|
||||
force = true;
|
||||
break;
|
||||
case "--write-settings":
|
||||
writeSettings = true;
|
||||
break;
|
||||
default: {
|
||||
if (!databasePath) {
|
||||
if (command === "daemon" && isCLICommand(token)) {
|
||||
@@ -237,6 +252,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
verbose,
|
||||
debug,
|
||||
force,
|
||||
writeSettings,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
@@ -399,7 +415,7 @@ export async function main(
|
||||
if (!options.verbose) return;
|
||||
}
|
||||
writeStderrLine(standardIo, prefix, message);
|
||||
});
|
||||
}, true);
|
||||
// Prevent replication result from being processed automatically in non-daemon commands.
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
@@ -411,16 +427,26 @@ export async function main(
|
||||
|
||||
// Setup settings handlers
|
||||
const settingService = serviceHubInstance.setting;
|
||||
const originalSettingsText = await fs.readFile(settingsPath, "utf-8").catch(() => undefined);
|
||||
let latestPreparedSettingsText: string | undefined;
|
||||
let preparedSettingsRevision = 0;
|
||||
let commandIsRunning = false;
|
||||
let commandPreparedSettingsTexts: string[] = [];
|
||||
|
||||
(settingService as InjectableSettingService<NodeServiceContext>).saveData.setHandler(
|
||||
async (data: ObsidianLiveSyncSettings) => {
|
||||
try {
|
||||
await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
latestPreparedSettingsText = JSON.stringify(data, null, 2);
|
||||
preparedSettingsRevision++;
|
||||
if (commandIsRunning) {
|
||||
commandPreparedSettingsTexts.push(latestPreparedSettingsText);
|
||||
}
|
||||
if (options.verbose) {
|
||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||
writeStderrLine(standardIo, `[Settings] Prepared an update for ${settingsPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, `[Settings] Failed to save:`, error);
|
||||
writeStderrLine(standardIo, `[Settings] Failed to prepare an update:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -503,24 +529,21 @@ export async function main(
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||
|
||||
// Restore settings file on any exit to undo lifecycle mutations.
|
||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||
process.on("exit", () => {
|
||||
if (settingsBackup) {
|
||||
const tmpPath = settingsPath + ".tmp";
|
||||
try {
|
||||
fsSync.writeFileSync(tmpPath, settingsBackup, "utf-8");
|
||||
fsSync.renameSync(tmpPath, settingsPath);
|
||||
} catch (err) {
|
||||
writeStderrLine(standardIo, "[Settings] Failed to restore settings on exit:", err);
|
||||
const writeSettingsAtomically = async (content: string | undefined): Promise<void> => {
|
||||
if (content === undefined || content === originalSettingsText) return;
|
||||
const temporaryPath = `${settingsPath}.${process.pid}.tmp`;
|
||||
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, content, "utf-8");
|
||||
await fs.rename(temporaryPath, settingsPath);
|
||||
if (options.verbose) {
|
||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.unlink(temporaryPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start the core
|
||||
try {
|
||||
@@ -531,9 +554,15 @@ export async function main(
|
||||
writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
|
||||
process.exit(1);
|
||||
}
|
||||
const settingsAfterLoadText = latestPreparedSettingsText
|
||||
? preserveStoredSetting(latestPreparedSettingsText, originalSettingsText, "useIndexedDBAdapter")
|
||||
: originalSettingsText;
|
||||
|
||||
// Capture sync settings before suspendAllSync() clobbers them.
|
||||
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
||||
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
||||
const settingsBeforeSuspend = cloneSettings(core.services.setting.currentSettings());
|
||||
const durableSettingsBeforeSuspend = cloneSettings(settingsBeforeSuspend);
|
||||
applyStoredSetting(durableSettingsBeforeSuspend, settingsAfterLoadText, "useIndexedDBAdapter");
|
||||
const originalSyncSettings = {
|
||||
liveSync: settingsBeforeSuspend.liveSync,
|
||||
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
||||
@@ -544,7 +573,19 @@ export async function main(
|
||||
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
||||
};
|
||||
await core.services.setting.suspendAllSync();
|
||||
const settingsAfterSuspend = cloneSettings(core.services.setting.currentSettings());
|
||||
await core.services.control.onReady();
|
||||
const settingsBeforeCommand = cloneSettings(core.services.setting.currentSettings());
|
||||
const transientSettingKeys = changedSettingKeys(settingsBeforeSuspend, settingsAfterSuspend);
|
||||
for (const key of CLI_RUNTIME_ONLY_SETTING_KEYS) {
|
||||
transientSettingKeys.add(key);
|
||||
}
|
||||
const durableSettingsBeforeCommand = reconcileDurableSettings({
|
||||
durableBase: durableSettingsBeforeSuspend,
|
||||
runtimeBaseline: settingsAfterSuspend,
|
||||
runtimeCurrent: settingsBeforeCommand,
|
||||
preserveKeys: transientSettingKeys,
|
||||
});
|
||||
|
||||
infoLog(`[Ready] LiveSync is running`);
|
||||
infoLog(`[Ready] Press Ctrl+C to stop`);
|
||||
@@ -568,14 +609,58 @@ export async function main(
|
||||
infoLog("");
|
||||
}
|
||||
|
||||
const result = await commandRunner(options, {
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
});
|
||||
commandPreparedSettingsTexts = [];
|
||||
let result: boolean;
|
||||
try {
|
||||
commandIsRunning = true;
|
||||
result = await commandRunner(options, {
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
});
|
||||
} finally {
|
||||
commandIsRunning = false;
|
||||
}
|
||||
|
||||
let settingsTextToCommit: string | undefined;
|
||||
if (result && options.command === "setup") {
|
||||
settingsTextToCommit = commandPreparedSettingsTexts[0];
|
||||
if (settingsTextToCommit === undefined) {
|
||||
throw new Error("The setup command completed without preparing its settings update.");
|
||||
}
|
||||
} else if (result && (isSettingsWriteCommand(options.command) || options.writeSettings)) {
|
||||
const runtimeSettingsAfterCommand = cloneSettings(core.services.setting.currentSettings());
|
||||
const durableSettingsAfterCommand = reconcileDurableSettings({
|
||||
durableBase: durableSettingsBeforeCommand,
|
||||
runtimeBaseline: settingsBeforeCommand,
|
||||
runtimeCurrent: runtimeSettingsAfterCommand,
|
||||
preserveKeys: transientSettingKeys,
|
||||
command: options.command,
|
||||
});
|
||||
|
||||
if (
|
||||
isSettingsWriteCommand(options.command) ||
|
||||
!settingsEqual(durableSettingsAfterCommand, durableSettingsBeforeSuspend)
|
||||
) {
|
||||
const runtimeSettings = cloneSettings(core.services.setting.currentSettings());
|
||||
const revisionBeforeSave = preparedSettingsRevision;
|
||||
try {
|
||||
await core.services.setting.updateSettings(() => cloneSettings(durableSettingsAfterCommand), true);
|
||||
if (preparedSettingsRevision === revisionBeforeSave || latestPreparedSettingsText === undefined) {
|
||||
throw new Error("The setting service did not prepare the requested settings update.");
|
||||
}
|
||||
settingsTextToCommit = latestPreparedSettingsText;
|
||||
} finally {
|
||||
await core.services.setting.updateSettings(() => runtimeSettings, false);
|
||||
}
|
||||
} else {
|
||||
settingsTextToCommit = settingsAfterLoadText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
||||
process.exitCode = 1;
|
||||
@@ -584,10 +669,12 @@ export async function main(
|
||||
}
|
||||
|
||||
if (options.command === "daemon" && result) {
|
||||
await writeSettingsAtomically(settingsTextToCommit);
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
await core.services.control.onUnload();
|
||||
await writeSettingsAtomically(settingsTextToCommit);
|
||||
}
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, `[Error] Failed to start:`, error);
|
||||
|
||||
@@ -206,4 +206,13 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.interval).toBe(30);
|
||||
});
|
||||
|
||||
it("parses --write-settings as a global option", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--write-settings", "ls"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.command).toBe("ls");
|
||||
expect(parsed.writeSettings).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.2-cli",
|
||||
"version": "1.0.5-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
|
||||
173
src/apps/cli/settingsPersistence.ts
Normal file
173
src/apps/cli/settingsPersistence.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { CLICommand } from "./commands/types";
|
||||
|
||||
const SETTINGS_WRITE_COMMANDS = new Set<CLICommand>([
|
||||
"setup",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
]);
|
||||
|
||||
const REMOTE_SETTINGS_WRITE_COMMANDS = new Set<CLICommand>([
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
]);
|
||||
|
||||
export const CLI_RUNTIME_ONLY_SETTING_KEYS = new Set<keyof ObsidianLiveSyncSettings>([
|
||||
"disableCheckingConfigMismatch",
|
||||
"suspendFileWatching",
|
||||
"suspendParseReplicationResult",
|
||||
]);
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (value === undefined) return value;
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function cloneSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
return cloneJsonValue(settings);
|
||||
}
|
||||
|
||||
function settingValuesEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function settingsEqual(left: ObsidianLiveSyncSettings, right: ObsidianLiveSyncSettings): boolean {
|
||||
return settingValuesEqual(left, right);
|
||||
}
|
||||
|
||||
function settingsKeys(...settings: ObsidianLiveSyncSettings[]): Set<keyof ObsidianLiveSyncSettings> {
|
||||
return new Set(settings.flatMap((value) => Object.keys(value) as Array<keyof ObsidianLiveSyncSettings>));
|
||||
}
|
||||
|
||||
function copySetting(
|
||||
target: ObsidianLiveSyncSettings,
|
||||
source: ObsidianLiveSyncSettings,
|
||||
key: keyof ObsidianLiveSyncSettings
|
||||
): void {
|
||||
const targetRecord = target as unknown as Record<string, unknown>;
|
||||
const sourceRecord = source as unknown as Record<string, unknown>;
|
||||
if (Object.prototype.hasOwnProperty.call(sourceRecord, key)) {
|
||||
targetRecord[key] = cloneJsonValue(sourceRecord[key]);
|
||||
} else {
|
||||
delete targetRecord[key];
|
||||
}
|
||||
}
|
||||
|
||||
function remoteSettingKeys(...settings: ObsidianLiveSyncSettings[]): Set<keyof ObsidianLiveSyncSettings> {
|
||||
const keys = new Set<keyof ObsidianLiveSyncSettings>([
|
||||
"remoteConfigurations",
|
||||
"activeConfigurationId",
|
||||
"P2P_ActiveRemoteConfigurationId",
|
||||
"remoteType",
|
||||
]);
|
||||
for (const current of settings) {
|
||||
for (const configuration of Object.values(current.remoteConfigurations ?? {})) {
|
||||
try {
|
||||
const parsed = ConnectionStringParser.parse(configuration.uri);
|
||||
for (const key of Object.keys(parsed.settings) as Array<keyof ObsidianLiveSyncSettings>) {
|
||||
keys.add(key);
|
||||
}
|
||||
} catch {
|
||||
// The setting service reports invalid remote configurations when loading them.
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function changedSettingKeys(
|
||||
before: ObsidianLiveSyncSettings,
|
||||
after: ObsidianLiveSyncSettings
|
||||
): Set<keyof ObsidianLiveSyncSettings> {
|
||||
const changed = new Set<keyof ObsidianLiveSyncSettings>();
|
||||
for (const key of settingsKeys(before, after)) {
|
||||
if (!settingValuesEqual(before[key], after[key])) {
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function isSettingsWriteCommand(command: CLICommand): boolean {
|
||||
return SETTINGS_WRITE_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
export function reconcileDurableSettings(options: {
|
||||
durableBase: ObsidianLiveSyncSettings;
|
||||
runtimeBaseline: ObsidianLiveSyncSettings;
|
||||
runtimeCurrent: ObsidianLiveSyncSettings;
|
||||
preserveKeys: ReadonlySet<keyof ObsidianLiveSyncSettings>;
|
||||
command?: CLICommand;
|
||||
}): ObsidianLiveSyncSettings {
|
||||
const durable = cloneSettings(options.durableBase);
|
||||
for (const key of settingsKeys(options.runtimeBaseline, options.runtimeCurrent)) {
|
||||
if (options.preserveKeys.has(key)) continue;
|
||||
if (!settingValuesEqual(options.runtimeBaseline[key], options.runtimeCurrent[key])) {
|
||||
copySetting(durable, options.runtimeCurrent, key);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.command) {
|
||||
if (REMOTE_SETTINGS_WRITE_COMMANDS.has(options.command)) {
|
||||
copySetting(durable, options.runtimeCurrent, "remoteConfigurations");
|
||||
copySetting(durable, options.runtimeCurrent, "activeConfigurationId");
|
||||
copySetting(durable, options.runtimeCurrent, "P2P_ActiveRemoteConfigurationId");
|
||||
|
||||
if (durable.activeConfigurationId) {
|
||||
activateRemoteConfiguration(durable, durable.activeConfigurationId);
|
||||
}
|
||||
} else {
|
||||
// Commands such as remote-status may activate a profile temporarily. Only
|
||||
// the dedicated remote settings commands are allowed to retain that switch.
|
||||
for (const key of remoteSettingKeys(options.durableBase, options.runtimeBaseline, options.runtimeCurrent)) {
|
||||
copySetting(durable, options.durableBase, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return durable;
|
||||
}
|
||||
|
||||
export function preserveStoredSetting(
|
||||
candidateText: string,
|
||||
originalText: string | undefined,
|
||||
key: keyof ObsidianLiveSyncSettings
|
||||
): string {
|
||||
if (originalText === undefined) return candidateText;
|
||||
try {
|
||||
const candidate = JSON.parse(candidateText) as ObsidianLiveSyncSettings;
|
||||
const original = JSON.parse(originalText) as ObsidianLiveSyncSettings;
|
||||
if (Object.prototype.hasOwnProperty.call(original, key)) {
|
||||
copySetting(candidate, original, key);
|
||||
} else {
|
||||
delete (candidate as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
return JSON.stringify(candidate, null, 2);
|
||||
} catch {
|
||||
return candidateText;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStoredSetting(
|
||||
target: ObsidianLiveSyncSettings,
|
||||
storedText: string | undefined,
|
||||
key: keyof ObsidianLiveSyncSettings
|
||||
): void {
|
||||
if (storedText === undefined) return;
|
||||
try {
|
||||
const stored = JSON.parse(storedText) as ObsidianLiveSyncSettings;
|
||||
if (Object.prototype.hasOwnProperty.call(stored, key)) {
|
||||
copySetting(target, stored, key);
|
||||
} else {
|
||||
delete (target as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
} catch {
|
||||
// The setting service owns validation and recovery of malformed files.
|
||||
}
|
||||
}
|
||||
162
src/apps/cli/settingsPersistence.unit.spec.ts
Normal file
162
src/apps/cli/settingsPersistence.unit.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import {
|
||||
applyStoredSetting,
|
||||
changedSettingKeys,
|
||||
cloneSettings,
|
||||
isSettingsWriteCommand,
|
||||
preserveStoredSetting,
|
||||
reconcileDurableSettings,
|
||||
} from "./settingsPersistence";
|
||||
|
||||
function settings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(cloneSettings(DEFAULT_SETTINGS), overrides);
|
||||
}
|
||||
|
||||
describe("CLI settings persistence", () => {
|
||||
it("identifies commands which change the settings file automatically", () => {
|
||||
expect(isSettingsWriteCommand("setup")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-add")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-rm")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-set")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-activate")).toBe(true);
|
||||
expect(isSettingsWriteCommand("ls")).toBe(false);
|
||||
expect(isSettingsWriteCommand("remote-status")).toBe(false);
|
||||
});
|
||||
|
||||
it("retains lasting changes without retaining CLI suspension values", () => {
|
||||
const durableBase = settings({
|
||||
liveSync: true,
|
||||
syncOnStart: true,
|
||||
periodicReplication: true,
|
||||
P2P_AutoStart: true,
|
||||
settingVersion: 9,
|
||||
customChunkSize: 40,
|
||||
});
|
||||
const runtimeBaseline = settings({
|
||||
...durableBase,
|
||||
liveSync: false,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
P2P_AutoStart: false,
|
||||
});
|
||||
const runtimeCurrent = settings({
|
||||
...runtimeBaseline,
|
||||
settingVersion: 10,
|
||||
customChunkSize: 60,
|
||||
});
|
||||
|
||||
const reconciled = reconcileDurableSettings({
|
||||
durableBase,
|
||||
runtimeBaseline,
|
||||
runtimeCurrent,
|
||||
preserveKeys: changedSettingKeys(durableBase, runtimeBaseline),
|
||||
command: "ls",
|
||||
});
|
||||
|
||||
expect(reconciled.liveSync).toBe(true);
|
||||
expect(reconciled.syncOnStart).toBe(true);
|
||||
expect(reconciled.periodicReplication).toBe(true);
|
||||
expect(reconciled.P2P_AutoStart).toBe(true);
|
||||
expect(reconciled.settingVersion).toBe(10);
|
||||
expect(reconciled.customChunkSize).toBe(60);
|
||||
});
|
||||
|
||||
it("retains a remote profile change and restores the durable sync values", () => {
|
||||
const durableBase = settings({
|
||||
liveSync: true,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
});
|
||||
const runtimeBaseline = settings({ ...durableBase, liveSync: false });
|
||||
const runtimeCurrent = settings({
|
||||
...runtimeBaseline,
|
||||
remoteConfigurations: {
|
||||
main: {
|
||||
id: "main",
|
||||
name: "Main",
|
||||
uri: "sls+https://user:pass@example.com/?db=notes",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "main",
|
||||
});
|
||||
activateRemoteConfiguration(runtimeCurrent, "main");
|
||||
|
||||
const reconciled = reconcileDurableSettings({
|
||||
durableBase,
|
||||
runtimeBaseline,
|
||||
runtimeCurrent,
|
||||
preserveKeys: changedSettingKeys(durableBase, runtimeBaseline),
|
||||
command: "remote-add",
|
||||
});
|
||||
|
||||
expect(reconciled.liveSync).toBe(true);
|
||||
expect(reconciled.activeConfigurationId).toBe("main");
|
||||
expect(reconciled.remoteConfigurations.main?.name).toBe("Main");
|
||||
expect(reconciled.couchDB_URI).toBe("https://example.com");
|
||||
expect(reconciled.couchDB_DBNAME).toBe("notes");
|
||||
});
|
||||
|
||||
it("does not retain a remote profile selected temporarily by an operational command", () => {
|
||||
const durableBase = settings({
|
||||
remoteConfigurations: {
|
||||
first: {
|
||||
id: "first",
|
||||
name: "First",
|
||||
uri: "sls+https://first:pass@example.com/?db=first",
|
||||
isEncrypted: false,
|
||||
},
|
||||
second: {
|
||||
id: "second",
|
||||
name: "Second",
|
||||
uri: "sls+https://second:pass@example.net/?db=second",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "first",
|
||||
});
|
||||
activateRemoteConfiguration(durableBase, "first");
|
||||
const runtimeBaseline = cloneSettings(durableBase);
|
||||
const runtimeCurrent = cloneSettings(durableBase);
|
||||
activateRemoteConfiguration(runtimeCurrent, "second");
|
||||
|
||||
const reconciled = reconcileDurableSettings({
|
||||
durableBase,
|
||||
runtimeBaseline,
|
||||
runtimeCurrent,
|
||||
preserveKeys: new Set(),
|
||||
command: "remote-status",
|
||||
});
|
||||
|
||||
expect(reconciled.activeConfigurationId).toBe("first");
|
||||
expect(reconciled.couchDB_URI).toBe("https://example.com");
|
||||
expect(reconciled.couchDB_USER).toBe("first");
|
||||
expect(reconciled.couchDB_DBNAME).toBe("first");
|
||||
});
|
||||
|
||||
it("preserves the stored adapter choice while the CLI uses its Node.js adapter", () => {
|
||||
const original = JSON.stringify({ useIndexedDBAdapter: true, settingVersion: 9 });
|
||||
const prepared = JSON.stringify({ useIndexedDBAdapter: false, settingVersion: 10 });
|
||||
const preserved = preserveStoredSetting(prepared, original, "useIndexedDBAdapter");
|
||||
const target = settings({ useIndexedDBAdapter: false });
|
||||
|
||||
applyStoredSetting(target, preserved, "useIndexedDBAdapter");
|
||||
|
||||
expect(JSON.parse(preserved).useIndexedDBAdapter).toBe(true);
|
||||
expect(target.useIndexedDBAdapter).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add the CLI adapter override to an older settings file", () => {
|
||||
const original = JSON.stringify({ settingVersion: 9 });
|
||||
const prepared = JSON.stringify({ useIndexedDBAdapter: false, settingVersion: 10 });
|
||||
const preserved = preserveStoredSetting(prepared, original, "useIndexedDBAdapter");
|
||||
const target = settings({ useIndexedDBAdapter: false });
|
||||
|
||||
applyStoredSetting(target, preserved, "useIndexedDBAdapter");
|
||||
|
||||
expect(JSON.parse(preserved)).not.toHaveProperty("useIndexedDBAdapter");
|
||||
expect(target).not.toHaveProperty("useIndexedDBAdapter");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts",
|
||||
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
|
||||
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
|
||||
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
|
||||
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
134
src/apps/cli/testdeno/test-settings-writeback.ts
Normal file
134
src/apps/cli/testdeno/test-settings-writeback.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareSettingsFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await Deno.mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
Deno.test("settings-changing commands persist durable settings without CLI runtime suspension", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-command");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
settings.liveSync = true;
|
||||
settings.syncOnStart = true;
|
||||
settings.periodicReplication = true;
|
||||
settings.P2P_Enabled = true;
|
||||
settings.P2P_AutoStart = true;
|
||||
settings.P2P_AutoBroadcast = true;
|
||||
await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2));
|
||||
|
||||
const result = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-add",
|
||||
"test-remote",
|
||||
"sls+https://user:pass@example.com/database"
|
||||
);
|
||||
assertEquals(result.code, 0, result.combined);
|
||||
const firstRemoteId = result.stdout.trim().split("\t")[0];
|
||||
assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`);
|
||||
|
||||
let persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
let remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>;
|
||||
assert(
|
||||
remotes.some((remote) => remote.name === "test-remote"),
|
||||
"remote-add did not persist the new profile"
|
||||
);
|
||||
assertEquals(persisted.liveSync, true);
|
||||
assertEquals(persisted.syncOnStart, true);
|
||||
assertEquals(persisted.periodicReplication, true);
|
||||
assertEquals(persisted.P2P_Enabled, true);
|
||||
assertEquals(persisted.P2P_AutoStart, true);
|
||||
assertEquals(persisted.P2P_AutoBroadcast, true);
|
||||
|
||||
const secondAdd = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-add",
|
||||
"second-remote",
|
||||
"sls+https://other:secret@example.net/second"
|
||||
);
|
||||
assertEquals(secondAdd.code, 0, secondAdd.combined);
|
||||
const secondRemoteId = secondAdd.stdout.trim().split("\t")[0];
|
||||
assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`);
|
||||
|
||||
const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId);
|
||||
assertEquals(activate.code, 0, activate.combined);
|
||||
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
assertEquals(persisted.activeConfigurationId, secondRemoteId);
|
||||
|
||||
const set = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-set",
|
||||
secondRemoteId,
|
||||
"sls+https://replacement:secret@example.org/replaced"
|
||||
);
|
||||
assertEquals(set.code, 0, set.combined);
|
||||
const exported = await runCli(databaseDir, "--settings", settingsFile, "remote-export", secondRemoteId);
|
||||
assertEquals(exported.code, 0, exported.combined);
|
||||
assert(exported.stdout.includes("replacement"), "remote-set did not persist the replacement URI");
|
||||
|
||||
const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId);
|
||||
assertEquals(remove.code, 0, remove.combined);
|
||||
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>;
|
||||
assert(!remotes.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal");
|
||||
assertEquals(persisted.activeConfigurationId, firstRemoteId);
|
||||
});
|
||||
|
||||
Deno.test("ordinary commands keep the settings file unchanged by default", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-readonly");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
settings.settingVersion = 9;
|
||||
const original = JSON.stringify(settings, null, 2);
|
||||
await Deno.writeTextFile(settingsFile, original);
|
||||
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "ls");
|
||||
assertEquals(result.code, 0, result.combined);
|
||||
assertEquals(await Deno.readTextFile(settingsFile), original);
|
||||
});
|
||||
|
||||
Deno.test("--write-settings persists durable start-up setting changes", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-explicit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
settings.settingVersion = 9;
|
||||
delete settings.useIndexedDBAdapter;
|
||||
await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2));
|
||||
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "--write-settings", "ls");
|
||||
assertEquals(result.code, 0, result.combined);
|
||||
|
||||
const persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
assertEquals(persisted.settingVersion, 10);
|
||||
assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file");
|
||||
});
|
||||
|
||||
Deno.test("failed settings-changing commands leave the settings file unchanged", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-failure");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const original = await Deno.readTextFile(settingsFile);
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote");
|
||||
assert(result.code !== 0, "remote-rm unexpectedly succeeded");
|
||||
assertEquals(await Deno.readTextFile(settingsFile), original);
|
||||
});
|
||||
@@ -41,6 +41,13 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev
|
||||
setupResult.combined.includes("[Command] setup ->"),
|
||||
`setup command did not execute expected code path\n${setupResult.combined}`
|
||||
);
|
||||
const persistedSetup = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
assertEquals(persistedSetup.isConfigured, true, "setup did not persist the configured state");
|
||||
assert(
|
||||
typeof persistedSetup.encryptedCouchDBConnection === "string" &&
|
||||
persistedSetup.encryptedCouchDBConnection.length > 0,
|
||||
"setup did not persist the encrypted connection settings"
|
||||
);
|
||||
|
||||
const run = (...args: string[]) => runCliOrFail(vaultDir, "--settings", settingsFile, ...args);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.2-webapp",
|
||||
"version": "1.0.5-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -29,6 +29,28 @@ Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, then open `index
|
||||
|
||||
Keep the page open while WebPeer is expected to announce or transfer changes.
|
||||
|
||||
## P2P connection check
|
||||
|
||||
`check.html` provides a disposable preflight check for P2P connectivity. It creates a random encrypted Setup URI and QR code locally, joins the generated room as a browser reference peer only after explicit confirmation, and displays the browser's WebRTC diagnostic totals beside the QR code.
|
||||
|
||||
Use a dedicated empty Vault for every device:
|
||||
|
||||
1. Select desktop or mobile, then prepare the check.
|
||||
2. Start the browser connection monitor.
|
||||
3. Open or scan the Setup URI in an empty Vault with Self-hosted LiveSync installed and enabled.
|
||||
4. Enter the separately displayed Setup URI passphrase.
|
||||
5. Keep both peers open for the observation period and watch the successful-connection total.
|
||||
6. Use **Show the Setup QR again** to return to the existing configuration without regenerating it.
|
||||
7. For the clearest device comparison, start a fresh check before testing the other device.
|
||||
|
||||
A successful total greater than zero means that the browser and target established at least one WebRTC connection. It does not verify note synchronisation, sustained connectivity, or a direct desktop-to-mobile path. If representative checks repeatedly do not connect, CouchDB is the more predictable synchronisation option. An optional final check can use two disposable empty Vaults to verify a note round trip directly between the user's devices.
|
||||
|
||||
After the first device connects, **Try another device without resetting** keeps the browser, room, credentials, first device, and cumulative counters in place. The page records a local baseline, shows the same QR code for another empty Vault, and reports an additional connection only after both a new successful connection state and another simultaneous active connection appear. Keep the first device connected. Connections are not device identities, so this same-room route is convenient but a fresh check remains easier to interpret because reconnect activity is isolated.
|
||||
|
||||
Serve the production build over HTTPS or from `localhost`. Downloading `check.html` alone is not supported because the page uses built module assets and origin-scoped browser storage. The generated credentials are temporary and must not replace the settings of a production Vault.
|
||||
|
||||
The detailed scope and result semantics are recorded in the [browser-assisted P2P connection-check ADR](../../../docs/adr/2026_07_p2p_connection_check.md).
|
||||
|
||||
## Storage and lifecycle
|
||||
|
||||
WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently:
|
||||
@@ -79,11 +101,21 @@ npm run test:unit --workspace webpeer
|
||||
npm run test:browser --workspace webpeer
|
||||
```
|
||||
|
||||
The focused browser-to-Obsidian E2E test builds both production artefacts, manages the local Compose P2P relay, applies the browser-generated Setup URI to two isolated empty Vaults without resetting the browser room, and retains the successful additional-device result under the Obsidian diagnostics directory:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:obsidian:p2p-connection-check:services
|
||||
```
|
||||
|
||||
Configure `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the E2E runner cannot discover them automatically.
|
||||
|
||||
The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary.
|
||||
|
||||
## Composition
|
||||
|
||||
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls.
|
||||
The WebPeer production bundle has two HTML entry points. `index.html` loads `src/main.ts` and the ordinary browser peer, while `check.html` loads `src/check.ts` and the isolated P2P connection check. The ordinary page links to the check, so it is a WebPeer feature without sharing its saved settings or runtime session.
|
||||
|
||||
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls. The connection-check modules create an isolated runtime with in-memory test settings and derive user-visible results from Commonlib's browser-side diagnostic events. Both entry points use the same WebPeer visual foundation.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
18
src/apps/webpeer/check.html
Normal file
18
src/apps/webpeer/check.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Prepare a disposable Setup URI and check a Self-hosted LiveSync P2P connection from the browser."
|
||||
/>
|
||||
<title>P2P connection check · Self-hosted LiveSync</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/check.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Peer-to-Peer Daemon on Browser</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Run a temporary Self-hosted LiveSync P2P peer and inspect its activity in the browser."
|
||||
/>
|
||||
<title>WebPeer · Self-hosted LiveSync</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -14,4 +18,4 @@
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.2-webpeer",
|
||||
"version": "1.0.5-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
601
src/apps/webpeer/src/P2PCheck.svelte
Normal file
601
src/apps/webpeer/src/P2PCheck.svelte
Normal file
@@ -0,0 +1,601 @@
|
||||
<script lang="ts">
|
||||
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import qrcode from "qrcode-generator";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
|
||||
import {
|
||||
generateP2PCheckSetup,
|
||||
resolveLocalP2PCheckRelayOverride,
|
||||
type GeneratedP2PCheckSetup,
|
||||
type P2PCheckTarget,
|
||||
} from "./P2PCheckSetup";
|
||||
import { P2PCheckSession } from "./P2PCheckSession";
|
||||
import {
|
||||
EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
P2P_CHECK_OBSERVATION_MILLISECONDS,
|
||||
captureP2PAdditionalCheckBaseline,
|
||||
countActiveP2PConnections,
|
||||
deriveP2PAdditionalCheckProgress,
|
||||
deriveP2PCheckOutcome,
|
||||
type P2PAdditionalCheckBaseline,
|
||||
type P2PAdditionalCheckOutcome,
|
||||
type P2PCheckOutcome,
|
||||
} from "./P2PCheckState";
|
||||
|
||||
const OUTCOME_COPY: Record<
|
||||
P2PCheckOutcome,
|
||||
{ readonly title: string; readonly body: string; readonly tone: string }
|
||||
> = {
|
||||
idle: {
|
||||
title: "Not monitoring yet",
|
||||
body: "Prepare the reference peer, then start monitoring before opening the Setup URI on the target device.",
|
||||
tone: "neutral",
|
||||
},
|
||||
waiting: {
|
||||
title: "Waiting for the device",
|
||||
body: "The browser reference peer is ready. Keep this page open and complete setup in the empty test Vault.",
|
||||
tone: "neutral",
|
||||
},
|
||||
connecting: {
|
||||
title: "Negotiating a connection",
|
||||
body: "A WebRTC connection attempt is in progress. The counters can increase more than once during negotiation.",
|
||||
tone: "progress",
|
||||
},
|
||||
retrying: {
|
||||
title: "An attempt failed; waiting for a retry",
|
||||
body: "A failed attempt is not final. Leave both peers open because a later retry can still succeed.",
|
||||
tone: "warning",
|
||||
},
|
||||
connected: {
|
||||
title: "P2P connection observed",
|
||||
body: "This browser and the target device established at least one WebRTC connection. This checks connectivity, not note synchronisation.",
|
||||
tone: "success",
|
||||
},
|
||||
inconclusive: {
|
||||
title: "No P2P connection observed",
|
||||
body: "No successful connection was seen during the observation period. Repeat the check on the networks you intend to use; if it remains unsuccessful, CouchDB is the more predictable choice.",
|
||||
tone: "warning",
|
||||
},
|
||||
error: {
|
||||
title: "The browser monitor could not start",
|
||||
body: "Check browser support, the secure-context requirement, and access to the signalling relay, then start a fresh check.",
|
||||
tone: "error",
|
||||
},
|
||||
};
|
||||
|
||||
const ADDITIONAL_OUTCOME_COPY: Record<
|
||||
P2PAdditionalCheckOutcome,
|
||||
{ readonly title: string; readonly body: string; readonly tone: string }
|
||||
> = {
|
||||
waiting: {
|
||||
title: "Waiting for another device",
|
||||
body: "The same Setup URI is ready. Keep the browser and first device open while another empty Vault joins.",
|
||||
tone: "neutral",
|
||||
},
|
||||
negotiating: {
|
||||
title: "New connection activity observed",
|
||||
body: "The counters or active connections changed. Waiting for both a new successful state and another simultaneous active connection.",
|
||||
tone: "progress",
|
||||
},
|
||||
connected: {
|
||||
title: "An additional connection was observed",
|
||||
body: "The browser gained another simultaneous active WebRTC connection after this attempt began.",
|
||||
tone: "success",
|
||||
},
|
||||
inconclusive: {
|
||||
title: "Another device was not identified",
|
||||
body: "The same-room baseline did not produce both signals during this observation period. Use a fresh check for a clean per-device result.",
|
||||
tone: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
interface AdditionalDeviceAttempt {
|
||||
readonly baseline: P2PAdditionalCheckBaseline;
|
||||
readonly startedAtElapsedMilliseconds: number;
|
||||
}
|
||||
|
||||
let target = $state<P2PCheckTarget>("desktop");
|
||||
let setup = $state<GeneratedP2PCheckSetup>();
|
||||
let qrDataURL = $state("");
|
||||
let preparing = $state(false);
|
||||
let preparationError = $state("");
|
||||
let monitorStarting = $state(false);
|
||||
let monitorActive = $state(false);
|
||||
let monitorError = $state("");
|
||||
let status = $state<P2PServerInfo>();
|
||||
let elapsedMilliseconds = $state(0);
|
||||
let copied = $state<"uri" | "passphrase">();
|
||||
let copyError = $state("");
|
||||
let freshCheckStarting = $state(false);
|
||||
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
|
||||
|
||||
let session: P2PCheckSession | undefined;
|
||||
let setupCard = $state<HTMLElement>();
|
||||
let setupHeading = $state<HTMLHeadingElement>();
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let monitorStartedAt = 0;
|
||||
|
||||
let diagnostics = $derived(status?.diag ?? EMPTY_P2P_CHECK_DIAGNOSTICS);
|
||||
let outcome = $derived(
|
||||
deriveP2PCheckOutcome(
|
||||
diagnostics,
|
||||
monitorActive,
|
||||
elapsedMilliseconds,
|
||||
monitorError !== ""
|
||||
)
|
||||
);
|
||||
let outcomeCopy = $derived(OUTCOME_COPY[outcome]);
|
||||
let activeConnections = $derived(countActiveP2PConnections(diagnostics));
|
||||
let observationSeconds = $derived(
|
||||
Math.floor(P2P_CHECK_OBSERVATION_MILLISECONDS / 1_000)
|
||||
);
|
||||
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
|
||||
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
|
||||
let additionalElapsedMilliseconds = $derived(
|
||||
additionalDeviceAttempt
|
||||
? Math.max(
|
||||
0,
|
||||
elapsedMilliseconds -
|
||||
additionalDeviceAttempt.startedAtElapsedMilliseconds
|
||||
)
|
||||
: 0
|
||||
);
|
||||
let additionalElapsedSeconds = $derived(
|
||||
Math.floor(additionalElapsedMilliseconds / 1_000)
|
||||
);
|
||||
let additionalProgress = $derived(
|
||||
additionalDeviceAttempt
|
||||
? deriveP2PAdditionalCheckProgress(
|
||||
diagnostics,
|
||||
additionalDeviceAttempt.baseline,
|
||||
additionalElapsedMilliseconds
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
let additionalOutcomeCopy = $derived(
|
||||
additionalProgress
|
||||
? ADDITIONAL_OUTCOME_COPY[additionalProgress.outcome]
|
||||
: undefined
|
||||
);
|
||||
|
||||
function createQRCodeDataURL(setupURI: string): string {
|
||||
const code = qrcode(0, "L");
|
||||
code.addData(setupURI);
|
||||
code.make();
|
||||
return code.createDataURL(4, 4);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function prepareCheck(): Promise<void> {
|
||||
preparing = true;
|
||||
preparationError = "";
|
||||
try {
|
||||
const relay = resolveLocalP2PCheckRelayOverride(window.location);
|
||||
const generated = await generateP2PCheckSetup(target, { relay });
|
||||
qrDataURL = createQRCodeDataURL(generated.setupURI);
|
||||
setup = generated;
|
||||
} catch (error) {
|
||||
preparationError = formatError(error);
|
||||
} finally {
|
||||
preparing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateElapsedTime(): void {
|
||||
elapsedMilliseconds = Date.now() - monitorStartedAt;
|
||||
}
|
||||
|
||||
async function startMonitor(): Promise<void> {
|
||||
if (!setup || monitorStarting || monitorActive) {
|
||||
return;
|
||||
}
|
||||
monitorStarting = true;
|
||||
monitorError = "";
|
||||
const newSession = new P2PCheckSession();
|
||||
session = newSession;
|
||||
try {
|
||||
await newSession.start(setup.browserSettings, setup.browserDeviceName, (nextStatus) => {
|
||||
status = nextStatus;
|
||||
});
|
||||
monitorActive = true;
|
||||
monitorStartedAt = Date.now();
|
||||
elapsedMilliseconds = 0;
|
||||
elapsedTimer = setInterval(updateElapsedTime, 250);
|
||||
} catch (error) {
|
||||
monitorError = formatError(error);
|
||||
monitorActive = false;
|
||||
} finally {
|
||||
monitorStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
|
||||
copyError = "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = kind;
|
||||
if (copyTimer !== undefined) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
copyTimer = setTimeout(() => {
|
||||
copied = undefined;
|
||||
}, 2_000);
|
||||
} catch (error) {
|
||||
copyError = `Copying failed: ${formatError(error)}. Select the text and copy it manually.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function startFreshCheck(): Promise<void> {
|
||||
freshCheckStarting = true;
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
}
|
||||
await session?.stop();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function scrollToSetupQRCode(): void {
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setupCard?.scrollIntoView({
|
||||
behavior: reducedMotion ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
setupHeading?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
async function showSetupQRCode(): Promise<void> {
|
||||
await tick();
|
||||
scrollToSetupQRCode();
|
||||
}
|
||||
|
||||
async function startAdditionalDeviceAttempt(): Promise<void> {
|
||||
if (
|
||||
!monitorActive ||
|
||||
outcome !== "connected" ||
|
||||
activeConnections === 0 ||
|
||||
additionalDeviceAttempt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
additionalDeviceAttempt = {
|
||||
baseline: captureP2PAdditionalCheckBaseline(diagnostics),
|
||||
startedAtElapsedMilliseconds: elapsedMilliseconds,
|
||||
};
|
||||
await showSetupQRCode();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
}
|
||||
if (copyTimer !== undefined) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
void session?.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content="#12233f" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="check-shell">
|
||||
<header class="hero">
|
||||
<a class="eyebrow" href="./index.html">Self-hosted LiveSync · WebPeer</a>
|
||||
<h1>P2P connection check</h1>
|
||||
<p class="hero-copy">
|
||||
See whether one LiveSync device can establish a WebRTC connection to this browser on
|
||||
the current network.
|
||||
</p>
|
||||
<div class="scope-note" role="note">
|
||||
<span aria-hidden="true">◇</span>
|
||||
<strong>Empty test Vaults only.</strong>
|
||||
This is a disposable connectivity check, not a synchronisation or backup test.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="step-card" aria-labelledby="choose-target-heading">
|
||||
<div class="step-number" aria-hidden="true">1</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Choose one target</p>
|
||||
<h2 id="choose-target-heading">Which device will connect to this browser?</h2>
|
||||
<p class="section-copy">
|
||||
Check desktop and mobile separately. A fresh test gives each device its own random
|
||||
room and clear diagnostic counters.
|
||||
</p>
|
||||
|
||||
<fieldset class="target-picker" disabled={setup !== undefined}>
|
||||
<legend class="visually-hidden">Target device</legend>
|
||||
<label class:chosen={target === "desktop"} class="target-option">
|
||||
<input type="radio" bind:group={target} value="desktop" />
|
||||
<span class="target-icon" aria-hidden="true">▰</span>
|
||||
<span>
|
||||
<strong>Desktop LiveSync</strong>
|
||||
<small>Browser ↔ desktop plug-in</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class:chosen={target === "mobile"} class="target-option">
|
||||
<input type="radio" bind:group={target} value="mobile" />
|
||||
<span class="target-icon mobile" aria-hidden="true">▯</span>
|
||||
<span>
|
||||
<strong>Mobile LiveSync</strong>
|
||||
<small>Browser ↔ mobile plug-in</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button class="primary-action" type="button" onclick={prepareCheck} disabled={preparing || setup !== undefined}>
|
||||
{preparing ? "Preparing locally…" : `Prepare ${targetLabel} check`}
|
||||
</button>
|
||||
<p class="privacy-line">
|
||||
Preparing generates and encrypts everything in this browser. It does not contact
|
||||
the signalling relay.
|
||||
</p>
|
||||
{#if preparationError}
|
||||
<p class="inline-error" role="alert">{preparationError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if setup}
|
||||
<section bind:this={setupCard} class="step-card setup-card" aria-labelledby="setup-heading">
|
||||
<div class="step-number" aria-hidden="true">2</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Set up the empty Vault</p>
|
||||
<h2 bind:this={setupHeading} id="setup-heading" tabindex="-1">
|
||||
{additionalDeviceAttempt
|
||||
? "Use this same one-off configuration on another device"
|
||||
: `Open this one-off configuration on ${targetLabel}`}
|
||||
</h2>
|
||||
{#if additionalDeviceAttempt}
|
||||
<p class="section-copy">
|
||||
Keep the browser and first device open. In another new empty Vault, scan
|
||||
this same QR code or open the same Setup URI, then enter the same separate
|
||||
passphrase.
|
||||
</p>
|
||||
<p class="reuse-note" role="note">
|
||||
The room, credentials, connection, and existing counters have not been
|
||||
reset. The additional-device result uses the values recorded when you
|
||||
selected the button.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="section-copy">
|
||||
In a new empty Vault with Self-hosted LiveSync installed and enabled, scan
|
||||
the QR code or open the Setup URI. Enter the separate passphrase when
|
||||
prompted.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="setup-grid">
|
||||
<div class="qr-panel">
|
||||
<img
|
||||
src={qrDataURL}
|
||||
alt={additionalDeviceAttempt
|
||||
? "Setup URI QR code for another device"
|
||||
: `Setup URI QR code for the ${targetLabel} check`}
|
||||
/>
|
||||
<p>
|
||||
{additionalDeviceAttempt
|
||||
? "This is the original encrypted Setup URI; it was not regenerated."
|
||||
: "QR contains the encrypted Setup URI only."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="credential-panel">
|
||||
<label for="setup-passphrase">Setup URI passphrase</label>
|
||||
<div class="copy-row compact">
|
||||
<input
|
||||
id="setup-passphrase"
|
||||
value={setup.setupPassphrase}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" onclick={() => copyText(setup!.setupPassphrase, "passphrase")}>
|
||||
{copied === "passphrase" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
|
||||
|
||||
<label for="setup-uri">Setup URI</label>
|
||||
<textarea
|
||||
id="setup-uri"
|
||||
rows="4"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
value={setup.setupURI}
|
||||
></textarea>
|
||||
<div class="button-row">
|
||||
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
|
||||
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
|
||||
</button>
|
||||
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
|
||||
</div>
|
||||
{#if copyError}
|
||||
<p class="inline-error" role="alert">{copyError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="session-details">
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>{targetLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Test Group ID</dt>
|
||||
<dd>{setup.groupId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Signalling relay</dt>
|
||||
<dd>{setup.relay}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step-card results-card" aria-labelledby="monitor-heading">
|
||||
<div class="step-number" aria-hidden="true">3</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Watch the browser diagnostics</p>
|
||||
<h2 id="monitor-heading">Start monitoring, then complete setup on {targetLabel}</h2>
|
||||
<p class="section-copy">
|
||||
Starting monitoring joins the configured signalling relay. Keep this page and
|
||||
the target Vault open for at least {observationSeconds} seconds.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="primary-action monitor-action"
|
||||
type="button"
|
||||
onclick={startMonitor}
|
||||
disabled={monitorStarting || monitorActive}
|
||||
>
|
||||
{monitorStarting ? "Starting browser peer…" : monitorActive ? "Monitoring is active" : "Start connection monitor"}
|
||||
</button>
|
||||
|
||||
<div class="outcome" class:success={outcomeCopy.tone === "success"} class:warning={outcomeCopy.tone === "warning"} class:error={outcomeCopy.tone === "error"} class:progress={outcomeCopy.tone === "progress"} aria-live="polite">
|
||||
<div class="outcome-mark" aria-hidden="true">
|
||||
{outcome === "connected" ? "✓" : outcome === "error" ? "!" : outcome === "inconclusive" ? "?" : "•"}
|
||||
</div>
|
||||
<div>
|
||||
<h3>{outcomeCopy.title}</h3>
|
||||
<p>{outcomeCopy.body}</p>
|
||||
{#if monitorActive}
|
||||
<small>{elapsedSeconds}s observed · {activeConnections} currently connected</small>
|
||||
{/if}
|
||||
{#if monitorError}
|
||||
<code>{monitorError}</code>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics" aria-label="WebRTC diagnostic totals since this page opened">
|
||||
<article>
|
||||
<span>New</span>
|
||||
<strong data-testid="diag-new">{diagnostics.totalNewConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article class="successful">
|
||||
<span>Successful</span>
|
||||
<strong data-testid="diag-successful">{diagnostics.totalSuccessfulConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article class="failed">
|
||||
<span>Failed</span>
|
||||
<strong data-testid="diag-failed">{diagnostics.totalFailedConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Closed</span>
|
||||
<strong data-testid="diag-closed">{diagnostics.totalClosedConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p class="counter-note">
|
||||
These are negotiation events, not device counts. <strong>Successful > 0</strong>
|
||||
is the connection signal; later failures or closures do not erase it.
|
||||
</p>
|
||||
|
||||
{#if additionalProgress && additionalOutcomeCopy}
|
||||
<div
|
||||
class="outcome additional-outcome"
|
||||
class:success={additionalOutcomeCopy.tone === "success"}
|
||||
class:warning={additionalOutcomeCopy.tone === "warning"}
|
||||
class:progress={additionalOutcomeCopy.tone === "progress"}
|
||||
aria-live="polite"
|
||||
data-testid="additional-device-outcome"
|
||||
>
|
||||
<div class="outcome-mark" aria-hidden="true">
|
||||
{additionalProgress.outcome === "connected"
|
||||
? "✓"
|
||||
: additionalProgress.outcome === "inconclusive"
|
||||
? "?"
|
||||
: "•"}
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-kicker">Another device in this room</p>
|
||||
<h3>{additionalOutcomeCopy.title}</h3>
|
||||
<p>{additionalOutcomeCopy.body}</p>
|
||||
<small>
|
||||
{additionalElapsedSeconds}s observed ·
|
||||
<span data-testid="additional-successful">
|
||||
+{additionalProgress.successfulConnections} successful
|
||||
</span>
|
||||
·
|
||||
<span data-testid="additional-active-connections">
|
||||
+{additionalProgress.activeConnections} active
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="counter-note additional-caveat">
|
||||
Same-room counters remain cumulative and connections are not device counts.
|
||||
The first device must stay connected; a fresh room remains the clearest
|
||||
per-device comparison.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="session-actions">
|
||||
<button class="secondary-action" type="button" onclick={showSetupQRCode}>
|
||||
Show the Setup QR again
|
||||
</button>
|
||||
{#if outcome === "connected" && !additionalDeviceAttempt}
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
onclick={startAdditionalDeviceAttempt}
|
||||
disabled={activeConnections === 0}
|
||||
>
|
||||
{activeConnections === 0
|
||||
? "Waiting for the first device to reconnect…"
|
||||
: "Try another device without resetting"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="secondary-action"
|
||||
type="button"
|
||||
onclick={startFreshCheck}
|
||||
disabled={freshCheckStarting}
|
||||
>
|
||||
{freshCheckStarting ? "Closing this check…" : "Start a fresh check for the other device"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="next-step" aria-labelledby="next-step-heading">
|
||||
<p class="section-kicker">What this tells you</p>
|
||||
<h2 id="next-step-heading">Use the result as a P2P preflight</h2>
|
||||
<div class="next-step-grid">
|
||||
<div>
|
||||
<h3>If both devices are observed</h3>
|
||||
<p>
|
||||
P2P looks plausible on these networks. Separate fresh checks give the
|
||||
clearest device comparison. For a stronger final check, use two disposable
|
||||
empty Vaults and verify a note round trip directly between your devices.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>If checks repeatedly do not connect</h3>
|
||||
<p>
|
||||
Network policy or NAT may make P2P unreliable. Use CouchDB when you need a
|
||||
predictable synchronisation path across these networks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
The generated credentials are disposable. Delete the empty test Vault afterwards and
|
||||
generate new credentials for any real setup.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
70
src/apps/webpeer/src/P2PCheckSession.ts
Normal file
70
src/apps/webpeer/src/P2PCheckSession.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
type P2PServerInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
import { WEBPEER_SETTINGS_KEY } from "./WebPeerPersistence";
|
||||
import { WebPeerRuntime } from "./WebPeerRuntime";
|
||||
|
||||
export const P2P_CHECK_SYSTEM_VAULT_NAME = "p2p-livesync-connection-check";
|
||||
|
||||
function createMemoryStore(settings: ObsidianLiveSyncSettings): SimpleStore<unknown> {
|
||||
const values = new Map<string, unknown>([[WEBPEER_SETTINGS_KEY, settings]]);
|
||||
return {
|
||||
db: Promise.resolve(undefined),
|
||||
get: async (key) => values.get(key),
|
||||
set: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
keys: async (from, to, count) => {
|
||||
const selected = [...values.keys()]
|
||||
.sort()
|
||||
.filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to));
|
||||
return count === undefined ? selected : selected.slice(0, count);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class P2PCheckSession {
|
||||
private runtime?: WebPeerRuntime;
|
||||
private removeStatusListener?: () => void;
|
||||
|
||||
async start(
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
browserDeviceName: string,
|
||||
onStatus: (status: P2PServerInfo) => void
|
||||
): Promise<void> {
|
||||
if (this.runtime) {
|
||||
throw new Error("This P2P connection-check session has already started");
|
||||
}
|
||||
|
||||
const runtime = new WebPeerRuntime({
|
||||
store: createMemoryStore(settings),
|
||||
deviceName: browserDeviceName,
|
||||
systemVaultName: P2P_CHECK_SYSTEM_VAULT_NAME,
|
||||
});
|
||||
this.runtime = runtime;
|
||||
this.removeStatusListener = runtime.events.onEvent(EVENT_SERVER_STATUS, onStatus);
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
await runtime.currentReplicator.makeSureOpened();
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.removeStatusListener?.();
|
||||
this.removeStatusListener = undefined;
|
||||
const runtime = this.runtime;
|
||||
this.runtime = undefined;
|
||||
await runtime?.shutdown();
|
||||
}
|
||||
}
|
||||
166
src/apps/webpeer/src/P2PCheckSetup.ts
Normal file
166
src/apps/webpeer/src/P2PCheckSetup.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
PREFERRED_BASE,
|
||||
createNewVaultSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
|
||||
export const P2P_CHECK_APP_ID = "self-hosted-livesync-p2p-check-v1";
|
||||
export const P2P_CHECK_REMOTE_NAME = "P2P connection check";
|
||||
|
||||
export type P2PCheckTarget = "desktop" | "mobile";
|
||||
|
||||
export interface GeneratedP2PCheckSetup {
|
||||
readonly target: P2PCheckTarget;
|
||||
readonly setupURI: string;
|
||||
readonly setupPassphrase: string;
|
||||
readonly groupId: string;
|
||||
readonly relay: string;
|
||||
readonly browserDeviceName: string;
|
||||
readonly browserSettings: ObsidianLiveSyncSettings;
|
||||
}
|
||||
|
||||
export interface P2PCheckSetupOptions {
|
||||
readonly relay?: string;
|
||||
}
|
||||
|
||||
export interface P2PCheckPageLocation {
|
||||
readonly hostname: string;
|
||||
readonly search: string;
|
||||
}
|
||||
|
||||
interface SharedP2PCheckCredentials {
|
||||
readonly groupId: string;
|
||||
readonly p2pPassphrase: string;
|
||||
readonly vaultPassphrase: string;
|
||||
}
|
||||
|
||||
const READABLE_SECRET_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
|
||||
const RANDOM_BYTE_ACCEPTANCE_LIMIT =
|
||||
Math.floor(256 / READABLE_SECRET_ALPHABET.length) * READABLE_SECRET_ALPHABET.length;
|
||||
|
||||
function generateReadableSecret(length: number): string {
|
||||
const crypto = compatGlobal.crypto;
|
||||
if (!crypto) {
|
||||
throw new Error("Web Crypto is required to prepare a P2P connection check");
|
||||
}
|
||||
|
||||
let result = "";
|
||||
const bytes = new Uint8Array(Math.max(16, length));
|
||||
while (result.length < length) {
|
||||
crypto.getRandomValues(bytes);
|
||||
for (const byte of bytes) {
|
||||
if (byte >= RANDOM_BYTE_ACCEPTANCE_LIMIT) {
|
||||
continue;
|
||||
}
|
||||
result += READABLE_SECRET_ALPHABET[byte % READABLE_SECRET_ALPHABET.length];
|
||||
if (result.length === length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateSetupPassphrase(): string {
|
||||
return generateReadableSecret(16).match(/.{4}/g)!.join("-");
|
||||
}
|
||||
|
||||
function createSettings(
|
||||
credentials: SharedP2PCheckCredentials,
|
||||
options: {
|
||||
readonly autoStart: boolean;
|
||||
readonly relay: string;
|
||||
readonly useDiagnostics: boolean;
|
||||
}
|
||||
): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, {
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
passphrase: credentials.vaultPassphrase,
|
||||
usePathObfuscation: true,
|
||||
P2P_Enabled: true,
|
||||
P2P_AppID: P2P_CHECK_APP_ID,
|
||||
P2P_roomID: credentials.groupId,
|
||||
P2P_passphrase: credentials.p2pPassphrase,
|
||||
P2P_relays: options.relay,
|
||||
P2P_AutoStart: options.autoStart,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_DevicePeerName: "",
|
||||
P2P_useDiagRTC: options.useDiagnostics,
|
||||
});
|
||||
upsertRemoteConfigurationInPlace(settings, "p2p", {
|
||||
name: P2P_CHECK_REMOTE_NAME,
|
||||
activate: true,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function resolveLocalP2PCheckRelayOverride(location: P2PCheckPageLocation): string | undefined {
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
||||
if (!loopbackHosts.has(location.hostname.toLowerCase())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestedRelay = new URLSearchParams(location.search).get("relay")?.trim();
|
||||
if (!requestedRelay) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const relay = new URL(requestedRelay);
|
||||
if (relay.protocol !== "ws:" && relay.protocol !== "wss:") {
|
||||
return undefined;
|
||||
}
|
||||
return relay.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateP2PCheckSetup(
|
||||
target: P2PCheckTarget,
|
||||
options: P2PCheckSetupOptions = {}
|
||||
): Promise<GeneratedP2PCheckSetup> {
|
||||
const relay = options.relay?.trim() || P2P_DEFAULT_SETTINGS.P2P_relays;
|
||||
const credentials: SharedP2PCheckCredentials = {
|
||||
groupId: generateP2PRoomId(),
|
||||
p2pPassphrase: generateReadableSecret(32),
|
||||
vaultPassphrase: generateReadableSecret(32),
|
||||
};
|
||||
const deviceSettings = createSettings(credentials, {
|
||||
autoStart: true,
|
||||
relay,
|
||||
useDiagnostics: false,
|
||||
});
|
||||
const browserSettings = createSettings(credentials, {
|
||||
autoStart: false,
|
||||
relay,
|
||||
useDiagnostics: true,
|
||||
});
|
||||
browserSettings.suspendParseReplicationResult = true;
|
||||
|
||||
const setupPassphrase = generateSetupPassphrase();
|
||||
const setupURI = await encodeSettingsToSetupURI(
|
||||
deviceSettings,
|
||||
setupPassphrase,
|
||||
["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"],
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
target,
|
||||
setupURI: setupURI.trim(),
|
||||
setupPassphrase,
|
||||
groupId: credentials.groupId,
|
||||
relay: deviceSettings.P2P_relays,
|
||||
browserDeviceName: `p2p-check-browser-${target}-${credentials.groupId.slice(-3)}`,
|
||||
browserSettings,
|
||||
};
|
||||
}
|
||||
129
src/apps/webpeer/src/P2PCheckState.ts
Normal file
129
src/apps/webpeer/src/P2PCheckState.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
|
||||
export const P2P_CHECK_OBSERVATION_MILLISECONDS = 60_000;
|
||||
|
||||
export type P2PCheckDiagnostics = P2PServerInfo["diag"];
|
||||
export type P2PCheckOutcome = "idle" | "waiting" | "connecting" | "retrying" | "connected" | "inconclusive" | "error";
|
||||
export type P2PAdditionalCheckOutcome = "waiting" | "negotiating" | "connected" | "inconclusive";
|
||||
|
||||
export interface P2PAdditionalCheckBaseline {
|
||||
readonly activeConnectionIds: readonly string[];
|
||||
readonly totalClosedConnections: number;
|
||||
readonly totalFailedConnections: number;
|
||||
readonly totalNewConnections: number;
|
||||
readonly totalSuccessfulConnections: number;
|
||||
}
|
||||
|
||||
export interface P2PAdditionalCheckProgress {
|
||||
readonly activeConnections: number;
|
||||
readonly closedConnections: number;
|
||||
readonly failedConnections: number;
|
||||
readonly newConnections: number;
|
||||
readonly newActiveConnectionIds: readonly string[];
|
||||
readonly outcome: P2PAdditionalCheckOutcome;
|
||||
readonly successfulConnections: number;
|
||||
}
|
||||
|
||||
export const EMPTY_P2P_CHECK_DIAGNOSTICS: P2PCheckDiagnostics = {
|
||||
totalNewConnections: 0,
|
||||
totalFailedConnections: 0,
|
||||
totalSuccessfulConnections: 0,
|
||||
totalClosedConnections: 0,
|
||||
details: {},
|
||||
};
|
||||
|
||||
export function deriveP2PCheckOutcome(
|
||||
diagnostics: P2PCheckDiagnostics,
|
||||
monitorActive: boolean,
|
||||
elapsedMilliseconds: number,
|
||||
monitorError = false
|
||||
): P2PCheckOutcome {
|
||||
if (monitorError) {
|
||||
return "error";
|
||||
}
|
||||
if (diagnostics.totalSuccessfulConnections > 0) {
|
||||
return "connected";
|
||||
}
|
||||
if (!monitorActive) {
|
||||
return "idle";
|
||||
}
|
||||
if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) {
|
||||
return "inconclusive";
|
||||
}
|
||||
if (diagnostics.totalFailedConnections > 0) {
|
||||
return "retrying";
|
||||
}
|
||||
if (diagnostics.totalNewConnections > 0 || Object.keys(diagnostics.details).length > 0) {
|
||||
return "connecting";
|
||||
}
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
export function countActiveP2PConnections(diagnostics: P2PCheckDiagnostics): number {
|
||||
return Object.values(diagnostics.details).filter(({ connectionState }) => connectionState === "connected").length;
|
||||
}
|
||||
|
||||
function activeConnectionIds(diagnostics: P2PCheckDiagnostics): string[] {
|
||||
return Object.entries(diagnostics.details)
|
||||
.filter(([, { connectionState }]) => connectionState === "connected")
|
||||
.map(([connectionId]) => connectionId);
|
||||
}
|
||||
|
||||
export function captureP2PAdditionalCheckBaseline(diagnostics: P2PCheckDiagnostics): P2PAdditionalCheckBaseline {
|
||||
return {
|
||||
activeConnectionIds: activeConnectionIds(diagnostics),
|
||||
totalClosedConnections: diagnostics.totalClosedConnections,
|
||||
totalFailedConnections: diagnostics.totalFailedConnections,
|
||||
totalNewConnections: diagnostics.totalNewConnections,
|
||||
totalSuccessfulConnections: diagnostics.totalSuccessfulConnections,
|
||||
};
|
||||
}
|
||||
|
||||
function counterIncrease(current: number, baseline: number): number {
|
||||
return Math.max(0, current - baseline);
|
||||
}
|
||||
|
||||
export function deriveP2PAdditionalCheckProgress(
|
||||
diagnostics: P2PCheckDiagnostics,
|
||||
baseline: P2PAdditionalCheckBaseline,
|
||||
elapsedMilliseconds: number
|
||||
): P2PAdditionalCheckProgress {
|
||||
const baselineConnectionIds = new Set(baseline.activeConnectionIds);
|
||||
const currentActiveConnectionIds = activeConnectionIds(diagnostics);
|
||||
const newActiveConnectionIds = currentActiveConnectionIds.filter(
|
||||
(connectionId) => !baselineConnectionIds.has(connectionId)
|
||||
);
|
||||
const activeConnections = counterIncrease(currentActiveConnectionIds.length, baseline.activeConnectionIds.length);
|
||||
const newConnections = counterIncrease(diagnostics.totalNewConnections, baseline.totalNewConnections);
|
||||
const successfulConnections = counterIncrease(
|
||||
diagnostics.totalSuccessfulConnections,
|
||||
baseline.totalSuccessfulConnections
|
||||
);
|
||||
const failedConnections = counterIncrease(diagnostics.totalFailedConnections, baseline.totalFailedConnections);
|
||||
const closedConnections = counterIncrease(diagnostics.totalClosedConnections, baseline.totalClosedConnections);
|
||||
|
||||
let outcome: P2PAdditionalCheckOutcome = "waiting";
|
||||
if (successfulConnections > 0 && activeConnections > 0 && newActiveConnectionIds.length > 0) {
|
||||
outcome = "connected";
|
||||
} else if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) {
|
||||
outcome = "inconclusive";
|
||||
} else if (
|
||||
newConnections > 0 ||
|
||||
successfulConnections > 0 ||
|
||||
failedConnections > 0 ||
|
||||
closedConnections > 0 ||
|
||||
newActiveConnectionIds.length > 0
|
||||
) {
|
||||
outcome = "negotiating";
|
||||
}
|
||||
|
||||
return {
|
||||
activeConnections,
|
||||
closedConnections,
|
||||
failedConnections,
|
||||
newConnections,
|
||||
newActiveConnectionIds,
|
||||
outcome,
|
||||
successfulConnections,
|
||||
};
|
||||
}
|
||||
@@ -26,90 +26,84 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="control">
|
||||
{#await synchronised then activeRuntime}
|
||||
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
|
||||
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
|
||||
{:catch error}
|
||||
<p>{error instanceof Error ? error.message : String(error)}</p>
|
||||
{/await}
|
||||
</div>
|
||||
<div class="log">
|
||||
<div class="status">
|
||||
{statusLine}
|
||||
</div>
|
||||
<div class="logslist" bind:this={elP}>
|
||||
{#each $logs as log}
|
||||
<p>{log}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content="#12233f" />
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
max-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@media (device-orientation: portrait) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
padding: 1em;
|
||||
min-width: 50%;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.log {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
@media (device-orientation: portrait) {
|
||||
.log {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
.control {
|
||||
padding: 1em 1em;
|
||||
overflow-y: scroll;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.status {
|
||||
flex-grow: 0;
|
||||
/* max-height: 40px; */
|
||||
/* height: 40px; */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logslist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
/* padding: 1em; */
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
/* max-height: calc(100% - 40px); */
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
<main class="webpeer-shell">
|
||||
<header class="webpeer-header">
|
||||
<div class="brand-lockup">
|
||||
<span class="brand-mark" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<small>Self-hosted LiveSync</small>
|
||||
<strong>WebPeer</strong>
|
||||
<span class="brand-description">A browser-hosted peer for temporary P2P transfers.</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="connection-check-link"
|
||||
href="./check.html"
|
||||
aria-label="Try the P2P connection check"
|
||||
>
|
||||
<span class="check-mark" aria-hidden="true">◇</span>
|
||||
<span>
|
||||
<small>Disposable connectivity check</small>
|
||||
<strong>Try the P2P connection check</strong>
|
||||
</span>
|
||||
<span class="check-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="workspace-grid">
|
||||
<section class="workspace-card control-card" aria-label="WebPeer controls">
|
||||
<div class="panel-meta">
|
||||
<p>Browser peer controls</p>
|
||||
<span>Stored in this browser</span>
|
||||
</div>
|
||||
|
||||
{#await synchronised then activeRuntime}
|
||||
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
|
||||
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
|
||||
{:catch error}
|
||||
<div class="runtime-error" role="alert">
|
||||
<strong>WebPeer could not start</strong>
|
||||
<p>{error instanceof Error ? error.message : String(error)}</p>
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<aside class="workspace-card log-card" aria-labelledby="activity-heading">
|
||||
<header class="log-header">
|
||||
<div>
|
||||
<p class="section-kicker">Live diagnostics</p>
|
||||
<h2 id="activity-heading">Activity log</h2>
|
||||
</div>
|
||||
<div class="status-pill" aria-live="polite">
|
||||
<span aria-hidden="true"></span>
|
||||
{statusLine || "Initialising"}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="logslist"
|
||||
bind:this={elP}
|
||||
role="log"
|
||||
aria-label="WebPeer activity log"
|
||||
>
|
||||
{#if $logs.length === 0}
|
||||
<p class="empty-log">Waiting for peer activity.</p>
|
||||
{:else}
|
||||
{#each $logs as log}
|
||||
<p class="log-entry">{log}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="webpeer-footer">
|
||||
WebPeer is experimental browser software. Keep this page open while it is connected or
|
||||
transferring changes.
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersisten
|
||||
export interface WebPeerRuntimeOptions {
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
deviceName?: string;
|
||||
systemVaultName?: string;
|
||||
}
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
@@ -55,12 +57,12 @@ export class WebPeerRuntime {
|
||||
private startPromise?: Promise<this>;
|
||||
private shutdownPromise?: Promise<void>;
|
||||
|
||||
constructor(options: WebPeerRuntimeOptions = {}) {
|
||||
constructor(private readonly options: WebPeerRuntimeOptions = {}) {
|
||||
const persistence = createWebPeerPersistence(options.store);
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
getSystemVaultName: () => WEBPEER_STORE_NAME,
|
||||
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
|
||||
settings: persistence.settings,
|
||||
restart: {
|
||||
schedule: () => this.scheduleRestart(),
|
||||
@@ -104,6 +106,10 @@ export class WebPeerRuntime {
|
||||
|
||||
private async startRuntime(): Promise<this> {
|
||||
await this.services.setting.loadSettings();
|
||||
const deviceName = this.options.deviceName?.trim();
|
||||
if (deviceName) {
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
}
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
|
||||
@@ -1,112 +1,505 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
--background-primary: #ffffff;
|
||||
--background-primary-alt: #e9e9e9;
|
||||
--size-4-1: 0.25em;
|
||||
--tag-background: #f0f0f0;
|
||||
--tag-border-width: 1px;
|
||||
--tag-border-color: #cfffdd;
|
||||
--background-modifier-success: #d4f3e9;
|
||||
--background-secondary: #f0f0f0;
|
||||
--background-modifier-error: #f8d7da;
|
||||
--background-modifier-error-hover: #f5c6cb;
|
||||
--interactive-accent: #007bff;
|
||||
--interactive-accent-hover: #0056b3;
|
||||
--text-normal: #333;
|
||||
--text-warning: #f0ad4e;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
.webpeer-shell {
|
||||
width: min(92rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 1.5rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
.webpeer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1rem 1.1rem 1rem 1.25rem;
|
||||
border: 1px solid rgba(204, 214, 227, 0.9);
|
||||
border-radius: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1a1a1a;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.25s;
|
||||
.brand-lockup > span:last-child,
|
||||
.connection-check-link > span:nth-child(2) {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.brand-lockup small,
|
||||
.connection-check-link small,
|
||||
.section-kicker,
|
||||
.panel-meta p {
|
||||
color: var(--blue);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
.brand-lockup strong {
|
||||
color: var(--navy);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
.brand-description {
|
||||
margin-top: 0.18rem;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 1rem;
|
||||
background: var(--navy);
|
||||
box-shadow: 0 10px 24px rgba(18, 35, 63, 0.2);
|
||||
}
|
||||
|
||||
.brand-mark::before,
|
||||
.brand-mark::after,
|
||||
.brand-mark span {
|
||||
position: absolute;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.brand-mark::before {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.brand-mark::after {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-color: #8eb1ff;
|
||||
background: #8eb1ff;
|
||||
}
|
||||
|
||||
.brand-mark span {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-width: 1px;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.connection-check-link {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
max-width: 25rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
color: #ffffff;
|
||||
border-radius: 0.95rem;
|
||||
background: var(--navy);
|
||||
box-shadow: 0 10px 26px rgba(18, 35, 63, 0.16);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.connection-check-link:hover {
|
||||
color: #ffffff;
|
||||
background: #1d385f;
|
||||
box-shadow: 0 13px 30px rgba(18, 35, 63, 0.22);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.connection-check-link small {
|
||||
color: #8eb1ff;
|
||||
}
|
||||
|
||||
.connection-check-link strong {
|
||||
margin-top: 0.12rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 0.7rem;
|
||||
color: #b8ceff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.check-arrow {
|
||||
color: #b8ceff;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(22rem, 0.8fr);
|
||||
align-items: start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(204, 214, 227, 0.9);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.control-card {
|
||||
padding: clamp(1.35rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-meta p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-meta span {
|
||||
padding: 0.35rem 0.55rem;
|
||||
color: #48627f;
|
||||
border: 1px solid #cfdae7;
|
||||
border-radius: 999px;
|
||||
background: #f5f8fb;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.control-card article {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.control-card article > h1 {
|
||||
margin: 0 0 1.15rem;
|
||||
color: var(--navy);
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.control-card h2 {
|
||||
margin: 1.45rem 0 0.75rem;
|
||||
padding-bottom: 0.45rem;
|
||||
color: var(--navy);
|
||||
border-bottom-color: var(--line);
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.control-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.control-card .panel-meta p {
|
||||
color: var(--blue);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.control-card details {
|
||||
margin: 0.8rem 0 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.control-card details[open] {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.control-card summary {
|
||||
padding: 0.12rem 0;
|
||||
}
|
||||
|
||||
.control-card details[open] > summary {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.control-card table {
|
||||
width: 100%;
|
||||
border-spacing: 0 0.35rem;
|
||||
}
|
||||
|
||||
.control-card th,
|
||||
.control-card td {
|
||||
padding: 0.55rem 0.4rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.control-card th {
|
||||
width: 32%;
|
||||
color: #40536b;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.control-card td {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.control-card input:not([type="checkbox"]):not([type="radio"]) {
|
||||
min-width: min(100%, 18rem);
|
||||
max-width: 100%;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.control-card input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.control-card label.is-dirty {
|
||||
border-radius: 0.55rem;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.control-card .important {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.control-card .important-sub {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.control-card .browser-p2p-transport-settings {
|
||||
margin-top: 1.3rem;
|
||||
padding-top: 0.3rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.control-card .browser-p2p-transport-settings label {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
.control-card .browser-p2p-transport-settings span {
|
||||
color: #40536b;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-error {
|
||||
padding: 1rem;
|
||||
color: var(--red);
|
||||
border: 1px solid #efb1b1;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.runtime-error p {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
overflow: hidden;
|
||||
background: var(--navy);
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #8eb1ff;
|
||||
}
|
||||
|
||||
.log-header h2 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
max-width: 55%;
|
||||
padding: 0.42rem 0.62rem;
|
||||
overflow: hidden;
|
||||
color: #dbe6f5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill > span {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #8eb1ff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(142, 177, 255, 0.13);
|
||||
}
|
||||
|
||||
.logslist {
|
||||
min-height: 34rem;
|
||||
max-height: calc(100vh - 10rem);
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
overflow: auto;
|
||||
color: #cad7e8;
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
|
||||
var(--navy);
|
||||
background-size: 100% 1.8rem;
|
||||
scrollbar-color: #4e6280 transparent;
|
||||
}
|
||||
|
||||
.log-entry,
|
||||
.empty-log {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
color: #cad7e8;
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
color: #8294ac;
|
||||
}
|
||||
|
||||
.webpeer-footer {
|
||||
max-width: 48rem;
|
||||
margin: 1.3rem auto 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.logslist {
|
||||
min-height: 18rem;
|
||||
max-height: 28rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.webpeer-shell {
|
||||
width: min(100% - 1rem, 46rem);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.webpeer-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.connection-check-link {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.control-card table.settings,
|
||||
.control-card table.settings tbody,
|
||||
.control-card table.settings tr,
|
||||
.control-card table.settings th,
|
||||
.control-card table.settings td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-card table.settings tr {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.control-card table.settings th,
|
||||
.control-card table.settings td {
|
||||
min-height: 0;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.control-card table.peers {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.brand-description {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.panel-meta span {
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
631
src/apps/webpeer/src/check.css
Normal file
631
src/apps/webpeer/src/check.css
Normal file
@@ -0,0 +1,631 @@
|
||||
.setup-card h2:focus-visible {
|
||||
outline: 3px solid rgba(42, 102, 220, 0.38);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.check-shell {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 4.5rem 0 3rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 51rem;
|
||||
margin: 0 auto 2.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
color: var(--blue);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.eyebrow:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.8rem 0 1rem;
|
||||
color: var(--navy);
|
||||
font-size: clamp(2.45rem, 7vw, 4.5rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 43rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e8d8a7;
|
||||
border-radius: 999px;
|
||||
color: #765119;
|
||||
background: #fff9e8;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.step-card,
|
||||
.next-step {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1.35rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: clamp(1.35rem, 4vw, 2.4rem);
|
||||
border: 1px solid rgba(204, 214, 227, 0.9);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: var(--navy);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.65rem;
|
||||
color: var(--navy);
|
||||
font-size: clamp(1.35rem, 3vw, 1.85rem);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.section-copy,
|
||||
.field-help,
|
||||
.privacy-line,
|
||||
.counter-note,
|
||||
.next-step p,
|
||||
footer {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
max-width: 51rem;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.target-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 0 0 1.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-height: 5.3rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: #fbfcfe;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.target-option:hover {
|
||||
border-color: #9cb7eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.target-option.chosen {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
}
|
||||
|
||||
.target-option input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--blue);
|
||||
}
|
||||
|
||||
.target-option strong,
|
||||
.target-option small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.target-option small {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.target-icon {
|
||||
color: var(--blue);
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.target-icon.mobile {
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action,
|
||||
.credential-panel button {
|
||||
border: 1px solid transparent;
|
||||
padding: 0.72rem 1rem;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
color: #ffffff;
|
||||
background: var(--blue);
|
||||
box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18);
|
||||
}
|
||||
|
||||
.primary-action:hover:not(:disabled) {
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
|
||||
.privacy-line {
|
||||
margin: 0.8rem 0 0;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
margin: 0.8rem 0 0;
|
||||
color: var(--red);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.28fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.qr-panel,
|
||||
.credential-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.qr-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.qr-panel img {
|
||||
display: block;
|
||||
width: min(100%, 31rem);
|
||||
height: auto;
|
||||
border: 0.7rem solid #ffffff;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.qr-panel p {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.credential-panel {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.credential-panel label {
|
||||
display: block;
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--navy);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.credential-panel label:not(:first-child) {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.credential-panel input,
|
||||
.credential-panel textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #bdc9d8;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
color: #213048;
|
||||
background: #ffffff;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.credential-panel input {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.credential-panel textarea {
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.copy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.credential-panel button,
|
||||
.secondary-action {
|
||||
color: #26405e;
|
||||
border-color: #bdc9d8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credential-panel button:hover,
|
||||
.secondary-action:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue-dark);
|
||||
}
|
||||
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.68rem 1rem;
|
||||
color: #ffffff;
|
||||
background: var(--navy);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
background: #1d385f;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reuse-note {
|
||||
margin: -0.35rem 0 1.2rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid #acc1ec;
|
||||
border-radius: 0.8rem;
|
||||
color: #34547c;
|
||||
background: var(--blue-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 1fr 1.5fr;
|
||||
gap: 0.7rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.session-details div {
|
||||
min-width: 0;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.7rem;
|
||||
background: #f2f5f9;
|
||||
}
|
||||
|
||||
.session-details dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-details dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--navy);
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.monitor-action {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.outcome.success {
|
||||
border-color: #9ad6c2;
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.outcome.warning {
|
||||
border-color: #e7cd83;
|
||||
background: var(--amber-soft);
|
||||
}
|
||||
|
||||
.outcome.error {
|
||||
border-color: #efb1b1;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.outcome.progress {
|
||||
border-color: #acc1ec;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.outcome-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: #7b8796;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.outcome.success .outcome-mark {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.outcome.warning .outcome-mark {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.outcome.error .outcome-mark {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.outcome.progress .outcome-mark {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.outcome h3 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.outcome p,
|
||||
.outcome small,
|
||||
.outcome code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #4f6074;
|
||||
}
|
||||
|
||||
.outcome small {
|
||||
margin-top: 0.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.outcome code {
|
||||
margin-top: 0.45rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.metrics article {
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.metrics span,
|
||||
.metrics small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metrics strong {
|
||||
display: block;
|
||||
margin: 0.2rem 0;
|
||||
color: var(--navy);
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metrics .successful strong {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.metrics .failed strong {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.counter-note {
|
||||
margin: 0.7rem 0 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.additional-outcome {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.additional-outcome .section-kicker {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.additional-caveat {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.next-step {
|
||||
display: block;
|
||||
margin-top: 1.25rem;
|
||||
color: #ecf2fb;
|
||||
border-color: transparent;
|
||||
background: var(--navy);
|
||||
}
|
||||
|
||||
.next-step h2,
|
||||
.next-step h3 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.next-step .section-kicker {
|
||||
color: #87adff;
|
||||
}
|
||||
|
||||
.next-step p {
|
||||
color: #c5d0df;
|
||||
}
|
||||
|
||||
.next-step-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.next-step-grid h3 {
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.next-step-grid p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 48rem;
|
||||
margin: 1.6rem auto 0;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.check-shell {
|
||||
width: min(100% - 1rem, 46rem);
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.target-picker,
|
||||
.setup-grid,
|
||||
.next-step-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.copy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.copy-row button,
|
||||
.button-row > *,
|
||||
.session-actions > *,
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
12
src/apps/webpeer/src/check.ts
Normal file
12
src/apps/webpeer/src/check.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { mount } from "svelte";
|
||||
|
||||
import P2PCheck from "./P2PCheck.svelte";
|
||||
import "./theme.css";
|
||||
import "./check.css";
|
||||
|
||||
const app = mount(P2PCheck, {
|
||||
target: _activeDocument.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from "svelte";
|
||||
import "./theme.css";
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
170
src/apps/webpeer/src/theme.css
Normal file
170
src/apps/webpeer/src/theme.css
Normal file
@@ -0,0 +1,170 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
color: #17253b;
|
||||
background: #f3f6fa;
|
||||
line-height: 1.5;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--ink: #17253b;
|
||||
--muted: #5d6c80;
|
||||
--line: #d8e0ea;
|
||||
--navy: #12233f;
|
||||
--blue: #2a66dc;
|
||||
--blue-dark: #1e4fae;
|
||||
--blue-soft: #eaf1ff;
|
||||
--green: #14795c;
|
||||
--green-soft: #e5f6ef;
|
||||
--amber: #9c6510;
|
||||
--amber-soft: #fff4d6;
|
||||
--red: #a33a3a;
|
||||
--red-soft: #ffeded;
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f8fafc;
|
||||
--shadow: 0 18px 55px rgba(24, 42, 72, 0.08);
|
||||
|
||||
/* Browser-hosted aliases required by the shared Obsidian P2P controls. */
|
||||
--background-primary: var(--surface);
|
||||
--background-primary-alt: #edf1f6;
|
||||
--background-secondary: #f2f5f9;
|
||||
--background-modifier-border: var(--line);
|
||||
--background-modifier-success: var(--green-soft);
|
||||
--background-modifier-error: var(--red-soft);
|
||||
--background-modifier-error-hover: #f8dada;
|
||||
--interactive-accent: var(--blue);
|
||||
--interactive-accent-hover: var(--blue-dark);
|
||||
--text-normal: var(--ink);
|
||||
--text-muted: var(--muted);
|
||||
--text-error: var(--red);
|
||||
--text-warning: var(--amber);
|
||||
--input-height: 2.75rem;
|
||||
--size-4-1: 0.25rem;
|
||||
--tag-background: #f0f3f7;
|
||||
--tag-border-width: 1px;
|
||||
--tag-border-color: var(--line);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgba(66, 117, 222, 0.11), transparent 26rem),
|
||||
#f3f6fa;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.button-link {
|
||||
min-height: 2.75rem;
|
||||
border-radius: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #bdc9d8;
|
||||
padding: 0.68rem 1rem;
|
||||
color: #26405e;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
background 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue-dark);
|
||||
}
|
||||
|
||||
button.mod-cta {
|
||||
color: #ffffff;
|
||||
border-color: var(--blue);
|
||||
background: var(--blue);
|
||||
box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18);
|
||||
}
|
||||
|
||||
button.mod-cta:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
border-color: var(--blue-dark);
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgba(42, 102, 220, 0.38);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid #bdc9d8;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.68rem 0.8rem;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
accent-color: var(--blue);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
summary {
|
||||
color: var(--navy);
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--navy);
|
||||
background: #cddcff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#app *,
|
||||
#app *::before,
|
||||
#app *::after {
|
||||
scroll-behavior: auto;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: "index.html",
|
||||
check: "check.html",
|
||||
// uitest: "uitest.html",
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,78 @@
|
||||
{
|
||||
", 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.": ", 이 보관함의 현재 상태를 가장 잘 설명하는 옵션을 선택해 주세요. 선택하신 내용에 따라 가장 적절한 방식으로 파일을 확인합니다.",
|
||||
"(Active)": "(활성)",
|
||||
"(BETA) Always overwrite with a newer file": "(베타) 항상 새로운 파일로 덮어쓰기",
|
||||
"(Beta) Use ignore files": "(베타) 제외 규칙 파일 사용",
|
||||
"(Days passed, 0 to disable automatic-deletion)": "(지난 일수, 0으로 설정하면 자동 삭제 비활성화)",
|
||||
"(e.g., after editing many files whilst offline)": "(예: 오프라인 상태에서 많은 파일을 편집한 뒤)",
|
||||
"(e.g., immediately after restoring on another computer, or having recovered from a backup)": "(예: 다른 컴퓨터에서 복원한 직후이거나 백업에서 복구한 경우)",
|
||||
"(e.g., setting up for the first time on a new smartphone, starting from a clean slate)": "(예: 새 스마트폰에서 처음 설정하여 빈 상태에서 시작하는 경우)",
|
||||
"(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": "(예: 청크를 원격에서 읽음) 이 옵션을 활성화하면, LiveSync는 청크를 로컬에 복제하지 않고 원격에서 직접 읽습니다. 커스텀 청크 크기를 키우는 것을 권장합니다.",
|
||||
"(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": "(MB) 이 값이 설정되면, 이보다 큰 로컬 및 원격 파일의 변경 사항은 건너뜁니다. 파일이 다시 작아지면 더 새로운 파일이 사용됩니다.",
|
||||
"(Mega chars)": "(백만 자 단위)",
|
||||
"(Missing)": "(없음)",
|
||||
"(Not recommended) If set, credentials will be stored in the file.": "(권장하지 않음) 설정한 경우 자격 증명이 파일에 저장됩니다.",
|
||||
"(Obsolete) Use an old adapter for compatibility": "(사용 중단) 호환성을 위해 이전 어댑터 사용",
|
||||
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.",
|
||||
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.",
|
||||
"(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.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
|
||||
"(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.": "(이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.",
|
||||
"↑: Overwrite Remote": "↑: 원격 덮어쓰기",
|
||||
"↓: Overwrite Local": "↓: 로컬 덮어쓰기",
|
||||
"⇅: Use newer": "⇅: 더 새로운 쪽 사용",
|
||||
"+1 week": "+1주",
|
||||
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 다음 연결된 기기가 감지되었습니다:\n${devices}",
|
||||
"⚠️ Important Notice": "⚠️ 중요 안내",
|
||||
"⚠️ Please Confirm the Following": "⚠️ 다음 내용을 확인해 주세요",
|
||||
"✔ SELECT": "✔ 선택",
|
||||
"✔ SYNC": "✔ 동기화",
|
||||
"✔ WATCH": "✔ 감시",
|
||||
"📡 Off": "📡 꺼짐",
|
||||
"📡 On": "📡 켜짐",
|
||||
"🔴 Disconnected": "🔴 연결 끊김",
|
||||
"🕵️ Diag": "🕵️ 진단",
|
||||
"🗑 Delete": "🗑 삭제",
|
||||
"🟢 Connected": "🟢 연결됨",
|
||||
"${count} issue(s) detected!": "${count}개의 문제가 감지되었습니다!",
|
||||
"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.": "Setup URI는 서버 주소와 인증 정보를 담은 하나의 문자열입니다. 서버 설치 스크립트가 URI를 생성했다면, 이를 사용해 간단하고 안전하게 구성할 수 있습니다.",
|
||||
"Accept": "수락",
|
||||
"Accept in session": "이 세션에서만 수락",
|
||||
"ACCEPTED": "수락됨",
|
||||
"ACCEPTED (in session)": "수락됨 (이 세션에서만)",
|
||||
"Access Key": "액세스 키",
|
||||
"Access Key ID": "액세스 키 ID",
|
||||
"Action": "작업",
|
||||
"Activate": "활성화",
|
||||
"Active Remote Configuration": "활성 원격 구성",
|
||||
"Add default patterns": "기본 패턴 추가",
|
||||
"Add new connection": "연결 추가",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
|
||||
"Advanced": "고급",
|
||||
"Advanced Settings": "고급 설정",
|
||||
"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.": "재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.",
|
||||
"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.": "재시작하면 서버의 데이터를 사용해 이 기기의 데이터베이스가 재구축됩니다. 이 보관함에 동기화되지 않은 파일이 있다면 서버 데이터와 충돌이 발생할 수 있습니다.",
|
||||
"After that, synchronise to a brand new vault on each other device with the new remote one by one.": "그런 다음 다른 기기에서도 하나씩 새 원격을 사용해 완전히 새로운 보관함에 동기화하세요.",
|
||||
"All checks passed successfully!": "모든 검사를 통과했습니다!",
|
||||
"All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": "모든 기기의 진행 값이 동일합니다(${progress}). 기기들이 동기화된 것으로 보이므로 가비지 컬렉션을 진행할 수 있습니다.",
|
||||
"All the same or non-existent": "모두 동일하거나 존재하지 않음",
|
||||
"Allow in session": "이 세션에서만 허용",
|
||||
"Allow permanently": "항상 허용",
|
||||
"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.": "또한 Peer-to-Peer 동기화를 사용 중이라면, 이 구성은 나중에 다른 방식으로 전환하여 원격 서버에 연결할 때 사용된다는 점에 유의해 주세요.",
|
||||
"Always prompt merge conflicts": "항상 병합 충돌 알림",
|
||||
"Analyse": "분석",
|
||||
"Analyse database usage": "데이터베이스 사용량 분석",
|
||||
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": "데이터베이스 사용량을 분석하고 직접 진단할 수 있도록 TSV 보고서를 생성합니다. 생성된 보고서는 원하는 스프레드시트에 붙여 넣어 확인할 수 있습니다.",
|
||||
"Apply All Selected": "선택 항목 모두 적용",
|
||||
"Apply Latest Change if Conflicting": "충돌 시 최신 변경 사항 적용",
|
||||
"Apply preset configuration": "프리셋 구성 적용",
|
||||
"Apply the settings": "설정 적용",
|
||||
"Ask a passphrase at every launch": "시작할 때마다 패스프레이즈 묻기",
|
||||
"Auto Connect": "자동 연결",
|
||||
"Auto Start P2P Connection": "P2P 연결 자동 시작",
|
||||
"Automatic": "자동",
|
||||
"Automatically Sync all files when opening Obsidian.": "Obsidian을 열 때 모든 파일을 자동으로 동기화합니다.",
|
||||
"Available Peers": "사용 가능한 피어",
|
||||
"Back": "뒤로",
|
||||
"Back to non-configured": "미구성 상태로 되돌리기",
|
||||
"Batch database update": "일괄 데이터베이스 업데이트",
|
||||
@@ -35,19 +80,31 @@
|
||||
"Batch size": "배치 크기",
|
||||
"Batch size of on-demand fetching": "필요 시 가져오기의 배치 크기",
|
||||
"Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": "v0.17.16 이전에는 로컬 데이터베이스에 이전 어댑터를 사용했습니다. 이제는 새로운 어댑터를 권장합니다. 하지만 로컬 데이터베이스 재구축이 필요합니다. 충분한 시간이 있을 때 이 토글을 비활성화해 주세요. 활성화된 상태로 두면 원격 데이터베이스에서 가져올 때도 이를 비활성화하라는 메시지가 나타납니다.",
|
||||
"Broadcasting?": "브로드캐스트란?",
|
||||
"Bucket Name": "버킷 이름",
|
||||
"by resetting the remote, you will be informed on other devices.": "원격을 초기화하면 다른 기기에서 알림을 받게 됩니다.",
|
||||
"Cancel": "취소",
|
||||
"Cancel Garbage Collection": "가비지 컬렉션 취소",
|
||||
"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.": "암호화 알고리즘을 변경하면 이전에 다른 알고리즘으로 암호화된 데이터에는 접근할 수 없게 됩니다. 데이터에 계속 접근할 수 있도록 모든 기기가 동일한 알고리즘을 사용하도록 설정해 주세요.",
|
||||
"Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.": "이 설정을 변경하려면 기존 데이터를 마이그레이션하고(시간이 다소 걸릴 수 있습니다) Obsidian을 재시작해야 합니다. 진행하기 전에 반드시 데이터를 백업해 주세요.",
|
||||
"Check": "확인",
|
||||
"Check and convert non-path-obfuscated files": "경로 난독화되지 않은 파일 검사 및 변환",
|
||||
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "아직 경로 난독화 ID로 변환되지 않은 문서를 확인하고 필요하면 변환합니다.",
|
||||
"Checking connection... Please wait.": "연결을 확인하는 중입니다... 잠시만 기다려 주세요.",
|
||||
"Chunks": "청크",
|
||||
"Close": "닫기",
|
||||
"Close & Disconnect": "닫고 연결 끊기",
|
||||
"Close this dialog": "이 대화 상자 닫기",
|
||||
"Closed:": "종료됨:",
|
||||
"cmdConfigSync.showCustomizationSync": "사용자 설정 동기화 표시",
|
||||
"Comma separated `.gitignore, .dockerignore`": "쉼표로 구분된 `.gitignore, .dockerignore`",
|
||||
"Command": "명령",
|
||||
"Communicating": "통신 중",
|
||||
"Compaction in progress on remote database...": "원격 데이터베이스에서 압축 정리를 진행 중입니다...",
|
||||
"Compaction on remote database completed successfully.": "원격 데이터베이스 압축 정리가 성공적으로 완료되었습니다.",
|
||||
"Compaction on remote database failed.": "원격 데이터베이스 압축 정리에 실패했습니다.",
|
||||
"Compaction on remote database timed out.": "원격 데이터베이스 압축 정리 시간이 초과되었습니다.",
|
||||
"Compare file": "파일 비교",
|
||||
"Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": "로컬 데이터베이스와 저장소 간의 파일 내용을 비교합니다. 일치하지 않으면 어떤 쪽을 유지할지 묻게 됩니다.",
|
||||
"Compatibility (Conflict Behaviour)": "호환성 (충돌 동작)",
|
||||
"Compatibility (Database structure)": "호환성 (데이터베이스 구조)",
|
||||
@@ -56,48 +113,78 @@
|
||||
"Compatibility (Remote Database)": "호환성 (원격 데이터베이스)",
|
||||
"Compatibility (Trouble addressed)": "호환성 (문제 대응)",
|
||||
"Compute revisions for chunks": "청크에 대한 리비전 계산",
|
||||
"Configuration": "구성",
|
||||
"Configuration Encryption": "구성 암호화",
|
||||
"Configure": "설정",
|
||||
"Configure And Change Remote": "원격 구성 및 변경",
|
||||
"Configure E2EE": "E2EE 구성",
|
||||
"Configure Remote": "원격 구성",
|
||||
"Configure the same server information as your other devices again, manually, very advanced users only.": "다른 기기와 동일한 서버 정보를 다시 직접 입력합니다. 숙련된 사용자 전용입니다.",
|
||||
"Connect": "연결",
|
||||
"Connected to Signaling Server (as Peer ID: ${peerId})": "시그널링 서버에 연결되었습니다 (피어 ID: ${peerId})",
|
||||
"Connected:": "연결됨:",
|
||||
"Connection Method": "연결 방법",
|
||||
"Connection Settings": "연결 설정",
|
||||
"Connection:": "연결:",
|
||||
"Continue anyway": "무시하고 계속",
|
||||
"Continue to CouchDB setup": "CouchDB 설정으로 계속",
|
||||
"Continue to Peer-to-Peer only setup": "Peer-to-Peer 전용 설정으로 계속",
|
||||
"Continue to S3/MinIO/R2 setup": "S3/MinIO/R2 설정으로 계속",
|
||||
"Copy": "복사",
|
||||
"Copy Report to clipboard": "보고서를 클립보드에 복사",
|
||||
"CouchDB Configuration": "CouchDB 구성",
|
||||
"CouchDB Connection Tweak": "CouchDB 연결 조정",
|
||||
"Create P2P remote": "P2P 원격 만들기",
|
||||
"Cross-platform": "크로스 플랫폼",
|
||||
"Current adapter: {adapter}": "현재 어댑터: {adapter}",
|
||||
"Custom Headers": "사용자 지정 헤더",
|
||||
"Customization Sync": "사용자 설정 동기화",
|
||||
"Customization Sync (Beta3)": "사용자 설정 동기화 (Beta3)",
|
||||
"Data Compression": "데이터 압축",
|
||||
"Data to Copy": "복사할 데이터",
|
||||
"Database -> Storage": "데이터베이스 -> 스토리지",
|
||||
"Database Adapter": "데이터베이스 어댑터",
|
||||
"Database Name": "데이터베이스 이름",
|
||||
"Database suffix": "데이터베이스 접미사",
|
||||
"Date": "날짜",
|
||||
"Default": "기본값",
|
||||
"Delay conflict resolution of inactive files": "비활성 파일의 충돌 해결 지연",
|
||||
"Delay merge conflict prompt for inactive files.": "비활성 파일의 병합 충돌 프롬프트 지연.",
|
||||
"Delete": "삭제",
|
||||
"Delete all customization sync data": "모든 사용자 설정 동기화 데이터 삭제",
|
||||
"Delete all data on the remote server.": "원격 서버의 모든 데이터를 삭제합니다.",
|
||||
"Delete All of": "모두 삭제할 대상",
|
||||
"Delete local database to reset or uninstall Self-hosted LiveSync": "Self-hosted LiveSync를 초기화하거나 제거하기 위해 로컬 데이터베이스를 삭제",
|
||||
"Delete old metadata of deleted files on start-up": "시작 시 삭제된 파일의 오래된 메타데이터 삭제",
|
||||
"Delete Remote Configuration": "원격 구성 삭제",
|
||||
"Delete remote configuration '{name}'?": "'{name}' 원격 구성을 삭제할까요?",
|
||||
"Delete remote configuration '${name}'?": "'${name}' 원격 구성을 삭제할까요?",
|
||||
"DENIED": "거부됨",
|
||||
"DENIED (in session)": "거부됨 (이 세션에서만)",
|
||||
"Deny": "거부",
|
||||
"Deny in session": "이 세션에서만 거부",
|
||||
"Deny permanently": "항상 거부",
|
||||
"Deselect all": "선택 모두 해제",
|
||||
"desktop": "데스크톱",
|
||||
"Detected Peers": "감지된 피어",
|
||||
"Developer": "개발자",
|
||||
"Device": "기기",
|
||||
"device name": "기기 이름",
|
||||
"Device name": "기기 이름",
|
||||
"Device name to identify the device. Please use shorter one for the stable peer detection, i.e., \"iphone-16\" or \"macbook-2021\".": "기기를 식별하기 위한 기기 이름입니다. 피어를 안정적으로 감지할 수 있도록 \"iphone-16\"이나 \"macbook-2021\"처럼 짧은 이름을 사용해 주세요.",
|
||||
"Device Peer ID": "기기 피어 ID",
|
||||
"Device Setup Method": "기기 설정 방법",
|
||||
"Devices:": "기기:",
|
||||
"Diagnostic RTCPeerConnection is enabled": "진단용 RTCPeerConnection이 활성화되어 있습니다",
|
||||
"dialog.yourLanguageAvailable": "Self-hosted LiveSync가 사용 중인 언어의 번역을 제공하므로 %{Display Language} 설정이 활성화되었습니다.\n\n참고: 모든 메시지가 번역되어 있지는 않습니다. 여러분의 기여를 기다리고 있습니다!\n참고 2: 이슈를 등록할 때는 **%{lang-def} 로 되돌린 뒤** 스크린샷과 메시지, 로그를 첨부해 주세요. 설정 대화 상자에서 되돌릴 수 있습니다.\n편하게 사용하실 수 있기를 바랍니다!",
|
||||
"dialog.yourLanguageAvailable.btnRevertToDefault": "%{lang-def} 유지",
|
||||
"dialog.yourLanguageAvailable.Title": " 번역을 사용할 수 있습니다!",
|
||||
"Diff": "차이",
|
||||
"Different": "다름",
|
||||
"Disables all synchronization and restart.": "모든 동기화를 비활성화하고 재시작합니다.",
|
||||
"Disables logging, only shows notifications. Please disable if you report an issue.": "로깅을 비활성화하고 알림만 표시합니다. 문제를 신고하는 경우 비활성화해 주세요.",
|
||||
"Disconnect": "연결 끊기",
|
||||
"Dismiss": "닫기",
|
||||
"Display Language": "표시 언어",
|
||||
"Display name": "표시 이름",
|
||||
"Do not check configuration mismatch before replication": "복제 전 구성 불일치 확인 안 함",
|
||||
@@ -136,33 +223,70 @@
|
||||
"Enable customization sync": "사용자 설정 동기화 활성화",
|
||||
"Enable Developers' Debug Tools.": "개발자 디버그 도구 활성화",
|
||||
"Enable edge case treatment features": "특수 사례 처리 기능 활성화",
|
||||
"Enable P2P Replicator": "P2P 복제기 활성화",
|
||||
"Enable poweruser features": "파워 유저 기능 활성화",
|
||||
"Enable this if your Object Storage doesn't support CORS": "객체 스토리지가 CORS를 지원하지 않는 경우 활성화하세요",
|
||||
"Enable this option to automatically apply the most recent change to documents even when it conflicts": "이 옵션을 활성화하면 충돌이 있어도 문서에 가장 최근 변경 사항을 자동으로 적용합니다",
|
||||
"Enabled": "활성화됨",
|
||||
"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.": "종단 간 암호화를 활성화하면 데이터가 원격 서버로 전송되기 전에 기기에서 암호화됩니다. 따라서 누군가 서버에 접근하더라도 패스프레이즈 없이는 데이터를 읽을 수 없습니다. 다른 기기에서 데이터를 복호화할 때 필요하므로 패스프레이즈를 반드시 기억해 두세요.",
|
||||
"Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": "원격 데이터베이스의 내용을 암호화합니다. 플러그인의 동기화 기능을 사용하는 경우 활성화를 권장합니다.",
|
||||
"Encrypting sensitive configuration items": "민감한 구성 항목 암호화",
|
||||
"Encryption Algorithm": "암호화 알고리즘",
|
||||
"Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": "암호화에 사용할 패스프레이즈입니다. 변경한 경우, 새로 암호화된 파일로 서버의 데이터베이스를 덮어써야 합니다.",
|
||||
"End-to-End Encryption": "종단 간 암호화",
|
||||
"Endpoint URL": "엔드포인트 URL",
|
||||
"Enhance chunk size": "청크 크기 확대",
|
||||
"Enter a folder prefix (optional)": "폴더 접두사를 입력하세요 (선택 사항)",
|
||||
"Enter Server Information": "서버 정보 입력",
|
||||
"Enter Setup URI": "Setup URI 입력",
|
||||
"Enter the server information manually": "서버 정보를 수동으로 입력",
|
||||
"Enter TURN credential": "TURN 자격 증명을 입력하세요",
|
||||
"Enter TURN username": "TURN 사용자 이름을 입력하세요",
|
||||
"Enter your Access Key ID": "액세스 키 ID를 입력하세요",
|
||||
"Enter your Bucket Name": "버킷 이름을 입력하세요",
|
||||
"Enter your database name": "데이터베이스 이름을 입력하세요",
|
||||
"Enter your JWT Key ID": "JWT 키 ID를 입력하세요",
|
||||
"Enter your JWT secret or private key": "JWT 시크릿 또는 개인 키를 입력하세요",
|
||||
"Enter your JWT Subject (CouchDB Username)": "JWT 주체(CouchDB 사용자 이름)를 입력하세요",
|
||||
"Enter your passphrase": "패스프레이즈를 입력하세요",
|
||||
"Enter your password": "비밀번호를 입력하세요",
|
||||
"Enter your Region (e.g., us-east-1, auto for R2)": "리전을 입력하세요 (예: us-east-1, R2는 auto)",
|
||||
"Enter your Secret Access Key": "시크릿 액세스 키를 입력하세요",
|
||||
"Enter your username": "사용자 이름을 입력하세요",
|
||||
"Error during connection test: ${reason}": "연결 테스트 중 오류가 발생했습니다: ${reason}",
|
||||
"Error during testAndFixSettings: ${reason}": "testAndFixSettings 실행 중 오류가 발생했습니다: ${reason}",
|
||||
"Experimental Settings": "실험적 설정",
|
||||
"Export": "내보내기",
|
||||
"Failed to connect to remote for compaction.": "압축 정리를 위해 원격 데이터베이스에 연결하지 못했습니다.",
|
||||
"Failed to connect to remote for compaction. ${reason}": "압축 정리를 위해 원격 데이터베이스에 연결하지 못했습니다. ${reason}",
|
||||
"Failed to connect to the server: ${reason}": "서버에 연결하지 못했습니다: ${reason}",
|
||||
"Failed to connect to the server. Please check your settings.": "서버에 연결하지 못했습니다. 설정을 확인해 주세요.",
|
||||
"Failed to connect to the signalling relay: ${reason}": "시그널링 중계 서버에 연결하지 못했습니다: ${reason}",
|
||||
"Failed to create replicator instance.": "복제기 인스턴스를 생성하지 못했습니다.",
|
||||
"Failed to parse Setup-URI.": "Setup-URI를 해석하지 못했습니다.",
|
||||
"Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": "가비지 컬렉션 전에 일회성 복제를 시작하지 못했습니다. 가비지 컬렉션을 취소합니다.",
|
||||
"Failed to start replication after Garbage Collection.": "가비지 컬렉션 후 복제를 시작하지 못했습니다.",
|
||||
"Failed:": "실패:",
|
||||
"Fetch": "가져오기",
|
||||
"Fetch chunks on demand": "필요 시 청크 원격 가져오기",
|
||||
"Fetch database with previous behaviour": "이전 동작으로 데이터베이스 가져오기",
|
||||
"Fetch remote settings": "원격 설정 가져오기",
|
||||
"FETCHING": "가져오는 중",
|
||||
"Fetching status...": "상태를 가져오는 중입니다...",
|
||||
"File integrity": "파일 무결성",
|
||||
"File to resolve conflict": "충돌을 해결할 파일",
|
||||
"File to view History": "기록을 볼 파일",
|
||||
"Filename": "파일명",
|
||||
"Final Confirmation: Overwrite Server Data with This Device's Files": "최종 확인: 이 기기의 파일로 서버 데이터 덮어쓰기",
|
||||
"First, please select the option that best describes your current situation.": "먼저 현재 상황에 가장 잘 맞는 항목을 선택해 주세요.",
|
||||
"Fix": "수정",
|
||||
"Flag and restart": "표시 후 재시작",
|
||||
"Flagged Selective": "플래그 지정 선택적",
|
||||
"Folder Prefix": "폴더 접두사",
|
||||
"For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.": "HS256/HS512 알고리즘에는 공유 시크릿 키를, ES256/ES512 알고리즘에는 pkcs8 PEM 형식의 개인 키를 입력하세요.",
|
||||
"Forces the file to be synced when opened.": "파일을 열 때 강제로 동기화합니다.",
|
||||
"Fresh Start Wipe": "초기화 후 새로 시작",
|
||||
"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.": "또한 서버 데이터에 이미 충돌이 존재한다면 그 상태 그대로 이 기기에 동기화되므로, 로컬에서 직접 해결해야 합니다.",
|
||||
"Garbage Collection cancelled by user.": "사용자가 가비지 컬렉션을 취소했습니다.",
|
||||
"Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": "가비지 컬렉션이 완료되었습니다. 삭제된 청크: ${deletedChunks} / ${totalChunks}. 소요 시간: ${seconds}초.",
|
||||
"Garbage Collection Confirmation": "가비지 컬렉션 확인",
|
||||
@@ -170,15 +294,33 @@
|
||||
"Garbage Collection: Found ${unusedChunks} unused chunks to delete.": "가비지 컬렉션: 삭제할 미사용 청크 ${unusedChunks}개를 찾았습니다.",
|
||||
"Garbage Collection: Scanned ${scanned} / ~${docCount}": "가비지 컬렉션: ${scanned} / ~${docCount} 검사함",
|
||||
"Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": "가비지 컬렉션: 검사 완료. 전체 청크 수: ${totalChunks}, 사용 중인 청크 수: ${usedChunks}",
|
||||
"Gathering information...": "정보를 수집하는 중입니다...",
|
||||
"Generate Random ID": "임의 ID 생성",
|
||||
"Group ID": "그룹 ID",
|
||||
"Handle files as Case-Sensitive": "파일을 대소문자 구분으로 처리",
|
||||
"Have you created a backup before proceeding?": "진행하기 전에 백업을 만드셨나요?",
|
||||
"Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.": "숨김 파일 동기화가 일시적으로 비활성화되었습니다. 필요하다면 가져오기가 끝난 뒤에 다시 활성화해 주세요.",
|
||||
"Hidden Files": "숨김 파일",
|
||||
"Hide completely": "완전히 숨기기",
|
||||
"Hide not applicable items": "해당 없는 항목 숨기기",
|
||||
"Higher (${local} > ${remote})": "더 높음 (${local} > ${remote})",
|
||||
"Highlight diff": "차이 강조",
|
||||
"How to display network errors when the sync server is unreachable.": "동기화 서버에 연결할 수 없을 때 네트워크 오류를 어떻게 표시할지 설정합니다.",
|
||||
"How would you like to configure the connection to your server?": "서버 연결을 어떻게 구성하시겠습니까?",
|
||||
"However, This should not be enabled if you want to increase your secrecy more.": "다만 기밀성을 더 높이고 싶다면 이 옵션은 활성화하지 않는 것이 좋습니다.",
|
||||
"I am adding a device to an existing synchronisation setup": "기존 동기화 구성에 기기를 추가합니다",
|
||||
"I am setting this up for the first time": "처음으로 설정합니다",
|
||||
"I am setting up a new server for the first time / I want to reset my existing server.": "새 서버를 처음 설정하거나, 기존 서버를 초기화하려고 합니다.",
|
||||
"I am unable to create a backup of my Vault.": "보관함을 백업할 수 없습니다.",
|
||||
"I am unable to create a backup of my Vaults.": "보관함들을 백업할 수 없습니다.",
|
||||
"I have created a backup of my Vault.": "보관함을 백업했습니다.",
|
||||
"I know my server details, let me enter them": "서버 정보를 알고 있으니 직접 입력하겠습니다",
|
||||
"I understand that all changes made on other smartphones or computers possibly could be lost.": "다른 스마트폰이나 컴퓨터에서 변경한 내용이 모두 사라질 수 있다는 점을 이해합니다.",
|
||||
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.": "다른 기기는 더 이상 동기화할 수 없게 되며, 동기화 정보를 초기화해야 한다는 점을 이해합니다.",
|
||||
"I understand that this action is irreversible once performed.": "이 작업은 한 번 수행하면 되돌릴 수 없다는 점을 이해합니다.",
|
||||
"I understand the risks and will proceed without a backup.": "위험을 이해하며 백업 없이 진행하겠습니다.",
|
||||
"I Understand, Overwrite Server": "이해했습니다, 서버 덮어쓰기",
|
||||
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "\"P2P 연결 자동 시작\"이 활성화되어 있으면 플러그인이 시작될 때 P2P 연결이 자동으로 시작됩니다.",
|
||||
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "비활성화(토글)되면 청크는 UI 스레드에서 분할됩니다 (이전 동작).",
|
||||
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "활성화하면 파일별 효율적인 사용자 설정 동기화가 사용됩니다. 이를 활성화할 때 소규모 데이터 구조 전환이 필요합니다. 모든 기기를 v0.23.18로 업데이트해야 합니다. 이를 활성화하면 이전 버전과의 호환성이 사라집니다.",
|
||||
"If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": "활성화하면 청크는 최대 100개 항목으로 분할됩니다. 하지만 중복 제거 기능이 약간 약해집니다.",
|
||||
@@ -191,16 +333,38 @@
|
||||
"If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": "이 옵션을 활성화하면 청크가 문단이나 의미 단위로 나뉘어 저장됩니다. 단, 이 기능은 일부 플랫폼에서는 지원되지 않을 수 있습니다.",
|
||||
"If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": "이 옵션을 활성화하면, 제외 규칙 파일에 일치하는 로컬 파일의 변경 사항은 건너뜁니다. 원격 변경 여부 또한 로컬의 제외 규칙 파일에 따라 판단됩니다.",
|
||||
"If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": "이 옵션이 활성화되면 PouchDB는 연결을 더이상 무한히 열어두지 않고 60초 동안 유지합니다. 그 시간 내에 변경 사항이 없으면 소켓을 닫고 다시 엽니다. 프록시가 요청 지속 시간을 제한할 때 유용하지만 리소스 사용량이 증가할 수 있습니다.",
|
||||
"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.": "CORS 문제를 피할 수 없다면 이 옵션을 시도해 볼 수 있습니다. Obsidian의 내부 API를 사용해 CouchDB 서버와 통신합니다. 웹 표준을 따르지는 않지만 동작합니다. 향후 Obsidian 버전에서 동작하지 않을 수 있다는 점에 유의하세요.",
|
||||
"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.": "CORS 문제를 피할 수 없다면 이 옵션을 시도해 볼 수 있습니다. Obsidian의 내부 API를 사용해 S3 서버와 통신합니다. 웹 표준을 따르지는 않지만 동작합니다. 향후 Obsidian 버전에서 동작하지 않을 수 있다는 점에 유의하세요.",
|
||||
"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.": "이 기기의 보관함에 동기화되지 않은 변경 사항이 있다면, 초기화 후 서버의 버전과 어긋나기 쉽습니다. 그 결과 많은 파일에서 충돌이 발생할 수 있습니다.",
|
||||
"If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": "IBM Cloudant를 사용하다가 페이로드 크기 제한에 도달했다면, 배치 크기와 배치 개수 제한을 더 낮은 값으로 줄여 주세요.",
|
||||
"If you understand the risks and still wish to proceed, select so.": "위험을 이해하고도 진행하려면 그렇게 선택해 주세요.",
|
||||
"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.": "버킷 안의 특정 폴더에 데이터를 저장하려면 여기에 폴더 접두사를 지정할 수 있습니다. 그렇지 않다면 비워 두어 버킷 최상위에 데이터를 저장하세요.",
|
||||
"If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.": "`LiveSync`를 사용하려면 변경 사항을 브로드캐스트해야 합니다. 이를 감지한 모든 `watching` 상태의 피어가 가져오기를 위한 복제를 시작합니다.",
|
||||
"Ignore": "무시",
|
||||
"Ignore and Proceed": "무시하고 계속",
|
||||
"Ignore files": "제외 규칙 파일",
|
||||
"Ignore patterns": "무시 패턴",
|
||||
"Import connection": "연결 가져오기",
|
||||
"IMPORTANT": "중요",
|
||||
"In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.": "대부분의 경우 기본 알고리즘(${algorithm})을 그대로 사용하는 것이 좋습니다. 이 설정은 기존 보관함이 다른 형식으로 암호화되어 있는 경우에만 필요합니다.",
|
||||
"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.": "이 경우 Self-hosted LiveSync는 모든 파일의 메타데이터를 다시 만들면서 의도적으로 충돌을 발생시킵니다. 파일 내용이 동일한 경우 이러한 충돌은 자동으로 해결됩니다.",
|
||||
"Incoming:": "수신:",
|
||||
"Incubate Chunks in Document": "문서 내 청크 임시 보관",
|
||||
"Initial Action": "초기 작업",
|
||||
"Initialise all journal history, On the next sync, every item will be received and sent.": "모든 저널 기록을 초기화합니다. 다음 동기화 때 모든 항목을 다시 받고 다시 보냅니다.",
|
||||
"Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": "저널 수신 기록을 초기화합니다. 다음 동기화 때 이 기기가 보낸 항목을 제외한 모든 항목을 다시 내려받습니다.",
|
||||
"Initialise journal sent history. On the next sync, every item except this device received will be sent again.": "저널 송신 기록을 초기화합니다. 다음 동기화 때 이 기기가 받은 항목을 제외한 모든 항목을 다시 보냅니다.",
|
||||
"Interval (sec)": "간격 (초)",
|
||||
"INVERTED": "반전됨",
|
||||
"Issue detection log:": "문제 감지 로그:",
|
||||
"It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.": "진행하기 전에 백업을 만드시기를 강력히 권장합니다. 백업 없이 진행하면 데이터가 손실될 수 있습니다.",
|
||||
"Just for a minute, please!": "잠시만 기다려 주세요!",
|
||||
"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.": "JWT(JSON Web Token) 인증을 사용하면 토큰으로 CouchDB 서버에 안전하게 인증할 수 있습니다. CouchDB 서버가 JWT를 받아들이도록 설정되어 있는지, 그리고 입력한 키와 설정이 서버 구성과 일치하는지 확인해 주세요. 참고로 이 기능은 그리 철저하게 검증되지는 않았습니다.",
|
||||
"JWT Algorithm": "JWT 알고리즘",
|
||||
"JWT Expiration Duration (minutes)": "JWT 만료 시간(분)",
|
||||
"JWT Key": "JWT 키",
|
||||
"JWT Key ID (kid)": "JWT 키 ID (kid)",
|
||||
"JWT Subject (sub)": "JWT 주체 (sub)",
|
||||
"K.exp": "실험 기능",
|
||||
"K.long_p2p_sync": "%{title_p2p_sync} (%{exp})",
|
||||
"K.P2P": "%{Peer}-to-%{Peer}",
|
||||
@@ -222,6 +386,7 @@
|
||||
"lang-zh-tw": "繁體中文",
|
||||
"Later": "나중에",
|
||||
"Limit: {datetime} ({timestamp})": "제한: {datetime} ({timestamp})",
|
||||
"live": "실시간",
|
||||
"LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": "LiveSync는 접두사로 구분되지 않은 동일한 이름의 보관함을 여러 개 처리할 수 없습니다. 이 값은 자동으로 구성되어야 합니다.",
|
||||
"liveSyncReplicator.beforeLiveSync": "LiveSync 전에 OneShot을 먼저 시작합니다...",
|
||||
"liveSyncReplicator.cantReplicateLowerValue": "더 낮은 값으로 복제할 수 없습니다.",
|
||||
@@ -250,6 +415,7 @@
|
||||
"liveSyncSetting.valueShouldBeInRange": "값은 ${min} < 값 < ${max} 범위에 있어야 합니다",
|
||||
"liveSyncSettings.btnApply": "적용",
|
||||
"Local Database Tweak": "로컬 데이터베이스 조정",
|
||||
"Local only": "로컬에만 있음",
|
||||
"Lock": "잠금",
|
||||
"Lock Server": "서버 잠금",
|
||||
"Lock the remote server to prevent synchronization with other devices.": "다른 기기와의 동기화를 방지하기 위해 원격 서버를 잠급니다.",
|
||||
@@ -258,6 +424,9 @@
|
||||
"logPane.pause": "일시 정지",
|
||||
"logPane.title": "Self-hosted LiveSync 로그",
|
||||
"logPane.wrap": "줄 바꿈",
|
||||
"Lower (${local} < ${remote})": "더 낮음 (${local} < ${remote})",
|
||||
"Maintenance Commands": "유지보수 명령",
|
||||
"Maintenance mode": "유지보수 모드",
|
||||
"Maximum delay for batch database updating": "일괄 데이터베이스 업데이트 최대 지연",
|
||||
"Maximum file size": "최대 파일 크기",
|
||||
"Maximum Incubating Chunk Size": "임시 보관 청크의 최대 크기",
|
||||
@@ -270,6 +439,7 @@
|
||||
"Merge": "병합",
|
||||
"Minimum delay for batch database updating": "일괄 데이터베이스 업데이트 최소 지연",
|
||||
"Minimum interval for syncing": "동기화 최소 간격",
|
||||
"Mixed": "혼합",
|
||||
"moduleCheckRemoteSize.logCheckingStorageSizes": "스토리지 크기 확인 중",
|
||||
"moduleCheckRemoteSize.logCurrentStorageSize": "원격 스토리지 크기: ${measuredSize}",
|
||||
"moduleCheckRemoteSize.logExceededWarning": "원격 스토리지 크기: ${measuredSize}이(가) ${notifySize}을(를) 초과했습니다",
|
||||
@@ -352,23 +522,39 @@
|
||||
"moduleMigration.titleWelcome": "Self-hosted LiveSync에 오신 것을 환영합니다",
|
||||
"moduleObsidianMenu.replicate": "복제",
|
||||
"More actions": "추가 작업",
|
||||
"Mostly Complete: Decision Required": "거의 완료: 결정이 필요합니다",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "원격에서 삭제된 파일을 삭제하는 대신 휴지통으로 이동합니다.",
|
||||
"My remote server is already set up. I want to join this device.": "원격 서버가 이미 설정되어 있습니다. 이 기기를 참여시키고 싶습니다.",
|
||||
"Name": "이름",
|
||||
"Network warning style": "네트워크 경고 표시 방식",
|
||||
"NEW": "신규",
|
||||
"New Remote": "새 원격",
|
||||
"Newer (${diff})": "더 새로움 (${diff})",
|
||||
"No checks have been performed yet.": "아직 검사를 수행하지 않았습니다.",
|
||||
"No connected device information found. Cancelling Garbage Collection.": "연결된 기기 정보를 찾을 수 없습니다. 가비지 컬렉션을 취소합니다.",
|
||||
"No Connection": "연결 없음",
|
||||
"No devices available. Waiting for other devices to connect...": "사용 가능한 기기가 없습니다. 다른 기기가 연결되기를 기다리는 중입니다...",
|
||||
"No Items.": "항목이 없습니다.",
|
||||
"No limit configured": "제한이 설정되지 않음",
|
||||
"NO PREVIEW": "미리 보기 없음",
|
||||
"No, please take me back": "아니요, 이전으로 돌아가겠습니다",
|
||||
"Node ID": "노드 ID",
|
||||
"Node Information Missing": "노드 정보 누락",
|
||||
"Non-Synchronising files": "동기화하지 않는 파일",
|
||||
"Normal Files": "일반 파일",
|
||||
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "모든 메시지가 번역되지 않았습니다. 오류 신고 시 \"기본값\"으로 되돌려 주세요.",
|
||||
"Not configured": "구성되지 않음",
|
||||
"Not now": "나중에",
|
||||
"Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.": "그룹 ID는 생성된 형식으로 제한되지 않으며, 어떤 문자열이든 그룹 ID로 사용할 수 있습니다.",
|
||||
"Note that you can generate a new Setup URI by running the \"Copy settings as a new Setup URI\" command in the command palette.": "명령 팔레트에서 \"Copy settings as a new Setup URI\" 명령을 실행하면 새 Setup URI를 생성할 수 있습니다.",
|
||||
"Notify all setting files": "모든 설정 파일 알림",
|
||||
"Notify customized": "사용자 설정 알림",
|
||||
"Notify when other device has newly customized.": "다른 기기에서 새로운 사용자 설정이 있을 때 알림을 받습니다.",
|
||||
"Notify when the estimated remote storage size exceeds on start up": "시작 시 예상 원격 스토리지 크기가 초과되면 알림",
|
||||
"Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": "한 번에 처리할 배치 개수입니다. 기본값은 40이고 최소값은 2입니다. 배치 크기와 함께 한 번에 메모리에 보관되는 문서 수를 결정합니다.",
|
||||
"Number of changes to sync at a time. Defaults to 50. Minimum is 2.": "한 번에 동기화할 변경 사항의 수입니다. 기본값은 50입니다. 최소값은 2입니다.",
|
||||
"Obfuscate Properties": "속성 난독화",
|
||||
"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.": "속성(예: 파일 경로, 크기, 생성 및 수정 날짜)을 난독화하면 원격 서버에서 파일과 폴더의 구조 및 이름을 파악하기 어려워져 보안이 한층 강화됩니다. 이는 개인 정보를 보호하는 데 도움이 되며, 권한 없는 사용자가 데이터에 대한 정보를 추측하기 어렵게 만듭니다.",
|
||||
"Obsidian version": "Obsidian 버전",
|
||||
"obsidianLiveSyncSettingTab.btnApply": "적용",
|
||||
"obsidianLiveSyncSettingTab.btnCheck": "확인",
|
||||
@@ -541,16 +727,29 @@
|
||||
"obsidianLiveSyncSettingTab.titleUpdateThinning": "업데이트 솎아내기",
|
||||
"obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": "⚠ CORS 출처가 일치하지 않습니다 ${from}->${to}",
|
||||
"obsidianLiveSyncSettingTab.warnNoAdmin": "⚠ 관리자 권한이 없습니다.",
|
||||
"Of course, we can back up the data before proceeding.": "물론 진행하기 전에 데이터를 백업할 수 있습니다.",
|
||||
"Off": "꺼짐",
|
||||
"Ok": "확인",
|
||||
"Old Algorithm": "이전 알고리즘",
|
||||
"Older (${diff})": "더 오래됨 (${diff})",
|
||||
"Older fallback (Slow, W/O WebAssembly)": "이전 대체 방식 (느림, WebAssembly 없음)",
|
||||
"On": "켜짐",
|
||||
"On the source device, from the command palette, run the 'Show settings as a QR code' command.": "원본 기기에서 명령 팔레트를 열고 'Show settings as a QR code' 명령을 실행합니다.",
|
||||
"On the source device, open Obsidian.": "원본 기기에서 Obsidian을 엽니다.",
|
||||
"On this device, please keep this Vault open.": "이 기기에서는 이 보관함을 계속 열어 두세요.",
|
||||
"On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.": "이 기기에서 카메라 앱으로 전환하거나 QR 코드 스캐너를 사용해 화면에 표시된 QR 코드를 스캔합니다.",
|
||||
"Open": "열기",
|
||||
"Open connection": "연결 열기",
|
||||
"Open P2P Setup...": "P2P 설정 열기...",
|
||||
"Open the dialog": "대화상자 열기",
|
||||
"Other files": "기타 파일",
|
||||
"Overwrite": "덮어쓰기",
|
||||
"Overwrite patterns": "덮어쓰기 패턴",
|
||||
"Overwrite remote": "원격 덮어쓰기",
|
||||
"Overwrite remote with local DB and passphrase.": "로컬 DB와 패스프레이즈로 원격을 덮어씁니다.",
|
||||
"Overwrite Server Data with This Device's Files": "이 기기의 파일로 서버 데이터를 덮어쓰기",
|
||||
"P2P Configuration": "P2P 구성",
|
||||
"P2P Status": "P2P 상태",
|
||||
"P2P.AskPassphraseForDecrypt": "원격 피어가 구성을 공유했습니다. 구성을 복호화하려면 패스프레이즈를 입력해 주세요.",
|
||||
"P2P.AskPassphraseForShare": "원격 피어가 이 기기의 구성을 요청했습니다. 구성을 공유하려면 패스프레이즈를 입력해 주세요. 이 대화상자를 취소하여 요청을 무시할 수 있습니다.",
|
||||
"P2P.DisabledButNeed": "%{title_p2p_sync}가 비활성화되어 있습니다. 정말로 활성화하시겠습니까?",
|
||||
@@ -574,36 +773,59 @@
|
||||
"paneMaintenance.remoteLockedResolvedDevice": "원격 데이터베이스가 잠겨 있지만 이 기기는 이미 승인되었습니다.",
|
||||
"paneMaintenance.unlockDatabaseReady": "데이터베이스 잠금 해제",
|
||||
"Passphrase": "패스프레이즈",
|
||||
"Passphrase is required.": "패스프레이즈가 필요합니다.",
|
||||
"Passphrase of sensitive configuration items": "민감한 구성 항목의 패스프레이즈",
|
||||
"password": "비밀번호",
|
||||
"Password": "비밀번호",
|
||||
"Paste a connection string": "연결 문자열 붙여넣기",
|
||||
"Paste the Setup URI generated from one of your active devices.": "사용 중인 기기 중 하나에서 생성한 Setup URI를 붙여 넣으세요.",
|
||||
"Path": "경로",
|
||||
"Path Obfuscation": "경로 난독화",
|
||||
"Patterns to match files for overwriting instead of merging": "병합 대신 덮어쓸 파일을 판별하는 패턴",
|
||||
"Patterns to match files for syncing": "동기화할 파일을 판별하는 패턴",
|
||||
"Peer ID:": "피어 ID:",
|
||||
"Peer to Peer Replicator": "Peer to Peer 복제기",
|
||||
"Peer-to-Peer only": "Peer-to-Peer 전용",
|
||||
"Peer-to-Peer Synchronisation": "피어 투 피어(P2P) 동기화",
|
||||
"Peers": "피어",
|
||||
"Per-file-saved customization sync": "파일별 저장 사용자 설정 동기화",
|
||||
"Perform": "실행",
|
||||
"Perform cleanup": "정리 실행",
|
||||
"Perform Garbage Collection": "가비지 컬렉션 실행",
|
||||
"Perform Garbage Collection to remove unused chunks and reduce database size.": "사용하지 않는 청크를 제거하고 데이터베이스 크기를 줄이기 위해 가비지 컬렉션을 실행합니다.",
|
||||
"Periodic Sync interval": "주기적 동기화 간격",
|
||||
"PERMANENT": "영구",
|
||||
"Pick a file to resolve conflict": "충돌을 해결할 파일 선택",
|
||||
"Pick a file to show history": "기록을 표시할 파일 선택",
|
||||
"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.": "종단 간 암호화 패스프레이즈는 동기화가 실제로 시작되기 전까지 검증되지 않는다는 점에 유의해 주세요. 이는 데이터를 보호하기 위한 보안 조치입니다.",
|
||||
"Please configure your end-to-end encryption settings.": "종단 간 암호화 설정을 구성해 주세요.",
|
||||
"Please disable 'Read chunks online' in settings to use Garbage Collection.": "가비지 컬렉션을 사용하려면 설정에서 'Read chunks online'을 비활성화해 주세요.",
|
||||
"Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": "가비지 컬렉션을 사용하려면 설정에서 'Compute revisions for chunks'를 활성화해 주세요.",
|
||||
"Please enter the CouchDB server information below.": "아래에 CouchDB 서버 정보를 입력해 주세요.",
|
||||
"Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.": "S3/MinIO/R2 호환 오브젝트 스토리지 서비스에 연결하는 데 필요한 정보를 입력해 주세요.",
|
||||
"Please enter the Peer-to-Peer Synchronisation information below.": "아래에 Peer-to-Peer 동기화 정보를 입력해 주세요.",
|
||||
"Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.": "서버 설치 과정이나 다른 기기에서 생성된 Setup URI를 보관함 패스프레이즈와 함께 입력해 주세요.",
|
||||
"Please follow the steps below to import settings from your existing device.": "기존 기기에서 설정을 가져오려면 아래 단계를 따라 주세요.",
|
||||
"PLEASE NOTE": "유의 사항",
|
||||
"Please select 'Cancel' explicitly to cancel this operation.": "이 작업을 취소하려면 반드시 \"취소\"를 명시적으로 선택해 주세요.",
|
||||
"Please select a method to import the settings from another device.": "다른 기기에서 설정을 가져올 방법을 선택해 주세요.",
|
||||
"Please select an active P2P remote configuration to change P2P sync targets.": "P2P 동기화 대상을 변경하려면 활성 P2P 원격 구성을 선택해 주세요.",
|
||||
"Please select an option to proceed": "계속하려면 항목을 선택해 주세요",
|
||||
"Please select the button below to restart and proceed to the data fetching confirmation.": "아래 버튼을 선택하면 재시작 후 데이터 가져오기 확인 단계로 진행합니다.",
|
||||
"Please select the button below to restart and proceed to the final confirmation.": "아래 버튼을 선택하면 재시작 후 최종 확인 단계로 진행합니다.",
|
||||
"Please select the type of server to which you are connecting.": "연결할 서버 유형을 선택해 주세요.",
|
||||
"Please select your situation.": "현재 상황을 선택해 주세요.",
|
||||
"Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": "이 기기를 식별할 기기 이름을 설정해 주세요. 이 이름은 기기 간에 고유해야 합니다. 설정하기 전까지는 이 기능을 활성화할 수 없습니다.",
|
||||
"Please set this device name": "이 기기의 이름을 설정해 주세요",
|
||||
"Please understand that this is intended behaviour.": "이는 의도된 동작이라는 점을 이해해 주세요.",
|
||||
"Plug-in version": "플러그인 버전",
|
||||
"Plugins": "플러그인",
|
||||
"Prepare the 'report' to create an issue": "이슈 생성을 위한 '보고서' 준비",
|
||||
"Presets": "프리셋",
|
||||
"Prevent fetching configuration from server": "서버에서 구성 가져오기 방지",
|
||||
"Proceed": "진행",
|
||||
"Proceed Garbage Collection": "가비지 컬렉션 계속",
|
||||
"Proceed to the next step.": "다음 단계로 진행합니다.",
|
||||
"Proceed with Setup URI": "Setup URI로 계속",
|
||||
"Proceeding with Garbage Collection, ignoring missing nodes.": "누락된 노드를 무시하고 가비지 컬렉션을 계속 진행합니다.",
|
||||
"Proceeding with Garbage Collection.": "가비지 컬렉션을 진행합니다.",
|
||||
@@ -629,15 +851,22 @@
|
||||
"RedFlag.FetchRemoteConfig.Title": "원격 구성 가져오기",
|
||||
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": "최신 버전이 아닌 모든 리비전을 제거하여 저장 공간을 줄입니다. 이 작업을 수행하려면 원격 서버와 로컬 클라이언트에 동일한 양의 여유 공간이 필요합니다.",
|
||||
"Reducing the frequency with which on-disk changes are reflected into the DB": "디스크 변경 사항이 데이터베이스에 반영되는 빈도를 줄입니다",
|
||||
"Refresh": "새로 고침",
|
||||
"Region": "지역",
|
||||
"Relay settings": "중계 서버 설정",
|
||||
"Reload": "다시 불러오기",
|
||||
"Remediation": "복구 조치",
|
||||
"Remediation Setting Changed": "복구 설정이 변경됨",
|
||||
"Remote Database Tweak (In sunset)": "원격 데이터베이스 조정 (폐기 예정)",
|
||||
"Remote Databases": "원격 데이터베이스",
|
||||
"Remote name": "원격 이름",
|
||||
"Remote only": "원격에만 있음",
|
||||
"Remote server type": "원격 서버 유형",
|
||||
"Remote Type": "원격 유형",
|
||||
"Rename": "이름 바꾸기",
|
||||
"Replicate now": "지금 복제",
|
||||
"Replicating": "복제 중",
|
||||
"Replicating...": "복제 중입니다...",
|
||||
"Replicator.Dialogue.Locked.Action.Dismiss": "재확인을 위해 취소",
|
||||
"Replicator.Dialogue.Locked.Action.Fetch": "이 기기의 동기화 재설정",
|
||||
"Replicator.Dialogue.Locked.Action.Unlock": "원격 데이터베이스 잠금 해제",
|
||||
@@ -660,6 +889,7 @@
|
||||
"Reset": "재설정",
|
||||
"Reset all": "모두 재설정",
|
||||
"Reset all journal counter": "모든 저널 카운터 재설정",
|
||||
"Reset and Resume Synchronisation": "동기화 초기화 후 재개",
|
||||
"Reset journal received history": "저널 수신 기록 재설정",
|
||||
"Reset journal sent history": "저널 송신 기록 재설정",
|
||||
"Reset notification threshold and check the remote database usage": "알림 임계값을 초기화하고 원격 데이터베이스 사용량 확인",
|
||||
@@ -672,14 +902,26 @@
|
||||
"Resolve all conflicted files": "충돌한 모든 파일 해결",
|
||||
"Resolve All conflicted files by the newer one": "충돌한 모든 파일을 최신 버전으로 해결",
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": "충돌한 모든 파일을 더 최신 버전으로 해결합니다. 주의: 이전 버전은 덮어써지며 복원할 수 없습니다.",
|
||||
"Restart and Fetch Data": "재시작 후 데이터 가져오기",
|
||||
"Restart and Initialise Server": "재시작 후 서버 초기화",
|
||||
"Restart Now": "지금 재시작",
|
||||
"Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?": "Obsidian을 재시작하는 것을 강력히 권장합니다. 재시작하기 전까지는 일부 변경 사항이 적용되지 않거나 화면이 일관되지 않게 표시될 수 있습니다. 지금 재시작하시겠습니까?",
|
||||
"Restore or reconstruct local database from remote.": "원격에서 로컬 데이터베이스를 복원하거나 재구축합니다.",
|
||||
"Rev": "리비전",
|
||||
"Revert changes": "변경 사항 되돌리기",
|
||||
"Revoke": "철회",
|
||||
"Room ID": "룸 ID",
|
||||
"Room ID suffix:": "룸 ID 접미사:",
|
||||
"Run Doctor": "진단 실행",
|
||||
"S3/MinIO/R2 Configuration": "S3/MinIO/R2 구성",
|
||||
"S3/MinIO/R2 Object Storage": "S3/MinIO/R2 객체 스토리지",
|
||||
"Same": "동일",
|
||||
"Same or local only": "동일하거나 로컬에만 있음",
|
||||
"Save and Apply": "저장 후 적용",
|
||||
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "설정을 마크다운 파일에 저장합니다. 새로운 설정이 도착하면 알림을 받게 됩니다. 플랫폼별로 다른 파일을 설정할 수 있습니다.",
|
||||
"Saving will be performed forcefully after this number of seconds.": "이 시간(초) 후에 강제로 저장이 수행됩니다.",
|
||||
"Scan a QR Code (Recommended for mobile)": "QR 코드 스캔(모바일 권장)",
|
||||
"Scan changes": "변경 사항 검사",
|
||||
"Scan changes on customization sync": "사용자 설정 동기화 시 변경 사항 검색",
|
||||
"Scan customization automatically": "사용자 설정 자동 검색",
|
||||
"Scan customization before replicating.": "복제하기 전에 사용자 설정을 검색합니다.",
|
||||
@@ -688,17 +930,28 @@
|
||||
"Scan for Broken files": "손상된 파일 검사",
|
||||
"Scan for hidden files before replication": "복제 전 숨김 파일 검사",
|
||||
"Scan hidden files periodically": "주기적으로 숨김 파일 검사",
|
||||
"Scan QR Code": "QR 코드 스캔",
|
||||
"Scan the QR code displayed on an active device using this device's camera.": "이 기기의 카메라로 사용 중인 기기에 표시된 QR 코드를 스캔하세요.",
|
||||
"Schedule and Restart": "예약 후 재시작",
|
||||
"Scram Switches": "긴급 정지 스위치",
|
||||
"Scram!": "긴급 정지",
|
||||
"Seconds, 0 to disable": "초 단위, 0으로 설정하면 비활성화",
|
||||
"Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": "초 단위입니다. 타이핑이나 저장을 중단한 후 이 시간동안 로컬 데이터베이스 저장이 지연됩니다.",
|
||||
"Secret Access Key": "시크릿 액세스 키",
|
||||
"Secret Key": "시크릿 키",
|
||||
"Select active P2P remote": "활성 P2P 원격 선택",
|
||||
"Select All Shiny": "새 항목 모두 선택",
|
||||
"Select Flagged Shiny": "플래그된 새 항목 선택",
|
||||
"Select P2P remote...": "P2P 원격 선택...",
|
||||
"Select the database adapter to use.": "사용할 데이터베이스 어댑터를 선택합니다.",
|
||||
"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.": "이 옵션을 선택하면 이 기기의 현재 데이터로 서버를 초기화합니다. 서버에 있는 기존 데이터는 모두 완전히 덮어써집니다.",
|
||||
"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.": "이 옵션을 선택하면 이 기기가 기존 서버에 참여합니다. 서버에 있는 기존 동기화 데이터를 이 기기로 가져와야 합니다.",
|
||||
"Selective": "선택적",
|
||||
"Send": "보내기",
|
||||
"Send chunks": "청크 보내기",
|
||||
"SENDING": "전송 중",
|
||||
"Server URI": "서버 URI",
|
||||
"SESSION": "세션",
|
||||
"Setting.GenerateKeyPair.Desc": "키 페어를 생성했습니다!\n\n참고: 이 키 페어는 다시 표시되지 않습니다. 안전한 곳에 저장해 주세요. 분실하면 새 키 페어를 생성해야 합니다.\n참고 2: 공개 키는 spki 형식이고, 개인 키는 pkcs8 형식입니다. 편의를 위해 공개 키의 줄 바꿈은 `\\n`으로 변환됩니다.\n참고 3: 공개 키는 원격 데이터베이스에, 개인 키는 로컬 기기에 설정해야 합니다.\n\n>[!FOR YOUR EYES ONLY]-\n> <div class=\"sls-keypair\">\n>\n> ### 공개 키\n> ```\n${public_key}\n> ```\n>\n> ### 개인 키\n> ```\n${private_key}\n> ```\n>\n> </div>\n\n>[!Both for copying]-\n>\n> <div class=\"sls-keypair\">\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n> </div>\n\n",
|
||||
"Setting.GenerateKeyPair.Title": "새 키 페어가 생성되었습니다!",
|
||||
"Setting.TroubleShooting": "문제 해결",
|
||||
@@ -707,7 +960,10 @@
|
||||
"Setting.TroubleShooting.ScanBrokenFiles": "손상된 파일 검사",
|
||||
"Setting.TroubleShooting.ScanBrokenFiles.Desc": "데이터베이스에 올바르게 저장되지 않은 파일을 검사합니다.",
|
||||
"SettingTab.Message.AskRebuild": "변경 사항을 적용하려면 원격 데이터베이스에서 가져와야 합니다. 계속 진행하시겠습니까?",
|
||||
"Setup Complete: Preparing to Fetch Synchronisation Data": "설정 완료: 동기화 데이터를 가져올 준비 중",
|
||||
"Setup Complete: Preparing to Initialise Server": "설정 완료: 서버를 초기화할 준비 중",
|
||||
"Setup URI dialog cancelled.": "Setup URI 대화 상자가 취소되었습니다.",
|
||||
"Setup-URI": "Setup-URI",
|
||||
"Setup.Apply.Buttons.ApplyAndFetch": "적용 후 가져오기",
|
||||
"Setup.Apply.Buttons.ApplyAndMerge": "적용 후 병합",
|
||||
"Setup.Apply.Buttons.ApplyAndRebuild": "적용 후 재구축",
|
||||
@@ -777,16 +1033,29 @@
|
||||
"Show status inside the editor": "편집기 내부에 상태 표시",
|
||||
"Show status on the status bar": "상태 표시줄에 상태 표시",
|
||||
"Show verbose log. Please enable if you report an issue.": "자세한 로그를 표시합니다. 문제를 신고하는 경우 활성화해 주세요.",
|
||||
"Signaling Server Connection": "시그널링 서버 연결",
|
||||
"Signalling Status": "시그널링 상태",
|
||||
"Skip and close": "건너뛰고 닫기",
|
||||
"Snippets": "스니펫",
|
||||
"Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": "일부 기기의 진행 값이 다릅니다(최대: ${maxProgress}, 최소: ${minProgress}).\n이는 일부 기기가 동기화를 완료하지 않았음을 의미할 수 있으며, 충돌로 이어질 수 있습니다. 계속 진행하기 전에 모든 기기가 동기화되었는지 반드시 확인하는 것을 강력히 권장합니다.",
|
||||
"Start Broadcasting": "브로드캐스트 시작",
|
||||
"Start change-broadcasting on Connect": "연결 시 변경 사항 브로드캐스트 시작",
|
||||
"Start Sync & Close": "동기화 시작 후 닫기",
|
||||
"Starts synchronisation when a file is saved.": "파일이 저장될 때 동기화를 시작합니다.",
|
||||
"Stat": "상태",
|
||||
"Stats": "통계",
|
||||
"Stop ⚡": "중지 ⚡",
|
||||
"Stop Broadcasting": "브로드캐스트 중지",
|
||||
"Stop reflecting database changes to storage files.": "데이터베이스 변경 사항을 스토리지 파일에 반영하는 것을 중단합니다.",
|
||||
"Stop watching for file changes.": "파일 변경 사항 감시를 중단합니다.",
|
||||
"Storage -> Database": "스토리지 -> 데이터베이스",
|
||||
"Strongly Recommended": "적극 권장",
|
||||
"Suppress notification of hidden files change": "숨김 파일 변경 알림 억제",
|
||||
"Suspend database reflecting": "데이터베이스 반영 일시 중단",
|
||||
"Suspend file watching": "파일 감시 일시 중단",
|
||||
"Switch to IDB": "IDB로 전환",
|
||||
"Switch to IndexedDB": "IndexedDB로 전환",
|
||||
"Sync": "동기화",
|
||||
"Sync after merging file": "파일 병합 후 동기화",
|
||||
"Sync automatically after merging files": "파일 병합 후 자동으로 동기화",
|
||||
"Sync Mode": "동기화 모드",
|
||||
@@ -794,25 +1063,56 @@
|
||||
"Sync on File Open": "파일 열기 시 동기화",
|
||||
"Sync on Save": "저장 시 동기화",
|
||||
"Sync on Startup": "시작 시 동기화",
|
||||
"Sync once": "한 번 동기화",
|
||||
"Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": "저널 파일을 활용하는 동기화 방식입니다. S3/MinIO/R2 호환 객체 스토리지를 미리 구성해 두어야 합니다.",
|
||||
"Synchronising files": "동기화할 파일",
|
||||
"Syncing": "동기화",
|
||||
"Syncing...": "동기화 중입니다...",
|
||||
"Target patterns": "대상 패턴",
|
||||
"Test Settings and Continue": "설정 테스트 후 계속",
|
||||
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "테스트 전용 - 파일의 새로운 사본을 동기화하여 파일 충돌을 해결하며, 수정된 파일을 덮어쓸 수 있습니다. 주의하세요.",
|
||||
"The connection to the server has been configured successfully. As the next step,": "서버 연결이 성공적으로 구성되었습니다. 다음 단계로,",
|
||||
"The delay for consecutive on-demand fetches": "연속 청크 요청 간 대기 시간",
|
||||
"The files in this Vault are almost identical to the server's.": "이 보관함의 파일은 서버의 파일과 거의 동일합니다.",
|
||||
"The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": "다음 승인된 노드에는 노드 정보가 없습니다:\n- ${missingNodes}\n\n이는 해당 노드가 한동안 연결되지 않았거나 이전 버전에 머물러 있음을 의미합니다.\n가능하다면 먼저 모든 기기를 업데이트하는 것이 좋습니다. 더 이상 사용하지 않는 기기가 있다면 원격을 한 번 잠가 승인된 노드를 모두 정리할 수 있습니다.",
|
||||
"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.": "그룹 ID와 패스프레이즈는 기기 그룹을 식별하는 데 사용됩니다. 동기화하려는 모든 기기에서 동일한 그룹 ID와 패스프레이즈를 사용해야 합니다.",
|
||||
"The Hash algorithm for chunk IDs": "청크 ID용 해시 알고리즘",
|
||||
"The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.": "IndexedDB 어댑터는 특정 상황에서 더 나은 성능을 보이는 경우가 많지만, LiveSync 모드에서 사용하면 메모리 누수를 일으키는 것으로 확인되었습니다. LiveSync 모드를 사용할 때는 IDB 어댑터를 사용해 주세요.",
|
||||
"the latest synchronisation data will be downloaded from the server to this device.": "서버의 최신 동기화 데이터를 이 기기로 내려받습니다.",
|
||||
"the local database, that is to say the synchronisation information, must be reconstituted.": "로컬 데이터베이스, 즉 동기화 정보를 다시 구성해야 합니다.",
|
||||
"The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": "청크를 문서 안에 임시 보관할 수 있는 최대 기간입니다. 이 기간을 넘긴 청크는 독립된 청크로 분리됩니다.",
|
||||
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "문서 안에 임시 보관할 수 있는 청크의 최대 개수입니다. 이 개수를 넘긴 청크는 즉시 독립된 청크로 분리됩니다.",
|
||||
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "문서 안에 임시 보관할 수 있는 청크의 최대 총 크기입니다. 이 크기를 넘긴 청크는 즉시 독립된 청크로 분리됩니다.",
|
||||
"The minimum interval for automatic synchronisation on event.": "이벤트 발생 시 자동 동기화의 최소 간격입니다.",
|
||||
"The remote is already set up, and the configuration is compatible (or got compatible by this operation).": "원격은 이미 설정되어 있으며, 구성도 호환됩니다(또는 이번 작업으로 호환되었습니다).",
|
||||
"The Setup-URI does not appear to be valid. Please check that you have copied it correctly.": "Setup-URI가 유효하지 않은 것으로 보입니다. 올바르게 복사했는지 확인해 주세요.",
|
||||
"The Setup-URI is valid and ready to use.": "Setup-URI가 유효하며 사용할 준비가 되었습니다.",
|
||||
"the single, authoritative master copy": "유일하고 확실한 원본이 됩니다",
|
||||
"the synchronisation data on the server will be built based on the current data on this device.": "이 기기의 현재 데이터를 바탕으로 서버의 동기화 데이터를 구축합니다.",
|
||||
"Themes": "테마",
|
||||
"There is a way to resolve this on other devices.": "다른 기기에서 이를 해결할 방법이 있습니다.",
|
||||
"There may be differences between the files in this Vault and the server.": "이 보관함의 파일과 서버의 파일 사이에 차이가 있을 수 있습니다.",
|
||||
"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.": "따라서 서버 정보를 수동으로 구성할 때에는 각별히 주의해 주시기 바랍니다. 잘못된 패스프레이즈를 입력하면 서버의 데이터가 손상됩니다.",
|
||||
"This can isolate your connections between devices. Use the same Room ID for the same devices.": "이를 통해 기기 간 연결을 서로 격리할 수 있습니다. 같은 기기들끼리는 동일한 룸 ID를 사용하세요.",
|
||||
"This device": "이 기기",
|
||||
"This device name": "이 기기의 이름",
|
||||
"This feature enables 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.": "기기 간에 직접 동기화하는 기능입니다. 서버는 필요 없지만 동기화가 이루어지려면 두 기기가 동시에 온라인 상태여야 하며, 일부 기능은 제한될 수 있습니다. 인터넷 연결은 시그널링(피어 탐지)에만 필요하고 데이터 전송에는 필요하지 않습니다.",
|
||||
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "URI가 없거나 세부 설정을 직접 구성하려는 사용자를 위한 고급 옵션입니다.",
|
||||
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "이는 매우 강력한 작업입니다. 보관함 폴더를 안전한 위치에 복사해 두시기를 강력히 권장합니다.",
|
||||
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "이 설계에 가장 적합한 동기화 방식입니다. 모든 기능을 사용할 수 있습니다. CouchDB 인스턴스를 미리 구성해 두어야 합니다.",
|
||||
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "이 패스프레이즈는 다른 기기로 복사되지 않습니다. 다시 구성할 때까지 `기본값`으로 설정됩니다.",
|
||||
"This password is used to encrypt the connection. Use something long enough.": "이 비밀번호는 연결을 암호화하는 데 사용됩니다. 충분히 긴 값을 사용하세요.",
|
||||
"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": "이 절차는 먼저 서버에서 기존 동기화 데이터를 모두 삭제합니다. 그다음 이 기기에 있는 보관함의 현재 상태(로컬 데이터베이스 포함)를 바탕으로 서버 데이터를 완전히 재구축합니다. 즉, 이 기기의 데이터가",
|
||||
"This setting must be the same even when connecting to multiple synchronisation destinations.": "이 설정은 여러 동기화 대상에 연결하는 경우에도 동일해야 합니다.",
|
||||
"This Vault is empty, or contains only new files that are not on the server.": "이 보관함은 비어 있거나, 서버에 없는 새 파일만 포함하고 있습니다.",
|
||||
"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.": "서버의 최신 데이터를 사용해 이 기기의 로컬 데이터베이스를 재구축합니다. 이 작업은 동기화 불일치를 해결하고 정상적인 동작을 복구하기 위한 것입니다.",
|
||||
"This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": "모든 파일의 청크를 다시 생성합니다. 누락된 청크가 있었다면 이 작업으로 오류가 해결될 수 있습니다.",
|
||||
"To minimise the creation of new conflicts": "새로운 충돌 발생을 최소화하기 위해",
|
||||
"Transfer Tweak": "전송 조정",
|
||||
"TURN Credential": "TURN 자격 증명",
|
||||
"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.": "TURN 서버 설정은 직접적인 P2P 연결을 막는 엄격한 NAT나 방화벽 뒤에 있는 경우에만 필요합니다. 대부분의 경우 이 항목들은 비워 두어도 됩니다.",
|
||||
"TURN Server URLs (comma-separated)": "TURN 서버 URL (쉼표로 구분)",
|
||||
"TURN Username": "TURN 사용자 이름",
|
||||
"TweakMismatchResolve.Action.DisableAutoAcceptCompatible": "자동 수용 비활성화",
|
||||
"TweakMismatchResolve.Action.Dismiss": "무시",
|
||||
"TweakMismatchResolve.Action.EnableAutoAcceptCompatible": "자동 수용 활성화",
|
||||
@@ -1105,21 +1405,36 @@
|
||||
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Peer-to-Peer 전용 설정으로 계속",
|
||||
"Ui.SetupWizard.SetupRemote.Title": "서버 정보 입력",
|
||||
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "모든 동기화된 기기 간 고유 이름입니다. 이 설정을 편집하려면 사용자 설정 동기화를 한 번 비활성화해 주세요.",
|
||||
"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.": "확신이 없다면 이 옵션을 선택하는 것은 다소 위험합니다. 이 옵션은 서버 구성이 이 기기와 호환된다고 가정합니다. 그렇지 않은 경우 데이터가 손실될 수 있습니다. 무엇을 하려는지 충분히 이해한 상태에서 진행해 주세요.",
|
||||
"Updating list...": "목록을 갱신하는 중입니다...",
|
||||
"URL": "URL",
|
||||
"Use a custom passphrase": "사용자 지정 패스프레이즈 사용",
|
||||
"Use a Setup URI (Recommended)": "Setup URI 사용(권장)",
|
||||
"Use Custom HTTP Handler": "커스텀 HTTP 핸들러 사용",
|
||||
"Use Diagnostic RTCPeerConnection for statistics": "통계에 진단용 RTCPeerConnection 사용",
|
||||
"Use dynamic iteration count": "동적 반복 횟수 사용",
|
||||
"Use internal API": "내부 API 사용",
|
||||
"Use Internal API": "내부 API 사용",
|
||||
"Use JWT Authentication": "JWT 인증 사용",
|
||||
"Use Path-Style Access": "경로 방식(Path-Style) 접근 사용",
|
||||
"Use Random Number": "임의의 숫자 사용",
|
||||
"Use Segmented-splitter": "의미 기반 분할 사용",
|
||||
"Use splitting-limit-capped chunk splitter": "분할 제한 상한 청크 분할기 사용",
|
||||
"Use the trash bin": "휴지통 사용",
|
||||
"Use timeouts instead of heartbeats": "하트비트 대신 타임아웃 사용",
|
||||
"Use vrtmrz's relay": "vrtmrz의 중계 서버 사용",
|
||||
"username": "사용자명",
|
||||
"Username": "사용자명",
|
||||
"Verbose Log": "자세한 로그",
|
||||
"Verify all": "모두 검증",
|
||||
"Verify and repair all files": "모든 파일 검증 및 복구",
|
||||
"Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": "경고! 이는 성능에 심각한 영향을 미칩니다. 로그는 기본 이름으로 동기화되지 않습니다. 로그에는 종종 기밀 정보가 포함되어 있으므로 주의해 주세요.",
|
||||
"WATCHING": "감시 중",
|
||||
"We can not use \"/\" to the device name": "기기 이름에는 \"/\"를 사용할 수 없습니다",
|
||||
"We can use only Secure (HTTPS) connections on Obsidian Mobile.": "Obsidian 모바일에서는 보안(HTTPS) 연결만 사용할 수 있습니다.",
|
||||
"We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": "이 기능이 활성화되어 있는 동안에는 기기 이름을 변경할 수 없습니다. 기기 이름을 변경하려면 이 기능을 비활성화하세요.",
|
||||
"We have to configure the device name": "기기 이름을 설정해야 합니다",
|
||||
"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.": "보관함 폴더를 안전한 위치에 복사해 두시기를 권장합니다. 충돌이 대량으로 발생하거나 실수로 잘못된 대상과 동기화한 경우에 대비할 수 있습니다.",
|
||||
"We will now guide you through a few questions to simplify the synchronisation setup.": "동기화 설정을 간단히 마칠 수 있도록 몇 가지 질문으로 안내해 드리겠습니다.",
|
||||
"We will now proceed with the server configuration.": "이제 서버 구성을 진행하겠습니다.",
|
||||
"Welcome to Self-hosted LiveSync": "Self-hosted LiveSync에 오신 것을 환영합니다",
|
||||
@@ -1130,5 +1445,8 @@
|
||||
"xxhash64 (Fastest)": "xxhash64 (가장 빠름)",
|
||||
"Yes, I want to add this device to my existing synchronisation": "예, 이 기기를 기존 동기화 구성에 추가하겠습니다",
|
||||
"Yes, I want to set up a new synchronisation": "예, 새 동기화를 설정하겠습니다",
|
||||
"You are adding this device to an existing synchronisation setup.": "이 기기를 기존 동기화 구성에 추가합니다."
|
||||
"You are adding this device to an existing synchronisation setup.": "이 기기를 기존 동기화 구성에 추가합니다.",
|
||||
"You can configure in the Obsidian Plugin Settings.": "Obsidian 플러그인 설정에서 구성할 수 있습니다.",
|
||||
"You should create a new synchronisation destination and rebuild your data there.": "새로운 동기화 대상을 만들고 그곳에서 데이터를 재구축해야 합니다.",
|
||||
"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.": "이 작업은 서버 데이터가 완전히 손상된 경우, 다른 모든 기기의 변경 사항이 더 이상 필요하지 않은 경우, 또는 데이터베이스 크기가 보관함 크기에 비해 비정상적으로 커진 경우처럼 예외적인 상황에서만 수행해야 합니다."
|
||||
}
|
||||
|
||||
@@ -1,34 +1,79 @@
|
||||
", 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.": ", 이 보관함의 현재 상태를 가장 잘 설명하는 옵션을 선택해 주세요. 선택하신 내용에 따라 가장 적절한 방식으로 파일을 확인합니다."
|
||||
(Active): (활성)
|
||||
(BETA) Always overwrite with a newer file: (베타) 항상 새로운 파일로 덮어쓰기
|
||||
(Beta) Use ignore files: (베타) 제외 규칙 파일 사용
|
||||
(Days passed, 0 to disable automatic-deletion): (지난 일수, 0으로 설정하면 자동 삭제 비활성화)
|
||||
(e.g., after editing many files whilst offline): "(예: 오프라인 상태에서 많은 파일을 편집한 뒤)"
|
||||
(e.g., immediately after restoring on another computer, or having recovered from a backup): "(예: 다른 컴퓨터에서 복원한 직후이거나 백업에서 복구한 경우)"
|
||||
(e.g., setting up for the first time on a new smartphone, starting from a clean slate): "(예: 새 스마트폰에서 처음 설정하여 빈 상태에서 시작하는 경우)"
|
||||
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.: "(예: 청크를 원격에서 읽음) 이 옵션을 활성화하면, LiveSync는 청크를 로컬에 복제하지 않고 원격에서 직접 읽습니다. 커스텀 청크 크기를 키우는 것을 권장합니다."
|
||||
(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.: (MB) 이 값이 설정되면, 이보다 큰 로컬 및 원격 파일의 변경 사항은 건너뜁니다. 파일이 다시 작아지면 더 새로운 파일이 사용됩니다.
|
||||
(Mega chars): (백만 자 단위)
|
||||
(Missing): (없음)
|
||||
(Not recommended) If set, credentials will be stored in the file.: (권장하지 않음) 설정한 경우 자격 증명이 파일에 저장됩니다.
|
||||
(Obsolete) Use an old adapter for compatibility: (사용 중단) 호환성을 위해 이전 어댑터 사용
|
||||
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.: (정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.
|
||||
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.: (정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.
|
||||
(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.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
|
||||
(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.: (이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.
|
||||
"↑: Overwrite Remote": "↑: 원격 덮어쓰기"
|
||||
"↓: Overwrite Local": "↓: 로컬 덮어쓰기"
|
||||
"⇅: Use newer": "⇅: 더 새로운 쪽 사용"
|
||||
+1 week: +1주
|
||||
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": |-
|
||||
> [!INFO]- 다음 연결된 기기가 감지되었습니다:
|
||||
${devices}
|
||||
⚠️ Important Notice: ⚠️ 중요 안내
|
||||
⚠️ Please Confirm the Following: ⚠️ 다음 내용을 확인해 주세요
|
||||
✔ SELECT: ✔ 선택
|
||||
✔ SYNC: ✔ 동기화
|
||||
✔ WATCH: ✔ 감시
|
||||
📡 Off: 📡 꺼짐
|
||||
📡 On: 📡 켜짐
|
||||
🔴 Disconnected: 🔴 연결 끊김
|
||||
🕵️ Diag: 🕵️ 진단
|
||||
🗑 Delete: 🗑 삭제
|
||||
🟢 Connected: 🟢 연결됨
|
||||
${count} issue(s) detected!: ${count}개의 문제가 감지되었습니다!
|
||||
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.: Setup URI는 서버 주소와 인증 정보를 담은 하나의 문자열입니다. 서버 설치 스크립트가 URI를 생성했다면, 이를 사용해 간단하고 안전하게 구성할 수 있습니다.
|
||||
Accept: 수락
|
||||
Accept in session: 이 세션에서만 수락
|
||||
ACCEPTED: 수락됨
|
||||
ACCEPTED (in session): 수락됨 (이 세션에서만)
|
||||
Access Key: 액세스 키
|
||||
Access Key ID: 액세스 키 ID
|
||||
Action: 작업
|
||||
Activate: 활성화
|
||||
Active Remote Configuration: 활성 원격 구성
|
||||
Add default patterns: 기본 패턴 추가
|
||||
Add new connection: 연결 추가
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
|
||||
Advanced: 고급
|
||||
Advanced Settings: 고급 설정
|
||||
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.: 재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.
|
||||
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.: 재시작하면 서버의 데이터를 사용해 이 기기의 데이터베이스가 재구축됩니다. 이 보관함에 동기화되지 않은 파일이 있다면 서버 데이터와 충돌이 발생할 수 있습니다.
|
||||
After that, synchronise to a brand new vault on each other device with the new remote one by one.: 그런 다음 다른 기기에서도 하나씩 새 원격을 사용해 완전히 새로운 보관함에 동기화하세요.
|
||||
All checks passed successfully!: 모든 검사를 통과했습니다!
|
||||
All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.: 모든 기기의 진행 값이 동일합니다(${progress}). 기기들이 동기화된 것으로 보이므로 가비지 컬렉션을 진행할 수 있습니다.
|
||||
All the same or non-existent: 모두 동일하거나 존재하지 않음
|
||||
Allow in session: 이 세션에서만 허용
|
||||
Allow permanently: 항상 허용
|
||||
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.: 또한 Peer-to-Peer 동기화를 사용 중이라면, 이 구성은 나중에 다른 방식으로 전환하여 원격 서버에 연결할 때 사용된다는 점에 유의해 주세요.
|
||||
Always prompt merge conflicts: 항상 병합 충돌 알림
|
||||
Analyse: 분석
|
||||
Analyse database usage: 데이터베이스 사용량 분석
|
||||
Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.: 데이터베이스 사용량을 분석하고 직접 진단할 수 있도록 TSV 보고서를 생성합니다. 생성된 보고서는 원하는 스프레드시트에 붙여 넣어 확인할 수 있습니다.
|
||||
Apply All Selected: 선택 항목 모두 적용
|
||||
Apply Latest Change if Conflicting: 충돌 시 최신 변경 사항 적용
|
||||
Apply preset configuration: 프리셋 구성 적용
|
||||
Apply the settings: 설정 적용
|
||||
Ask a passphrase at every launch: 시작할 때마다 패스프레이즈 묻기
|
||||
Auto Connect: 자동 연결
|
||||
Auto Start P2P Connection: P2P 연결 자동 시작
|
||||
Automatic: 자동
|
||||
Automatically Sync all files when opening Obsidian.: Obsidian을 열 때 모든 파일을 자동으로 동기화합니다.
|
||||
Available Peers: 사용 가능한 피어
|
||||
Back: 뒤로
|
||||
Back to non-configured: 미구성 상태로 되돌리기
|
||||
Batch database update: 일괄 데이터베이스 업데이트
|
||||
@@ -36,20 +81,32 @@ Batch limit: 배치 개수 제한
|
||||
Batch size: 배치 크기
|
||||
Batch size of on-demand fetching: 필요 시 가져오기의 배치 크기
|
||||
Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.: v0.17.16 이전에는 로컬 데이터베이스에 이전 어댑터를 사용했습니다. 이제는 새로운 어댑터를 권장합니다. 하지만 로컬 데이터베이스 재구축이 필요합니다. 충분한 시간이 있을 때 이 토글을 비활성화해 주세요. 활성화된 상태로 두면 원격 데이터베이스에서 가져올 때도 이를 비활성화하라는 메시지가 나타납니다.
|
||||
Broadcasting?: 브로드캐스트란?
|
||||
Bucket Name: 버킷 이름
|
||||
by resetting the remote, you will be informed on other devices.: 원격을 초기화하면 다른 기기에서 알림을 받게 됩니다.
|
||||
Cancel: 취소
|
||||
Cancel Garbage Collection: 가비지 컬렉션 취소
|
||||
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.: 암호화 알고리즘을 변경하면 이전에 다른 알고리즘으로 암호화된 데이터에는 접근할 수 없게 됩니다. 데이터에 계속 접근할 수 있도록 모든 기기가 동일한 알고리즘을 사용하도록 설정해 주세요.
|
||||
Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.: 이 설정을 변경하려면 기존 데이터를 마이그레이션하고(시간이 다소 걸릴 수 있습니다) Obsidian을 재시작해야 합니다. 진행하기 전에 반드시 데이터를 백업해 주세요.
|
||||
Check: 확인
|
||||
Check and convert non-path-obfuscated files: 경로 난독화되지 않은 파일 검사 및 변환
|
||||
Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.: 아직 경로 난독화 ID로 변환되지 않은 문서를 확인하고 필요하면 변환합니다.
|
||||
Checking connection... Please wait.: 연결을 확인하는 중입니다... 잠시만 기다려 주세요.
|
||||
Chunks: 청크
|
||||
Close: 닫기
|
||||
Close & Disconnect: 닫고 연결 끊기
|
||||
Close this dialog: 이 대화 상자 닫기
|
||||
"Closed:": "종료됨:"
|
||||
cmdConfigSync:
|
||||
showCustomizationSync: 사용자 설정 동기화 표시
|
||||
Comma separated `.gitignore, .dockerignore`: 쉼표로 구분된 `.gitignore, .dockerignore`
|
||||
Command: 명령
|
||||
Communicating: 통신 중
|
||||
Compaction in progress on remote database...: 원격 데이터베이스에서 압축 정리를 진행 중입니다...
|
||||
Compaction on remote database completed successfully.: 원격 데이터베이스 압축 정리가 성공적으로 완료되었습니다.
|
||||
Compaction on remote database failed.: 원격 데이터베이스 압축 정리에 실패했습니다.
|
||||
Compaction on remote database timed out.: 원격 데이터베이스 압축 정리 시간이 초과되었습니다.
|
||||
Compare file: 파일 비교
|
||||
Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.: 로컬 데이터베이스와 저장소 간의 파일 내용을 비교합니다. 일치하지 않으면 어떤 쪽을 유지할지 묻게 됩니다.
|
||||
Compatibility (Conflict Behaviour): 호환성 (충돌 동작)
|
||||
Compatibility (Database structure): 호환성 (데이터베이스 구조)
|
||||
@@ -58,43 +115,69 @@ Compatibility (Metadata): 호환성 (메타데이터)
|
||||
Compatibility (Remote Database): 호환성 (원격 데이터베이스)
|
||||
Compatibility (Trouble addressed): 호환성 (문제 대응)
|
||||
Compute revisions for chunks: 청크에 대한 리비전 계산
|
||||
Configuration: 구성
|
||||
Configuration Encryption: 구성 암호화
|
||||
Configure: 설정
|
||||
Configure And Change Remote: 원격 구성 및 변경
|
||||
Configure E2EE: E2EE 구성
|
||||
Configure Remote: 원격 구성
|
||||
Configure the same server information as your other devices again, manually, very advanced users only.: 다른 기기와 동일한 서버 정보를 다시 직접 입력합니다. 숙련된 사용자 전용입니다.
|
||||
Connect: 연결
|
||||
"Connected to Signaling Server (as Peer ID: ${peerId})": "시그널링 서버에 연결되었습니다 (피어 ID: ${peerId})"
|
||||
"Connected:": "연결됨:"
|
||||
Connection Method: 연결 방법
|
||||
Connection Settings: 연결 설정
|
||||
"Connection:": "연결:"
|
||||
Continue anyway: 무시하고 계속
|
||||
Continue to CouchDB setup: CouchDB 설정으로 계속
|
||||
Continue to Peer-to-Peer only setup: Peer-to-Peer 전용 설정으로 계속
|
||||
Continue to S3/MinIO/R2 setup: S3/MinIO/R2 설정으로 계속
|
||||
Copy: 복사
|
||||
Copy Report to clipboard: 보고서를 클립보드에 복사
|
||||
CouchDB Configuration: CouchDB 구성
|
||||
CouchDB Connection Tweak: CouchDB 연결 조정
|
||||
Create P2P remote: P2P 원격 만들기
|
||||
Cross-platform: 크로스 플랫폼
|
||||
"Current adapter: {adapter}": "현재 어댑터: {adapter}"
|
||||
Custom Headers: 사용자 지정 헤더
|
||||
Customization Sync: 사용자 설정 동기화
|
||||
Customization Sync (Beta3): 사용자 설정 동기화 (Beta3)
|
||||
Data Compression: 데이터 압축
|
||||
Data to Copy: 복사할 데이터
|
||||
Database -> Storage: 데이터베이스 -> 스토리지
|
||||
Database Adapter: 데이터베이스 어댑터
|
||||
Database Name: 데이터베이스 이름
|
||||
Database suffix: 데이터베이스 접미사
|
||||
Date: 날짜
|
||||
Default: 기본값
|
||||
Delay conflict resolution of inactive files: 비활성 파일의 충돌 해결 지연
|
||||
Delay merge conflict prompt for inactive files.: 비활성 파일의 병합 충돌 프롬프트 지연.
|
||||
Delete: 삭제
|
||||
Delete all customization sync data: 모든 사용자 설정 동기화 데이터 삭제
|
||||
Delete all data on the remote server.: 원격 서버의 모든 데이터를 삭제합니다.
|
||||
Delete All of: 모두 삭제할 대상
|
||||
Delete local database to reset or uninstall Self-hosted LiveSync: Self-hosted LiveSync를 초기화하거나 제거하기 위해 로컬 데이터베이스를 삭제
|
||||
Delete old metadata of deleted files on start-up: 시작 시 삭제된 파일의 오래된 메타데이터 삭제
|
||||
Delete Remote Configuration: 원격 구성 삭제
|
||||
Delete remote configuration '{name}'?: "'{name}' 원격 구성을 삭제할까요?"
|
||||
Delete remote configuration '${name}'?: "'${name}' 원격 구성을 삭제할까요?"
|
||||
DENIED: 거부됨
|
||||
DENIED (in session): 거부됨 (이 세션에서만)
|
||||
Deny: 거부
|
||||
Deny in session: 이 세션에서만 거부
|
||||
Deny permanently: 항상 거부
|
||||
Deselect all: 선택 모두 해제
|
||||
desktop: 데스크톱
|
||||
Detected Peers: 감지된 피어
|
||||
Developer: 개발자
|
||||
Device: 기기
|
||||
device name: 기기 이름
|
||||
Device name: 기기 이름
|
||||
Device name to identify the device. Please use shorter one for the stable peer detection, i.e., "iphone-16" or "macbook-2021".: 기기를 식별하기 위한 기기 이름입니다. 피어를 안정적으로 감지할 수 있도록 "iphone-16"이나 "macbook-2021"처럼 짧은 이름을 사용해 주세요.
|
||||
Device Peer ID: 기기 피어 ID
|
||||
Device Setup Method: 기기 설정 방법
|
||||
"Devices:": "기기:"
|
||||
Diagnostic RTCPeerConnection is enabled: 진단용 RTCPeerConnection이 활성화되어 있습니다
|
||||
dialog:
|
||||
yourLanguageAvailable:
|
||||
_value: |-
|
||||
@@ -105,8 +188,12 @@ dialog:
|
||||
편하게 사용하실 수 있기를 바랍니다!
|
||||
btnRevertToDefault: "%{lang-def} 유지"
|
||||
Title: " 번역을 사용할 수 있습니다!"
|
||||
Diff: 차이
|
||||
Different: 다름
|
||||
Disables all synchronization and restart.: 모든 동기화를 비활성화하고 재시작합니다.
|
||||
Disables logging, only shows notifications. Please disable if you report an issue.: 로깅을 비활성화하고 알림만 표시합니다. 문제를 신고하는 경우 비활성화해 주세요.
|
||||
Disconnect: 연결 끊기
|
||||
Dismiss: 닫기
|
||||
Display Language: 표시 언어
|
||||
Display name: 표시 이름
|
||||
Do not check configuration mismatch before replication: 복제 전 구성 불일치 확인 안 함
|
||||
@@ -177,33 +264,70 @@ Enable advanced features: 고급 기능 활성화
|
||||
Enable customization sync: 사용자 설정 동기화 활성화
|
||||
Enable Developers' Debug Tools.: 개발자 디버그 도구 활성화
|
||||
Enable edge case treatment features: 특수 사례 처리 기능 활성화
|
||||
Enable P2P Replicator: P2P 복제기 활성화
|
||||
Enable poweruser features: 파워 유저 기능 활성화
|
||||
Enable this if your Object Storage doesn't support CORS: 객체 스토리지가 CORS를 지원하지 않는 경우 활성화하세요
|
||||
Enable this option to automatically apply the most recent change to documents even when it conflicts: 이 옵션을 활성화하면 충돌이 있어도 문서에 가장 최근 변경 사항을 자동으로 적용합니다
|
||||
Enabled: 활성화됨
|
||||
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.: 종단 간 암호화를 활성화하면 데이터가 원격 서버로 전송되기 전에 기기에서 암호화됩니다. 따라서 누군가 서버에 접근하더라도 패스프레이즈 없이는 데이터를 읽을 수 없습니다. 다른 기기에서 데이터를 복호화할 때 필요하므로 패스프레이즈를 반드시 기억해 두세요.
|
||||
Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.: 원격 데이터베이스의 내용을 암호화합니다. 플러그인의 동기화 기능을 사용하는 경우 활성화를 권장합니다.
|
||||
Encrypting sensitive configuration items: 민감한 구성 항목 암호화
|
||||
Encryption Algorithm: 암호화 알고리즘
|
||||
Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.: 암호화에 사용할 패스프레이즈입니다. 변경한 경우, 새로 암호화된 파일로 서버의 데이터베이스를 덮어써야 합니다.
|
||||
End-to-End Encryption: 종단 간 암호화
|
||||
Endpoint URL: 엔드포인트 URL
|
||||
Enhance chunk size: 청크 크기 확대
|
||||
Enter a folder prefix (optional): 폴더 접두사를 입력하세요 (선택 사항)
|
||||
Enter Server Information: 서버 정보 입력
|
||||
Enter Setup URI: Setup URI 입력
|
||||
Enter the server information manually: 서버 정보를 수동으로 입력
|
||||
Enter TURN credential: TURN 자격 증명을 입력하세요
|
||||
Enter TURN username: TURN 사용자 이름을 입력하세요
|
||||
Enter your Access Key ID: 액세스 키 ID를 입력하세요
|
||||
Enter your Bucket Name: 버킷 이름을 입력하세요
|
||||
Enter your database name: 데이터베이스 이름을 입력하세요
|
||||
Enter your JWT Key ID: JWT 키 ID를 입력하세요
|
||||
Enter your JWT secret or private key: JWT 시크릿 또는 개인 키를 입력하세요
|
||||
Enter your JWT Subject (CouchDB Username): JWT 주체(CouchDB 사용자 이름)를 입력하세요
|
||||
Enter your passphrase: 패스프레이즈를 입력하세요
|
||||
Enter your password: 비밀번호를 입력하세요
|
||||
Enter your Region (e.g., us-east-1, auto for R2): "리전을 입력하세요 (예: us-east-1, R2는 auto)"
|
||||
Enter your Secret Access Key: 시크릿 액세스 키를 입력하세요
|
||||
Enter your username: 사용자 이름을 입력하세요
|
||||
"Error during connection test: ${reason}": "연결 테스트 중 오류가 발생했습니다: ${reason}"
|
||||
"Error during testAndFixSettings: ${reason}": "testAndFixSettings 실행 중 오류가 발생했습니다: ${reason}"
|
||||
Experimental Settings: 실험적 설정
|
||||
Export: 내보내기
|
||||
Failed to connect to remote for compaction.: 압축 정리를 위해 원격 데이터베이스에 연결하지 못했습니다.
|
||||
Failed to connect to remote for compaction. ${reason}: 압축 정리를 위해 원격 데이터베이스에 연결하지 못했습니다. ${reason}
|
||||
"Failed to connect to the server: ${reason}": "서버에 연결하지 못했습니다: ${reason}"
|
||||
Failed to connect to the server. Please check your settings.: 서버에 연결하지 못했습니다. 설정을 확인해 주세요.
|
||||
"Failed to connect to the signalling relay: ${reason}": "시그널링 중계 서버에 연결하지 못했습니다: ${reason}"
|
||||
Failed to create replicator instance.: 복제기 인스턴스를 생성하지 못했습니다.
|
||||
Failed to parse Setup-URI.: Setup-URI를 해석하지 못했습니다.
|
||||
Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.: 가비지 컬렉션 전에 일회성 복제를 시작하지 못했습니다. 가비지 컬렉션을 취소합니다.
|
||||
Failed to start replication after Garbage Collection.: 가비지 컬렉션 후 복제를 시작하지 못했습니다.
|
||||
"Failed:": "실패:"
|
||||
Fetch: 가져오기
|
||||
Fetch chunks on demand: 필요 시 청크 원격 가져오기
|
||||
Fetch database with previous behaviour: 이전 동작으로 데이터베이스 가져오기
|
||||
Fetch remote settings: 원격 설정 가져오기
|
||||
FETCHING: 가져오는 중
|
||||
Fetching status...: 상태를 가져오는 중입니다...
|
||||
File integrity: 파일 무결성
|
||||
File to resolve conflict: 충돌을 해결할 파일
|
||||
File to view History: 기록을 볼 파일
|
||||
Filename: 파일명
|
||||
"Final Confirmation: Overwrite Server Data with This Device's Files": "최종 확인: 이 기기의 파일로 서버 데이터 덮어쓰기"
|
||||
First, please select the option that best describes your current situation.: 먼저 현재 상황에 가장 잘 맞는 항목을 선택해 주세요.
|
||||
Fix: 수정
|
||||
Flag and restart: 표시 후 재시작
|
||||
Flagged Selective: 플래그 지정 선택적
|
||||
Folder Prefix: 폴더 접두사
|
||||
For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.: HS256/HS512 알고리즘에는 공유 시크릿 키를, ES256/ES512 알고리즘에는 pkcs8 PEM 형식의 개인 키를 입력하세요.
|
||||
Forces the file to be synced when opened.: 파일을 열 때 강제로 동기화합니다.
|
||||
Fresh Start Wipe: 초기화 후 새로 시작
|
||||
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.: 또한 서버 데이터에 이미 충돌이 존재한다면 그 상태 그대로 이 기기에 동기화되므로, 로컬에서 직접 해결해야 합니다.
|
||||
Garbage Collection cancelled by user.: 사용자가 가비지 컬렉션을 취소했습니다.
|
||||
"Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": "가비지 컬렉션이 완료되었습니다. 삭제된 청크: ${deletedChunks} / ${totalChunks}. 소요 시간: ${seconds}초."
|
||||
Garbage Collection Confirmation: 가비지 컬렉션 확인
|
||||
@@ -211,15 +335,33 @@ Garbage Collection V3 (Beta): 가비지 컬렉션 V3 (Beta)
|
||||
"Garbage Collection: Found ${unusedChunks} unused chunks to delete.": "가비지 컬렉션: 삭제할 미사용 청크 ${unusedChunks}개를 찾았습니다."
|
||||
"Garbage Collection: Scanned ${scanned} / ~${docCount}": "가비지 컬렉션: ${scanned} / ~${docCount} 검사함"
|
||||
"Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": "가비지 컬렉션: 검사 완료. 전체 청크 수: ${totalChunks}, 사용 중인 청크 수: ${usedChunks}"
|
||||
Gathering information...: 정보를 수집하는 중입니다...
|
||||
Generate Random ID: 임의 ID 생성
|
||||
Group ID: 그룹 ID
|
||||
Handle files as Case-Sensitive: 파일을 대소문자 구분으로 처리
|
||||
Have you created a backup before proceeding?: 진행하기 전에 백업을 만드셨나요?
|
||||
Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.: 숨김 파일 동기화가 일시적으로 비활성화되었습니다. 필요하다면 가져오기가 끝난 뒤에 다시 활성화해 주세요.
|
||||
Hidden Files: 숨김 파일
|
||||
Hide completely: 완전히 숨기기
|
||||
Hide not applicable items: 해당 없는 항목 숨기기
|
||||
Higher (${local} > ${remote}): 더 높음 (${local} > ${remote})
|
||||
Highlight diff: 차이 강조
|
||||
How to display network errors when the sync server is unreachable.: 동기화 서버에 연결할 수 없을 때 네트워크 오류를 어떻게 표시할지 설정합니다.
|
||||
How would you like to configure the connection to your server?: 서버 연결을 어떻게 구성하시겠습니까?
|
||||
However, This should not be enabled if you want to increase your secrecy more.: 다만 기밀성을 더 높이고 싶다면 이 옵션은 활성화하지 않는 것이 좋습니다.
|
||||
I am adding a device to an existing synchronisation setup: 기존 동기화 구성에 기기를 추가합니다
|
||||
I am setting this up for the first time: 처음으로 설정합니다
|
||||
I am setting up a new server for the first time / I want to reset my existing server.: 새 서버를 처음 설정하거나, 기존 서버를 초기화하려고 합니다.
|
||||
I am unable to create a backup of my Vault.: 보관함을 백업할 수 없습니다.
|
||||
I am unable to create a backup of my Vaults.: 보관함들을 백업할 수 없습니다.
|
||||
I have created a backup of my Vault.: 보관함을 백업했습니다.
|
||||
I know my server details, let me enter them: 서버 정보를 알고 있으니 직접 입력하겠습니다
|
||||
I understand that all changes made on other smartphones or computers possibly could be lost.: 다른 스마트폰이나 컴퓨터에서 변경한 내용이 모두 사라질 수 있다는 점을 이해합니다.
|
||||
I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.: 다른 기기는 더 이상 동기화할 수 없게 되며, 동기화 정보를 초기화해야 한다는 점을 이해합니다.
|
||||
I understand that this action is irreversible once performed.: 이 작업은 한 번 수행하면 되돌릴 수 없다는 점을 이해합니다.
|
||||
I understand the risks and will proceed without a backup.: 위험을 이해하며 백업 없이 진행하겠습니다.
|
||||
I Understand, Overwrite Server: 이해했습니다, 서버 덮어쓰기
|
||||
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in launches.: '"P2P 연결 자동 시작"이 활성화되어 있으면 플러그인이 시작될 때 P2P 연결이 자동으로 시작됩니다.'
|
||||
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).: 비활성화(토글)되면 청크는 UI 스레드에서 분할됩니다 (이전 동작).
|
||||
If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.: 활성화하면 파일별 효율적인 사용자 설정 동기화가 사용됩니다. 이를 활성화할 때 소규모 데이터 구조 전환이 필요합니다. 모든 기기를 v0.23.18로 업데이트해야 합니다. 이를 활성화하면 이전 버전과의 호환성이 사라집니다.
|
||||
If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.: 활성화하면 청크는 최대 100개 항목으로 분할됩니다. 하지만 중복 제거 기능이 약간 약해집니다.
|
||||
@@ -232,16 +374,38 @@ If this enabled, All files are handled as case-Sensitive (Previous behaviour).:
|
||||
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.: 이 옵션을 활성화하면 청크가 문단이나 의미 단위로 나뉘어 저장됩니다. 단, 이 기능은 일부 플랫폼에서는 지원되지 않을 수 있습니다.
|
||||
If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.: 이 옵션을 활성화하면, 제외 규칙 파일에 일치하는 로컬 파일의 변경 사항은 건너뜁니다. 원격 변경 여부 또한 로컬의 제외 규칙 파일에 따라 판단됩니다.
|
||||
If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.: 이 옵션이 활성화되면 PouchDB는 연결을 더이상 무한히 열어두지 않고 60초 동안 유지합니다. 그 시간 내에 변경 사항이 없으면 소켓을 닫고 다시 엽니다. 프록시가 요청 지속 시간을 제한할 때 유용하지만 리소스 사용량이 증가할 수 있습니다.
|
||||
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.: CORS 문제를 피할 수 없다면 이 옵션을 시도해 볼 수 있습니다. Obsidian의 내부 API를 사용해 CouchDB 서버와 통신합니다. 웹 표준을 따르지는 않지만 동작합니다. 향후 Obsidian 버전에서 동작하지 않을 수 있다는 점에 유의하세요.
|
||||
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.: CORS 문제를 피할 수 없다면 이 옵션을 시도해 볼 수 있습니다. Obsidian의 내부 API를 사용해 S3 서버와 통신합니다. 웹 표준을 따르지는 않지만 동작합니다. 향후 Obsidian 버전에서 동작하지 않을 수 있다는 점에 유의하세요.
|
||||
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.: 이 기기의 보관함에 동기화되지 않은 변경 사항이 있다면, 초기화 후 서버의 버전과 어긋나기 쉽습니다. 그 결과 많은 파일에서 충돌이 발생할 수 있습니다.
|
||||
If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.: IBM Cloudant를 사용하다가 페이로드 크기 제한에 도달했다면, 배치 크기와 배치 개수 제한을 더 낮은 값으로 줄여 주세요.
|
||||
If you understand the risks and still wish to proceed, select so.: 위험을 이해하고도 진행하려면 그렇게 선택해 주세요.
|
||||
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.: 버킷 안의 특정 폴더에 데이터를 저장하려면 여기에 폴더 접두사를 지정할 수 있습니다. 그렇지 않다면 비워 두어 버킷 최상위에 데이터를 저장하세요.
|
||||
If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.: "`LiveSync`를 사용하려면 변경 사항을 브로드캐스트해야 합니다. 이를 감지한 모든 `watching` 상태의 피어가 가져오기를 위한 복제를 시작합니다."
|
||||
Ignore: 무시
|
||||
Ignore and Proceed: 무시하고 계속
|
||||
Ignore files: 제외 규칙 파일
|
||||
Ignore patterns: 무시 패턴
|
||||
Import connection: 연결 가져오기
|
||||
IMPORTANT: 중요
|
||||
In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.: 대부분의 경우 기본 알고리즘(${algorithm})을 그대로 사용하는 것이 좋습니다. 이 설정은 기존 보관함이 다른 형식으로 암호화되어 있는 경우에만 필요합니다.
|
||||
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.: 이 경우 Self-hosted LiveSync는 모든 파일의 메타데이터를 다시 만들면서 의도적으로 충돌을 발생시킵니다. 파일 내용이 동일한 경우 이러한 충돌은 자동으로 해결됩니다.
|
||||
"Incoming:": "수신:"
|
||||
Incubate Chunks in Document: 문서 내 청크 임시 보관
|
||||
Initial Action: 초기 작업
|
||||
Initialise all journal history, On the next sync, every item will be received and sent.: 모든 저널 기록을 초기화합니다. 다음 동기화 때 모든 항목을 다시 받고 다시 보냅니다.
|
||||
Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.: 저널 수신 기록을 초기화합니다. 다음 동기화 때 이 기기가 보낸 항목을 제외한 모든 항목을 다시 내려받습니다.
|
||||
Initialise journal sent history. On the next sync, every item except this device received will be sent again.: 저널 송신 기록을 초기화합니다. 다음 동기화 때 이 기기가 받은 항목을 제외한 모든 항목을 다시 보냅니다.
|
||||
Interval (sec): 간격 (초)
|
||||
INVERTED: 반전됨
|
||||
"Issue detection log:": "문제 감지 로그:"
|
||||
It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.: 진행하기 전에 백업을 만드시기를 강력히 권장합니다. 백업 없이 진행하면 데이터가 손실될 수 있습니다.
|
||||
Just for a minute, please!: 잠시만 기다려 주세요!
|
||||
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.: JWT(JSON Web Token) 인증을 사용하면 토큰으로 CouchDB 서버에 안전하게 인증할 수 있습니다. CouchDB 서버가 JWT를 받아들이도록 설정되어 있는지, 그리고 입력한 키와 설정이 서버 구성과 일치하는지 확인해 주세요. 참고로 이 기능은 그리 철저하게 검증되지는 않았습니다.
|
||||
JWT Algorithm: JWT 알고리즘
|
||||
JWT Expiration Duration (minutes): JWT 만료 시간(분)
|
||||
JWT Key: JWT 키
|
||||
JWT Key ID (kid): JWT 키 ID (kid)
|
||||
JWT Subject (sub): JWT 주체 (sub)
|
||||
K:
|
||||
exp: 실험 기능
|
||||
long_p2p_sync: "%{title_p2p_sync} (%{exp})"
|
||||
@@ -264,6 +428,7 @@ lang-zh: 简体中文
|
||||
lang-zh-tw: 繁體中文
|
||||
Later: 나중에
|
||||
"Limit: {datetime} ({timestamp})": "제한: {datetime} ({timestamp})"
|
||||
live: 실시간
|
||||
LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.: LiveSync는 접두사로 구분되지 않은 동일한 이름의 보관함을 여러 개 처리할 수 없습니다. 이 값은 자동으로 구성되어야 합니다.
|
||||
liveSyncReplicator:
|
||||
beforeLiveSync: LiveSync 전에 OneShot을 먼저 시작합니다...
|
||||
@@ -297,6 +462,7 @@ liveSyncSetting:
|
||||
liveSyncSettings:
|
||||
btnApply: 적용
|
||||
Local Database Tweak: 로컬 데이터베이스 조정
|
||||
Local only: 로컬에만 있음
|
||||
Lock: 잠금
|
||||
Lock Server: 서버 잠금
|
||||
Lock the remote server to prevent synchronization with other devices.: 다른 기기와의 동기화를 방지하기 위해 원격 서버를 잠급니다.
|
||||
@@ -306,6 +472,9 @@ logPane:
|
||||
pause: 일시 정지
|
||||
title: Self-hosted LiveSync 로그
|
||||
wrap: 줄 바꿈
|
||||
Lower (${local} < ${remote}): 더 낮음 (${local} < ${remote})
|
||||
Maintenance Commands: 유지보수 명령
|
||||
Maintenance mode: 유지보수 모드
|
||||
Maximum delay for batch database updating: 일괄 데이터베이스 업데이트 최대 지연
|
||||
Maximum file size: 최대 파일 크기
|
||||
Maximum Incubating Chunk Size: 임시 보관 청크의 최대 크기
|
||||
@@ -318,6 +487,7 @@ Memory cache size (by total items): 메모리 캐시 크기 (총 항목 수)
|
||||
Merge: 병합
|
||||
Minimum delay for batch database updating: 일괄 데이터베이스 업데이트 최소 지연
|
||||
Minimum interval for syncing: 동기화 최소 간격
|
||||
Mixed: 혼합
|
||||
moduleCheckRemoteSize:
|
||||
logCheckingStorageSizes: 스토리지 크기 확인 중
|
||||
logCurrentStorageSize: "원격 스토리지 크기: ${measuredSize}"
|
||||
@@ -509,23 +679,39 @@ moduleMigration:
|
||||
moduleObsidianMenu:
|
||||
replicate: 복제
|
||||
More actions: 추가 작업
|
||||
"Mostly Complete: Decision Required": "거의 완료: 결정이 필요합니다"
|
||||
Move remotely deleted files to the trash, instead of deleting.: 원격에서 삭제된 파일을 삭제하는 대신 휴지통으로 이동합니다.
|
||||
My remote server is already set up. I want to join this device.: 원격 서버가 이미 설정되어 있습니다. 이 기기를 참여시키고 싶습니다.
|
||||
Name: 이름
|
||||
Network warning style: 네트워크 경고 표시 방식
|
||||
NEW: 신규
|
||||
New Remote: 새 원격
|
||||
Newer (${diff}): 더 새로움 (${diff})
|
||||
No checks have been performed yet.: 아직 검사를 수행하지 않았습니다.
|
||||
No connected device information found. Cancelling Garbage Collection.: 연결된 기기 정보를 찾을 수 없습니다. 가비지 컬렉션을 취소합니다.
|
||||
No Connection: 연결 없음
|
||||
No devices available. Waiting for other devices to connect...: 사용 가능한 기기가 없습니다. 다른 기기가 연결되기를 기다리는 중입니다...
|
||||
No Items.: 항목이 없습니다.
|
||||
No limit configured: 제한이 설정되지 않음
|
||||
NO PREVIEW: 미리 보기 없음
|
||||
No, please take me back: 아니요, 이전으로 돌아가겠습니다
|
||||
Node ID: 노드 ID
|
||||
Node Information Missing: 노드 정보 누락
|
||||
Non-Synchronising files: 동기화하지 않는 파일
|
||||
Normal Files: 일반 파일
|
||||
Not all messages have been translated. And, please revert to "Default" when reporting errors.: 모든 메시지가 번역되지 않았습니다. 오류 신고 시 "기본값"으로 되돌려 주세요.
|
||||
Not configured: 구성되지 않음
|
||||
Not now: 나중에
|
||||
Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.: 그룹 ID는 생성된 형식으로 제한되지 않으며, 어떤 문자열이든 그룹 ID로 사용할 수 있습니다.
|
||||
Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.: 명령 팔레트에서 "Copy settings as a new Setup URI" 명령을 실행하면 새 Setup URI를 생성할 수 있습니다.
|
||||
Notify all setting files: 모든 설정 파일 알림
|
||||
Notify customized: 사용자 설정 알림
|
||||
Notify when other device has newly customized.: 다른 기기에서 새로운 사용자 설정이 있을 때 알림을 받습니다.
|
||||
Notify when the estimated remote storage size exceeds on start up: 시작 시 예상 원격 스토리지 크기가 초과되면 알림
|
||||
Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.: 한 번에 처리할 배치 개수입니다. 기본값은 40이고 최소값은 2입니다. 배치 크기와 함께 한 번에 메모리에 보관되는 문서 수를 결정합니다.
|
||||
Number of changes to sync at a time. Defaults to 50. Minimum is 2.: 한 번에 동기화할 변경 사항의 수입니다. 기본값은 50입니다. 최소값은 2입니다.
|
||||
Obfuscate Properties: 속성 난독화
|
||||
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.: "속성(예: 파일 경로, 크기, 생성 및 수정 날짜)을 난독화하면 원격 서버에서 파일과 폴더의 구조 및 이름을 파악하기 어려워져 보안이 한층 강화됩니다. 이는 개인 정보를 보호하는 데 도움이 되며, 권한 없는 사용자가 데이터에 대한 정보를 추측하기 어렵게 만듭니다."
|
||||
Obsidian version: Obsidian 버전
|
||||
obsidianLiveSyncSettingTab:
|
||||
btnApply: 적용
|
||||
@@ -733,11 +919,22 @@ obsidianLiveSyncSettingTab:
|
||||
titleUpdateThinning: 업데이트 솎아내기
|
||||
warnCorsOriginUnmatched: ⚠ CORS 출처가 일치하지 않습니다 ${from}->${to}
|
||||
warnNoAdmin: ⚠ 관리자 권한이 없습니다.
|
||||
Of course, we can back up the data before proceeding.: 물론 진행하기 전에 데이터를 백업할 수 있습니다.
|
||||
Off: 꺼짐
|
||||
Ok: 확인
|
||||
Old Algorithm: 이전 알고리즘
|
||||
Older (${diff}): 더 오래됨 (${diff})
|
||||
Older fallback (Slow, W/O WebAssembly): 이전 대체 방식 (느림, WebAssembly 없음)
|
||||
On: 켜짐
|
||||
On the source device, from the command palette, run the 'Show settings as a QR code' command.: 원본 기기에서 명령 팔레트를 열고 'Show settings as a QR code' 명령을 실행합니다.
|
||||
On the source device, open Obsidian.: 원본 기기에서 Obsidian을 엽니다.
|
||||
On this device, please keep this Vault open.: 이 기기에서는 이 보관함을 계속 열어 두세요.
|
||||
On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.: 이 기기에서 카메라 앱으로 전환하거나 QR 코드 스캐너를 사용해 화면에 표시된 QR 코드를 스캔합니다.
|
||||
Open: 열기
|
||||
Open connection: 연결 열기
|
||||
Open P2P Setup...: P2P 설정 열기...
|
||||
Open the dialog: 대화상자 열기
|
||||
Other files: 기타 파일
|
||||
Overwrite: 덮어쓰기
|
||||
Overwrite patterns: 덮어쓰기 패턴
|
||||
Overwrite remote: 원격 덮어쓰기
|
||||
@@ -770,42 +967,67 @@ P2P:
|
||||
SyncAlreadyRunning: P2P 동기화가 이미 실행 중입니다.
|
||||
SyncCompleted: P2P 동기화가 완료되었습니다.
|
||||
SyncStartedWith: ${name}과의 P2P 동기화가 시작되었습니다.
|
||||
P2P Configuration: P2P 구성
|
||||
P2P Status: P2P 상태
|
||||
paneMaintenance:
|
||||
markDeviceResolvedAfterBackup: 백업 후 이 기기를 해결됨으로 표시
|
||||
remoteLockedAndDeviceNotAccepted: 원격 데이터베이스가 잠겨 있으며 이 기기는 아직 승인되지 않았습니다.
|
||||
remoteLockedResolvedDevice: 원격 데이터베이스가 잠겨 있지만 이 기기는 이미 승인되었습니다.
|
||||
unlockDatabaseReady: 데이터베이스 잠금 해제
|
||||
Passphrase: 패스프레이즈
|
||||
Passphrase is required.: 패스프레이즈가 필요합니다.
|
||||
Passphrase of sensitive configuration items: 민감한 구성 항목의 패스프레이즈
|
||||
password: 비밀번호
|
||||
Password: 비밀번호
|
||||
Paste a connection string: 연결 문자열 붙여넣기
|
||||
Paste the Setup URI generated from one of your active devices.: 사용 중인 기기 중 하나에서 생성한 Setup URI를 붙여 넣으세요.
|
||||
Path: 경로
|
||||
Path Obfuscation: 경로 난독화
|
||||
Patterns to match files for overwriting instead of merging: 병합 대신 덮어쓸 파일을 판별하는 패턴
|
||||
Patterns to match files for syncing: 동기화할 파일을 판별하는 패턴
|
||||
"Peer ID:": "피어 ID:"
|
||||
Peer to Peer Replicator: Peer to Peer 복제기
|
||||
Peer-to-Peer only: Peer-to-Peer 전용
|
||||
Peer-to-Peer Synchronisation: 피어 투 피어(P2P) 동기화
|
||||
Peers: 피어
|
||||
Per-file-saved customization sync: 파일별 저장 사용자 설정 동기화
|
||||
Perform: 실행
|
||||
Perform cleanup: 정리 실행
|
||||
Perform Garbage Collection: 가비지 컬렉션 실행
|
||||
Perform Garbage Collection to remove unused chunks and reduce database size.: 사용하지 않는 청크를 제거하고 데이터베이스 크기를 줄이기 위해 가비지 컬렉션을 실행합니다.
|
||||
Periodic Sync interval: 주기적 동기화 간격
|
||||
PERMANENT: 영구
|
||||
Pick a file to resolve conflict: 충돌을 해결할 파일 선택
|
||||
Pick a file to show history: 기록을 표시할 파일 선택
|
||||
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.: 종단 간 암호화 패스프레이즈는 동기화가 실제로 시작되기 전까지 검증되지 않는다는 점에 유의해 주세요. 이는 데이터를 보호하기 위한 보안 조치입니다.
|
||||
Please configure your end-to-end encryption settings.: 종단 간 암호화 설정을 구성해 주세요.
|
||||
Please disable 'Read chunks online' in settings to use Garbage Collection.: 가비지 컬렉션을 사용하려면 설정에서 'Read chunks online'을 비활성화해 주세요.
|
||||
Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.: 가비지 컬렉션을 사용하려면 설정에서 'Compute revisions for chunks'를 활성화해 주세요.
|
||||
Please enter the CouchDB server information below.: 아래에 CouchDB 서버 정보를 입력해 주세요.
|
||||
Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.: S3/MinIO/R2 호환 오브젝트 스토리지 서비스에 연결하는 데 필요한 정보를 입력해 주세요.
|
||||
Please enter the Peer-to-Peer Synchronisation information below.: 아래에 Peer-to-Peer 동기화 정보를 입력해 주세요.
|
||||
Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.: 서버 설치 과정이나 다른 기기에서 생성된 Setup URI를 보관함 패스프레이즈와 함께 입력해 주세요.
|
||||
Please follow the steps below to import settings from your existing device.: 기존 기기에서 설정을 가져오려면 아래 단계를 따라 주세요.
|
||||
PLEASE NOTE: 유의 사항
|
||||
Please select 'Cancel' explicitly to cancel this operation.: 이 작업을 취소하려면 반드시 "취소"를 명시적으로 선택해 주세요.
|
||||
Please select a method to import the settings from another device.: 다른 기기에서 설정을 가져올 방법을 선택해 주세요.
|
||||
Please select an active P2P remote configuration to change P2P sync targets.: P2P 동기화 대상을 변경하려면 활성 P2P 원격 구성을 선택해 주세요.
|
||||
Please select an option to proceed: 계속하려면 항목을 선택해 주세요
|
||||
Please select the button below to restart and proceed to the data fetching confirmation.: 아래 버튼을 선택하면 재시작 후 데이터 가져오기 확인 단계로 진행합니다.
|
||||
Please select the button below to restart and proceed to the final confirmation.: 아래 버튼을 선택하면 재시작 후 최종 확인 단계로 진행합니다.
|
||||
Please select the type of server to which you are connecting.: 연결할 서버 유형을 선택해 주세요.
|
||||
Please select your situation.: 현재 상황을 선택해 주세요.
|
||||
Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.: 이 기기를 식별할 기기 이름을 설정해 주세요. 이 이름은 기기 간에 고유해야 합니다. 설정하기 전까지는 이 기능을 활성화할 수 없습니다.
|
||||
Please set this device name: 이 기기의 이름을 설정해 주세요
|
||||
Please understand that this is intended behaviour.: 이는 의도된 동작이라는 점을 이해해 주세요.
|
||||
Plug-in version: 플러그인 버전
|
||||
Plugins: 플러그인
|
||||
Prepare the 'report' to create an issue: 이슈 생성을 위한 '보고서' 준비
|
||||
Presets: 프리셋
|
||||
Prevent fetching configuration from server: 서버에서 구성 가져오기 방지
|
||||
Proceed: 진행
|
||||
Proceed Garbage Collection: 가비지 컬렉션 계속
|
||||
Proceed to the next step.: 다음 단계로 진행합니다.
|
||||
Proceed with Setup URI: Setup URI로 계속
|
||||
Proceeding with Garbage Collection, ignoring missing nodes.: 누락된 노드를 무시하고 가비지 컬렉션을 계속 진행합니다.
|
||||
Proceeding with Garbage Collection.: 가비지 컬렉션을 진행합니다.
|
||||
@@ -869,15 +1091,22 @@ RedFlag:
|
||||
Title: 원격 구성 가져오기
|
||||
Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.: 최신 버전이 아닌 모든 리비전을 제거하여 저장 공간을 줄입니다. 이 작업을 수행하려면 원격 서버와 로컬 클라이언트에 동일한 양의 여유 공간이 필요합니다.
|
||||
Reducing the frequency with which on-disk changes are reflected into the DB: 디스크 변경 사항이 데이터베이스에 반영되는 빈도를 줄입니다
|
||||
Refresh: 새로 고침
|
||||
Region: 지역
|
||||
Relay settings: 중계 서버 설정
|
||||
Reload: 다시 불러오기
|
||||
Remediation: 복구 조치
|
||||
Remediation Setting Changed: 복구 설정이 변경됨
|
||||
Remote Database Tweak (In sunset): 원격 데이터베이스 조정 (폐기 예정)
|
||||
Remote Databases: 원격 데이터베이스
|
||||
Remote name: 원격 이름
|
||||
Remote only: 원격에만 있음
|
||||
Remote server type: 원격 서버 유형
|
||||
Remote Type: 원격 유형
|
||||
Rename: 이름 바꾸기
|
||||
Replicate now: 지금 복제
|
||||
Replicating: 복제 중
|
||||
Replicating...: 복제 중입니다...
|
||||
Replicator:
|
||||
Dialogue:
|
||||
Locked:
|
||||
@@ -917,6 +1146,7 @@ Resend all chunks to the remote.: 모든 청크를 원격으로 다시 보냅니
|
||||
Reset: 재설정
|
||||
Reset all: 모두 재설정
|
||||
Reset all journal counter: 모든 저널 카운터 재설정
|
||||
Reset and Resume Synchronisation: 동기화 초기화 후 재개
|
||||
Reset journal received history: 저널 수신 기록 재설정
|
||||
Reset journal sent history: 저널 송신 기록 재설정
|
||||
Reset notification threshold and check the remote database usage: 알림 임계값을 초기화하고 원격 데이터베이스 사용량 확인
|
||||
@@ -929,14 +1159,26 @@ Resolve All: 모두 해결
|
||||
Resolve all conflicted files: 충돌한 모든 파일 해결
|
||||
Resolve All conflicted files by the newer one: 충돌한 모든 파일을 최신 버전으로 해결
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": "충돌한 모든 파일을 더 최신 버전으로 해결합니다. 주의: 이전 버전은 덮어써지며 복원할 수 없습니다."
|
||||
Restart and Fetch Data: 재시작 후 데이터 가져오기
|
||||
Restart and Initialise Server: 재시작 후 서버 초기화
|
||||
Restart Now: 지금 재시작
|
||||
Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?: Obsidian을 재시작하는 것을 강력히 권장합니다. 재시작하기 전까지는 일부 변경 사항이 적용되지 않거나 화면이 일관되지 않게 표시될 수 있습니다. 지금 재시작하시겠습니까?
|
||||
Restore or reconstruct local database from remote.: 원격에서 로컬 데이터베이스를 복원하거나 재구축합니다.
|
||||
Rev: 리비전
|
||||
Revert changes: 변경 사항 되돌리기
|
||||
Revoke: 철회
|
||||
Room ID: 룸 ID
|
||||
"Room ID suffix:": "룸 ID 접미사:"
|
||||
Run Doctor: 진단 실행
|
||||
S3/MinIO/R2 Configuration: S3/MinIO/R2 구성
|
||||
S3/MinIO/R2 Object Storage: S3/MinIO/R2 객체 스토리지
|
||||
Same: 동일
|
||||
Same or local only: 동일하거나 로컬에만 있음
|
||||
Save and Apply: 저장 후 적용
|
||||
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.: 설정을 마크다운 파일에 저장합니다. 새로운 설정이 도착하면 알림을 받게 됩니다. 플랫폼별로 다른 파일을 설정할 수 있습니다.
|
||||
Saving will be performed forcefully after this number of seconds.: 이 시간(초) 후에 강제로 저장이 수행됩니다.
|
||||
Scan a QR Code (Recommended for mobile): QR 코드 스캔(모바일 권장)
|
||||
Scan changes: 변경 사항 검사
|
||||
Scan changes on customization sync: 사용자 설정 동기화 시 변경 사항 검색
|
||||
Scan customization automatically: 사용자 설정 자동 검색
|
||||
Scan customization before replicating.: 복제하기 전에 사용자 설정을 검색합니다.
|
||||
@@ -945,17 +1187,28 @@ Scan customization periodically: 주기적으로 사용자 설정 검색
|
||||
Scan for Broken files: 손상된 파일 검사
|
||||
Scan for hidden files before replication: 복제 전 숨김 파일 검사
|
||||
Scan hidden files periodically: 주기적으로 숨김 파일 검사
|
||||
Scan QR Code: QR 코드 스캔
|
||||
Scan the QR code displayed on an active device using this device's camera.: 이 기기의 카메라로 사용 중인 기기에 표시된 QR 코드를 스캔하세요.
|
||||
Schedule and Restart: 예약 후 재시작
|
||||
Scram Switches: 긴급 정지 스위치
|
||||
Scram!: 긴급 정지
|
||||
Seconds, 0 to disable: 초 단위, 0으로 설정하면 비활성화
|
||||
Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.: 초 단위입니다. 타이핑이나 저장을 중단한 후 이 시간동안 로컬 데이터베이스 저장이 지연됩니다.
|
||||
Secret Access Key: 시크릿 액세스 키
|
||||
Secret Key: 시크릿 키
|
||||
Select active P2P remote: 활성 P2P 원격 선택
|
||||
Select All Shiny: 새 항목 모두 선택
|
||||
Select Flagged Shiny: 플래그된 새 항목 선택
|
||||
Select P2P remote...: P2P 원격 선택...
|
||||
Select the database adapter to use.: 사용할 데이터베이스 어댑터를 선택합니다.
|
||||
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.: 이 옵션을 선택하면 이 기기의 현재 데이터로 서버를 초기화합니다. 서버에 있는 기존 데이터는 모두 완전히 덮어써집니다.
|
||||
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.: 이 옵션을 선택하면 이 기기가 기존 서버에 참여합니다. 서버에 있는 기존 동기화 데이터를 이 기기로 가져와야 합니다.
|
||||
Selective: 선택적
|
||||
Send: 보내기
|
||||
Send chunks: 청크 보내기
|
||||
SENDING: 전송 중
|
||||
Server URI: 서버 URI
|
||||
SESSION: 세션
|
||||
Setting:
|
||||
GenerateKeyPair:
|
||||
Desc: |+
|
||||
@@ -1003,6 +1256,8 @@ Setting:
|
||||
SettingTab:
|
||||
Message:
|
||||
AskRebuild: 변경 사항을 적용하려면 원격 데이터베이스에서 가져와야 합니다. 계속 진행하시겠습니까?
|
||||
"Setup Complete: Preparing to Fetch Synchronisation Data": "설정 완료: 동기화 데이터를 가져올 준비 중"
|
||||
"Setup Complete: Preparing to Initialise Server": "설정 완료: 서버를 초기화할 준비 중"
|
||||
Setup URI dialog cancelled.: Setup URI 대화 상자가 취소되었습니다.
|
||||
Setup:
|
||||
Apply:
|
||||
@@ -1098,6 +1353,7 @@ Setup:
|
||||
PlaceholderPassphrase: 보관함 패스프레이즈를 입력하세요
|
||||
Title: Setup URI 입력
|
||||
ValidInfo: Setup URI가 유효하며 사용할 준비가 되었습니다.
|
||||
Setup-URI: Setup-URI
|
||||
Should we keep folders that don't have any files inside?: 내부에 파일이 없는 폴더를 유지하시겠습니까?
|
||||
Should we only check for conflicts when a file is opened?: 파일을 열 때만 충돌을 확인하시겠습니까?
|
||||
Should we prompt you about conflicting files when a file is opened?: 파일을 열 때 충돌하는 파일에 대해 알림을 표시하시겠습니까?
|
||||
@@ -1111,18 +1367,31 @@ Show status icon instead of file warnings banner: 파일 경고 배너 대신
|
||||
Show status inside the editor: 편집기 내부에 상태 표시
|
||||
Show status on the status bar: 상태 표시줄에 상태 표시
|
||||
Show verbose log. Please enable if you report an issue.: 자세한 로그를 표시합니다. 문제를 신고하는 경우 활성화해 주세요.
|
||||
Signaling Server Connection: 시그널링 서버 연결
|
||||
Signalling Status: 시그널링 상태
|
||||
Skip and close: 건너뛰고 닫기
|
||||
Snippets: 스니펫
|
||||
"Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": |-
|
||||
일부 기기의 진행 값이 다릅니다(최대: ${maxProgress}, 최소: ${minProgress}).
|
||||
이는 일부 기기가 동기화를 완료하지 않았음을 의미할 수 있으며, 충돌로 이어질 수 있습니다. 계속 진행하기 전에 모든 기기가 동기화되었는지 반드시 확인하는 것을 강력히 권장합니다.
|
||||
Start Broadcasting: 브로드캐스트 시작
|
||||
Start change-broadcasting on Connect: 연결 시 변경 사항 브로드캐스트 시작
|
||||
Start Sync & Close: 동기화 시작 후 닫기
|
||||
Starts synchronisation when a file is saved.: 파일이 저장될 때 동기화를 시작합니다.
|
||||
Stat: 상태
|
||||
Stats: 통계
|
||||
Stop ⚡: 중지 ⚡
|
||||
Stop Broadcasting: 브로드캐스트 중지
|
||||
Stop reflecting database changes to storage files.: 데이터베이스 변경 사항을 스토리지 파일에 반영하는 것을 중단합니다.
|
||||
Stop watching for file changes.: 파일 변경 사항 감시를 중단합니다.
|
||||
Storage -> Database: 스토리지 -> 데이터베이스
|
||||
Strongly Recommended: 적극 권장
|
||||
Suppress notification of hidden files change: 숨김 파일 변경 알림 억제
|
||||
Suspend database reflecting: 데이터베이스 반영 일시 중단
|
||||
Suspend file watching: 파일 감시 일시 중단
|
||||
Switch to IDB: IDB로 전환
|
||||
Switch to IndexedDB: IndexedDB로 전환
|
||||
Sync: 동기화
|
||||
Sync after merging file: 파일 병합 후 동기화
|
||||
Sync automatically after merging files: 파일 병합 후 자동으로 동기화
|
||||
Sync Mode: 동기화 모드
|
||||
@@ -1130,30 +1399,61 @@ Sync on Editor Save: 편집기 저장 시 동기화
|
||||
Sync on File Open: 파일 열기 시 동기화
|
||||
Sync on Save: 저장 시 동기화
|
||||
Sync on Startup: 시작 시 동기화
|
||||
Sync once: 한 번 동기화
|
||||
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.: 저널 파일을 활용하는 동기화 방식입니다. S3/MinIO/R2 호환 객체 스토리지를 미리 구성해 두어야 합니다.
|
||||
Synchronising files: 동기화할 파일
|
||||
Syncing: 동기화
|
||||
Syncing...: 동기화 중입니다...
|
||||
Target patterns: 대상 패턴
|
||||
Test Settings and Continue: 설정 테스트 후 계속
|
||||
Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.: 테스트 전용 - 파일의 새로운 사본을 동기화하여 파일 충돌을 해결하며, 수정된 파일을 덮어쓸 수 있습니다. 주의하세요.
|
||||
The connection to the server has been configured successfully. As the next step,: 서버 연결이 성공적으로 구성되었습니다. 다음 단계로,
|
||||
The delay for consecutive on-demand fetches: 연속 청크 요청 간 대기 시간
|
||||
The files in this Vault are almost identical to the server's.: 이 보관함의 파일은 서버의 파일과 거의 동일합니다.
|
||||
"The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": |-
|
||||
다음 승인된 노드에는 노드 정보가 없습니다:
|
||||
- ${missingNodes}
|
||||
|
||||
이는 해당 노드가 한동안 연결되지 않았거나 이전 버전에 머물러 있음을 의미합니다.
|
||||
가능하다면 먼저 모든 기기를 업데이트하는 것이 좋습니다. 더 이상 사용하지 않는 기기가 있다면 원격을 한 번 잠가 승인된 노드를 모두 정리할 수 있습니다.
|
||||
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.: 그룹 ID와 패스프레이즈는 기기 그룹을 식별하는 데 사용됩니다. 동기화하려는 모든 기기에서 동일한 그룹 ID와 패스프레이즈를 사용해야 합니다.
|
||||
The Hash algorithm for chunk IDs: 청크 ID용 해시 알고리즘
|
||||
The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.: IndexedDB 어댑터는 특정 상황에서 더 나은 성능을 보이는 경우가 많지만, LiveSync 모드에서 사용하면 메모리 누수를 일으키는 것으로 확인되었습니다. LiveSync 모드를 사용할 때는 IDB 어댑터를 사용해 주세요.
|
||||
the latest synchronisation data will be downloaded from the server to this device.: 서버의 최신 동기화 데이터를 이 기기로 내려받습니다.
|
||||
the local database, that is to say the synchronisation information, must be reconstituted.: 로컬 데이터베이스, 즉 동기화 정보를 다시 구성해야 합니다.
|
||||
The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.: 청크를 문서 안에 임시 보관할 수 있는 최대 기간입니다. 이 기간을 넘긴 청크는 독립된 청크로 분리됩니다.
|
||||
The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.: 문서 안에 임시 보관할 수 있는 청크의 최대 개수입니다. 이 개수를 넘긴 청크는 즉시 독립된 청크로 분리됩니다.
|
||||
The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.: 문서 안에 임시 보관할 수 있는 청크의 최대 총 크기입니다. 이 크기를 넘긴 청크는 즉시 독립된 청크로 분리됩니다.
|
||||
The minimum interval for automatic synchronisation on event.: 이벤트 발생 시 자동 동기화의 최소 간격입니다.
|
||||
The remote is already set up, and the configuration is compatible (or got compatible by this operation).: 원격은 이미 설정되어 있으며, 구성도 호환됩니다(또는 이번 작업으로 호환되었습니다).
|
||||
The Setup-URI does not appear to be valid. Please check that you have copied it correctly.: Setup-URI가 유효하지 않은 것으로 보입니다. 올바르게 복사했는지 확인해 주세요.
|
||||
The Setup-URI is valid and ready to use.: Setup-URI가 유효하며 사용할 준비가 되었습니다.
|
||||
the single, authoritative master copy: 유일하고 확실한 원본이 됩니다
|
||||
the synchronisation data on the server will be built based on the current data on this device.: 이 기기의 현재 데이터를 바탕으로 서버의 동기화 데이터를 구축합니다.
|
||||
Themes: 테마
|
||||
There is a way to resolve this on other devices.: 다른 기기에서 이를 해결할 방법이 있습니다.
|
||||
There may be differences between the files in this Vault and the server.: 이 보관함의 파일과 서버의 파일 사이에 차이가 있을 수 있습니다.
|
||||
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.: 따라서 서버 정보를 수동으로 구성할 때에는 각별히 주의해 주시기 바랍니다. 잘못된 패스프레이즈를 입력하면 서버의 데이터가 손상됩니다.
|
||||
This can isolate your connections between devices. Use the same Room ID for the same devices.: 이를 통해 기기 간 연결을 서로 격리할 수 있습니다. 같은 기기들끼리는 동일한 룸 ID를 사용하세요.
|
||||
This device: 이 기기
|
||||
This device name: 이 기기의 이름
|
||||
This feature enables 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.: 기기 간에 직접 동기화하는 기능입니다. 서버는 필요 없지만 동기화가 이루어지려면 두 기기가 동시에 온라인 상태여야 하며, 일부 기능은 제한될 수 있습니다. 인터넷 연결은 시그널링(피어 탐지)에만 필요하고 데이터 전송에는 필요하지 않습니다.
|
||||
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.: URI가 없거나 세부 설정을 직접 구성하려는 사용자를 위한 고급 옵션입니다.
|
||||
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.: 이는 매우 강력한 작업입니다. 보관함 폴더를 안전한 위치에 복사해 두시기를 강력히 권장합니다.
|
||||
This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.: 이 설계에 가장 적합한 동기화 방식입니다. 모든 기능을 사용할 수 있습니다. CouchDB 인스턴스를 미리 구성해 두어야 합니다.
|
||||
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.: 이 패스프레이즈는 다른 기기로 복사되지 않습니다. 다시 구성할 때까지 `기본값`으로 설정됩니다.
|
||||
This password is used to encrypt the connection. Use something long enough.: 이 비밀번호는 연결을 암호화하는 데 사용됩니다. 충분히 긴 값을 사용하세요.
|
||||
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: 이 절차는 먼저 서버에서 기존 동기화 데이터를 모두 삭제합니다. 그다음 이 기기에 있는 보관함의 현재 상태(로컬 데이터베이스 포함)를 바탕으로 서버 데이터를 완전히 재구축합니다. 즉, 이 기기의 데이터가
|
||||
This setting must be the same even when connecting to multiple synchronisation destinations.: 이 설정은 여러 동기화 대상에 연결하는 경우에도 동일해야 합니다.
|
||||
This Vault is empty, or contains only new files that are not on the server.: 이 보관함은 비어 있거나, 서버에 없는 새 파일만 포함하고 있습니다.
|
||||
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.: 서버의 최신 데이터를 사용해 이 기기의 로컬 데이터베이스를 재구축합니다. 이 작업은 동기화 불일치를 해결하고 정상적인 동작을 복구하기 위한 것입니다.
|
||||
This will recreate chunks for all files. If there were missing chunks, this may fix the errors.: 모든 파일의 청크를 다시 생성합니다. 누락된 청크가 있었다면 이 작업으로 오류가 해결될 수 있습니다.
|
||||
To minimise the creation of new conflicts: 새로운 충돌 발생을 최소화하기 위해
|
||||
Transfer Tweak: 전송 조정
|
||||
TURN Credential: TURN 자격 증명
|
||||
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.: TURN 서버 설정은 직접적인 P2P 연결을 막는 엄격한 NAT나 방화벽 뒤에 있는 경우에만 필요합니다. 대부분의 경우 이 항목들은 비워 두어도 됩니다.
|
||||
TURN Server URLs (comma-separated): TURN 서버 URL (쉼표로 구분)
|
||||
TURN Username: TURN 사용자 이름
|
||||
TweakMismatchResolve:
|
||||
Action:
|
||||
DisableAutoAcceptCompatible: 자동 수용 비활성화
|
||||
@@ -1523,21 +1823,36 @@ Ui:
|
||||
ProceedP2P: Peer-to-Peer 전용 설정으로 계속
|
||||
Title: 서버 정보 입력
|
||||
Unique name between all synchronized devices. To edit this setting, please disable customization sync once.: 모든 동기화된 기기 간 고유 이름입니다. 이 설정을 편집하려면 사용자 설정 동기화를 한 번 비활성화해 주세요.
|
||||
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.: 확신이 없다면 이 옵션을 선택하는 것은 다소 위험합니다. 이 옵션은 서버 구성이 이 기기와 호환된다고 가정합니다. 그렇지 않은 경우 데이터가 손실될 수 있습니다. 무엇을 하려는지 충분히 이해한 상태에서 진행해 주세요.
|
||||
Updating list...: 목록을 갱신하는 중입니다...
|
||||
URL: URL
|
||||
Use a custom passphrase: 사용자 지정 패스프레이즈 사용
|
||||
Use a Setup URI (Recommended): Setup URI 사용(권장)
|
||||
Use Custom HTTP Handler: 커스텀 HTTP 핸들러 사용
|
||||
Use Diagnostic RTCPeerConnection for statistics: 통계에 진단용 RTCPeerConnection 사용
|
||||
Use dynamic iteration count: 동적 반복 횟수 사용
|
||||
Use internal API: 내부 API 사용
|
||||
Use Internal API: 내부 API 사용
|
||||
Use JWT Authentication: JWT 인증 사용
|
||||
Use Path-Style Access: 경로 방식(Path-Style) 접근 사용
|
||||
Use Random Number: 임의의 숫자 사용
|
||||
Use Segmented-splitter: 의미 기반 분할 사용
|
||||
Use splitting-limit-capped chunk splitter: 분할 제한 상한 청크 분할기 사용
|
||||
Use the trash bin: 휴지통 사용
|
||||
Use timeouts instead of heartbeats: 하트비트 대신 타임아웃 사용
|
||||
Use vrtmrz's relay: vrtmrz의 중계 서버 사용
|
||||
username: 사용자명
|
||||
Username: 사용자명
|
||||
Verbose Log: 자세한 로그
|
||||
Verify all: 모두 검증
|
||||
Verify and repair all files: 모든 파일 검증 및 복구
|
||||
Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.: 경고! 이는 성능에 심각한 영향을 미칩니다. 로그는 기본 이름으로 동기화되지 않습니다. 로그에는 종종 기밀 정보가 포함되어 있으므로 주의해 주세요.
|
||||
WATCHING: 감시 중
|
||||
We can not use "/" to the device name: 기기 이름에는 "/"를 사용할 수 없습니다
|
||||
We can use only Secure (HTTPS) connections on Obsidian Mobile.: Obsidian 모바일에서는 보안(HTTPS) 연결만 사용할 수 있습니다.
|
||||
We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.: 이 기능이 활성화되어 있는 동안에는 기기 이름을 변경할 수 없습니다. 기기 이름을 변경하려면 이 기능을 비활성화하세요.
|
||||
We have to configure the device name: 기기 이름을 설정해야 합니다
|
||||
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.: 보관함 폴더를 안전한 위치에 복사해 두시기를 권장합니다. 충돌이 대량으로 발생하거나 실수로 잘못된 대상과 동기화한 경우에 대비할 수 있습니다.
|
||||
We will now guide you through a few questions to simplify the synchronisation setup.: 동기화 설정을 간단히 마칠 수 있도록 몇 가지 질문으로 안내해 드리겠습니다.
|
||||
We will now proceed with the server configuration.: 이제 서버 구성을 진행하겠습니다.
|
||||
Welcome to Self-hosted LiveSync: Self-hosted LiveSync에 오신 것을 환영합니다
|
||||
@@ -1549,3 +1864,6 @@ xxhash64 (Fastest): xxhash64 (가장 빠름)
|
||||
Yes, I want to add this device to my existing synchronisation: 예, 이 기기를 기존 동기화 구성에 추가하겠습니다
|
||||
Yes, I want to set up a new synchronisation: 예, 새 동기화를 설정하겠습니다
|
||||
You are adding this device to an existing synchronisation setup.: 이 기기를 기존 동기화 구성에 추가합니다.
|
||||
You can configure in the Obsidian Plugin Settings.: Obsidian 플러그인 설정에서 구성할 수 있습니다.
|
||||
You should create a new synchronisation destination and rebuild your data there.: 새로운 동기화 대상을 만들고 그곳에서 데이터를 재구축해야 합니다.
|
||||
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.: 이 작업은 서버 데이터가 완전히 손상된 경우, 다른 모든 기기의 변경 사항이 더 이상 필요하지 않은 경우, 또는 데이터베이스 크기가 보관함 크기에 비해 비정상적으로 커진 경우처럼 예외적인 상황에서만 수행해야 합니다.
|
||||
|
||||
@@ -176,8 +176,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
|
||||
});
|
||||
void addPanel(paneEl, "Remediation").then((paneEl) => {
|
||||
let dateEl: HTMLSpanElement;
|
||||
new Setting(paneEl)
|
||||
const setting = new Setting(paneEl);
|
||||
const dateEl = setting.controlEl.createSpan();
|
||||
setting
|
||||
.addText((text) => {
|
||||
const updateDateText = () => {
|
||||
if (this.editingSettings.maxMTimeForReflectEvents == 0) {
|
||||
@@ -188,7 +189,6 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
text.inputEl.before((dateEl = activeDocument.createSpan()));
|
||||
text.inputEl.type = "datetime-local";
|
||||
if (this.editingSettings.maxMTimeForReflectEvents > 0) {
|
||||
const date = new Date(this.editingSettings.maxMTimeForReflectEvents);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { panePatches } from "./PanePatches.ts";
|
||||
|
||||
const remediationHarness = vi.hoisted(() => {
|
||||
const dateElement = { textContent: "" };
|
||||
const createSpan = vi.fn(() => dateElement);
|
||||
const inputEl = {
|
||||
type: "",
|
||||
};
|
||||
const textComponent = {
|
||||
inputEl,
|
||||
onChange: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
createSpan,
|
||||
dateElement,
|
||||
inputEl,
|
||||
textComponent,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class LiveSyncSetting {
|
||||
controlEl = {
|
||||
createSpan: remediationHarness.createSpan,
|
||||
};
|
||||
|
||||
addText(callback: (text: typeof remediationHarness.textComponent) => void): this {
|
||||
callback(remediationHarness.textComponent);
|
||||
return this;
|
||||
}
|
||||
|
||||
setAuto(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
addApplyButton(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireToggle(): this {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, "activeDocument");
|
||||
vi.clearAllMocks();
|
||||
remediationHarness.dateElement.textContent = "";
|
||||
remediationHarness.inputEl.type = "";
|
||||
});
|
||||
|
||||
describe("panePatches remediation setting", () => {
|
||||
it("creates the status element in the setting control instead of the document", () => {
|
||||
const hierarchyError = new DOMException(
|
||||
"Failed to execute 'appendChild' on 'Node': Only one element on document allowed.",
|
||||
"HierarchyRequestError"
|
||||
);
|
||||
const createSpan = vi.fn(() => {
|
||||
throw hierarchyError;
|
||||
});
|
||||
Object.defineProperty(globalThis, "activeDocument", {
|
||||
configurable: true,
|
||||
value: { createSpan },
|
||||
});
|
||||
|
||||
const host = {
|
||||
addOnSaved: vi.fn(),
|
||||
editingSettings: {
|
||||
maxMTimeForReflectEvents: 0,
|
||||
},
|
||||
requestUpdate: vi.fn(),
|
||||
};
|
||||
const addPanel = vi.fn((_paneEl: HTMLElement, title: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (title === "Remediation") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
panePatches.call(
|
||||
host as never,
|
||||
{} as HTMLElement,
|
||||
{
|
||||
addPanel,
|
||||
} as never
|
||||
);
|
||||
expect(createSpan).not.toHaveBeenCalled();
|
||||
expect(remediationHarness.createSpan).toHaveBeenCalledOnce();
|
||||
expect(remediationHarness.dateElement.textContent).toBe("No limit configured");
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,10 @@ export function paneSyncSettings(
|
||||
),
|
||||
});
|
||||
}
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("allowSleepDuringSynchronisation");
|
||||
if (!this.services.API.isMobile()) {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("allowSleepDuringSynchronisationOnDesktop");
|
||||
}
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
|
||||
@@ -91,6 +91,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
appLifecycleService: appLifecycle,
|
||||
databaseEventService: databaseEvents,
|
||||
activityRunner: screenWakeLock,
|
||||
isMobile: () => API.isMobile(),
|
||||
});
|
||||
const replication = new ObsidianReplicationService(context, {
|
||||
APIService: API,
|
||||
|
||||
@@ -11,17 +11,53 @@ import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceC
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import type { ReplicatorServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
type ActivityOptions = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type ObsidianReplicatorServiceDependencies = ReplicatorServiceDependencies & {
|
||||
isMobile: () => boolean;
|
||||
};
|
||||
|
||||
type SleepPreferenceSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
"allowSleepDuringSynchronisation" | "allowSleepDuringSynchronisationOnDesktop"
|
||||
>;
|
||||
|
||||
export function shouldAllowSleepDuringSynchronisation(settings: SleepPreferenceSettings, isMobile: boolean): boolean {
|
||||
return settings.allowSleepDuringSynchronisation || (!isMobile && settings.allowSleepDuringSynchronisationOnDesktop);
|
||||
}
|
||||
|
||||
function withSleepPreference(dependencies: ObsidianReplicatorServiceDependencies): ReplicatorServiceDependencies {
|
||||
const activityRunner = dependencies.activityRunner;
|
||||
if (!activityRunner) return dependencies;
|
||||
return {
|
||||
...dependencies,
|
||||
activityRunner: {
|
||||
async run<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
|
||||
const allowSleep = shouldAllowSleepDuringSynchronisation(
|
||||
dependencies.settingService.currentSettings(),
|
||||
dependencies.isMobile()
|
||||
);
|
||||
return allowSleep ? await task() : await activityRunner.run(task, options);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ObsidianDatabaseEventService extends InjectableDatabaseEventService<ObsidianServiceContext> {}
|
||||
|
||||
// InjectableReplicatorService
|
||||
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {
|
||||
readonly boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
constructor(context: ObsidianServiceContext, dependencies: ObsidianReplicatorServiceDependencies) {
|
||||
super(context, withSleepPreference(dependencies));
|
||||
}
|
||||
|
||||
async runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: ActivityOptions
|
||||
|
||||
@@ -1,25 +1,53 @@
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ObsidianReplicatorService } from "./ObsidianServices";
|
||||
import { ObsidianReplicatorService, shouldAllowSleepDuringSynchronisation } from "./ObsidianServices";
|
||||
|
||||
function handler() {
|
||||
return { addHandler: vi.fn() };
|
||||
}
|
||||
|
||||
describe("ObsidianReplicatorService", () => {
|
||||
it.each([
|
||||
{ general: false, desktop: false, mobile: false, expected: false },
|
||||
{ general: false, desktop: true, mobile: false, expected: true },
|
||||
{ general: false, desktop: true, mobile: true, expected: false },
|
||||
{ general: true, desktop: false, mobile: false, expected: true },
|
||||
{ general: true, desktop: false, mobile: true, expected: true },
|
||||
])("applies the sleep preference policy: $general/$desktop/$mobile", ({ general, desktop, mobile, expected }) => {
|
||||
expect(
|
||||
shouldAllowSleepDuringSynchronisation(
|
||||
{
|
||||
allowSleepDuringSynchronisation: general,
|
||||
allowSleepDuringSynchronisationOnDesktop: desktop,
|
||||
},
|
||||
mobile
|
||||
)
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("tracks local application activity without extending remote activity", async () => {
|
||||
const activity = promiseWithResolvers<void>();
|
||||
const service = new ObsidianReplicatorService({ events: {}, translate: String } as never, {
|
||||
settingService: { onRealiseSetting: handler() },
|
||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
||||
databaseEventService: {
|
||||
onResetDatabase: handler(),
|
||||
onDatabaseInitialisation: handler(),
|
||||
onDatabaseInitialised: handler(),
|
||||
onDatabaseHasReady: handler(),
|
||||
},
|
||||
activityRunner: { run: vi.fn(async (task: () => Promise<void>) => await task()) },
|
||||
} as never);
|
||||
const service = new ObsidianReplicatorService(
|
||||
{ events: {}, translate: String } as never,
|
||||
{
|
||||
settingService: {
|
||||
onRealiseSetting: handler(),
|
||||
currentSettings: () => ({
|
||||
allowSleepDuringSynchronisation: false,
|
||||
allowSleepDuringSynchronisationOnDesktop: false,
|
||||
}),
|
||||
},
|
||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
||||
databaseEventService: {
|
||||
onResetDatabase: handler(),
|
||||
onDatabaseInitialisation: handler(),
|
||||
onDatabaseInitialised: handler(),
|
||||
onDatabaseHasReady: handler(),
|
||||
},
|
||||
activityRunner: { run: vi.fn(async (task: () => Promise<void>) => await task()) },
|
||||
isMobile: () => false,
|
||||
} as never
|
||||
);
|
||||
|
||||
const running = service.runBoundedLocalApplicationActivity(() => activity.promise);
|
||||
|
||||
@@ -32,4 +60,34 @@ describe("ObsidianReplicatorService", () => {
|
||||
expect(service.boundedLocalApplicationActivityCount.value).toBe(0);
|
||||
expect(service.boundedRemoteActivityCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("allows desktop sleep throughout bounded synchronisation activity when configured", async () => {
|
||||
const runWithWakeLock = vi.fn(async (task: () => Promise<void>) => await task());
|
||||
const service = new ObsidianReplicatorService(
|
||||
{ events: {}, translate: String } as never,
|
||||
{
|
||||
settingService: {
|
||||
onRealiseSetting: handler(),
|
||||
currentSettings: () => ({
|
||||
allowSleepDuringSynchronisation: false,
|
||||
allowSleepDuringSynchronisationOnDesktop: true,
|
||||
}),
|
||||
},
|
||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
||||
databaseEventService: {
|
||||
onResetDatabase: handler(),
|
||||
onDatabaseInitialisation: handler(),
|
||||
onDatabaseInitialised: handler(),
|
||||
onDatabaseHasReady: handler(),
|
||||
},
|
||||
activityRunner: { run: runWithWakeLock },
|
||||
isMobile: () => false,
|
||||
} as never
|
||||
);
|
||||
|
||||
await service.runBoundedRemoteActivity(async () => undefined);
|
||||
await service.runBoundedLocalApplicationActivity(async () => undefined);
|
||||
|
||||
expect(runWithWakeLock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
12
styles.css
12
styles.css
@@ -481,6 +481,11 @@ span.ls-mark-cr::after {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.is-mobile.is-ios .livesync-status {
|
||||
box-sizing: border-box;
|
||||
top: calc(var(--header-height) + var(--view-header-height, var(--header-height)) + var(--size-4-2));
|
||||
}
|
||||
|
||||
div.workspace-leaf-content[data-type="bases"] .livesync-status {
|
||||
top: calc(var(--bases-header-height) + var(--header-height));
|
||||
padding: 5px;
|
||||
@@ -493,6 +498,13 @@ div.workspace-leaf-content[data-type="bases"] .livesync-status {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.is-mobile.is-ios div.workspace-leaf-content[data-type="bases"] .livesync-status {
|
||||
top: calc(
|
||||
var(--bases-header-height) + var(--header-height) + var(--view-header-height, var(--header-height)) +
|
||||
var(--size-4-2)
|
||||
);
|
||||
}
|
||||
|
||||
.livesync-status div {
|
||||
opacity: 0.6;
|
||||
-webkit-filter: grayscale(100%);
|
||||
|
||||
131
test/apps/webpeer/P2PCheckSetup.unit.spec.ts
Normal file
131
test/apps/webpeer/P2PCheckSetup.unit.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
P2P_CHECK_APP_ID,
|
||||
generateP2PCheckSetup,
|
||||
resolveLocalP2PCheckRelayOverride,
|
||||
} from "@/apps/webpeer/src/P2PCheckSetup";
|
||||
|
||||
describe("P2P connection-check setup", () => {
|
||||
it.each(["desktop", "mobile"] as const)(
|
||||
"creates an isolated, disposable %s Setup URI and diagnostic browser peer",
|
||||
async (target) => {
|
||||
const generated = await generateP2PCheckSetup(target);
|
||||
const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase);
|
||||
|
||||
expect(decoded).not.toBe(false);
|
||||
if (decoded === false) {
|
||||
throw new Error("The generated Setup URI could not be decoded");
|
||||
}
|
||||
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
|
||||
expect(generated.target).toBe(target);
|
||||
expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/);
|
||||
expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/);
|
||||
expect(effective).toEqual(
|
||||
expect.objectContaining({
|
||||
remoteType: REMOTE_P2P,
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
usePathObfuscation: true,
|
||||
P2P_Enabled: true,
|
||||
P2P_AppID: P2P_CHECK_APP_ID,
|
||||
P2P_roomID: generated.groupId,
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
})
|
||||
);
|
||||
expect(decoded.P2P_DevicePeerName).toBeUndefined();
|
||||
expect(decoded.P2P_useDiagRTC).toBeUndefined();
|
||||
expect(effective.passphrase).toHaveLength(32);
|
||||
expect(effective.P2P_passphrase).toHaveLength(32);
|
||||
expect(effective.passphrase).not.toBe(effective.P2P_passphrase);
|
||||
expect(effective.P2P_AutoAccepting).toBe(DEFAULT_SETTINGS.P2P_AutoAccepting);
|
||||
expect(effective.P2P_AutoSyncPeers).toBe("");
|
||||
expect(effective.P2P_AutoWatchPeers).toBe("");
|
||||
expect(effective.P2P_SyncOnReplication).toBe("");
|
||||
|
||||
const remoteConfigurations = Object.values(decoded.remoteConfigurations ?? {});
|
||||
expect(remoteConfigurations).toHaveLength(1);
|
||||
expect(decoded.activeConfigurationId).toBe(remoteConfigurations[0].id);
|
||||
expect(decoded.P2P_ActiveRemoteConfigurationId).toBe(remoteConfigurations[0].id);
|
||||
const deviceRemote = ConnectionStringParser.parse(remoteConfigurations[0].uri);
|
||||
expect(deviceRemote).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "p2p",
|
||||
settings: expect.objectContaining({
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect("P2P_useDiagRTC" in deviceRemote.settings).toBe(false);
|
||||
|
||||
expect(generated.browserSettings).toEqual(
|
||||
expect.objectContaining({
|
||||
remoteType: REMOTE_P2P,
|
||||
P2P_Enabled: true,
|
||||
P2P_AppID: P2P_CHECK_APP_ID,
|
||||
P2P_roomID: effective.P2P_roomID,
|
||||
P2P_passphrase: effective.P2P_passphrase,
|
||||
passphrase: effective.passphrase,
|
||||
P2P_AutoStart: false,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_useDiagRTC: true,
|
||||
})
|
||||
);
|
||||
expect(generated.browserDeviceName).toMatch(/^p2p-check-browser-(?:desktop|mobile)-/);
|
||||
const browserConfigurations = Object.values(generated.browserSettings.remoteConfigurations ?? {});
|
||||
expect(browserConfigurations).toHaveLength(1);
|
||||
const browserRemote = ConnectionStringParser.parse(browserConfigurations[0].uri);
|
||||
expect(browserRemote).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "p2p",
|
||||
settings: expect.objectContaining({
|
||||
P2P_AutoStart: false,
|
||||
P2P_AutoBroadcast: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect("P2P_useDiagRTC" in browserRemote.settings).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("creates independent rooms and secrets for separate checks", async () => {
|
||||
const first = await generateP2PCheckSetup("desktop");
|
||||
const second = await generateP2PCheckSetup("desktop");
|
||||
|
||||
expect(second.groupId).not.toBe(first.groupId);
|
||||
expect(second.setupPassphrase).not.toBe(first.setupPassphrase);
|
||||
expect(second.browserSettings.P2P_passphrase).not.toBe(first.browserSettings.P2P_passphrase);
|
||||
expect(second.browserSettings.passphrase).not.toBe(first.browserSettings.passphrase);
|
||||
});
|
||||
|
||||
it("uses the same explicitly selected relay for the Setup URI and browser peer", async () => {
|
||||
const relay = "ws://127.0.0.1:4010/";
|
||||
const generated = await generateP2PCheckSetup("desktop", { relay });
|
||||
const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase);
|
||||
|
||||
expect(decoded).not.toBe(false);
|
||||
if (decoded === false) {
|
||||
throw new Error("The generated Setup URI could not be decoded");
|
||||
}
|
||||
expect(generated.relay).toBe(relay);
|
||||
expect(decoded.P2P_relays).toBe(relay);
|
||||
expect(generated.browserSettings.P2P_relays).toBe(relay);
|
||||
});
|
||||
|
||||
it("accepts a relay query override only from a loopback-served check page", () => {
|
||||
const search = "?relay=ws%3A%2F%2F127.0.0.1%3A4010%2F";
|
||||
|
||||
expect(resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search })).toBe("ws://127.0.0.1:4010/");
|
||||
expect(resolveLocalP2PCheckRelayOverride({ hostname: "localhost", search })).toBe("ws://127.0.0.1:4010/");
|
||||
expect(resolveLocalP2PCheckRelayOverride({ hostname: "example.com", search })).toBeUndefined();
|
||||
expect(
|
||||
resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search: "?relay=https%3A%2F%2Fexample.com" })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
179
test/apps/webpeer/P2PCheckState.unit.spec.ts
Normal file
179
test/apps/webpeer/P2PCheckState.unit.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
P2P_CHECK_OBSERVATION_MILLISECONDS,
|
||||
captureP2PAdditionalCheckBaseline,
|
||||
deriveP2PAdditionalCheckProgress,
|
||||
deriveP2PCheckOutcome,
|
||||
} from "@/apps/webpeer/src/P2PCheckState";
|
||||
|
||||
describe("P2P connection-check outcome", () => {
|
||||
it("waits for a target after the browser monitor joins the room", () => {
|
||||
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, true, 5_000)).toBe("waiting");
|
||||
});
|
||||
|
||||
it("reports an active negotiation before any connection succeeds", () => {
|
||||
expect(
|
||||
deriveP2PCheckOutcome(
|
||||
{
|
||||
...EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
totalNewConnections: 1,
|
||||
details: {
|
||||
"rtc-1": {
|
||||
connectionState: "connecting",
|
||||
iceConnectionState: "checking",
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
10_000
|
||||
)
|
||||
).toBe("connecting");
|
||||
});
|
||||
|
||||
it("keeps a failed attempt retryable until the observation period expires", () => {
|
||||
const diagnostics = {
|
||||
...EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
totalNewConnections: 1,
|
||||
totalFailedConnections: 1,
|
||||
};
|
||||
|
||||
expect(deriveP2PCheckOutcome(diagnostics, true, 15_000)).toBe("retrying");
|
||||
expect(deriveP2PCheckOutcome(diagnostics, true, P2P_CHECK_OBSERVATION_MILLISECONDS)).toBe("inconclusive");
|
||||
});
|
||||
|
||||
it("lets a later success take precedence over failures and closure", () => {
|
||||
expect(
|
||||
deriveP2PCheckOutcome(
|
||||
{
|
||||
...EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
totalNewConnections: 2,
|
||||
totalFailedConnections: 1,
|
||||
totalSuccessfulConnections: 1,
|
||||
totalClosedConnections: 1,
|
||||
},
|
||||
true,
|
||||
P2P_CHECK_OBSERVATION_MILLISECONDS * 2
|
||||
)
|
||||
).toBe("connected");
|
||||
});
|
||||
|
||||
it("distinguishes an idle page and a monitor-start error", () => {
|
||||
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0)).toBe("idle");
|
||||
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0, true)).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-room additional-device progress", () => {
|
||||
const firstDeviceDiagnostics = {
|
||||
...EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
totalNewConnections: 1,
|
||||
totalSuccessfulConnections: 1,
|
||||
details: {
|
||||
"rtc-a": {
|
||||
connectionState: "connected" as const,
|
||||
iceConnectionState: "connected" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("captures the counter and currently active connection baseline", () => {
|
||||
expect(captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics)).toEqual({
|
||||
activeConnectionIds: ["rtc-a"],
|
||||
totalClosedConnections: 0,
|
||||
totalFailedConnections: 0,
|
||||
totalNewConnections: 1,
|
||||
totalSuccessfulConnections: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake a reconnect from the first peer for an additional device", () => {
|
||||
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
|
||||
const progress = deriveP2PAdditionalCheckProgress(
|
||||
{
|
||||
...firstDeviceDiagnostics,
|
||||
totalNewConnections: 2,
|
||||
totalSuccessfulConnections: 2,
|
||||
details: {
|
||||
"rtc-a": {
|
||||
connectionState: "closed",
|
||||
iceConnectionState: "closed",
|
||||
},
|
||||
"rtc-b": {
|
||||
connectionState: "connected",
|
||||
iceConnectionState: "connected",
|
||||
},
|
||||
},
|
||||
},
|
||||
baseline,
|
||||
10_000
|
||||
);
|
||||
|
||||
expect(progress).toEqual({
|
||||
activeConnections: 0,
|
||||
closedConnections: 0,
|
||||
failedConnections: 0,
|
||||
newConnections: 1,
|
||||
newActiveConnectionIds: ["rtc-b"],
|
||||
outcome: "negotiating",
|
||||
successfulConnections: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires both a new successful state and another simultaneous active connection", () => {
|
||||
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
|
||||
|
||||
expect(
|
||||
deriveP2PAdditionalCheckProgress(
|
||||
{
|
||||
...firstDeviceDiagnostics,
|
||||
details: {
|
||||
...firstDeviceDiagnostics.details,
|
||||
"rtc-b": {
|
||||
connectionState: "connected",
|
||||
iceConnectionState: "connected",
|
||||
},
|
||||
},
|
||||
},
|
||||
baseline,
|
||||
10_000
|
||||
).outcome
|
||||
).toBe("negotiating");
|
||||
|
||||
expect(
|
||||
deriveP2PAdditionalCheckProgress(
|
||||
{
|
||||
...firstDeviceDiagnostics,
|
||||
totalNewConnections: 2,
|
||||
totalSuccessfulConnections: 2,
|
||||
details: {
|
||||
...firstDeviceDiagnostics.details,
|
||||
"rtc-b": {
|
||||
connectionState: "connected",
|
||||
iceConnectionState: "connected",
|
||||
},
|
||||
},
|
||||
},
|
||||
baseline,
|
||||
10_000
|
||||
)
|
||||
).toEqual({
|
||||
activeConnections: 1,
|
||||
closedConnections: 0,
|
||||
failedConnections: 0,
|
||||
newConnections: 1,
|
||||
newActiveConnectionIds: ["rtc-b"],
|
||||
outcome: "connected",
|
||||
successfulConnections: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an additional-device attempt inconclusive after its own observation period", () => {
|
||||
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
|
||||
expect(
|
||||
deriveP2PAdditionalCheckProgress(firstDeviceDiagnostics, baseline, P2P_CHECK_OBSERVATION_MILLISECONDS)
|
||||
.outcome
|
||||
).toBe("inconclusive");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -73,4 +73,28 @@ describe("WebPeer runtime composition", () => {
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
|
||||
});
|
||||
|
||||
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
store: createMemoryStore(),
|
||||
deviceName: " p2p-check-browser-desktop-abc ",
|
||||
systemVaultName: "p2p-check-vault",
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_AutoStart: false,
|
||||
P2P_Enabled: false,
|
||||
});
|
||||
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined);
|
||||
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
|
||||
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
|
||||
expect(openDatabase).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,15 +392,18 @@ Deno.test({
|
||||
screenshotDirectory,
|
||||
"webpeer-initial.png",
|
||||
);
|
||||
await configureP2PPane(webPeerPage.locator(".control"), {
|
||||
deviceName: webPeerDeviceName,
|
||||
passphrase: p2pPassphrase,
|
||||
relay,
|
||||
room,
|
||||
turnCredential,
|
||||
turnServers,
|
||||
turnUsername,
|
||||
});
|
||||
await configureP2PPane(
|
||||
webPeerPage.getByRole("region", { name: "WebPeer controls" }),
|
||||
{
|
||||
deviceName: webPeerDeviceName,
|
||||
passphrase: p2pPassphrase,
|
||||
relay,
|
||||
room,
|
||||
turnCredential,
|
||||
turnServers,
|
||||
turnUsername,
|
||||
},
|
||||
);
|
||||
await webPeerPage.getByRole("button", { name: "Connect", exact: true })
|
||||
.click();
|
||||
await webPeerPage.getByText("Connected to Signaling Server", {
|
||||
|
||||
@@ -21,6 +21,12 @@ Deno.test({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByText("No Connection", { exact: true }).waitFor();
|
||||
assertEquals(
|
||||
await page
|
||||
.getByRole("link", { name: "Try the P2P connection check", exact: true })
|
||||
.getAttribute("href"),
|
||||
"./check.html"
|
||||
);
|
||||
|
||||
await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
|
||||
await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer");
|
||||
@@ -44,10 +50,7 @@ Deno.test({
|
||||
await page.getByText("Optional TURN server settings", { exact: true }).click();
|
||||
assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478");
|
||||
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
|
||||
assertEquals(
|
||||
await page.getByPlaceholder("Enter TURN credential").inputValue(),
|
||||
"browser-turn-credential"
|
||||
);
|
||||
assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential");
|
||||
assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true);
|
||||
assertNoPageFailures();
|
||||
} finally {
|
||||
@@ -56,3 +59,82 @@ Deno.test({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "WebPeer: P2P connection check prepares a local Setup URI and zeroed diagnostics",
|
||||
sanitizeOps: false,
|
||||
sanitizeResources: false,
|
||||
async fn() {
|
||||
const server = await startStaticServer(webPeerDist);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const target of ["desktop", "mobile"] as const) {
|
||||
const page = await browser.newPage();
|
||||
const assertNoPageFailures = observePageFailures(page);
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}check.html`);
|
||||
await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (target === "mobile") {
|
||||
await page.locator('input[type="radio"][value="mobile"]').check();
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: `Prepare ${target} check`, exact: true }).click();
|
||||
await page.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }).waitFor({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const setupURI = await page.getByLabel("Setup URI", { exact: true }).inputValue();
|
||||
const passphrase = await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue();
|
||||
const qrSource = await page
|
||||
.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true })
|
||||
.getAttribute("src");
|
||||
|
||||
assertEquals(setupURI.startsWith("obsidian://setuplivesync?settings="), true);
|
||||
assertEquals(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/.test(passphrase), true);
|
||||
assertEquals(qrSource?.startsWith("data:image/"), true);
|
||||
for (const label of ["Setup URI", "Setup URI passphrase"] as const) {
|
||||
const credentialField = page.getByLabel(label, { exact: true });
|
||||
assertEquals(await credentialField.getAttribute("autocomplete"), "off");
|
||||
assertEquals(await credentialField.getAttribute("spellcheck"), "false");
|
||||
}
|
||||
assertEquals(await page.getByTestId("diag-new").textContent(), "0");
|
||||
assertEquals(await page.getByTestId("diag-successful").textContent(), "0");
|
||||
assertEquals(await page.getByTestId("diag-failed").textContent(), "0");
|
||||
assertEquals(await page.getByTestId("diag-closed").textContent(), "0");
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Start connection monitor", exact: true }).isVisible(),
|
||||
true
|
||||
);
|
||||
assertEquals(
|
||||
await page
|
||||
.getByRole("button", { name: "Try another device without resetting", exact: true })
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
|
||||
await page.locator(".results-card").scrollIntoViewIfNeeded();
|
||||
await page.getByRole("button", { name: "Show the Setup QR again", exact: true }).click();
|
||||
await waitFor(
|
||||
async () =>
|
||||
await page
|
||||
.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true })
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= document.documentElement.clientHeight;
|
||||
}),
|
||||
`The ${target} Setup QR did not return to the viewport`
|
||||
);
|
||||
assertEquals(await page.getByLabel("Setup URI", { exact: true }).inputValue(), setupURI);
|
||||
assertNoPageFailures();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,6 +151,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
||||
|
||||
`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
|
||||
|
||||
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
@@ -223,6 +225,8 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
|
||||
- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds.
|
||||
- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`.
|
||||
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
|
||||
- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`.
|
||||
- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port.
|
||||
|
||||
406
test/e2e-obsidian/scripts/p2p-connection-check.ts
Normal file
406
test/e2e-obsidian/scripts/p2p-connection-check.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readFile, stat } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { connect } from "node:net";
|
||||
import { dirname, extname, relative, resolve } from "node:path";
|
||||
import { chromium, type Browser, type ConsoleMessage, type Page } from "playwright";
|
||||
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import {
|
||||
acknowledgeDisabledOptionalFeatures,
|
||||
captureAndStartInitialisation,
|
||||
confirmRebuild,
|
||||
enterSetupURI,
|
||||
finishInitialisation,
|
||||
resumeCompatibilityReviewIfShown,
|
||||
type SetupArtifact,
|
||||
type SetupCaptureNames,
|
||||
} from "../runner/setupUri.ts";
|
||||
import { obsidianRemoteDebuggingPort } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
|
||||
const captures: SetupCaptureNames = { scenario: "p2p-connection-check", guide: "p2p-setup" };
|
||||
const connectionTimeoutMs = Number(process.env.E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS ?? 60000);
|
||||
const webPeerDist = resolve(process.cwd(), "src/apps/webpeer/dist");
|
||||
|
||||
type StaticServer = {
|
||||
readonly baseUrl: string;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
function contentType(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8";
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8";
|
||||
case ".js":
|
||||
return "text/javascript; charset=utf-8";
|
||||
case ".json":
|
||||
case ".map":
|
||||
return "application/json; charset=utf-8";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolveClose, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolveClose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startStaticServer(): Promise<StaticServer> {
|
||||
const distribution = await stat(webPeerDist).catch(() => undefined);
|
||||
if (!distribution?.isDirectory()) {
|
||||
throw new Error(
|
||||
`WebPeer production bundle was not found at ${webPeerDist}. Build the webpeer workspace first.`
|
||||
);
|
||||
}
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const requestedPath = decodeURIComponent(url.pathname).replace(/^\/+/, "") || "index.html";
|
||||
const candidate = resolve(webPeerDist, requestedPath);
|
||||
const relativePath = relative(webPeerDist, candidate);
|
||||
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
|
||||
response.writeHead(404).end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidateStat = await stat(candidate);
|
||||
const filePath = candidateStat.isDirectory() ? resolve(candidate, "index.html") : candidate;
|
||||
const body = await readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": contentType(filePath),
|
||||
});
|
||||
response.end(body);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
response.writeHead(404).end("Not found");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
response.writeHead(500).end("Internal server error");
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error("The WebPeer static server did not expose a TCP port.");
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/`,
|
||||
close: async () => await closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForRelay(relay: string): Promise<void> {
|
||||
const endpoint = new URL(relay);
|
||||
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
|
||||
throw new Error(`P2P relay must use ws: or wss:, received ${endpoint.protocol}`);
|
||||
}
|
||||
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
|
||||
const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname;
|
||||
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
|
||||
let lastError: unknown;
|
||||
let consecutiveConnections = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await new Promise<void>((resolveConnection, reject) => {
|
||||
const socket = connect({ host, port });
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolveConnection();
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("connection timed out"));
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
consecutiveConnections += 1;
|
||||
if (consecutiveConnections >= 3) return;
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
consecutiveConnections = 0;
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function readDiagnostics(page: Page): Promise<Record<string, string>> {
|
||||
return {
|
||||
new: (await page.getByTestId("diag-new").textContent())?.trim() ?? "",
|
||||
successful: (await page.getByTestId("diag-successful").textContent())?.trim() ?? "",
|
||||
failed: (await page.getByTestId("diag-failed").textContent())?.trim() ?? "",
|
||||
closed: (await page.getByTestId("diag-closed").textContent())?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSuccessfulConnection(page: Page, consoleErrors: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => Number(document.querySelector('[data-testid="diag-successful"]')?.textContent ?? "0") > 0,
|
||||
undefined,
|
||||
{ timeout: connectionTimeoutMs }
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\n` +
|
||||
`Browser diagnostics: ${JSON.stringify(await readDiagnostics(page))}\n` +
|
||||
`Browser console errors: ${JSON.stringify(consoleErrors)}`
|
||||
);
|
||||
}
|
||||
await page.getByRole("heading", { name: "P2P connection observed", exact: true }).waitFor({
|
||||
timeout: 5000,
|
||||
});
|
||||
return await readDiagnostics(page);
|
||||
}
|
||||
|
||||
function sessionPorts(): readonly [number, number] {
|
||||
const first = obsidianRemoteDebuggingPort();
|
||||
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
|
||||
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
|
||||
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
|
||||
}
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
|
||||
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
|
||||
}
|
||||
|
||||
function npmBinary(): string {
|
||||
return process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
}
|
||||
|
||||
function runNpmScript(script: string, optional = false): Promise<void> {
|
||||
return new Promise((resolveRun, reject) => {
|
||||
const child = spawn(npmBinary(), ["run", script], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0 || optional) {
|
||||
resolveRun();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runScenario(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
|
||||
const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`;
|
||||
await waitForRelay(relay);
|
||||
|
||||
const server = await startStaticServer();
|
||||
let browser: Browser | undefined;
|
||||
const vaults: TemporaryVault[] = [];
|
||||
const sessions: ObsidianLiveSyncSession[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-");
|
||||
vaults.push(firstVault);
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message));
|
||||
page.on("console", (message: ConsoleMessage) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
|
||||
const checkUrl = new URL("check.html", server.baseUrl);
|
||||
checkUrl.searchParams.set("relay", relay);
|
||||
await page.goto(checkUrl.href);
|
||||
await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ timeout: 30000 });
|
||||
await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click();
|
||||
await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const artifact: SetupArtifact = {
|
||||
setupURI: await page.getByLabel("Setup URI", { exact: true }).inputValue(),
|
||||
setupPassphrase: await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(),
|
||||
};
|
||||
if (!artifact.setupURI.startsWith("obsidian://setuplivesync?settings=")) {
|
||||
throw new Error("The browser did not generate a Setup URI.");
|
||||
}
|
||||
await page.getByText(relay, { exact: true }).waitFor({ timeout: 5000 });
|
||||
await page.getByRole("button", { name: "Start connection monitor", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Monitoring is active", exact: true }).waitFor({ timeout: 30000 });
|
||||
|
||||
const [firstPort, secondPort] = sessionPorts();
|
||||
const firstSession = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault: firstVault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
env: sessionEnvironment(firstPort),
|
||||
});
|
||||
sessions.push(firstSession);
|
||||
|
||||
await enterSetupURI(firstPort, "new", artifact, captures);
|
||||
await captureAndStartInitialisation(firstPort, "new", captures);
|
||||
await confirmRebuild(firstPort, captures);
|
||||
await acknowledgeDisabledOptionalFeatures(firstPort, captures);
|
||||
const firstSetupState = await finishInitialisation(firstPort, cli.binary, firstSession.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(firstPort);
|
||||
if (!firstSetupState.p2pEnabled || firstSetupState.p2pRelays !== relay) {
|
||||
throw new Error(
|
||||
`The first Obsidian device did not apply the browser P2P setup: ${JSON.stringify(firstSetupState)}`
|
||||
);
|
||||
}
|
||||
|
||||
const firstDiagnostics = await waitForSuccessfulConnection(page, consoleErrors);
|
||||
const tryAnotherDevice = page.getByRole("button", {
|
||||
name: "Try another device without resetting",
|
||||
exact: true,
|
||||
});
|
||||
await tryAnotherDevice.click({ timeout: connectionTimeoutMs });
|
||||
await page
|
||||
.getByRole("heading", { name: "Use this same one-off configuration on another device", exact: true })
|
||||
.waitFor({ timeout: 5000 });
|
||||
await page.getByAltText("Setup URI QR code for another device", { exact: true }).waitFor({ timeout: 5000 });
|
||||
if ((await page.getByLabel("Setup URI", { exact: true }).inputValue()) !== artifact.setupURI) {
|
||||
throw new Error("The additional-device action regenerated or replaced the Setup URI.");
|
||||
}
|
||||
|
||||
const secondVault = await createTemporaryVault("obsidian-livesync-p2p-check-second-e2e-");
|
||||
vaults.push(secondVault);
|
||||
const secondSession = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault: secondVault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
env: sessionEnvironment(secondPort),
|
||||
});
|
||||
sessions.push(secondSession);
|
||||
|
||||
await enterSetupURI(secondPort, "new", artifact, captures);
|
||||
await captureAndStartInitialisation(secondPort, "new", captures);
|
||||
await confirmRebuild(secondPort, captures);
|
||||
await acknowledgeDisabledOptionalFeatures(secondPort, captures);
|
||||
const secondSetupState = await finishInitialisation(secondPort, cli.binary, secondSession.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(secondPort);
|
||||
if (
|
||||
!secondSetupState.p2pEnabled ||
|
||||
secondSetupState.p2pRelays !== relay ||
|
||||
secondSetupState.p2pRoomId !== firstSetupState.p2pRoomId
|
||||
) {
|
||||
throw new Error(
|
||||
`The second Obsidian device did not reuse the browser P2P setup: ${JSON.stringify(secondSetupState)}`
|
||||
);
|
||||
}
|
||||
|
||||
await page.getByRole("heading", { name: "An additional connection was observed", exact: true }).waitFor({
|
||||
timeout: connectionTimeoutMs,
|
||||
});
|
||||
const diagnostics = await readDiagnostics(page);
|
||||
if (pageErrors.length > 0) {
|
||||
throw new Error(`The browser page reported runtime errors: ${JSON.stringify(pageErrors)}`);
|
||||
}
|
||||
|
||||
const screenshotPath = resolve(
|
||||
process.env.E2E_P2P_CHECK_SCREENSHOT ??
|
||||
resolve(
|
||||
process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e",
|
||||
"p2p-connection-check-browser-success.png"
|
||||
)
|
||||
);
|
||||
await mkdir(dirname(screenshotPath), { recursive: true });
|
||||
await page.locator(".results-card").screenshot({
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
path: screenshotPath,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Browser-to-two-Obsidian P2P connection check succeeded through ${relay}. ` +
|
||||
`First diagnostics: ${JSON.stringify(firstDiagnostics)}; final diagnostics: ${JSON.stringify(diagnostics)}`
|
||||
);
|
||||
console.log(`Browser result screenshot: ${screenshotPath}`);
|
||||
if (consoleErrors.length > 0) {
|
||||
console.warn(`Browser console errors after successful connection: ${JSON.stringify(consoleErrors)}`);
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions.reverse()) {
|
||||
await session.app.stop().catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
for (const vault of vaults.reverse()) {
|
||||
await vault.dispose().catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
await browser?.close().catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
await server.close().catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const manageP2P = process.argv.includes("--manage-p2p");
|
||||
let shouldStopP2P = false;
|
||||
try {
|
||||
if (manageP2P) {
|
||||
await runNpmScript("test:docker-p2p:stop", true);
|
||||
await runNpmScript("test:docker-p2p:start");
|
||||
shouldStopP2P = true;
|
||||
}
|
||||
await runScenario();
|
||||
} finally {
|
||||
if (shouldStopP2P) {
|
||||
await runNpmScript("test:docker-p2p:stop", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -18,7 +18,14 @@ type LiveSyncTestPlugin = {
|
||||
core: {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings(): { versionUpFlash: string };
|
||||
currentSettings(): {
|
||||
versionUpFlash: string;
|
||||
allowSleepDuringSynchronisation: boolean;
|
||||
allowSleepDuringSynchronisationOnDesktop: boolean;
|
||||
useAdvancedMode: boolean;
|
||||
usePowerUserMode: boolean;
|
||||
useEdgeCaseMode: boolean;
|
||||
};
|
||||
getSmallConfig(key: string): string | null;
|
||||
};
|
||||
};
|
||||
@@ -196,6 +203,30 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
|
||||
|
||||
async function verifyEffectiveSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const sleepPreferences = await page.evaluate(() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
||||
const settings = plugin.core.services.setting.currentSettings();
|
||||
return {
|
||||
general: settings.allowSleepDuringSynchronisation,
|
||||
desktop: settings.allowSleepDuringSynchronisationOnDesktop,
|
||||
advancedMode: settings.useAdvancedMode,
|
||||
powerUserMode: settings.usePowerUserMode,
|
||||
edgeCaseMode: settings.useEdgeCaseMode,
|
||||
};
|
||||
});
|
||||
if (
|
||||
sleepPreferences.general !== false ||
|
||||
sleepPreferences.desktop !== true ||
|
||||
sleepPreferences.advancedMode !== false ||
|
||||
sleepPreferences.powerUserMode !== false ||
|
||||
sleepPreferences.edgeCaseMode !== false
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}`
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
@@ -205,6 +236,16 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const settingsClass = await liveSyncSettings.getAttribute("class");
|
||||
for (const modeClass of [
|
||||
"menu-setting-advanced-disabled",
|
||||
"menu-setting-poweruser-disabled",
|
||||
"menu-setting-edgecase-disabled",
|
||||
]) {
|
||||
if (!settingsClass?.split(/\s+/u).includes(modeClass)) {
|
||||
throw new Error(`The settings UI did not disable ${modeClass}.`);
|
||||
}
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
|
||||
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
|
||||
@@ -226,6 +267,35 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
});
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
|
||||
const generalSleepSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Allow sleep during synchronisation", { exact: true }),
|
||||
});
|
||||
await generalSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await generalSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 0) {
|
||||
throw new Error("The general sleep preference must be disabled by default.");
|
||||
}
|
||||
|
||||
const desktopSleepSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Allow sleep during synchronisation on the desktop", { exact: true }),
|
||||
});
|
||||
await desktopSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await desktopSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 1) {
|
||||
throw new Error("The desktop sleep preference must be enabled by default.");
|
||||
}
|
||||
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click();
|
||||
const advancedModeSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Enable advanced features", { exact: true }),
|
||||
});
|
||||
await advancedModeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await advancedModeSetting.locator(".checkbox-container").click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".sls-setting")?.classList.contains("menu-setting-advanced-enabled") === true,
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
|
||||
|
||||
const deletionPanel = liveSyncSettings
|
||||
.locator("h4.sls-setting-panel-title")
|
||||
.filter({ hasText: "Deletion Propagation" })
|
||||
@@ -271,8 +341,9 @@ async function main(): Promise<void> {
|
||||
syncAfterMerge: false,
|
||||
periodicReplication: false,
|
||||
handleFilenameCaseSensitive: false,
|
||||
useAdvancedMode: true,
|
||||
useEdgeCaseMode: true,
|
||||
useAdvancedMode: false,
|
||||
usePowerUserMode: false,
|
||||
useEdgeCaseMode: false,
|
||||
},
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
|
||||
53
updates.md
53
updates.md
@@ -12,6 +12,59 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.0.5
|
||||
|
||||
5th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- Added settings to control whether finite synchronisation operations keep the screen awake. Desktop devices now allow automatic sleep by default, while mobile devices retain screen-awake protection unless the general option is enabled (#1073).
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Improved
|
||||
|
||||
- Korean translations now cover the complete current catalogue across Setup, P2P, remote configuration, diagnostics, and maintenance (PR #1075). Thank you to @motolies for the improvement!
|
||||
|
||||
#### Fixed
|
||||
|
||||
- The in-editor LiveSync status on iOS now remains below the view-header controls instead of overlapping them (PR #1067). Thank you to @Hsiii for the improvement!
|
||||
|
||||
## 1.0.4
|
||||
|
||||
5th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Testing or saving a fresh remote configuration no longer tries to access the local database while constructing a replicator, avoiding 'Local database is not ready yet' failures before local database initialisation (#1064).
|
||||
|
||||
### Command-line tool
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070).
|
||||
|
||||
## 1.0.3
|
||||
|
||||
3rd August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions.
|
||||
- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771).
|
||||
|
||||
### Interface
|
||||
|
||||
#### Fixed
|
||||
|
||||
- The Remediation setting now displays its configured modification-time limit without raising a `HierarchyRequestError`.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
31st July, 2026
|
||||
|
||||
@@ -52,15 +52,14 @@ export function renderReleasePrBody(version, baseBranch) {
|
||||
: "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes";
|
||||
const holdInstruction = isPrerelease
|
||||
? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.`
|
||||
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Promotion remains on hold until the exact release commit has been integrated into the repository's default branch.`;
|
||||
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Before the exact release commit reaches the repository's default branch, remove the pre-release designation and make this exact release the latest stable release so Community Directory review does not inspect a manifest version whose GitHub Release remains a pre-release.`;
|
||||
const completionInstructions = isPrerelease
|
||||
? [
|
||||
"- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action",
|
||||
]
|
||||
? ["- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action"]
|
||||
: [
|
||||
`- [ ] After BRAT validation passes, mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
|
||||
"- [ ] After BRAT validation passes, remove the pre-release designation and make this exact release the latest stable release",
|
||||
`- [ ] Confirm this exact release is no longer a pre-release, then mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
|
||||
"- [ ] Integrate the exact release commit through the reviewed branch chain into the repository's default branch",
|
||||
"- [ ] Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release",
|
||||
"- [ ] Confirm the default branch contains the exact release commit and metadata",
|
||||
"- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate",
|
||||
];
|
||||
|
||||
|
||||
@@ -206,43 +206,12 @@ describe("release workflow", () => {
|
||||
expect(stable).toContain(
|
||||
"Publish the GitHub Release initially as a pre-release without replacing the latest stable release"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"After BRAT validation passes, mark this pull request ready and merge it into `main` with a merge commit"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"Integrate the exact release commit through the reviewed branch chain into the repository's default branch"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate"
|
||||
);
|
||||
expect(stable.indexOf("After BRAT validation passes")).toBeLessThan(
|
||||
stable.indexOf("Integrate the exact release commit")
|
||||
);
|
||||
expect(stable.indexOf("Integrate the exact release commit")).toBeLessThan(
|
||||
stable.indexOf("Confirm the default branch contains the exact release metadata")
|
||||
);
|
||||
expect(stable.indexOf("Confirm the default branch contains the exact release metadata")).toBeLessThan(
|
||||
stable.indexOf("Create the stable CLI tag")
|
||||
);
|
||||
expect(stable).not.toContain("prerelease=false");
|
||||
});
|
||||
|
||||
it("summarises immutable pre-releases separately from stable versions awaiting promotion", () => {
|
||||
it("requires stable pre-release staging to defer CLI publication", () => {
|
||||
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
|
||||
|
||||
expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then');
|
||||
expect(workflow).toContain(
|
||||
"Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
"After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
"Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then'
|
||||
);
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
"1.0.0-rc.1": "1.7.2",
|
||||
"1.0.0": "1.7.2",
|
||||
"1.0.1": "1.7.2",
|
||||
"1.0.2": "1.7.2"
|
||||
"1.0.2": "1.7.2",
|
||||
"1.0.3": "1.7.2",
|
||||
"1.0.4": "1.7.2",
|
||||
"1.0.5": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user