mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Lands the Phase 2 surface from
docs/architecture-shadow-vault.md §3: the local-disk + URL-handler
plumbing that lets us open a shadow vault for a profile in a new
Obsidian window. No connect happens here; the auto-connect inside
the new window is Phase 4.
What's in:
src/shadow/ObsidianRegistry.ts
Reads / atomically rewrites obsidian.json (Obsidian's per-user
list of known vaults) so Obsidian's `obsidian://open?path=…`
URL handler accepts the shadow vault path directly instead of
falling back to its picker UI. Per-OS path resolution
(Windows %APPDATA%, macOS ~/Library/Application Support, Linux
$XDG_CONFIG_HOME) — never hardcodes a specific user. register()
is idempotent and case-insensitive on Windows.
src/shadow/ShadowVaultBootstrap.ts
Materialises ~/.obsidian-remote/vaults/<profile-id>/ with
.obsidian/community-plugins.json (["remote-ssh"]),
.obsidian/plugins/remote-ssh/ as a symlink (junction on
Windows) or recursive copy fallback, and data.json containing
the profile config + an `autoConnectProfileId` marker the
shadow window will read in Phase 4. Re-running for the same
profile refreshes the plugin install (so dev iterations land
immediately) and rewrites data.json but never touches files
Obsidian itself created.
src/shadow/WindowSpawner.ts
Builds the `obsidian://open?path=…` URL and fires it via
window.open(). Pluggable opener so tests can record without
actually launching browsers.
src/shadow/ShadowVaultManager.ts
Top-level orchestrator: bootstrap → spawn.
src/main.ts
Hidden command "Remote SSH: Debug: open shadow vault for
active profile (Phase 2 POC)". Visible only when an active
profile is selected (no need to be connected; this is a
local-disk operation).
What's NOT in (deferred):
- Settings UI rewires of the Connect button — Phase 3.
- Auto-connect-on-startup inside the shadow window — Phase 4.
(data.json is written with the marker; nothing reads it yet.)
- Removing the legacy reconcileVaultRoot / autoPatchAdapter
paths — Phase 5.
- The shadow data.json is intentionally minimal for Phase 2:
profiles + activeProfileId + autoConnectProfileId. Phase 4
will add hostKeyStore / secrets / clientId / ... so the
auto-connect actually has everything it needs.
Tests: 274 → 296, all green. New unit tests cover:
- ObsidianRegistry: per-OS default path; reads preserve unknown
top-level keys (adblock, cli, …); register adds a hex16 id
with current ts; idempotent on the same path; preserves
untouched vault entries; canonicalises trailing slashes;
case-insensitive on Windows; atomic write leaves no .tmp file.
- ShadowVaultBootstrap: layoutFor returns the expected paths;
sanitises ids that contain `..` / `/` so the vault dir stays
inside baseDir; bootstrap creates dir + plugin install +
community-plugins.json + data.json + registry entry; idempotent;
writes ALL profiles into data.json; refreshes plugin install
on re-bootstrap.
- WindowSpawner: builds `obsidian://open?path=…` with proper
URL encoding; returns the URL it fired.
- ShadowVaultManager: bootstrap before spawn; does not spawn
when bootstrap throws.
Manual smoke (after merge):
1. Reload plugin in dev vault.
2. Make sure a profile is active (Settings → Remote SSH).
3. Command palette → "Remote SSH: Debug: open shadow vault…".
4. Notice reports "opened shadow vault for <name>
(symlink|copy, newly registered)".
5. New Obsidian window opens at
~/.obsidian-remote/vaults/<profile-id>/, empty, with
Remote SSH plugin pre-installed (still disabled on first
load — Obsidian's default for community plugins; user
enables once).
6. ~/.obsidian-remote/vaults/<profile-id>/ on disk has the
expected layout; obsidian.json has a new vault entry with
a fresh hex16 id.
Bump 0.4.7 → 0.4.8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 lines
1.5 KiB
TypeScript
36 lines
1.5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { WindowSpawner, type UrlOpener } from '../src/shadow/WindowSpawner';
|
|
|
|
function recordingOpener() {
|
|
const calls: string[] = [];
|
|
const opener: UrlOpener = { openUrl(url) { calls.push(url); } };
|
|
return { opener, calls };
|
|
}
|
|
|
|
describe('WindowSpawner', () => {
|
|
it('builds an obsidian://open?path=… URL with the encoded vault path', () => {
|
|
const { opener, calls } = recordingOpener();
|
|
const spawner = new WindowSpawner(opener);
|
|
const url = spawner.spawn('C:\\Users\\alice\\.obsidian-remote\\vaults\\p1');
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]).toBe(url);
|
|
expect(url.startsWith('obsidian://open?path=')).toBe(true);
|
|
expect(url).toContain(encodeURIComponent('C:\\Users\\alice\\.obsidian-remote\\vaults\\p1'));
|
|
});
|
|
|
|
it('encodes characters that would otherwise break the URL', () => {
|
|
const { opener, calls } = recordingOpener();
|
|
const spawner = new WindowSpawner(opener);
|
|
spawner.spawn('/path/with spaces/and?question&');
|
|
expect(calls[0]).toContain(encodeURIComponent('/path/with spaces/and?question&'));
|
|
// Spaces / `?` / `&` must NOT appear unencoded after `path=`.
|
|
const after = calls[0].slice('obsidian://open?path='.length);
|
|
expect(after).not.toMatch(/[ ?&]/);
|
|
});
|
|
|
|
it('returns the URL it fired (so callers can log or surface it)', () => {
|
|
const { opener } = recordingOpener();
|
|
const url = new WindowSpawner(opener).spawn('/a/b');
|
|
expect(url).toBe('obsidian://open?path=' + encodeURIComponent('/a/b'));
|
|
});
|
|
});
|