@mnstry/atelier 0.2.0-alpha.10 → 0.2.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +681 -39
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +292 -48
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +32 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +124 -17
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +115 -14
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -37
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +240 -51
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +19 -4
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. package/templates/external-project-workspace/pack-lifecycle.json +1 -1
@@ -13,6 +13,17 @@ import { refuse } from './errors.mjs'
13
13
  // is true when the command-line tool answered that no vault is open. The
14
14
  // production probe is a separate module that only the real command-line
15
15
  // entries load; every test passes its own.
16
+ //
17
+ // A version can also come from Atelier's plugin: a plugin that holds a live
18
+ // lease runs inside the app and reports `apiVersion`, the version of exactly
19
+ // that app. It stands in only where the command-line tool gives no version:
20
+ // the tool reaches the app a publication coordinates with, which may be
21
+ // another app holding the same vault, so its own answer decides wherever it
22
+ // gives one. Such an observation carries `versionSource: 'plugin'` and
23
+ // qualifies with reason `plugin-reported`; whether the app is installed where
24
+ // Atelier looks, with its command-line tool, is still the probe's answer, and
25
+ // so is whether it runs: a lease outlives a crashed app by up to its lease
26
+ // time, so while the process table shows no app, a report stands for none.
16
27
 
17
28
  // The publication protocol sets the view's undocumented `lastSavedData` field.
18
29
  // 1.13.7 is the only app version that protocol was proven on, so it is the
@@ -64,6 +75,13 @@ export function meetsMinimumAppVersion(version, floor = MINIMUM_APP_VERSION) {
64
75
  // command, `version` too, with this line (observed on 1.13.7, on either output
65
76
  // stream and with either exit status). It is not a version.
66
77
  const NO_VAULT_OPEN = /^vault not found\.?$/i
78
+ // While a vault window is still loading, right after the app started or opened
79
+ // a vault, the tool answers a command with this line instead (observed on
80
+ // 1.13.7). The app is not up yet: this is not a version either.
81
+ const COMMAND_NOT_READY = /^error: command "[^"]*" not found\b/i
82
+ // With its command line turned off (the default of a new installation: Settings > General > Advanced), the app
83
+ // answers every command but a URL with this line (1.13.7). It runs and is reachable only by URL: not a version.
84
+ const CLI_TURNED_OFF = /^command line interface is not enabled\b/i
67
85
 
68
86
  // Pure. What one `version` call of the command-line tool answered:
69
87
  // { version, noVaultOpen }. `exited` is false for a call that failed or exited
@@ -71,10 +89,47 @@ const NO_VAULT_OPEN = /^vault not found\.?$/i
71
89
  export function readVersionAnswer({ stdout = '', stderr = '', exited = true } = {}) {
72
90
  const lines = [stdout, stderr].flatMap((text) => (typeof text === 'string' ? text.split('\n') : [])).map((line) => line.trim())
73
91
  if (lines.some((line) => NO_VAULT_OPEN.test(line))) return { version: null, noVaultOpen: true }
92
+ if (lines.some((line) => CLI_TURNED_OFF.test(line))) return { version: null, noVaultOpen: false, cliOff: true }
93
+ if (lines.some((line) => COMMAND_NOT_READY.test(line))) return { version: null, noVaultOpen: false }
74
94
  const text = typeof stdout === 'string' ? stdout.trim() : ''
75
95
  return { version: exited && text !== '' ? text : null, noVaultOpen: false }
76
96
  }
77
97
 
98
+ // Pure. Whether a reply of the command-line tool came from the app itself: a
99
+ // version, or one of the lines the app answers with (no vault open, a command
100
+ // not ready yet, the command line turned off). The tool's own message while it
101
+ // cannot reach the app ("The CLI is unable to find Obsidian …") is not one, and
102
+ // nor is silence: the app is not up.
103
+ export function appAnswered({ stdout = '', stderr = '', exited = true } = {}) {
104
+ const lines = [stdout, stderr].flatMap((text) => (typeof text === 'string' ? text.split('\n') : [])).map((line) => line.trim())
105
+ if (lines.some((line) => NO_VAULT_OPEN.test(line) || CLI_TURNED_OFF.test(line) || COMMAND_NOT_READY.test(line))) return true
106
+ const text = typeof stdout === 'string' ? stdout.trim() : ''
107
+ return exited && parseAppVersion(text) !== null
108
+ }
109
+
110
+ // Pure. What one `eval` call of the command-line tool answered:
111
+ // { answered: true, value } with the value the script returned, parsed as
112
+ // JSON, or { answered: false, reason }. The tool prints a returned string as
113
+ // it is after `=> `; a string that is itself JSON text is parsed once more.
114
+ // `reason` is `no-vault-open` for the answer the app gives with no vault open,
115
+ // `cli-failed` for a call that failed, and `no-value` otherwise.
116
+ export function readEvalAnswer({ stdout = '', stderr = '', failed = false } = {}) {
117
+ const lines = [stdout, stderr].flatMap((text) => (typeof text === 'string' ? text.split('\n') : [])).map((line) => line.trim())
118
+ if (lines.some((line) => NO_VAULT_OPEN.test(line))) return { answered: false, reason: 'no-vault-open' }
119
+ if (lines.some((line) => CLI_TURNED_OFF.test(line))) return { answered: false, reason: 'cli-turned-off' }
120
+ if (failed) return { answered: false, reason: 'cli-failed' }
121
+ const text = typeof stdout === 'string' ? stdout : ''
122
+ const start = text.indexOf('=> ')
123
+ if (start < 0) return { answered: false, reason: 'no-value' }
124
+ try {
125
+ let value = JSON.parse(text.slice(start + 3))
126
+ if (typeof value === 'string') { try { value = JSON.parse(value) } catch { /* a plain string */ } }
127
+ return { answered: true, value }
128
+ } catch {
129
+ return { answered: false, reason: 'no-value' }
130
+ }
131
+ }
132
+
78
133
  // Pure. `requireVersion: false` lets an installed app that is positively not
79
134
  // running pass without a version: nothing is published through an app that
80
135
  // does not run, and the version is asked for again once it does. An app whose
@@ -83,7 +138,18 @@ export function readVersionAnswer({ stdout = '', stderr = '', exited = true } =
83
138
  export function qualifyApp(observation, { requireVersion = true, floor = MINIMUM_APP_VERSION } = {}) {
84
139
  const seen = observation !== null && typeof observation === 'object' ? observation : {}
85
140
  const base = { floor, version: typeof seen.version === 'string' ? seen.version.slice(0, 80) : null, running: seen.running === true ? true : seen.running === false ? false : null }
141
+ if (seen.versionSource === 'plugin') {
142
+ // The plugin runs inside the app it reports: that app runs and has a vault open.
143
+ const reported = { ...base, running: true, versionSource: 'plugin' }
144
+ if (seen.installed !== true) return { ...reported, outcome: 'app-missing', reason: 'no-app-found' }
145
+ if (seen.cli !== true) return { ...reported, outcome: 'app-cli-unavailable', reason: 'cli-capability-absent' }
146
+ if (reported.version === null) return { ...reported, outcome: 'app-version-unsupported', reason: 'version-unknown' }
147
+ if (parseAppVersion(reported.version) === null) return { ...reported, outcome: 'app-version-unsupported', reason: 'version-unreadable' }
148
+ if (!meetsMinimumAppVersion(reported.version, floor)) return { ...reported, outcome: 'app-version-unsupported', reason: 'below-minimum-version' }
149
+ return { ...reported, outcome: 'qualified', reason: 'plugin-reported', versionChecked: true }
150
+ }
86
151
  if (seen.installed !== true) return { ...base, outcome: 'app-missing', reason: 'no-app-found' }
152
+ if (seen.cliOff === true) return { ...base, running: true, outcome: 'app-cli-unavailable', reason: 'cli-turned-off' }
87
153
  if (seen.cli !== true) return { ...base, outcome: 'app-cli-unavailable', reason: 'cli-capability-absent' }
88
154
  if (seen.noVaultOpen === true) return { ...base, outcome: 'app-version-unsupported', reason: 'no-vault-open' }
89
155
  if (base.version === null) {
@@ -103,27 +169,62 @@ export async function inspectApp(appProbe) {
103
169
  // The adapter factory of a service that reaches a real app. The editor adapter
104
170
  // is constructed only after the app qualified; otherwise the factory refuses,
105
171
  // typed, and the engine records that reason and publishes nothing. The answer
106
- // is remembered briefly so a tick over many views asks once.
172
+ // is remembered briefly so a tick over many views asks once, unless it let an
173
+ // adapter through without a checked version. `createAdapter` receives the
174
+ // qualification with its input: an app that was not running qualified without
175
+ // a version, and an adapter built on that answer must not coordinate with an
176
+ // app started since (see createEditorAdapter).
177
+ //
178
+ // The app is asked only when an adapter is wanted, that is when a view is
179
+ // about to be published. An appProbe with `inspect()` is asked without
180
+ // blocking, and the factory and `qualification()` then answer promises; one
181
+ // with only `inspectSync()` is asked synchronously.
182
+ //
183
+ // When the service passes `pluginReport` (one launch of the plugin holds a
184
+ // live lease on the view), the probe is asked as without one, through the same
185
+ // remembered answer, and that answer decides wherever the tool gave a version,
186
+ // found no app or no tool, or the process table showed no app running. Only
187
+ // where the tool gave no version while an app may run does the plugin's
188
+ // version stand in; such an answer is never reused for another view or a later
189
+ // call. The adapter still coordinates through its own channel, which must
190
+ // answer for the vault before anything is published.
107
191
  export function createQualifiedAdapterFactory({ appProbe, createAdapter, floor = MINIMUM_APP_VERSION, maxAgeMs = 10_000, now = () => Date.now() } = {}) {
108
- if (typeof appProbe?.inspectSync !== 'function') throw new TypeError('the qualified adapter factory needs an appProbe with inspectSync()')
192
+ const waits = typeof appProbe?.inspect === 'function'
193
+ if (!waits && typeof appProbe?.inspectSync !== 'function') throw new TypeError('the qualified adapter factory needs an appProbe with inspect() or inspectSync()')
109
194
  if (typeof createAdapter !== 'function') throw new TypeError('the qualified adapter factory needs createAdapter')
110
195
  let last = null
111
- const qualification = () => {
112
- if (last === null || now() - last.at > maxAgeMs) {
113
- let observation
114
- try { observation = appProbe.inspectSync() } catch { observation = null }
115
- last = { at: now(), result: qualifyApp(observation, { requireVersion: false, floor }) }
116
- }
117
- return last.result
196
+ // An answer that lets an adapter through without a checked version (no app
197
+ // was running, or none was found) is never reused: an app started since is
198
+ // asked for its version by the next call, not refused on the old answer. An
199
+ // answer that checked a version, or that refuses, is reused for `maxAgeMs`.
200
+ const reusable = (result) => result.versionChecked === true || (result.outcome !== 'qualified' && result.outcome !== 'app-missing')
201
+ const current = () => last !== null && reusable(last.result) && now() - last.at <= maxAgeMs
202
+ const learn = (observation) => { last = { at: now(), result: qualifyApp(observation, { requireVersion: false, floor }) }; return last.result }
203
+ const qualification = waits
204
+ ? async () => { if (current()) return last.result; let observation; try { observation = await appProbe.inspect() } catch { observation = null } return learn(observation) }
205
+ : () => { if (current()) return last.result; let observation; try { observation = appProbe.inspectSync() } catch { observation = null } return learn(observation) }
206
+ const withPlugin = (probed, report) => {
207
+ if (report === null) return probed
208
+ // No app in the process table: a live lease is the echo of an app that is gone (it lapses within the lease time) or
209
+ // of a process holding the key, and vouches for no version. The probe's own answer stands, so an adapter built on it
210
+ // never coordinates with an app started since, whose version nobody checked.
211
+ if (probed.version !== null || probed.running === false || probed.outcome === 'app-missing' || probed.outcome === 'app-cli-unavailable') return probed
212
+ return qualifyApp({ installed: true, cli: true, version: typeof report?.appVersion === 'string' ? report.appVersion : null, versionSource: 'plugin' }, { requireVersion: true, floor })
118
213
  }
119
- const factory = (input) => {
120
- const result = qualification()
214
+ let shown = null
215
+ const build = (input, probed) => {
216
+ const result = withPlugin(probed, input?.pluginReport ?? null)
217
+ shown = result
121
218
  // No app at all is not an unqualified app: the publisher's own path needs none, and its adapter finds no process.
122
219
  if (result.outcome !== 'qualified' && result.outcome !== 'app-missing') refuse(result.outcome, 'the installed Obsidian does not qualify; nothing is published through it', { reason: result.reason, floor: result.floor, version: result.version })
123
- return createAdapter(input)
220
+ return createAdapter({ ...input, qualification: result })
124
221
  }
222
+ const factory = waits ? async (input) => build(input, await qualification()) : (input) => build(input, qualification())
125
223
  factory.qualification = qualification
126
- // What was last learned, without asking again: for a status answer.
127
- factory.lastQualification = () => last?.result ?? null
224
+ // What was last learned, from the probe or from a plugin, without asking again: for a status answer.
225
+ factory.lastQualification = () => shown ?? last?.result ?? null
226
+ // Drops what was learned, so the next adapter asks the app again: a tick somebody asked for does not reuse an
227
+ // answer from before the app changed.
228
+ factory.forget = () => { last = null; shown = null }
128
229
  return factory
129
230
  }
@@ -1,8 +1,12 @@
1
1
  import { execFile, spawnSync } from 'node:child_process'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
- import { defaultCliPath, defaultObsidianProcessProbe } from '../../projection/obsidian/publication/transport.mjs'
5
- import { readVersionAnswer } from './app-capability.mjs'
4
+ import { NEUTRAL_DIRECTORY, defaultCliPath, defaultObsidianProcessProbe, routedCall } from '../../projection/obsidian/publication/transport.mjs'
5
+ import { obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../../projection/obsidian/publication/vault-list.mjs'
6
+ import { realPathAsStored } from '../../project/private-state.mjs'
7
+ import { appAnswered, readEvalAnswer, readVersionAnswer } from './app-capability.mjs'
8
+ import { registerVaultInObsidianSettings } from './app-registration.mjs'
9
+ import { launchPlan, runLaunchPlan, urlProcessed } from './launch-plan.mjs'
6
10
 
7
11
  // The production seams of `obsidian open` and of the service's adapter
8
12
  // factory: the only code that asks the installed Obsidian anything or asks the
@@ -11,13 +15,22 @@ import { readVersionAnswer } from './app-capability.mjs'
11
15
  // This module is imported by exactly two places, both real command-line
12
16
  // entries and both behind an explicit `--adapter=obsidian-cli`: the service
13
17
  // entry (service-main.mjs) and the command entry (src/commands/obsidian.mjs,
14
- // `production: true`). No test imports it, and importing it leaves a trace
15
- // below that the test suite asserts is absent.
18
+ // `production: true`). No test of the default suite imports it in-process, and
19
+ // importing it leaves a trace below that the test suite asserts is absent. One
20
+ // test imports it in a child process, with a stand-in for the command-line
21
+ // tool, to read the version answers it parses; the opt-in real-app suite
22
+ // imports it to reach an isolated app through that app's private HOME.
16
23
  //
17
- // Status: written against the documented command-line interface and NOT yet
18
- // exercised against a running app. The acceptance work qualifies it on an
19
- // isolated host before any of it is relied on; until then every answer it
20
- // cannot establish is the failing one.
24
+ // Status: exercised against an isolated Obsidian 1.13.7 (installer 1.12.7) on
25
+ // macOS by the opt-in real-app suite in test/obsidian-first-open-real-app.test.mjs;
26
+ // every answer it cannot establish is the failing one.
27
+ //
28
+ // Where each call runs matters: the app answers a command in the window of the
29
+ // vault its list routes the call to, opening that vault when it is closed (see
30
+ // vault-list.mjs). A call about one vault goes where its route says, in its
31
+ // folder or naming its id, and is not made without a route that reaches only
32
+ // that vault; every other call runs in a directory that is no vault, so the
33
+ // directory this process was started in never picks, or opens, a vault.
21
34
  globalThis[Symbol.for('mnstry.atelier.obsidian.production-seams-loaded')] = true
22
35
 
23
36
  const CLI_TIMEOUT_MS = 5000
@@ -32,74 +45,150 @@ function cliExists(cliPath) {
32
45
  try { return fs.statSync(cliPath).isFile() } catch { return false }
33
46
  }
34
47
 
35
- const NOT_ASKED = Object.freeze({ version: null, noVaultOpen: false })
48
+ const NOT_ASKED = Object.freeze({ version: null, noVaultOpen: false, cliOff: false })
36
49
 
37
50
  const observationOf = ({ platform, cliPath, processes, answer }) => {
38
51
  const appPath = installedAppPath(platform)
39
52
  const present = cliExists(cliPath)
40
- const answered = answer.version !== null || answer.noVaultOpen
53
+ const answered = answer.version !== null || answer.noVaultOpen || answer.cliOff === true
41
54
  return {
42
55
  installed: appPath === null ? (present !== false && (answered || present === true)) : fs.existsSync(appPath),
43
56
  cli: present === true || answered,
44
57
  running: processes === 'running' ? true : processes === 'absent' ? false : null,
45
58
  version: answer.version,
46
59
  ...(answer.noVaultOpen ? { noVaultOpen: true } : {}),
60
+ ...(answer.cliOff === true ? { cliOff: true } : {}),
47
61
  }
48
62
  }
49
63
 
50
- export function createProductionAppProbe({ platform = process.platform, env = process.env, cliPath = defaultCliPath(platform) } = {}) {
51
- const options = { env, timeout: CLI_TIMEOUT_MS, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 1024 * 1024 }
64
+ // `processProbe` reads the process table ('running', 'absent' or 'unknown');
65
+ // `workingDirectory` is where calls that are about no vault run.
66
+ export function createProductionAppProbe({ platform = process.platform, env = process.env, cliPath = defaultCliPath(platform), processProbe = () => defaultObsidianProcessProbe({ platform }), workingDirectory = NEUTRAL_DIRECTORY } = {}) {
67
+ const options = { env, cwd: workingDirectory, timeout: CLI_TIMEOUT_MS, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 1024 * 1024 }
68
+ const processes = () => { try { return processProbe() } catch { return 'unknown' } }
52
69
  return {
53
70
  // For the service's adapter factory, which is synchronous. The version is asked for only while an app runs.
54
71
  // Both output streams are read, whatever the exit status: the answer with no vault open is not a version.
55
72
  inspectSync() {
56
- const processes = defaultObsidianProcessProbe({ platform })
57
- if (processes === 'absent') return observationOf({ platform, cliPath, processes, answer: NOT_ASKED })
73
+ const seen = processes()
74
+ if (seen === 'absent') return observationOf({ platform, cliPath, processes: seen, answer: NOT_ASKED })
58
75
  let answer = NOT_ASKED
59
76
  try {
60
77
  const reply = spawnSync(cliPath, ['version'], { ...options, stdio: ['ignore', 'pipe', 'pipe'] })
61
78
  answer = readVersionAnswer({ stdout: reply.stdout, stderr: reply.stderr, exited: reply.error === undefined && reply.status === 0 })
62
79
  } catch { answer = NOT_ASKED }
63
- return observationOf({ platform, cliPath, processes, answer })
80
+ return observationOf({ platform, cliPath, processes: seen, answer })
64
81
  },
65
82
  inspect() {
66
- const processes = defaultObsidianProcessProbe({ platform })
67
- if (processes === 'absent') return Promise.resolve(observationOf({ platform, cliPath, processes, answer: NOT_ASKED }))
83
+ const seen = processes()
84
+ if (seen === 'absent') return Promise.resolve(observationOf({ platform, cliPath, processes: seen, answer: NOT_ASKED }))
68
85
  return new Promise((resolve) => {
69
- execFile(cliPath, ['version'], options, (error, stdout, stderr) => resolve(observationOf({ platform, cliPath, processes, answer: readVersionAnswer({ stdout, stderr, exited: !error }) })))
86
+ execFile(cliPath, ['version'], options, (error, stdout, stderr) => resolve(observationOf({ platform, cliPath, processes: seen, answer: readVersionAnswer({ stdout, stderr, exited: !error }) })))
70
87
  })
71
88
  },
72
- // Whether the app answers for exactly this vault and has finished reading it.
73
- vaultState({ vaultRoot }) {
74
- const code = '(()=>JSON.stringify({basePath:app.vault.adapter.basePath,ready:app.metadataCache.initialized===true}))()'
89
+ // Whether the app answers for exactly this vault and has finished reading it. Asked where `route` (a
90
+ // `vaultRoute` of the app's list) says: from inside the vault's folder, or naming its id, so the app answers in
91
+ // that vault's window whichever window has focus, and opens it when it is known and closed. Without a route that
92
+ // reaches only this vault nothing is asked. Both sides are compared by real path, in the stored spelling.
93
+ vaultState({ vaultRoot, route }) {
94
+ let target
95
+ try { target = realPathAsStored(vaultRoot) } catch { return Promise.resolve({ answered: false, indexReady: false }) }
96
+ if (route?.how !== 'folder' && route?.how !== 'id') return Promise.resolve({ answered: false, indexReady: false })
97
+ const where = routedCall(route, workingDirectory)
75
98
  return new Promise((resolve) => {
76
- execFile(cliPath, ['eval', `code=${code}`], options, (error, stdout) => {
77
- if (error) return resolve({ answered: false, indexReady: false })
78
- try {
79
- const text = String(stdout)
80
- const value = JSON.parse(JSON.parse(text.slice(text.indexOf('=> ') + 3)))
81
- return resolve({ answered: value.basePath === vaultRoot, indexReady: value.basePath === vaultRoot && value.ready === true })
82
- } catch { return resolve({ answered: false, indexReady: false }) }
99
+ execFile(cliPath, [...where.args, 'eval', `code=${VAULT_STATE_CODE}`], { ...options, cwd: where.cwd }, (error, stdout, stderr) => {
100
+ const answer = readEvalAnswer({ stdout, stderr, failed: Boolean(error) })
101
+ const value = answer.answered ? answer.value : null
102
+ const answered = value !== null && typeof value === 'object' && value.basePath === target
103
+ resolve({ answered, indexReady: answered && value.ready === true })
83
104
  })
84
105
  })
85
106
  },
86
107
  }
87
108
  }
88
109
 
89
- // Asks the operating system to open the vault in Obsidian. No shell.
90
- export function createProductionLauncher({ platform = process.platform, env = process.env } = {}) {
91
- const command = platform === 'darwin' ? '/usr/bin/open' : platform === 'linux' ? 'xdg-open' : null
110
+ // The fixed scripts the app runs for `open`. The only variable input is a JSON
111
+ // payload that travels base64-encoded, so a path never becomes code. The app's
112
+ // folder is taken as the file system stores it, as the target is and as the
113
+ // bridge takes it, so a vault the app lists under another letter case answers.
114
+ const VAULT_STATE_CODE = "(()=>{let p=app.vault.adapter.basePath;try{const f=require('fs');p=process.platform!=='win32'&&f.realpathSync.native?f.realpathSync.native(p):f.realpathSync(p)}catch(e){}return JSON.stringify({basePath:p,ready:app.metadataCache.initialized===true})})()"
115
+ const VAULT_LIST_CODE = "(()=>JSON.stringify({vaults:require('electron').ipcRenderer.sendSync('vault-list')}))()"
116
+ // `vault-open` with `false` adds an existing folder to the app's vault list (the app writes its own file) and opens
117
+ // it in a window; it answers true, or a message. With `true` it would create a new folder, which is never wanted.
118
+ const VAULT_REGISTER_SCRIPT = "return JSON.stringify({result:require('electron').ipcRenderer.sendSync('vault-open',P.path,false)})"
119
+ function vaultRegisterCode(vaultRoot) {
120
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot) || vaultRoot.includes('\u0000')) throw new TypeError('vaultRoot must be an absolute path')
121
+ const data = Buffer.from(JSON.stringify({ path: vaultRoot }), 'utf8').toString('base64')
122
+ return `(()=>{const P=JSON.parse(new TextDecoder().decode(Uint8Array.from(atob('${data}'),c=>c.charCodeAt(0))));${VAULT_REGISTER_SCRIPT}})()`
123
+ }
124
+
125
+ // The vault list of the app, and adding this view's vault to it:
126
+ //
127
+ // listThroughApp() -> { answered: true, vaults } | { answered: false, reason }
128
+ // registerThroughApp({ vaultRoot }) -> { answered: true, result } | { answered: false, reason }
129
+ // readSettings() -> readObsidianSettings answer (never writes)
130
+ // registerInSettings({ vaultRoot }) -> registerVaultInObsidianSettings answer
131
+ //
132
+ // Through the app only while it runs and answers; in its settings file only
133
+ // while no Obsidian runs, which registerVaultInObsidianSettings checks itself.
134
+ // A Flatpak or snap build found for this account (`sandbox`) never reads that
135
+ // file: it is then neither read nor written (`obsidian-sandboxed`), and a vault
136
+ // is added through the app only. Adding a vault opens a window, so a call may
137
+ // take longer than a version answer.
138
+ export function createProductionAppRegistry({
139
+ platform = process.platform, env = process.env, cliPath = defaultCliPath(platform), processProbe = () => defaultObsidianProcessProbe({ platform }), workingDirectory = NEUTRAL_DIRECTORY,
140
+ userDataDir = obsidianUserDataDir({ platform, env }), sandbox = obsidianSandboxedBuild({ platform, env }), timeoutMs = CLI_TIMEOUT_MS * 3,
141
+ } = {}) {
142
+ const sandboxed = { ok: false, code: 'obsidian-sandboxed', message: `this Obsidian is a ${sandbox} build, which reads its vault list inside its sandbox, where Atelier does not write` }
143
+ const evaluate = (code) => new Promise((resolve) => {
144
+ execFile(cliPath, ['eval', `code=${code}`], { env, cwd: workingDirectory, timeout: timeoutMs, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => resolve(readEvalAnswer({ stdout, stderr, failed: Boolean(error) })))
145
+ })
92
146
  return {
93
- open({ vaultRoot }) {
94
- if (command === null) return Promise.resolve({ launched: false, reason: 'launcher-platform-unqualified' })
95
- const uri = `obsidian://open?path=${encodeURIComponent(vaultRoot)}`
96
- return new Promise((resolve) => {
97
- execFile(command, [uri], { env, timeout: 15_000 }, (error) => resolve(error ? { launched: false, reason: 'os-open-failed' } : { launched: true, reason: 'os-open-accepted' }))
147
+ async listThroughApp() {
148
+ const answer = await evaluate(VAULT_LIST_CODE)
149
+ if (!answer.answered) return answer
150
+ const vaults = answer.value?.vaults
151
+ return vaults !== null && typeof vaults === 'object' && !Array.isArray(vaults) ? { answered: true, vaults } : { answered: false, reason: 'no-value' }
152
+ },
153
+ async registerThroughApp({ vaultRoot }) {
154
+ const answer = await evaluate(vaultRegisterCode(vaultRoot))
155
+ return answer.answered ? { answered: true, result: answer.value?.result ?? null } : answer
156
+ },
157
+ readSettings: () => (sandbox === null ? readObsidianSettings({ userDataDir }) : sandboxed),
158
+ registerInSettings: ({ vaultRoot }) => (sandbox !== null ? sandboxed : userDataDir === null
159
+ ? { ok: false, code: 'obsidian-settings-location-unknown', message: 'where Obsidian keeps its settings on this system is not known' }
160
+ : registerVaultInObsidianSettings({ userDataDir, vaultRoot, processProbe })),
161
+ }
162
+ }
163
+
164
+ // Shows the vault in Obsidian by the steps of launchPlan, carried out by
165
+ // runLaunchPlan: a quit app is started plainly (so it reopens every vault its
166
+ // list flags open) and handed the vault's URL through its command-line tool once
167
+ // the app itself answers the tool; a running app is handed the URL through its
168
+ // tool, which takes a URL even with its command line turned off, and through the
169
+ // operating system when the tool did not take it. A URL is never handed to the
170
+ // operating system for an app this launch started. No shell.
171
+ export function createProductionLauncher({
172
+ platform = process.platform, env = process.env, cliPath = defaultCliPath(platform), workingDirectory = NEUTRAL_DIRECTORY, waitMs, pollMs, sleep,
173
+ } = {}) {
174
+ const osCommand = platform === 'darwin' ? '/usr/bin/open' : platform === 'linux' ? 'xdg-open' : null
175
+ const run = (file, args, timeout = 15_000) => new Promise((resolve) => {
176
+ execFile(file, args, { env, cwd: workingDirectory, timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => resolve({ failed: Boolean(error), stdout: String(stdout ?? ''), stderr: String(stderr ?? '') }))
177
+ })
178
+ return {
179
+ open({ vaultId = null, vaultPath = null, appRunning }) {
180
+ const plan = launchPlan({ platform, appRunning, vaultId, vaultPath })
181
+ return runLaunchPlan(plan, {
182
+ start: async () => !(await run(osCommand, ['-b', 'md.obsidian'])).failed,
183
+ answered: async () => { const reply = await run(cliPath, ['version'], CLI_TIMEOUT_MS); return appAnswered({ stdout: reply.stdout, stderr: reply.stderr, exited: !reply.failed }) },
184
+ handLink: async (uri) => urlProcessed((await run(cliPath, [uri], CLI_TIMEOUT_MS)).stdout),
185
+ osOpen: async (uri) => !(await run(osCommand, [uri])).failed,
186
+ ...(waitMs === undefined ? {} : { waitMs }), ...(pollMs === undefined ? {} : { pollMs }), ...(sleep === undefined ? {} : { sleep }),
98
187
  })
99
188
  },
100
189
  }
101
190
  }
102
191
 
103
192
  export function createProductionAppSeams(options = {}) {
104
- return { appProbe: createProductionAppProbe(options), launcher: createProductionLauncher(options) }
193
+ return { appProbe: createProductionAppProbe(options), launcher: createProductionLauncher(options), registry: createProductionAppRegistry(options) }
105
194
  }
@@ -0,0 +1,166 @@
1
+ import { randomBytes as cryptoRandomBytes } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { MAX_OBSIDIAN_SETTINGS_BYTES, OBSIDIAN_SETTINGS_FILE, enclosingVaults, findVaultEntry, readObsidianSettings } from '../../projection/obsidian/publication/vault-list.mjs'
5
+ import { openRegularFileNoFollow, realPathAsStored, syncPrivateDirectory } from '../../project/private-state.mjs'
6
+
7
+ // Adding a view's vault to Obsidian's own vault list while Obsidian is not
8
+ // running, so that `obsidian://open?path=` finds it when the app starts.
9
+ //
10
+ // Outside Atelier's own storage, it is the only file of another application
11
+ // Atelier writes. The list is `obsidian.json` in the app's user-data directory
12
+ // (see vault-list.mjs). The app reads it when it starts and rewrites all of it
13
+ // whenever its list changes, so while it runs the file is the app's: a vault
14
+ // is then added through the app itself (`open` does that through its command
15
+ // line), never here. Here, the file is written only while the injected process
16
+ // probe says, positively, that no Obsidian runs, read immediately before and
17
+ // again immediately before the rename, and:
18
+ //
19
+ // - never for a Flatpak or snap build, which reads its list inside its
20
+ // sandbox and never this file (`sandbox`, see obsidianSandboxedBuild);
21
+ // - only an existing file, in an existing user-data directory, both this
22
+ // user's own, a real directory and a regular file (no link, and no second
23
+ // name: a hard link would keep the old list), holding a JSON object: a file
24
+ // Obsidian has not written is never created;
25
+ // - never for a vault inside a folder the list has as a vault already: the
26
+ // app would show its notes in that vault too, and a call run in its folder
27
+ // would reach that vault (see vault-list.mjs);
28
+ // - every other key and every other vault entry is kept as the app wrote
29
+ // it, as values: the app itself rewrites the file with JSON.stringify;
30
+ // - the new entry is { path: the vault root's real path, ts: now, open:
31
+ // true } under a fresh 16-hex id that no entry has;
32
+ // - the new file is no larger than a settings file this module reads;
33
+ // - the bytes as they were are kept beside it in
34
+ // `obsidian.json.atelier-backup-<UTC time>`, fsynced, before the file is
35
+ // replaced; the replacement is a temporary file in the same directory,
36
+ // fsynced, renamed over it, and the directory is fsynced. Of these
37
+ // backups, the first (the list as it was before Atelier wrote it) and the
38
+ // latest are kept, and any between them removed;
39
+ // - a file that changed since it was read is not replaced.
40
+ //
41
+ // Every refusal writes nothing, leaves nothing behind and is typed: a file
42
+ // created on the way is removed again whichever step after its creation fails.
43
+
44
+ const refused = (code, message) => ({ ok: false, code, message })
45
+ const currentUid = () => (typeof process.getuid === 'function' ? process.getuid() : null)
46
+ const compactUtc = (ms) => new Date(ms).toISOString().replace(/[-:.]/g, '')
47
+ const BACKUP = new RegExp(`^${OBSIDIAN_SETTINGS_FILE.replaceAll('.', '\\.')}\\.atelier-backup-\\d{8}T\\d{9}Z$`)
48
+
49
+ // Keeps the first backup, the list as it was before Atelier ever wrote it, and `latest`; removes the ones between.
50
+ // Their names sort as their times. A backup that cannot be removed stays.
51
+ function pruneBackups(directory, latest) {
52
+ let names
53
+ try { names = fs.readdirSync(directory).filter((name) => BACKUP.test(name)).sort() } catch { return }
54
+ for (const name of names.slice(1)) {
55
+ if (name === latest) continue
56
+ const file = path.join(directory, name)
57
+ try { if (fs.lstatSync(file).isFile()) fs.unlinkSync(file) } catch { /* it stays */ }
58
+ }
59
+ }
60
+
61
+ // Creates `file`, which must not exist (an exclusive create fails on anything
62
+ // there, a link included), with these bytes, fsynced. The file exists exactly
63
+ // when the create returned; any failure after it removes the file again.
64
+ function writeNewFile(file, bytes, mode) {
65
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), mode)
66
+ try {
67
+ try {
68
+ fs.writeFileSync(descriptor, bytes)
69
+ fs.fchmodSync(descriptor, mode)
70
+ fs.fsyncSync(descriptor)
71
+ } finally {
72
+ fs.closeSync(descriptor)
73
+ }
74
+ } catch (error) {
75
+ try { fs.unlinkSync(file) } catch { /* already gone */ }
76
+ throw error
77
+ }
78
+ }
79
+
80
+ function readBytesNoFollow(file) {
81
+ const descriptor = openRegularFileNoFollow(file)
82
+ try { return fs.readFileSync(descriptor) } finally { try { fs.closeSync(descriptor) } catch { /* read already, or failed */ } }
83
+ }
84
+
85
+ // { ok: true, registered: 'already' | 'written', entry: { id, path, open }, confirmed, vaults, backupPath?, reason? }
86
+ // or a typed refusal { ok: false, code, message }. `confirmed` is false when an
87
+ // app appeared right after the rename (`app-started-during-registration`): it
88
+ // may have read the list before it, so whoever asked verifies through the
89
+ // app; and when the written file could not be read back to find the entry
90
+ // (`registration-not-read-back`). `vaults` is the list as it was found or
91
+ // written. `processProbe()` answers 'absent', 'running' or 'unknown'; only
92
+ // 'absent' allows a write. `sandbox` names a Flatpak or snap build found for
93
+ // this account, which refuses (`obsidian-sandboxed`).
94
+ export function registerVaultInObsidianSettings({ userDataDir, vaultRoot, processProbe, sandbox = null, now = () => Date.now(), randomBytes = cryptoRandomBytes, uid = currentUid() } = {}) {
95
+ if (typeof processProbe !== 'function') throw new TypeError('registering a vault needs a process probe')
96
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot) || vaultRoot.includes('\u0000')) throw new TypeError('vaultRoot must be an absolute path')
97
+ if (sandbox !== null) return refused('obsidian-sandboxed', `this Obsidian is a ${sandbox} build, which reads its vault list inside its sandbox, where Atelier does not write`)
98
+ const absent = () => { try { return processProbe() === 'absent' } catch { return false } }
99
+ if (!absent()) return refused('app-may-be-running', 'an Obsidian process may be running, and its vault list belongs to it')
100
+ const settings = readObsidianSettings({ userDataDir, uid })
101
+ if (!settings.ok) return settings
102
+ const known = findVaultEntry(settings.vaults, vaultRoot)
103
+ if (known) return { ok: true, registered: 'already', entry: known, confirmed: true, vaults: settings.vaults }
104
+ if (settings.links > 1) return refused('obsidian-settings-unsafe', 'the Obsidian settings file has a second name (a hard link), which a replacement would leave with the old list')
105
+ if (enclosingVaults({ vaults: settings.vaults, vaultRoot }).length > 0) return refused('vault-inside-another-vault', 'Obsidian lists a vault at a folder that contains this vault\'s folder')
106
+
107
+ let folder
108
+ try { folder = realPathAsStored(vaultRoot) } catch { return refused('vault-root-missing', 'the view\'s vault folder does not exist') }
109
+ if (!fs.statSync(folder).isDirectory()) return refused('vault-root-missing', 'the view\'s vault folder is not a directory')
110
+ let id
111
+ do { id = randomBytes(8).toString('hex') } while (Object.hasOwn(settings.vaults, id))
112
+ const at = now()
113
+ const document = { ...settings.document, vaults: { ...settings.vaults, [id]: { path: folder, ts: at, open: true } } }
114
+ const bytes = Buffer.from(JSON.stringify(document), 'utf8')
115
+ if (bytes.length > MAX_OBSIDIAN_SETTINGS_BYTES) return refused('obsidian-settings-too-large', 'with this vault the Obsidian settings file would be larger than a settings file can be')
116
+ const directory = path.dirname(settings.file)
117
+ const backupPath = path.join(directory, `${OBSIDIAN_SETTINGS_FILE}.atelier-backup-${compactUtc(at)}`)
118
+ const temporary = path.join(directory, `.${OBSIDIAN_SETTINGS_FILE}.atelier-${randomBytes(6).toString('hex')}.tmp`)
119
+ const created = []
120
+ const removeCreated = () => { for (const file of created.splice(0)) try { fs.unlinkSync(file) } catch { /* already gone */ } }
121
+ try {
122
+ writeNewFile(temporary, bytes, settings.mode)
123
+ created.push(temporary)
124
+ writeNewFile(backupPath, settings.bytes, settings.mode)
125
+ created.push(backupPath)
126
+ } catch (error) {
127
+ removeCreated()
128
+ return refused(error.code === 'EEXIST' ? 'obsidian-settings-changed' : 'obsidian-settings-unwritable', 'the Obsidian settings directory cannot be written')
129
+ }
130
+ // Immediately before the rename: still no app, and still the bytes that were read.
131
+ if (!absent()) { removeCreated(); return refused('app-may-be-running', 'an Obsidian process appeared, and its vault list belongs to it') }
132
+ let current
133
+ try { current = readBytesNoFollow(settings.file) } catch { current = null }
134
+ if (current === null || !current.equals(settings.bytes)) { removeCreated(); return refused('obsidian-settings-changed', 'the Obsidian settings file changed while the vault was being added') }
135
+ try {
136
+ fs.renameSync(temporary, settings.file)
137
+ } catch {
138
+ removeCreated()
139
+ return refused('obsidian-settings-unwritable', 'the Obsidian settings file cannot be replaced')
140
+ }
141
+ created.splice(0)
142
+ try { syncPrivateDirectory(directory) } catch { /* the rename is done; a directory that cannot be fsynced is not undone */ }
143
+ pruneBackups(directory, path.basename(backupPath))
144
+ const stillAbsent = absent()
145
+ const written = readObsidianSettings({ userDataDir, uid })
146
+ const entry = written.ok ? findVaultEntry(written.vaults, vaultRoot) : null
147
+ const reason = !stillAbsent ? 'app-started-during-registration' : entry === null ? 'registration-not-read-back' : null
148
+ return {
149
+ ok: true, registered: 'written', entry: entry ?? { id, path: folder, open: true }, backupPath, confirmed: reason === null, vaults: written.ok ? written.vaults : document.vaults,
150
+ ...(reason === null ? {} : { reason }),
151
+ }
152
+ }
153
+
154
+ // What the app looks like, as one comparable text, from what can be seen
155
+ // without asking it anything: whether an Obsidian process runs (`processes`, a
156
+ // process probe's 'running', 'absent' or 'unknown'), and which vaults its list
157
+ // shows open (`settings`, a `readObsidianSettings` answer or null). The app
158
+ // writes that list whenever a vault window opens or closes. The engine tries a
159
+ // view that did not settle again as soon as this changes; the app itself is
160
+ // asked only when a view is published.
161
+ export function appStateSignature({ processes = null, settings = null } = {}) {
162
+ const open = settings?.ok === true
163
+ ? Object.values(settings.vaults).filter((entry) => entry !== null && typeof entry === 'object' && entry.open === true && typeof entry.path === 'string').map((entry) => entry.path).sort()
164
+ : null
165
+ return JSON.stringify([typeof processes === 'string' ? processes : null, open])
166
+ }