@mnstry/atelier 0.2.0-alpha.11 → 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.
- package/CHANGELOG.md +344 -0
- package/README.md +10 -6
- package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
- package/contracts/compat-baseline.json +1 -1
- package/docs/assurance-controls.md +3 -2
- package/docs/install.md +3 -3
- package/docs/local-services.md +62 -11
- package/docs/obsidian-contract.md +669 -35
- package/docs/obsidian-plugin.md +585 -0
- package/docs/obsidian.md +233 -50
- package/docs/release-engineering.md +7 -1
- package/docs/upgrade.md +23 -3
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
- package/fixtures/obsidian/edits/workspace.json +1 -1
- package/fixtures/obsidian/materialization/expected-full.json +21 -21
- package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
- package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
- package/package.json +7 -3
- package/plugins/obsidian/main.js +444 -0
- package/plugins/obsidian/manifest.json +9 -0
- package/plugins/obsidian/styles.css +22 -0
- package/src/commands/obsidian.mjs +89 -10
- package/src/contracts/corpus.mjs +6 -0
- package/src/egress/forbidden-egress.mjs +2 -0
- package/src/project/private-state.mjs +10 -1
- package/src/projection/obsidian/contracts.mjs +224 -18
- package/src/projection/obsidian/edits/apply.mjs +12 -7
- package/src/projection/obsidian/edits/regions.mjs +137 -42
- package/src/projection/obsidian/materialize/index.mjs +6 -3
- package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
- package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
- package/src/projection/obsidian/materialize/redaction.mjs +327 -0
- package/src/projection/obsidian/materialize/settings.mjs +113 -34
- package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
- package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
- package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
- package/src/projection/obsidian/proposals/observation.mjs +10 -5
- package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
- package/src/projection/obsidian/publication/index.mjs +2 -1
- package/src/projection/obsidian/publication/publisher.mjs +173 -26
- package/src/projection/obsidian/publication/transport.mjs +63 -8
- package/src/projection/obsidian/publication/vault-list.mjs +199 -0
- package/src/projection/obsidian/recovery/restart.mjs +3 -1
- package/src/projection/obsidian/recovery/store.mjs +11 -4
- package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
- package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
- package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
- package/src/runtime/obsidian/app-capability.mjs +104 -12
- package/src/runtime/obsidian/app-production-seams.mjs +126 -39
- package/src/runtime/obsidian/app-registration.mjs +166 -0
- package/src/runtime/obsidian/engine.mjs +127 -11
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +5 -1
- package/src/runtime/obsidian/launch-plan.mjs +88 -0
- package/src/runtime/obsidian/lifecycle.mjs +105 -14
- package/src/runtime/obsidian/opening.mjs +227 -55
- package/src/runtime/obsidian/pending-edits.mjs +10 -0
- package/src/runtime/obsidian/plugin-channel.mjs +281 -0
- package/src/runtime/obsidian/plugin-choice.mjs +170 -0
- package/src/runtime/obsidian/plugin-drift.mjs +76 -0
- package/src/runtime/obsidian/plugin-presence.mjs +48 -0
- package/src/runtime/obsidian/service-main.mjs +18 -5
- package/src/runtime/obsidian/service-record.mjs +36 -0
- package/src/runtime/obsidian/service-server.mjs +50 -12
- package/src/runtime/obsidian/service.mjs +100 -6
- package/src/runtime/obsidian/state-store.mjs +13 -1
- package/src/server/local-sidecar.mjs +24 -5
- package/src/server/server.mjs +18 -5
- package/src/upgrade/upgrade.mjs +1 -1
- 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) {
|
|
@@ -108,8 +174,23 @@ export async function inspectApp(appProbe) {
|
|
|
108
174
|
// qualification with its input: an app that was not running qualified without
|
|
109
175
|
// a version, and an adapter built on that answer must not coordinate with an
|
|
110
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.
|
|
111
191
|
export function createQualifiedAdapterFactory({ appProbe, createAdapter, floor = MINIMUM_APP_VERSION, maxAgeMs = 10_000, now = () => Date.now() } = {}) {
|
|
112
|
-
|
|
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()')
|
|
113
194
|
if (typeof createAdapter !== 'function') throw new TypeError('the qualified adapter factory needs createAdapter')
|
|
114
195
|
let last = null
|
|
115
196
|
// An answer that lets an adapter through without a checked version (no app
|
|
@@ -117,22 +198,33 @@ export function createQualifiedAdapterFactory({ appProbe, createAdapter, floor =
|
|
|
117
198
|
// asked for its version by the next call, not refused on the old answer. An
|
|
118
199
|
// answer that checked a version, or that refuses, is reused for `maxAgeMs`.
|
|
119
200
|
const reusable = (result) => result.versionChecked === true || (result.outcome !== 'qualified' && result.outcome !== 'app-missing')
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return
|
|
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 })
|
|
127
213
|
}
|
|
128
|
-
|
|
129
|
-
|
|
214
|
+
let shown = null
|
|
215
|
+
const build = (input, probed) => {
|
|
216
|
+
const result = withPlugin(probed, input?.pluginReport ?? null)
|
|
217
|
+
shown = result
|
|
130
218
|
// No app at all is not an unqualified app: the publisher's own path needs none, and its adapter finds no process.
|
|
131
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 })
|
|
132
220
|
return createAdapter({ ...input, qualification: result })
|
|
133
221
|
}
|
|
222
|
+
const factory = waits ? async (input) => build(input, await qualification()) : (input) => build(input, qualification())
|
|
134
223
|
factory.qualification = qualification
|
|
135
|
-
// What was last learned, without asking again: for a status answer.
|
|
136
|
-
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 }
|
|
137
229
|
return factory
|
|
138
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 {
|
|
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,15 +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 in-process, and
|
|
15
|
-
// a trace below that the test suite asserts is absent. One
|
|
16
|
-
// a child process, with a stand-in for the command-line
|
|
17
|
-
// version answers it parses
|
|
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.
|
|
18
23
|
//
|
|
19
|
-
// Status:
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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.
|
|
23
34
|
globalThis[Symbol.for('mnstry.atelier.obsidian.production-seams-loaded')] = true
|
|
24
35
|
|
|
25
36
|
const CLI_TIMEOUT_MS = 5000
|
|
@@ -34,74 +45,150 @@ function cliExists(cliPath) {
|
|
|
34
45
|
try { return fs.statSync(cliPath).isFile() } catch { return false }
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
const NOT_ASKED = Object.freeze({ version: null, noVaultOpen: false })
|
|
48
|
+
const NOT_ASKED = Object.freeze({ version: null, noVaultOpen: false, cliOff: false })
|
|
38
49
|
|
|
39
50
|
const observationOf = ({ platform, cliPath, processes, answer }) => {
|
|
40
51
|
const appPath = installedAppPath(platform)
|
|
41
52
|
const present = cliExists(cliPath)
|
|
42
|
-
const answered = answer.version !== null || answer.noVaultOpen
|
|
53
|
+
const answered = answer.version !== null || answer.noVaultOpen || answer.cliOff === true
|
|
43
54
|
return {
|
|
44
55
|
installed: appPath === null ? (present !== false && (answered || present === true)) : fs.existsSync(appPath),
|
|
45
56
|
cli: present === true || answered,
|
|
46
57
|
running: processes === 'running' ? true : processes === 'absent' ? false : null,
|
|
47
58
|
version: answer.version,
|
|
48
59
|
...(answer.noVaultOpen ? { noVaultOpen: true } : {}),
|
|
60
|
+
...(answer.cliOff === true ? { cliOff: true } : {}),
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
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' } }
|
|
54
69
|
return {
|
|
55
70
|
// For the service's adapter factory, which is synchronous. The version is asked for only while an app runs.
|
|
56
71
|
// Both output streams are read, whatever the exit status: the answer with no vault open is not a version.
|
|
57
72
|
inspectSync() {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
73
|
+
const seen = processes()
|
|
74
|
+
if (seen === 'absent') return observationOf({ platform, cliPath, processes: seen, answer: NOT_ASKED })
|
|
60
75
|
let answer = NOT_ASKED
|
|
61
76
|
try {
|
|
62
77
|
const reply = spawnSync(cliPath, ['version'], { ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
63
78
|
answer = readVersionAnswer({ stdout: reply.stdout, stderr: reply.stderr, exited: reply.error === undefined && reply.status === 0 })
|
|
64
79
|
} catch { answer = NOT_ASKED }
|
|
65
|
-
return observationOf({ platform, cliPath, processes, answer })
|
|
80
|
+
return observationOf({ platform, cliPath, processes: seen, answer })
|
|
66
81
|
},
|
|
67
82
|
inspect() {
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
83
|
+
const seen = processes()
|
|
84
|
+
if (seen === 'absent') return Promise.resolve(observationOf({ platform, cliPath, processes: seen, answer: NOT_ASKED }))
|
|
70
85
|
return new Promise((resolve) => {
|
|
71
|
-
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 }) })))
|
|
72
87
|
})
|
|
73
88
|
},
|
|
74
|
-
// Whether the app answers for exactly this vault and has finished reading it.
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
|
77
98
|
return new Promise((resolve) => {
|
|
78
|
-
execFile(cliPath, ['eval', `code=${
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return resolve({ answered: value.basePath === vaultRoot, indexReady: value.basePath === vaultRoot && value.ready === true })
|
|
84
|
-
} 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 })
|
|
85
104
|
})
|
|
86
105
|
})
|
|
87
106
|
},
|
|
88
107
|
}
|
|
89
108
|
}
|
|
90
109
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
})
|
|
94
146
|
return {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 }),
|
|
100
187
|
})
|
|
101
188
|
},
|
|
102
189
|
}
|
|
103
190
|
}
|
|
104
191
|
|
|
105
192
|
export function createProductionAppSeams(options = {}) {
|
|
106
|
-
return { appProbe: createProductionAppProbe(options), launcher: createProductionLauncher(options) }
|
|
193
|
+
return { appProbe: createProductionAppProbe(options), launcher: createProductionLauncher(options), registry: createProductionAppRegistry(options) }
|
|
107
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
|
+
}
|