mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Plugin settings were lost on every restart of the shadow vault — and the
real settings on the remote were destroyed in the process.
Root cause is an ordering problem the connect-time pull cannot fix.
Obsidian loads community plugins during startup, and each plugin's
`onload()` calls `Plugin.loadData()` -> reads
`<configDir>/plugins/<id>/data.json` — BEFORE remote-ssh has connected
over SSH and patched the adapter. That read therefore always hits the
real FileSystemAdapter, i.e. the LOCAL shadow disk. We cannot get in
front of it: the SSH connect is async and lands at layout-ready.
Nothing ever wrote that local copy. `saveData()` went through the
patched adapter straight to the remote, so the local file stayed empty
and every restart booted the plugin on DEFAULTS — which the plugin then
saved back, overwriting the good settings on the remote too. The plugin
CODE survived (main.js/manifest.json/styles.css are round-tripped by
PLUGIN_BINARY_FILES), which is exactly the reported asymmetry: "the
plugin is still installed, but its settings are gone".
Two changes:
1. SftpDataAdapter: write-through. A successful `<configDir>/**` write is
now mirrored onto the local shadow disk with the same bytes that
landed on the remote. The local disk becomes a warm cache, so the
startup read sees the settings this device last saved no matter how
the shadow window was launched (Connect from the source window, or
reopening the vault directly — the latter never runs preSpawnPull).
Best-effort: a mirror failure never fails the remote write. The NOTE
tree stays virtual — only the configDir is mirrored.
2. PathMapper: `plugins/*/data.json` is now per-device, redirected into
`<configDir>/user/<clientId>/`. `saveData()` fires on every settings
change, so a shared remote path would reintroduce the perpetual
write-conflict that the four core config files were redirected to
escape (b0e2773). Deliberately `plugins/*/data.json` and NOT
`plugins` — the plugin's CODE stays SHARED at the identity path so a
plugin installed on one machine still loads on every other. This
required teaching the pattern matcher a `*` segment (exactly one
path segment, never across `/`), and making crossing-point detection
wildcard-aware so listing a plugin's own dir merges in this client's
private data.json.
Multi-device is robust by construction: two devices can no longer
collide on one remote settings file.
Tests: new PathMapper cases pinning settings-private/code-shared and the
`*`-does-not-over-match boundary; new SftpDataAdapter cases pinning the
mirror, the per-device remote path, that notes are NOT mirrored, and that
a mirror failure does not fail the write. Full suite green (1223 passed).
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
PathMapper,
|
|
sanitizeClientId,
|
|
defaultClientId,
|
|
defaultUserName,
|
|
DEFAULT_PRIVATE_PATTERNS,
|
|
} from '../src/path/PathMapper';
|
|
|
|
const ID = 'host-a';
|
|
|
|
describe('sanitizeClientId', () => {
|
|
it('passes through ASCII alphanumerics + dot/hyphen/underscore', () => {
|
|
expect(sanitizeClientId('GERMI')).toBe('GERMI');
|
|
expect(sanitizeClientId('node-1.local_2')).toBe('node-1.local_2');
|
|
});
|
|
|
|
it('replaces unsafe characters with hyphen and trims them at the edges', () => {
|
|
expect(sanitizeClientId('host with spaces')).toBe('host-with-spaces');
|
|
expect(sanitizeClientId('!!evil!!')).toBe('evil');
|
|
});
|
|
|
|
it('falls back to "unknown" when the input is empty after sanitisation', () => {
|
|
expect(sanitizeClientId('')).toBe('unknown');
|
|
expect(sanitizeClientId('!!!')).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('defaultClientId / defaultUserName', () => {
|
|
it('defaultClientId returns a non-empty sanitized string', () => {
|
|
const id = defaultClientId();
|
|
expect(id).not.toBe('');
|
|
// The same string should pass through sanitize unchanged.
|
|
expect(sanitizeClientId(id)).toBe(id);
|
|
});
|
|
|
|
it('defaultUserName returns a non-empty string', () => {
|
|
const u = defaultUserName();
|
|
expect(u).not.toBe('');
|
|
});
|
|
});
|
|
|
|
describe('PathMapper.isPrivate', () => {
|
|
const m = new PathMapper(ID);
|
|
|
|
it('matches the canonical private files', () => {
|
|
expect(m.isPrivate('.obsidian/workspace.json')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/cache.zlib')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/types.json')).toBe(true);
|
|
});
|
|
|
|
it('treats per-device settings (app/appearance/core-plugins/hotkeys) as private', () => {
|
|
// Moved from shared → per-client so two devices (or one device
|
|
// across reconnects) never collide on a single shared copy — the
|
|
// perpetual write-conflict this fixes.
|
|
expect(m.isPrivate('.obsidian/app.json')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/appearance.json')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/core-plugins.json')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(true);
|
|
});
|
|
|
|
it('matches inside a private directory pattern', () => {
|
|
expect(m.isPrivate('.obsidian/cache/index')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/cache/sub/x.bin')).toBe(true);
|
|
});
|
|
|
|
it('rejects sibling paths that share a prefix', () => {
|
|
expect(m.isPrivate('.obsidian/cache.zlib2')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/workspace.json.bak')).toBe(false);
|
|
});
|
|
|
|
it('rejects regular vault content', () => {
|
|
expect(m.isPrivate('Notes/foo.md')).toBe(false);
|
|
// community-plugins.json stays shared (round-tripped with a forced
|
|
// remote-ssh union), so it is NOT redirected per-client.
|
|
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
|
|
});
|
|
|
|
it('tolerates a leading slash on the input', () => {
|
|
expect(m.isPrivate('/.obsidian/workspace.json')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// #342 / #429 — a plugin's SETTINGS go per-device: `Plugin.saveData()` fires on
|
|
// every settings change, so a shared `plugins/<id>/data.json` is the same
|
|
// perpetual write-conflict the four core config files were redirected to
|
|
// escape. Its CODE must stay SHARED, or a plugin installed on one machine
|
|
// stops loading on the others (that round-trip is PLUGIN_BINARY_FILES).
|
|
describe('PathMapper — plugin settings vs plugin code (#342 / #429)', () => {
|
|
const m = new PathMapper(ID);
|
|
|
|
it('privatises a plugin data.json', () => {
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/data.json')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/plugins/tasknotes/data.json')).toBe(true);
|
|
});
|
|
|
|
it('leaves the plugin code SHARED', () => {
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/main.js')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/manifest.json')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/plugins/claudian/styles.css')).toBe(false);
|
|
});
|
|
|
|
it('does not over-match: `*` spans exactly one segment', () => {
|
|
expect(m.isPrivate('.obsidian/plugins')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/plugins/claudian')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/plugins/a/b/data.json')).toBe(false);
|
|
expect(m.isPrivate('.obsidian/data.json')).toBe(false);
|
|
expect(m.isPrivate('Notes/data.json')).toBe(false);
|
|
});
|
|
|
|
it('redirects data.json per-client, keeping code at the identity path', () => {
|
|
expect(m.toRemote('.obsidian/plugins/claudian/data.json'))
|
|
.toBe('.obsidian/user/host-a/plugins/claudian/data.json');
|
|
expect(m.toRemote('.obsidian/plugins/claudian/main.js'))
|
|
.toBe('.obsidian/plugins/claudian/main.js');
|
|
});
|
|
|
|
it('round-trips through toVault', () => {
|
|
const remote = m.toRemote('.obsidian/plugins/claudian/data.json');
|
|
expect(m.toVault(remote)).toBe('.obsidian/plugins/claudian/data.json');
|
|
});
|
|
|
|
it('listing a plugin dir merges in this client private subtree', () => {
|
|
const plan = m.resolveListing('.obsidian/plugins/claudian');
|
|
expect(plan.primary).toBe('.obsidian/plugins/claudian');
|
|
expect(plan.mergeFromUser).toBe(true);
|
|
expect(plan.userSubtree).toBe('.obsidian/user/host-a/plugins/claudian');
|
|
// The `user` sibling only exists at the configDir level, so nothing to
|
|
// hide at this depth.
|
|
expect(plan.hideUserDirName).toBeUndefined();
|
|
});
|
|
|
|
it('still hides the user subdir when listing the configDir itself', () => {
|
|
const plan = m.resolveListing('.obsidian');
|
|
expect(plan.mergeFromUser).toBe(true);
|
|
expect(plan.userSubtree).toBe('.obsidian/user/host-a');
|
|
expect(plan.hideUserDirName).toBe('user');
|
|
});
|
|
});
|
|
|
|
describe('PathMapper.isCrossingPoint', () => {
|
|
it('flags `.obsidian/` because every private pattern lives directly under it', () => {
|
|
const m = new PathMapper(ID);
|
|
expect(m.isCrossingPoint('.obsidian')).toBe(true);
|
|
});
|
|
|
|
it('does not flag the private dirs themselves (those are private, not crossing)', () => {
|
|
const m = new PathMapper(ID);
|
|
expect(m.isCrossingPoint('.obsidian/cache')).toBe(false);
|
|
expect(m.isCrossingPoint('.obsidian/workspace.json')).toBe(false);
|
|
});
|
|
|
|
it('does not flag unrelated parents', () => {
|
|
const m = new PathMapper(ID);
|
|
expect(m.isCrossingPoint('Notes')).toBe(false);
|
|
expect(m.isCrossingPoint('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PathMapper.toRemote / toVault', () => {
|
|
const m = new PathMapper(ID);
|
|
|
|
it('redirects private files into the per-client subtree', () => {
|
|
expect(m.toRemote('.obsidian/workspace.json'))
|
|
.toBe('.obsidian/user/host-a/workspace.json');
|
|
expect(m.toRemote('.obsidian/cache/foo.bin'))
|
|
.toBe('.obsidian/user/host-a/cache/foo.bin');
|
|
expect(m.toRemote('.obsidian/app.json'))
|
|
.toBe('.obsidian/user/host-a/app.json');
|
|
});
|
|
|
|
it('passes non-private paths through unchanged', () => {
|
|
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
|
|
expect(m.toRemote('.obsidian/community-plugins.json')).toBe('.obsidian/community-plugins.json');
|
|
expect(m.toRemote('.obsidian')).toBe('.obsidian');
|
|
});
|
|
|
|
it('toVault inverts a redirected path back to its vault-relative form', () => {
|
|
expect(m.toVault('.obsidian/user/host-a/workspace.json'))
|
|
.toBe('.obsidian/workspace.json');
|
|
expect(m.toVault('.obsidian/user/host-a/cache/foo.bin'))
|
|
.toBe('.obsidian/cache/foo.bin');
|
|
});
|
|
|
|
it('leaves another client\'s subtree alone (so the caller can filter it out)', () => {
|
|
expect(m.toVault('.obsidian/user/host-b/workspace.json'))
|
|
.toBe('.obsidian/user/host-b/workspace.json');
|
|
});
|
|
|
|
it('uses the configured client id for the redirect prefix', () => {
|
|
const other = new PathMapper('SomeBox');
|
|
expect(other.toRemote('.obsidian/workspace.json'))
|
|
.toBe('.obsidian/user/SomeBox/workspace.json');
|
|
});
|
|
|
|
it('isolates per-device settings by client id (no cross-machine clobber)', () => {
|
|
// The whole point of making app.json per-device: two machines writing
|
|
// "the same" config file land on DIFFERENT remote paths, so neither can
|
|
// trip the other's write-precondition (the perpetual conflict fixed here).
|
|
const a = new PathMapper('host-a');
|
|
const b = new PathMapper('host-b');
|
|
expect(a.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-a/app.json');
|
|
expect(b.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-b/app.json');
|
|
expect(a.toRemote('.obsidian/app.json')).not.toBe(b.toRemote('.obsidian/app.json'));
|
|
});
|
|
});
|
|
|
|
describe('PathMapper.resolveListing', () => {
|
|
const m = new PathMapper(ID);
|
|
|
|
it('asks the caller to merge `.obsidian` with the user subtree, hiding the user/ dir', () => {
|
|
const r = m.resolveListing('.obsidian');
|
|
expect(r.primary).toBe('.obsidian');
|
|
expect(r.mergeFromUser).toBe(true);
|
|
expect(r.userSubtree).toBe('.obsidian/user/host-a');
|
|
expect(r.hideUserDirName).toBe('user');
|
|
});
|
|
|
|
it('redirects a list of a private directory entirely', () => {
|
|
const r = m.resolveListing('.obsidian/cache');
|
|
expect(r.primary).toBe('.obsidian/user/host-a/cache');
|
|
expect(r.mergeFromUser).toBe(false);
|
|
});
|
|
|
|
it('passes ordinary listings through', () => {
|
|
const r = m.resolveListing('Notes');
|
|
expect(r.primary).toBe('Notes');
|
|
expect(r.mergeFromUser).toBe(false);
|
|
});
|
|
|
|
it('passes `.obsidian/plugins` through unmerged (not a crossing point under default patterns)', () => {
|
|
const r = m.resolveListing('.obsidian/plugins');
|
|
expect(r.primary).toBe('.obsidian/plugins');
|
|
expect(r.mergeFromUser).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PathMapper with custom patterns', () => {
|
|
it('respects caller-supplied private patterns instead of the defaults', () => {
|
|
// Patterns must live under .obsidian/ so they redirect cleanly into the
|
|
// per-client subtree; this test extends the list with a hypothetical
|
|
// graph-experimental.json that ships with a future Obsidian version.
|
|
const m = new PathMapper(ID, ['.obsidian/graph-experimental.json']);
|
|
expect(m.isPrivate('.obsidian/graph-experimental.json')).toBe(true);
|
|
expect(m.isPrivate('.obsidian/workspace.json')).toBe(false); // not in custom list
|
|
expect(m.toRemote('.obsidian/graph-experimental.json'))
|
|
.toBe('.obsidian/user/host-a/graph-experimental.json');
|
|
});
|
|
|
|
it('exports a stable default pattern list', () => {
|
|
expect(DEFAULT_PRIVATE_PATTERNS).toContain('.obsidian/workspace.json');
|
|
expect(DEFAULT_PRIVATE_PATTERNS).toContain('.obsidian/cache');
|
|
});
|
|
});
|