Commit graph

882 commits

Author SHA1 Message Date
sotashimozono
5b0442cb41
Merge pull request #468 from sotashimozono/feat/background-full-index
feat(vault): background full index — graph, search and links see the whole vault again
2026-07-14 22:20:36 +09:00
Souta
e371ee38c8 Merge next into feat/background-full-index (bump beta to 1.1.8-beta.4) 2026-07-14 22:08:26 +09:00
sotashimozono
64a768253a
Merge pull request #467 from sotashimozono/fix/stale-read-size-check
fix(adapter,shadow): stale reads on same-second edits; uninstall now propagates
2026-07-14 21:54:07 +09:00
Souta
5788802aad chore: bump beta channel to 1.1.8-beta.3 2026-07-14 21:53:23 +09:00
Souta
aa3ce2621b feat(vault): background full-tree index so links/search see the whole vault
The lazy connect populate (`lazyFolderLoad`, on by default) walks only the
root's IMMEDIATE children, and `LazyFolderLoader` deepens a folder only when
the user CLICKS it open. Everything below depth 1 was therefore absent from
`vault.fileMap` entirely — and Obsidian resolves links, embeds, search, graph,
backlinks and Quick Switcher only against `fileMap`. So a `[[link]]` into a
never-expanded subfolder silently did not resolve, and an image embedded from
one did not render. A vault that registered 12 of 30,000 files reported
"connected" and looked fine.

Fix the product, not the tests: add `BackgroundIndexer`, which completes the
model in the background AFTER connect, so connect stays as fast as #448/#449
made it.

- Fast path (daemon `fs.walk`): one recursive walk (paginated server-side, so
  cheap), fed to `buildChunked` depth-by-depth — parents always land in an
  earlier call than their children, and cancellation gets frequent cut points.
- Fallback (SFTP): breadth-first, ONE FOLDER AT A TIME. The fallback costs one
  `adapter.list` per directory, so a dir-heavy tree must trickle, not stall.
- Yields between every unit (`requestIdleCallback`, else a 0 ms timer) and uses
  a 100-entry chunk — smaller than the connect populate's 500 — so the UI stays
  responsive while the user works.
- Safe to race the click-driven `LazyFolderLoader` and the live
  `FsChangeListener`: `VaultModelBuilder`'s inserts are idempotent, so a
  double-insert is a counted skip, never a duplicate. Completed folders are
  `markLoaded()` so a later expand click doesn't re-walk them.
- Never reads file CONTENT — registering the path is all metadataCache needs.
- Cancelled on disconnect (alongside `lazyLoader`) and restarted on reconnect.
  A cancelled pass marks nothing, so it degrades exactly back to lazy rather
  than leaving a folder that claims to be loaded but is empty.

Progress is surfaced honestly: status-bar text while indexing, one Notice on
completion (suppressed when nothing was missing), `logger.info` every 500
entries. `lazyFolderLoad: false` still does the full eager walk at connect and
skips the background pass entirely — its `fileMap` is already complete.

Also adds `BulkWalker.hasFastPath()` (purely additive) so a caller can CHOOSE
a traversal strategy up-front; `walk()` only reports which path it took after
the expensive part is already done.

Turns green (no test touched):
  e2e/links-metadata.spec.ts → "5 — a wiki link into a SUBFOLDER resolves"
  e2e/images.spec.ts → "embedded image inside an UNEXPANDED subfolder renders"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:52:14 +09:00
Souta
49e48a992e fix(adapter,shadow): stale reads on same-second edits; uninstall now propagates
Two product defects the new E2E matrix pinned red. Both fixed; both tests go
green off the product, not off a weakened assertion.

1. STALE READ (cache-pressure.spec). `readBuffer` revalidated a cached read on
   `s.mtime === cached.mtime` ALONE and never compared the size — though the
   very same `stat` returns it, and uses it two lines later to size the
   transfer. SFTP reports mtime at 1-SECOND resolution
   (`SftpClient`: `mtime: stats.mtime * 1000`), so two edits inside one
   wall-clock second collapse onto the same value: the user keeps reading a
   version that no longer exists on the server, then saves it back over the
   real one. Not theoretical — `reflect.spec.ts` sleeps 1.1 s between remote
   edits to stay green, which is this bug wearing a workaround.

   Now revalidates on mtime AND size. A same-second edit that preserves the
   byte length is still invisible (that needs a content hash or a server-side
   change counter); this closes the common case for free. Verified the guard is
   load-bearing: disabling the size check makes the new unit test fail.

2. UNINSTALL NEVER PROPAGATED (plugin-code-roundtrip.spec). Both
   pullCommunityPlugins and pushCommunityPlugins used a monotonic UNION, so
   `community-plugins.json` could only ever GROW: a local uninstall never
   reached the remote, and the next connect's pull RESURRECTED it. The user
   could not uninstall anything, ever.

   A union cannot tell "I never had it" from "I removed it", so `mergePluginIds`
   is now a 3-WAY merge against a per-device BASE — the list as it stood at the
   end of the last successful sync on this device. Base lives at
   `~/.obsidian-remote/state/<profile-id>/community-plugins.base.json`,
   deliberately OUTSIDE every vault so writeThroughConfig never mirrors it and
   PathMapper never redirects it.

   - No base yet -> degrades exactly to the old union (first-run fallback:
     removals can't be inferred, but nothing is lost).
   - Only the PUSH commits a base, and only once both sides hold the converged
     list — a base written by preSpawnPull's pull would make the later connect
     read `base \ remote` as a remote removal of this device's own additions.
     Never committing on pull also makes pre-spawn + connect idempotent.
   - An unreadable or ABSENT remote contributes NO removals — "absent" must
     never be read as "everything was uninstalled elsewhere".
   - remote-ssh is never removable from either side.
   - Tie-break is ADD WINS: re-uninstalling is one click; silently losing a
     plugin you just installed is invisible data loss.
   - A first bootstrap DISCARDS a stale base: deleting the shadow vault and
     reconnecting is the documented recovery step, and without this the first
     push would have stripped the user's entire enabled-plugin list off the
     remote.

Full suite: 1238 passed (was 1227), no regressions.
2026-07-14 21:41:21 +09:00
sotashimozono
f7e8a69880
Merge pull request #464 from sotashimozono/test/e2e-hard-matrix
test(e2e): hard-test matrix — images, plugins, fs visibility, links, cache pressure (17 → 60)
2026-07-14 20:51:24 +09:00
Souta
92ed4297fc test(e2e): make sync.spec deterministic — it was passing by luck
sync.spec's create/edit/delete went red once the Settings overlay was closed.
The overlay was not what made them work; removing it just made the whole run
faster and exposed two latent faults the spec had carried all along.

1. THE REAL RACE. `beforeAll` used `connectAndOpenShadow`, which returns as
   soon as the PLUGIN LOADS — the SSH connect, the ADAPTER PATCH and
   populateVaultFromRemote all land later, at layout-ready. A write fired
   before the patch goes to the shadow vault's LOCAL directory and never
   reaches the remote. That is the same race restart-settings.spec hit, and
   the same pre-connect window #342/#429 lives in. With the overlay up the run
   was slow enough that the connect had usually landed first; without it, it
   hadn't. Now: capture the shadow path via the building blocks and block on
   `waitForShadowVaultLoaded` before any test runs.

2. SILENT SKIPS. The rename was `if (await inlineTitle.isVisible(...))` — when
   false it was skipped without a word, leaving the note as `Untitled.md`, and
   the test then failed on `remote.exists('e2e-test-<stamp>.md')` with a
   message that named nothing. `delete` had the same shape around its confirm
   dialog. Note that Playwright's isVisible() does NOT hit-test (an element
   under an overlay still reports visible) while click() does — so overlay
   presence silently flipped which steps worked. Both branches are gone; every
   stage is now asserted and dumps the active file / fileMap / open modals on
   failure.

Drive path is now `app.vault.create/modify/delete` via page.evaluate. In the
shadow window `app.vault.adapter` IS the patched adapter, so the write still
traverses every line of plugin code a keystroke would have reached; the only
thing dropped is Obsidian's own contenteditable→debounce→vault.modify step,
which is not the product under test. The REMOTE assertions — independent SFTP
ground truth via RemoteVerifier — are unchanged, and two are stronger:
`edit` now asserts the original text is GONE (a stray append used to satisfy
it), and `delete` now asserts the file was actually THERE first (it could
previously pass vacuously). Fixed waitForTimeout sleeps replaced with
expect.poll against the remote.
2026-07-14 20:34:44 +09:00
Souta
07e1911ef1 test(e2e): close the Settings overlay that was swallowing every click
Root cause of the click-driven failures, and it is suite-wide.

`launchObsidian` -> `ensurePluginLoaded` -> `dismissTrustDialog` clicks "Trust
author and enable plugins". Obsidian's OWN reaction to that gesture is to open
its Settings dialog on the Community-plugins tab — a `.modal-container.mod-dim`
full-window overlay. Playwright cannot click through it:

    locator.click: Timeout 15000ms exceeded.
      - element is visible, enabled and stable
      - <div data-setting-id="plugins" ...> from <div class="modal-container
        mod-dim"> subtree intercepts pointer events

Every spec has been running under that overlay, in every window, since the
trust-dismiss path was added. It went unnoticed because the probes that do NOT
click are immune — page.evaluate over vault/metadataCache, and Node-side
fetches — so the model-level tests passed and only click-driven tests were hurt,
and those merely HUNG (Playwright's default actionTimeout is 0 = wait forever),
which read as "the product is slow", not "the harness is blocked".

`demo.spec.ts` is the single place in the repo that ever knew: it presses Esc,
with a comment saying exactly this. Every other spec ate it.

Fixed at the source: `launchObsidian` now calls the new `dismissBlockingModals`
once, for everyone. It LOGS what it closed, so a modal that IS meaningful (a
host-key prompt, PendingPluginsModal) shows up in the CI log rather than being
silently swallowed. Paired with `actionTimeout: 30_000` (previous commit), a
covered element now fails fast with Playwright's own intercept diagnostic — the
message that solved this — instead of burning the test timeout in silence.

This is why `images › embedded image in a root note renders` regressed to red
and why `links-metadata › 4 — clicking a wiki link opens the target` failed:
neither note was ever opened; the file-row click was intercepted. Both should
now go green. The genuine product reds are untouched.
2026-07-14 20:10:34 +09:00
Souta
c76206ab66 test(e2e): fix the harness bugs the first run exposed; keep the real reds red
The first CI run mixed genuine product defects with three HARNESS faults.
A red that isn't a real defect is as corrosive as a green that isn't real,
so separate them. No assertion was weakened.

1. UNBOUNDED ACTIONS (the systemic one). playwright.config.ts set no
   `actionTimeout`, so Playwright's default of 0 — wait forever — applied to
   every click. Obsidian's File Explorer under Xvfb can leave a node ATTACHED
   BUT NEVER ACTIONABLE (hover popovers swallow pointer events, rows paint
   lazily), so a bare `.click()` blocked until the 180 s test timeout and
   reported nothing. Three tests burned 3.0 min each with no message, reading
   as "the product hung" when the harness was simply waiting. Now bounded at
   30 s, and the specs additionally dump vault-model / DOM diagnostics on
   expiry, so a stuck locator explains itself instead of eating the clock.

2. RENDERER CSP BLOCKED THE BRIDGE PROBE. images' four bridge tests failed
   with a contentless `TypeError: Failed to fetch`. Obsidian's renderer CSP
   forbids page-JS `fetch()` to `http://127.0.0.1:<port>` (connect-src) —
   NOT a ResourceBridge defect. Proof: in the same run the root-note `<img>`
   embed test PASSED on the very same URL, because img-src is allowed. The
   measurement was wrong, not the product. Bytes and headers are now fetched
   from the NODE test process (no CSP), with a real PNG IHDR parse replacing
   createImageBitmap; `<img>` rendering is still asserted in-page, which is
   the actual user path. Every assertion kept, including "the 2048px source
   must come back at the 1024 cap AND smaller than the original" — the check
   that catches ResourceBridge silently falling back to the full binary.

3. CROSS-SPEC CONTAMINATION — caused by a real defect. sync.spec's
   `create — new note appears on remote`, previously green, went red. The new
   specs seed fixture plugins into the remote's community-plugins.json, and
   because `pushCommunityPlugins` is a monotonic UNION (the very defect
   plugin-code-roundtrip test 6 pins), the ids can never be removed — so every
   later spec's shadow vault pulled them in at connect. New helpers/remote-reset.ts
   force-restores the remote's community-plugins.json to its exact original
   bytes and purges fixture plugin dirs (including the per-device copies under
   .obsidian/user/<clientId>/) in afterAll regardless of outcome, plus a
   defensive pre-clean in beforeAll. That a test harness MUST do this is itself
   evidence of the defect's blast radius — it is recorded in the docblock.
   Test 6 is byte-for-byte unchanged and stays red.

Also fixed a FALSE GREEN: links test 8 (Quick Switcher finds a note in an
unexpanded subfolder) searched a folder that test 6 expands earlier in the same
session, so its green proved nothing. It now uses an independent `-sub2/`
fixture that no other test touches. Expected to go honestly red — the same
user-visible harm as test 5.
2026-07-14 19:37:24 +09:00
Souta
6b054a8047 test(e2e): hard-test matrix across images, plugins, fs, links, cache (17 -> 60)
Tests encode CORRECT behaviour. Assertions were NOT weakened to make them
pass. Several are expected RED — each one is a real defect to fix, not a
test to soften. No test.skip, no test.fixme.

New helpers:
  remote-verifier: mkdirp / rmrf / readBinaryFile / setMtime
  remote-fixtures: makePng (a REAL decodable RGBA PNG with random pixels —
    2048x1536 lands at 10.3 MB, CRC + zlib verified; the old makeOnePngOf was
    a 1x1 zero-padded blob that could never support a dimension assertion),
    makeSvg, makePdf, makeFakePlugin (a genuinely loadable plugin; version is
    a parameter because code convergence is version-ordered), seedPluginOnRemote
  obsidian: expandFolderInExplorer — clicks the product's own lazy-load hook
    (.nav-folder-title[data-path]) so a deferred folder load can be driven

images.spec.ts (9) — getResourcePath/ResourceBridge/webview. Bridge-level
  fetch assertions run BEFORE any preview render so a renderer crash still
  leaves a verdict on the bytes served. Asserts the 2048px source is actually
  downscaled to the 1024 cap AND is smaller than the original — the byte check
  is what catches ResourceBridge silently falling back to the full binary when
  the thumbnail RPC fails (it swallows that today). Range requests, SVG,
  PDF, and stale-generation-after-remote-replace covered.
  EXPECTED RED: an image embedded from an unexpanded subfolder.

fs-visibility.spec.ts (10) — the OPEN HALF OF #429. A plugin doing
  fs.writeFileSync(join(adapter.basePath, 'note.md')) must reach the remote
  vault; a remote note must be readable via raw fs; getFullPath/getFilePath
  must resolve. Two vehicles: in-page window.require('fs'), and a REAL fake
  plugin whose onload() does the write — a byte-for-byte reproduction of the
  field report.
  EXPECTED RED (7 of 10). These cannot be fixed by weakening the assertions.
  The only correct fixes are (i) mirror the note tree with a local->remote
  watcher, or (ii) patch getFullPath/getFilePath over a real FS-backed view.

links-metadata.spec.ts (8) — metadataCache over the virtual vault.
  EXPECTED RED: a wiki link into a subfolder does not resolve, and Quick
  Switcher cannot reach a note in an unexpanded folder — because lazy folder
  loading walks the ROOT ONLY (main.ts: walk('', !lazy)), so those files are
  absent from vault.fileMap entirely and metadataCache resolves only against
  fileMap. Test 6 is the discriminator: it tells us whether the fix is an
  eager/background full index or metadata invalidation on lazy insert.

cache-pressure.spec.ts (10) — LRU eviction and TTL correctness. Eviction is
  PROVEN via readCache.stats(), never inferred; the probe itself is a
  precondition test that hard-fails rather than continue unproven.
  EXPECTED RED — NEW DEFECT: SftpDataAdapter revalidates a cached read on
  mtime EQUALITY ALONE and never compares the remote size, though stat()
  already returns it. SFTP mtime is 1-second resolution, so two edits in the
  same wall-clock second serve a STALE copy. (That is why reflect.spec.ts has
  to sleep 1.1 s to make its own test pass.) Deliberately placed in an SFTP
  suite: on RPC the daemon's fs.changed push invalidates the entry and MASKS
  the defect, so asserting it there would be flaky-red instead of honest-red.

plugin-code-roundtrip.spec.ts (6) — plugin CODE across the device boundary:
  pull, load-on-next-start, push, data.json stays out of the code set, and
  no-downgrade version-ordered convergence.
  EXPECTED RED — DEFECT: uninstalling a plugin does not propagate.
  pushCommunityPlugins computes a monotonic UNION, so a removal can never
  reach the remote, and the next connect's pull RESURRECTS it. Needs tombstone
  / real removal semantics in the product.
2026-07-14 18:11:57 +09:00
sotashimozono
20b2246c30
Merge pull request #463 from sotashimozono/test/e2e-trustworthy
test(e2e): make the suite trustworthy + actually test the restart (#342/#429)
2026-07-14 17:37:02 +09:00
Souta
a9a9e7a4fe test(e2e): wait for the shadow window to CONNECT before exercising the adapter
The first CI run of restart-settings.spec.ts failed with a local-disk
ENOENT:

  ENOENT: no such file or directory, open
  '.../.obsidian-remote/vaults/E2E Test--vault (4)/.obsidian/plugins/e2e-settings-probe/data.json'

That is the STOCK FileSystemAdapter, not ours: `launchObsidian` returns
once the plugin has LOADED, but the SSH connect + adapter patch only land
later, at layout-ready. The spec wrote immediately and hit the unpatched
adapter, which does not mkdir -p and so died on the missing parent dir.

Fixed by waiting on `waitForShadowVaultLoaded` (the existing oracle the
connect-* specs use: getMarkdownFiles()>=1 AND a file-explorer leaf) after
landing in the shadow window.

The failure is worth recording rather than just fixing: it is a live
demonstration of the exact window #342/#429 lives in — at startup the vault
adapter is NOT ours yet, which is precisely why a plugin's loadData() reads
the local shadow disk and why that disk has to already be warm.

The post-restart assertions keep that property deliberately: the local-disk
check runs BEFORE waiting for the connect (that is the pre-connect window a
plugin's loadData() actually sees), and only then do we wait for the session
to fully reconnect and verify the remote copy was not clobbered by a
defaults re-save.
2026-07-14 17:32:59 +09:00
Souta
463b01dcf4 chore: bump beta channel to 1.1.8-beta.0 2026-07-14 17:25:59 +09:00
Souta
1207550967 test(e2e): make the suite trustworthy, and actually test the restart (#342/#429)
A green E2E run did not mean the product worked. Three separate ways the
suite could pass while broken, plus the one scenario it never covered.

1. GREEN-BY-SKIPPING. `sync.spec.ts` and `reflect.spec.ts` still used the
   old skip gate:

       try { obsidian = await connectAndOpenShadow(...); connected = true; }
       catch (e) { test.skip(true, `connectAndOpenShadow failed: ${e}`); }

   A THROWN connect — i.e. a genuinely broken plugin — was swallowed into a
   skip, and CI went green. Same for `if (!connected) test.skip(...)` and for
   sshd being down. `helpers/sshd.ts` already exists to prevent exactly this
   and even documents why ("that is how 1.0.49 shipped broken"); the
   connect-* specs were migrated to `assertSshdReachable()` and these two
   were left behind. Migrated now: both hard-fail, and the connect is no
   longer wrapped in try/catch. A broken connect is RED.

2. THE RECORDER RAN AS A TEST. `demo.spec.ts` asserts nothing — it drives the
   UI to emit PNG frames for the README GIF — but `testMatch: '**/*.spec.ts'`
   pulled it into the behavioural "Obsidian E2E smoke" job, where it burns
   minutes and can only go red for reasons unrelated to the product. Now
   `testIgnore`d by default; `demo-capture.yml` sets E2E_DEMO=1 to opt back
   in, so the GIF still regenerates.

3. NOTHING EVER RESTARTED OBSIDIAN. #342/#429 is an ORDERING bug: Obsidian
   loads plugins and calls `Plugin.loadData()` — reading data.json off the
   LOCAL shadow disk — BEFORE remote-ssh connects and patches the adapter. It
   is only observable across a real quit + relaunch. No spec did that.
   `tests/integration/restart-roundtrip.e2e.test.ts` is not it: despite the
   name it is a vitest that never starts Obsidian — it calls ShadowVaultBootstrap
   against a tmpdir and asserts the bootstrap's own output. It proves the pull
   works; it never proves Obsidian READS it.

   New `e2e/restart-settings.spec.ts` closes that: connect → write a plugin's
   data.json through the PATCHED adapter (byte-for-byte what saveData() does)
   → assert the remote stored it PER-DEVICE and not at the shared identity
   path → assert the write-through landed it on LOCAL disk → QUIT Obsidian →
   RELAUNCH on the SAME vault → assert the settings are still on disk (what
   loadData() reads at the next startup) and that the remote copy was not
   clobbered by a defaults re-save.

   Against a pre-1.1.7 build this fails at the local-disk assertion: the file
   is never created, because the write only ever reached the remote. That is
   the regression 1.1.7 fixed and, until now, nothing verified end-to-end.

The harness already supported the restart — `connectAndOpenShadow` has always
done quit+relaunch (onto the shadow vault); the same-vault case is three lines.
It was simply never written.
2026-07-14 17:25:17 +09:00
github-actions[bot]
72d0c1509a
Merge pull request #462 from sotashimozono/main
sync: main → next
2026-07-14 08:11:34 +00:00
sotashimozono
e0f3117ca6
Merge pull request #461 from sotashimozono/next
release: 1.1.7
2026-07-14 17:10:10 +09:00
sotashimozono
fa7504cccb
Merge pull request #460 from sotashimozono/release/cut-1.1.7
chore(release): promote 1.1.7-beta.1 -> 1.1.7 (stable)
2026-07-14 14:49:24 +09:00
Souta
99d4ad09c0 chore(release): promote 1.1.7-beta.1 -> 1.1.7 (stable)
Strip the -beta.N suffix so release.yml accepts the version landing on
main. Syncs plugin + root manifest.json / manifest-beta.json /
versions.json (minAppVersion 1.5.0). Version-only -- no code change.

Ships the #342 / #429 fix: third-party plugin settings now survive a
restart (config write-through to the local shadow disk) and are
per-device on the remote, so two machines can no longer collide on one
settings file.
2026-07-14 14:44:48 +09:00
sotashimozono
6d59a8fb95
Merge pull request #459 from sotashimozono/fix/plugin-settings-persistence
fix(config): persist third-party plugin settings across restarts (#342, #429)
2026-07-14 14:43:50 +09:00
Souta
64782eeff2 fix(adapter): harden config write-through against symlink clobber + traversal
Review of the write-through found two real defects it had introduced.

1. SYMLINK CLOBBER (the #455 bug class, reintroduced). writeThroughConfig
   mirrored every `<configDir>/**` write, and `fs.writeFileSync` FOLLOWS
   symlinks. `ShadowVaultBootstrap.installPlugin` symlinks remote-ssh's own
   main.js / manifest.json / styles.css in the shadow vault back to the
   SOURCE vault's real files, so any adapter write to those paths (e.g.
   updating the plugin from inside the shadow window) would have written
   straight through the link and overwritten the source vault's install —
   bricking it, exactly as #455 described.

   Now: lstat the target and refuse to mirror a symlink, and realpath the
   parent so a symlinked ANCESTOR (a stale whole-dir plugin symlink) cannot
   redirect the write out of the shadow root either. Skipping costs nothing —
   the remote write already succeeded, and for plugin CODE the local copy is
   owned by the pull/push binary round-trip, not by this cache.

   Pinned by a regression test that fails loudly without the guard: with it
   disabled the source vault's real file is observably overwritten
   ('SOURCE-REAL-FILE' -> 'NEW-BUNDLE-BYTES').

2. PATH TRAVERSAL. The containment guard was a `startsWith(configDir + '/')`
   on the RAW string, applied before `..` was resolved — so
   `.obsidian/../evil.txt` passed the check and `path.join` then resolved it
   out of the shadow root (on win32, backslash separators were a second way
   through). The vault path reaches the adapter from Obsidian and from other
   plugins; it is not trusted input.

   Now: resolve first, then verify the resolved path is the shadow root or
   inside it. Pinned by a test that asserts nothing lands outside the root.

Full suite green: 80 files, 1225 passed.
2026-07-14 14:39:00 +09:00
Souta
0a8d521982 fix(ci): restore package-lock dep tree; bump version fields only
The previous commit regenerated package-lock.json with
`npm install --package-lock-only` on Windows, which pruned the
platform-specific optional deps (@emnapi/*) from the tree. `npm ci` then
refused to install (lock out of sync with package.json), failing every
CI job before it ran.

Restore the lockfile's dependency tree verbatim from next and hand-edit
only the two version fields, so the diff is 1.1.7-beta.0 -> 1.1.7-beta.1
and nothing else.
2026-07-14 14:29:00 +09:00
Souta
40ef493c9c fix(config): persist third-party plugin settings across restarts (#342, #429)
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).
2026-07-14 14:24:26 +09:00
sotashimozono
63622b0338
Merge pull request #456 from sotashimozono/dependabot/go_modules/server/golang.org/x/image-0.44.0
chore(deps): bump golang.org/x/image from 0.43.0 to 0.44.0 in /server
2026-07-14 13:59:05 +09:00
sotashimozono
bdb1bbb047
Merge branch 'next' into dependabot/go_modules/server/golang.org/x/image-0.44.0 2026-07-14 13:57:50 +09:00
sotashimozono
7b71a96767
Merge pull request #457 from sotashimozono/dependabot/npm_and_yarn/plugin/dev-deps-d258f5e6e8
chore(deps-dev): bump the dev-deps group across 1 directory with 7 updates
2026-07-14 13:57:47 +09:00
dependabot[bot]
9a0daf2125
chore(deps-dev): bump the dev-deps group across 1 directory with 7 updates
Bumps the dev-deps group with 5 updates in the /plugin directory:

| Package | From | To |
| --- | --- | --- |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.0` | `26.1.1` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.62.1` | `8.64.0` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` |
| [eslint](https://github.com/eslint/eslint) | `10.6.0` | `10.7.0` |
| [fast-check](https://github.com/dubzzz/fast-check/tree/HEAD/packages/fast-check) | `4.8.0` | `4.9.0` |



Updates `@types/node` from 26.1.0 to 26.1.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@typescript-eslint/eslint-plugin` from 8.62.1 to 8.64.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.64.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.62.1 to 8.64.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.64.0/packages/parser)

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8)

Updates `eslint` from 10.6.0 to 10.7.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.6.0...v10.7.0)

Updates `fast-check` from 4.8.0 to 4.9.0
- [Release notes](https://github.com/dubzzz/fast-check/releases)
- [Changelog](https://github.com/dubzzz/fast-check/blob/main/packages/fast-check/CHANGELOG.md)
- [Commits](https://github.com/dubzzz/fast-check/commits/v4.9.0/packages/fast-check)

Updates `vitest` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-deps
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-deps
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-deps
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-deps
- dependency-name: eslint
  dependency-version: 10.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-deps
- dependency-name: fast-check
  dependency-version: 4.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-deps
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 04:48:20 +00:00
sotashimozono
0331491297
Merge pull request #455 from bepitulaz/fix/shadow-self-install-guard
fix(plugin): guard shadow bootstrap against self-install when Connect is clicked inside the shadow window (1.1.7-beta.0)
2026-07-14 13:45:14 +09:00
sotashimozono
70c7f1cc96
Merge branch 'next' into fix/shadow-self-install-guard 2026-07-13 11:49:39 +09:00
dependabot[bot]
820649f6c5
chore(deps): bump golang.org/x/image from 0.43.0 to 0.44.0 in /server
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/image/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 00:09:46 +00:00
sotashimozono
8945702fa7
Merge pull request #454 from sotashimozono/dependabot/npm_and_yarn/plugin/dev-deps-7edfe5bce4
chore(deps-dev): bump the dev-deps group in /plugin with 2 updates
2026-07-12 09:00:11 +09:00
Asep Bagja Priandana
550740dfd7 fix(plugin): guard shadow bootstrap against self-install when Connect is clicked inside the shadow window (1.1.7-beta.0) 2026-07-11 11:25:41 +03:00
dependabot[bot]
dab8b16d98
chore(deps-dev): bump the dev-deps group in /plugin with 2 updates
Bumps the dev-deps group in /plugin with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [eslint-plugin-obsidianmd](https://github.com/obsidianmd/eslint-plugin).


Updates `@types/node` from 26.0.1 to 26.1.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-plugin-obsidianmd` from 0.3.0 to 0.4.1
- [Release notes](https://github.com/obsidianmd/eslint-plugin/releases)
- [Commits](https://github.com/obsidianmd/eslint-plugin/compare/0.3.0...0.4.1)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-deps
- dependency-name: eslint-plugin-obsidianmd
  dependency-version: 0.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 00:09:42 +00:00
github-actions[bot]
f47bf18c77
Merge pull request #453 from sotashimozono/main
sync: main → next
2026-07-03 06:14:02 +00:00
sotashimozono
9ebc520464
Merge pull request #436 from sotashimozono/next
release: next → main — stage 1.1.6 (do not merge yet)
2026-07-03 15:12:47 +09:00
sotashimozono
4b35c3fa43
Merge pull request #452 from sotashimozono/release/cut-1.1.6
chore(release): promote 1.1.6 (drop beta — ready to cut)
2026-07-03 15:07:32 +09:00
Souta
e022e28a5c chore(release): promote 1.1.6-beta.18 -> 1.1.6 (stable)
Strip the -beta.N suffix so release.yml accepts the version landing on
main. Syncs plugin + root manifest.json / manifest-beta.json /
versions.json (minAppVersion 1.5.0). Version-only -- no code change.
2026-07-03 14:56:25 +09:00
sotashimozono
5d9f7e9b5a
Merge pull request #451 from sotashimozono/test/prespawn-per-client-pathmap
test(shadow): pin #450 per-device config pre-spawn path redirect
2026-07-03 14:37:15 +09:00
Souta
bae11ec737 test(shadow): pin #450 per-device config pre-spawn path redirect
Extract preSpawnPull's inline remote-path resolution into a pure
`preSpawnRemotePath` (PathMapper.toRemote -> remote-base join) so the
#450 CRITICAL redirect is unit-testable without standing up a standalone
SftpClient. Behavior-preserving -- main.ts calls the extracted function.

The four per-device config files (app/appearance/core-plugins/hotkeys)
must resolve to `<configDir>/user/<clientId>/...`; joining the bare
shared identity path clobbered per-device config on every shadow-window
spawn. New tests pin: per-client redirect for the four files, identity
for shared files (community-plugins.json, plugin binaries), "." base
join, and cross-client isolation.
2026-07-03 14:32:32 +09:00
sotashimozono
25d67843d7
Merge pull request #450 from sotashimozono/fix/per-device-config-hide-dotfiles
fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles
2026-07-02 22:15:57 +09:00
Souta
1d75cf911b fix(review): address /review-pr findings on #450 — preSpawnPull bypass + dotfile leak + misleading Notice
6-dimension multi-agent review surfaced one CRITICAL and several correctness/
clarity issues in the per-device-config + hide-dotfiles change:

CRITICAL — preSpawnPull bypassed PathMapper (main.ts). The pre-spawn config pull
built `toRemote` by joining the remote base only, so it read the OLD shared
identity path `<configDir>/app.json` and clobbered the freshly-redirected
per-device config on EVERY shadow-window spawn — silently undoing the fix. Now it
applies the same `PathMapper.toRemote` the shadow's adapter uses (from
`resolveClientId(settings)` + configDir), so the four config files pull from this
client's `user/<id>/` subtree; identity for non-private paths keeps
community-plugins.json / plugin binaries shared, unchanged.

MEDIUM — cross-client leak + configDir hardcode (BulkWalker). The dotfile filter
excepted a hard-coded `.obsidian`, which (a) drifted from the configurable
`app.vault.configDir` and (b) let every other client's `.obsidian/user/<id>/*`
private state (incl the 4 newly-redirected files) materialize as editable files
in the File Explorer. Dropped the exception — config is loaded off the local
shadow disk, never from the walked model, so hiding the whole `.obsidian` subtree
costs nothing and isolates foreign clients. `isHiddenPath` simplifies to the
codebase's `startsWith('.')` / `.some()` idiom.

HIGH — misleading "0 files" Notice (main.ts + BulkWalker). An all-dotfile remote
filtered to zero entries and fired "0 files found — check remotePath", the wrong
remediation. BulkWalkResult now carries `hiddenCount`; populate distinguishes
"0 visible, N hidden" with an accurate message and logs the count.

LOW — user-facing Notices + a comment said "shared-config" for files that are now
per-device; corrected. (Constant/method rename of SHARED_OBSIDIAN_CONFIG_FILES,
and a legacy→per-client one-time settings migration, are deferred — see PR notes.)

Tests: BulkWalker dotfile test updated (config dir now dropped) + fallback-path
filter test + hiddenCount assertions; PathMapper cross-machine isolation test;
stale restart-roundtrip comment corrected. tsc/lint clean; vitest 1206 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:10:37 +09:00
Souta
b0e2773f1a fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles
Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems:

1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause
   (read from the code, not guessed): a conflict fires when the read cache's
   mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon
   compares mtime as UnixMilli on both sides, so a single device's sequential
   note edits can't perpetually conflict. What CAN: app.json / appearance.json /
   core-plugins.json / hotkeys.json were the only config files NOT redirected
   per-client — they sat at the shared identity path, so two sessions (or one
   device across reconnects) both wrote the SAME remote file and every settings
   save tripped PreconditionFailed on the other's mtime.

   Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES
   so PathMapper redirects each into this client's `<configDir>/user/<id>/`
   subtree (exactly what workspace.json/graph/cache already do). No shared path
   → the conflict is impossible by construction. The existing shared-config
   round-trip keeps working — now on the per-client path, so each machine gets
   its own remote backup + cross-session persistence. This is the user's own
   ask: "config should be settable per accessing device".

2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now:
   - BulkWalker drops dot-prefixed entries from every walk result (full walk AND
     each lazy per-folder deepen), keeping only the vault config dir — matching
     Obsidian's own default of hiding dot-names. A dot-DIR hides its whole
     subtree, not just its row.
   - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so
     the daemon prunes these huge caches server-side (never walked/transferred),
     the perf half of "hide `.julia` by default".

tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a
BulkWalker dotfile-filter test; config round-trip integration tests still green
because seed-write and pull-read go through the same PathMapper). Ships beta.17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:41:35 +09:00
sotashimozono
24f645a633
Merge pull request #449 from sotashimozono/feat/lazy-folder-loading
feat(vault): lazy per-folder loading — deepen the remote tree on File-Explorer expand
2026-07-02 21:06:13 +09:00
Souta
4ae3170cc0 feat(vault): lazy per-folder loading — deepen the remote tree on File-Explorer expand
Dogfooding: even chunked (#448/beta.15), a deep dir-heavy vault (work/Vault with
tens of thousands of dirs nested deep) is too heavy to walk + materialise in
full at connect. Load ONE folder level at a time instead.

A real-Obsidian spike settled the two runtime unknowns: (U2) a childless
TFolder still renders an expand arrow (mod-collapsible), so an unloaded folder
is openable; (U1) File Explorer renders from the in-memory model and does NOT
call adapter.list on expand, so deepen-on-expand is driven by a DOM
.nav-folder-title click hook (the element carries the folder's data-path).

- BulkWalker.walk gains a `recursive` param; recursive:false returns just a
  folder's immediate children (fs.walk / one-level adapter.list), honouring
  walkIgnoreDirs per level.
- LazyFolderLoader: idempotent, deduped per-folder deepen (walk one level ->
  buildChunked). Unit tests cover recursive-false walk, idempotency,
  concurrent-dedup, markLoaded, retry-on-failure, reset.
- populateVaultFromRemote: with `lazyFolderLoad` (default true) it walks only
  the root level, wires the loader, marks root loaded, and installs a
  capture-phase .nav-folder-title click hook that deepens on first expand.
  `lazyFolderLoad:false` restores the full eager walk. Loader dropped on
  disconnect.

ignore (reduce N) and lazy (limit depth) compose; the live fs.changed watch
still works against a partial tree via ensureParents. Graph/search see only
loaded branches for now (accepted; revisit later). tsc/lint/vitest green
(1202). Ships as beta.16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:25:59 +09:00
sotashimozono
c01cba6968
Merge pull request #448 from sotashimozono/perf/chunked-vault-build
perf(vault): chunk connect-time populate — large vaults no longer freeze the UI
2026-07-02 19:45:27 +09:00
Souta
0c9f3a523b perf(vault): chunk the connect-time populate so large vaults don't freeze the UI
Dogfooding beta.14 (RPC daemon finally up): a profile pointed at a large
remote vault (~/work with tens of thousands of files) froze Obsidian for tens
of seconds on connect and the File Explorer stayed empty. Root cause:
VaultModelBuilder.build materialised every walked entry and fired a
vault.trigger('create') for each in ONE synchronous JS tick — 30k+ inserts +
30k+ events block the main thread.

- VaultModelBuilder: extract the per-entry insert into `insertBuildEntry`
  (shared by buildSync); add `buildChunked(entries, chunkSize=500)` that
  processes the folders-first list in chunks and yields to the event loop
  between them, so the tree fills in progressively and the window stays
  responsive. Ordering is preserved (parents before children) and inserts are
  idempotent, so a live fs.changed insert landing between chunks can't
  double-add.
- populateVaultFromRemote uses buildChunked.

Structure-only caching (persist the walked tree to skip the cold re-walk on
reconnect) and lazy per-folder loading are follow-ups; this alone removes the
freeze. tsc/lint/vitest green (1196). Ships as beta.15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:43:22 +09:00
sotashimozono
7eccbbf8bb
Merge pull request #447 from sotashimozono/fix/daemon-version-aware-cache
feat(daemon): version-aware provisioning — auto-refresh daemon on plugin upgrade
2026-07-02 19:13:00 +09:00
Souta
79aace49c3 fix(daemon): sha-verify on the fast path + keep the dev-daemon marker in E2E
Addresses /review-pr findings on #447 and the E2E smoke failure they caused.

- F1 (code-reviewer + silent-failure): the connect fast-path trusted
  existsSync alone, re-opening the "deploy an unverified binary" gap one layer
  above DaemonDownloader's fixed sha-check. Now records `daemonBinarySha` and
  re-hashes the cached file on the fast path; a post-write-corrupted binary
  fails the check and is re-fetched (network-free). Version + sha must both
  match to reuse.
- F2 (silent-failure): a saveSettings() failure after a successful download
  was caught by the generic "download failed -> SFTP" handler and misreported.
  Marker persistence is now its own non-fatal try — the verified binary is
  still returned; a persist failure just re-verifies next connect.
- F3 (comment-analyzer): corrected the dev-install marker comment (a stale
  binary is refreshed only when its sha differs, not "always on a bump").
- F4/F5 (pr-test): new ensureDaemonBinary.test.ts covers the locateDaemonBinary
  .dev-daemon gate and the fast-path reuse/skip (sha match, sha mismatch,
  version mismatch).

E2E fix: the new locateDaemonBinary `.dev-daemon` gate broke the Obsidian smoke
run — E2E stages the daemon via `build:server` (no dev-install), so the staged
binary was unmarked -> gated out -> no RPC -> the live-reflect specs failed.
build-server.mjs now writes the `.dev-daemon` marker; the E2E vault-scaffold
copies server-bin/ verbatim so it rides along. (Release daemons are built via
the Makefile, never marked — correct: users' downloads stay subject to the sha
refresh.)

tsc/lint/vitest green (1193). Still beta.14 (same PR #447).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:07:38 +09:00
Souta
8e258b4ce6 feat(daemon): version-aware provisioning — refresh the daemon on plugin upgrade
Dogfooding beta.13: the static-daemon fix shipped, but users still ran the
old dynamically-linked binary — the cache was keyed by ARCH only
(cacheHit = existsSync), so a plugin upgrade never re-downloaded the daemon.
And locateDaemonBinary returned any staged binary at the dev filename, so a
prior *download* masqueraded as a dev build and skipped the refresh entirely.

Now (single cached file, no per-version pileup):
- DaemonDownloader validates the cache by SHA against the release manifest,
  not mere existence: reuse iff the cached bytes still match the expected sha,
  else re-download (atomic overwrite = old dropped). A daemon that didn't
  change across a bump is NOT re-downloaded; a changed/stale one is.
- ensureDaemonBinary fast-paths on `settings.daemonBinaryVersion === version`
  (cached file present) -> no GitHub round-trip, works offline; on mismatch it
  runs the sha re-check and records the new version after provisioning.
- locateDaemonBinary now requires a `.dev-daemon` marker (written by
  dev-install) to treat a staged binary as a genuine dev build; an unmarked
  download is re-validated instead of trusted. This is what makes an existing
  store/BRAT user's stale binary auto-replace seamlessly (no manual delete).

Implements the deferred #406 "re-verify cached binary sha before reuse".
tsc/lint/vitest green (1188). Ships as beta.14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:48:49 +09:00
sotashimozono
fc22d6b950
Merge pull request #446 from sotashimozono/fix/daemon-static-cgo
fix(server): build daemon fully static (CGO_ENABLED=0) — glibc-independent
2026-07-02 16:46:50 +09:00