@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
@@ -0,0 +1,444 @@
1
+ 'use strict'
2
+
3
+ // Atelier's own plugin. Atelier ships it into every vault it manages, under
4
+ // .obsidian/plugins/atelier-projection/, beside a data file that names the
5
+ // maintenance service of the workspace and this vault's key (its bearer). The
6
+ // person never installs it; Obsidian asks once per vault whether to trust the
7
+ // vault's plugins, and until then (or in restricted mode, or on an app older
8
+ // than 1.13.7) everything works without it.
9
+ //
10
+ // Phase 1: presence and status, read-only. While the vault is open the plugin
11
+ // tells the service so (a lease renewed every two seconds, released when the
12
+ // plugin unloads), reports the app version it runs in, and shows the view's
13
+ // freshness in the status bar and in "Atelier: show status".
14
+ //
15
+ // The key never leaves this machine's files. The plugin first asks whatever
16
+ // listens at the service's address to prove that it holds the key, and sends
17
+ // nothing that names this vault until it did; then it proves the same, and
18
+ // every later request and answer is sealed with a key for that session only.
19
+ //
20
+ // What it never does: write a file (a note, a setting, its own data file),
21
+ // run code it is sent, or reach anything but that one service, on a literal
22
+ // loopback address, with no name to resolve.
23
+
24
+ const obsidian = require('obsidian')
25
+
26
+ // Held equal to src/projection/obsidian/plugin-bridge/channel.mjs by the test suite.
27
+ const CHANNEL = Object.freeze({
28
+ pluginId: 'atelier-projection',
29
+ protocol: 'atelier-obsidian-plugin-channel/v2',
30
+ dataSchema: 'atelier-obsidian-plugin-data/v1',
31
+ statusSchema: 'atelier-obsidian-plugin-status/v1',
32
+ routes: Object.freeze({ challenge: '/plugin/challenge', hello: '/plugin/hello', lease: '/plugin/lease', release: '/plugin/release', status: '/plugin/status' }),
33
+ renewEveryMs: 2000,
34
+ requestTimeoutMs: 1500,
35
+ maxResponseBytes: 64 * 1024,
36
+ minimumAppVersion: '1.13.7',
37
+ })
38
+
39
+ const BEARER = /^[A-Za-z0-9_-]{43}$/
40
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
41
+ const NONCE = /^[0-9a-f]{64}$/
42
+ const MAC = /^[0-9a-f]{64}$/
43
+ const HANDSHAKE_ID = /^ph-[0-9a-f]{32}$/
44
+ const SESSION_ID = /^ps-[0-9a-f]{32}$/
45
+ const VERSION = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-([0-9A-Za-z.]+))?/
46
+
47
+ // -1, 0 or 1; null when either side is not a version. A prerelease ranks below its release.
48
+ function compareVersions(left, right) {
49
+ const [a, b] = [VERSION.exec(String(left || '')), VERSION.exec(String(right || ''))]
50
+ if (!a || !b) return null
51
+ for (let index = 1; index <= 3; index += 1) if (Number(a[index]) !== Number(b[index])) return Number(a[index]) < Number(b[index]) ? -1 : 1
52
+ if (Boolean(a[4]) === Boolean(b[4])) return a[4] === b[4] || !a[4] ? 0 : a[4] < b[4] ? -1 : 1
53
+ return a[4] ? -1 : 1
54
+ }
55
+
56
+ function meetsFloor(version) {
57
+ const order = compareVersions(version, CHANNEL.minimumAppVersion)
58
+ return order !== null && order >= 0
59
+ }
60
+
61
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
62
+
63
+ // The handshake and session computations of plugin-bridge/channel.mjs; the
64
+ // test suite holds the two to each other. Every input but the vault path is a
65
+ // shape without a line break, and the vault path is the only input of its
66
+ // computation, so joining them by lines is unambiguous.
67
+ function transcript(label, fields) {
68
+ return [CHANNEL.protocol, label].concat(fields.map(String)).join('\n')
69
+ }
70
+ function hmac(crypto, key, label, fields) {
71
+ return crypto.createHmac('sha256', key).update(transcript(label, fields), 'utf8').digest()
72
+ }
73
+ function hmacHex(crypto, key, label, fields) {
74
+ return hmac(crypto, key, label, fields).toString('hex')
75
+ }
76
+ // Two MACs in hex, compared in time that does not depend on where they differ.
77
+ function sameMac(presented, expected) {
78
+ if (typeof presented !== 'string' || !MAC.test(presented) || presented.length !== expected.length) return false
79
+ let difference = 0
80
+ for (let index = 0; index < expected.length; index += 1) difference |= presented.charCodeAt(index) ^ expected.charCodeAt(index)
81
+ return difference === 0
82
+ }
83
+ // The exact address the service is reached at, as both proofs bind it.
84
+ const authorityOf = (channel) => (channel.host === '::1' ? `[::1]:${channel.port}` : `127.0.0.1:${channel.port}`)
85
+
86
+ // The channel this vault's data file names, or null when it names none that can be used.
87
+ function channelOf(data) {
88
+ if (!isPlainObject(data) || data.schema !== CHANNEL.dataSchema || !isPlainObject(data.channel)) return null
89
+ const { host, port } = data.channel
90
+ if (host !== '127.0.0.1' && host !== '::1') return null
91
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) return null
92
+ if (typeof data.scopeId !== 'string' || !IDENTIFIER.test(data.scopeId) || typeof data.bearer !== 'string' || !BEARER.test(data.bearer)) return null
93
+ return Object.freeze({ host, port, scopeId: data.scopeId, bearer: data.bearer })
94
+ }
95
+
96
+ // What the status bar says. `held` carries the number of held edits.
97
+ const LABELS = Object.freeze({
98
+ connecting: 'Atelier: connecting',
99
+ current: 'Atelier: current',
100
+ updating: 'Atelier: updating',
101
+ stale: 'Atelier: stale',
102
+ unreachable: 'Atelier: service unreachable',
103
+ 'not-set-up': 'Atelier: not set up',
104
+ unsupported: 'Atelier: needs a newer Obsidian',
105
+ })
106
+
107
+ function labelOf(view) {
108
+ return view.state === 'held' ? `Atelier: held (${view.held})` : LABELS[view.state]
109
+ }
110
+
111
+ const plural = (count, one, many) => (count === 1 ? one : many)
112
+
113
+ // The two ways a round can fail to reach the view. Between them the status bar moves only once two rounds agree.
114
+ const ROUND_FAILURES = new Set(['not-set-up', 'unreachable'])
115
+
116
+ // Notices on a transition into a state that needs a person, and back out of one.
117
+ const ATTENTION = Object.freeze({
118
+ held: (view) => `Atelier kept ${plural(view.held, 'an edit', `${view.held} edits`)} you made. This view is not updated over ${plural(view.held, 'it', 'them')} until ${plural(view.held, 'it is', 'they are')} applied or withdrawn.`,
119
+ stale: () => 'Atelier: this view is not current. "Atelier: show status" says why.',
120
+ unreachable: () => 'Atelier: the maintenance service does not answer. The vault stays as it is.',
121
+ unsupported: () => `Atelier's plugin needs Obsidian ${CHANNEL.minimumAppVersion} or later. The vault keeps working without it.`,
122
+ })
123
+
124
+ // The view as the service reports it. Anything but current, updating or held is stale.
125
+ function viewOfStatus(body) {
126
+ const view = isPlainObject(body) && body.schema === CHANNEL.statusSchema && isPlainObject(body.view) ? body.view : null
127
+ const common = { reason: view && typeof view.reason === 'string' ? view.reason : 'no-freshness-recorded', report: isPlainObject(body) ? body : null }
128
+ if (view === null) return { state: 'stale', ...common }
129
+ if (view.state === 'current') return { state: 'current', ...common }
130
+ if (view.state === 'updating') return { state: 'updating', ...common }
131
+ if (view.state === 'held-for-your-edit') return { state: 'held', held: Number.isInteger(view.heldNoteCount) ? view.heldNoteCount : 0, ...common }
132
+ return { state: 'stale', ...common }
133
+ }
134
+
135
+ const readable = (code) => (typeof code === 'string' && code !== '' ? code.replace(/[-_]+/g, ' ') : 'none')
136
+ const byteLength = (text) => new TextEncoder().encode(text).length
137
+
138
+ // One request to the service. Resolves, never rejects:
139
+ // { kind: 'response', statusCode, body } body is a JSON object or null
140
+ // { kind: 'unreachable', code } nothing usable came back
141
+ function post(http, channel, route, body) {
142
+ return new Promise((resolve) => {
143
+ let settled = false
144
+ const settle = (value) => { if (!settled) { settled = true; resolve(value) } }
145
+ const text = JSON.stringify(body)
146
+ const authority = authorityOf(channel)
147
+ let request
148
+ try {
149
+ // A literal loopback address, whatever the data file said: nothing is resolved by name.
150
+ request = http.request({ host: channel.host === '::1' ? '::1' : '127.0.0.1', family: channel.host === '::1' ? 6 : 4, port: channel.port, method: 'POST', path: route, agent: false, timeout: CHANNEL.requestTimeoutMs,
151
+ headers: { Host: authority, 'Content-Type': 'application/json', 'Content-Length': byteLength(text), Connection: 'close' } }, (response) => {
152
+ let size = 0
153
+ let received = ''
154
+ response.setEncoding('utf8')
155
+ response.on('data', (chunk) => {
156
+ size += chunk.length
157
+ if (size > CHANNEL.maxResponseBytes) { settle({ kind: 'unreachable', code: 'answer-too-large' }); request.destroy() } else received += chunk
158
+ })
159
+ response.on('end', () => {
160
+ let parsed = null
161
+ try { parsed = JSON.parse(received) } catch { parsed = null }
162
+ settle({ kind: 'response', statusCode: response.statusCode, body: isPlainObject(parsed) ? parsed : null })
163
+ })
164
+ response.on('error', () => settle({ kind: 'unreachable', code: 'answer-failed' }))
165
+ })
166
+ } catch {
167
+ settle({ kind: 'unreachable', code: 'request-failed' })
168
+ return
169
+ }
170
+ request.on('timeout', () => { settle({ kind: 'unreachable', code: 'timeout' }); request.destroy() })
171
+ request.on('error', (error) => settle({ kind: 'unreachable', code: error && error.code === 'ECONNREFUSED' ? 'refused' : 'request-failed' }))
172
+ request.end(text)
173
+ })
174
+ }
175
+
176
+ class AtelierStatusModal extends obsidian.Modal {
177
+ constructor(app, rows) {
178
+ super(app)
179
+ this.rows = rows
180
+ }
181
+
182
+ onOpen() {
183
+ this.setTitle('Atelier status')
184
+ const list = this.contentEl.createEl('dl', { cls: 'atelier-projection-status' })
185
+ for (const [label, value] of this.rows) {
186
+ list.createEl('dt', { text: label })
187
+ list.createEl('dd', { text: value })
188
+ }
189
+ }
190
+
191
+ onClose() {
192
+ this.contentEl.empty()
193
+ }
194
+ }
195
+
196
+ class AtelierProjectionPlugin extends obsidian.Plugin {
197
+ async onload() {
198
+ this.channel = null
199
+ this.session = null
200
+ this.cycling = null
201
+ this.unloaded = false
202
+ this.view = { state: 'connecting', reason: 'not-yet-asked', report: null }
203
+ this.shownState = null
204
+ // A round's failure that differs from the failure shown, not yet seen twice in a row.
205
+ this.pendingFailure = null
206
+ this.appVersion = typeof obsidian.apiVersion === 'string' ? obsidian.apiVersion : null
207
+ this.statusBarEl = this.addStatusBarItem()
208
+ this.statusBarEl.addClass('atelier-projection-status-bar')
209
+ this.registerDomEvent(this.statusBarEl, 'click', () => this.showStatus())
210
+ this.addCommand({ id: 'show-status', name: 'Show status', callback: () => this.showStatus() })
211
+ this.setView(this.view)
212
+
213
+ if (!meetsFloor(this.appVersion)) {
214
+ // Older than the floor, or a version that cannot be read: visible, and the channel stays closed.
215
+ this.setView({ state: 'unsupported', reason: 'app-below-minimum-version', report: null })
216
+ return
217
+ }
218
+ try {
219
+ this.http = require('http')
220
+ this.fs = require('fs')
221
+ this.crypto = require('crypto')
222
+ } catch {
223
+ this.http = null
224
+ }
225
+ if (!this.http || typeof this.http.request !== 'function' || !this.fs || typeof this.fs.realpathSync !== 'function' || !this.crypto || typeof this.crypto.createHmac !== 'function' || typeof this.crypto.randomBytes !== 'function') {
226
+ this.setView({ state: 'not-set-up', reason: 'node-modules-unavailable', report: null })
227
+ return
228
+ }
229
+ // This launch of the plugin: the service tells two apps holding the same vault apart by it.
230
+ this.instanceId = `pi-${this.crypto.randomBytes(16).toString('hex')}`
231
+ await this.readChannel()
232
+ this.registerInterval(globalThis.setInterval(() => { void this.cycle() }, CHANNEL.renewEveryMs))
233
+ // Not awaited: the app finishes loading while the first round trip is under way.
234
+ void this.cycle()
235
+ }
236
+
237
+ onunload() {
238
+ // The lease lapses by itself; releasing it says at once that the vault closed. A round still under way stops at its
239
+ // next step, and a session it opens after this is released at once.
240
+ this.unloaded = true
241
+ const session = this.session
242
+ this.session = null
243
+ if (this.channel && session) void this.release(this.channel, session)
244
+ }
245
+
246
+ // Obsidian calls this when data.json changed on disk: Atelier republished the channel.
247
+ async onExternalSettingsChange() {
248
+ await this.readChannel()
249
+ // A round under way still speaks for the old channel; the next one uses the new.
250
+ if (this.cycling) await this.cycling
251
+ await this.cycle()
252
+ }
253
+
254
+ async readChannel() {
255
+ let data = null
256
+ try { data = await this.loadData() } catch { data = null }
257
+ const next = channelOf(data)
258
+ const same = next && this.channel && next.host === this.channel.host && next.port === this.channel.port && next.scopeId === this.channel.scopeId && next.bearer === this.channel.bearer
259
+ if (!same) this.session = null
260
+ this.channel = next
261
+ if (next === null) this.setView({ state: 'not-set-up', reason: data === null ? 'no-channel-data' : 'channel-data-unusable', report: null })
262
+ }
263
+
264
+ // One round: shake hands (once per session), renew the lease, read the status.
265
+ cycle() {
266
+ if (this.cycling) return this.cycling
267
+ this.cycling = this.runCycle().finally(() => { this.cycling = null })
268
+ return this.cycling
269
+ }
270
+
271
+ // Whether a round that started for `channel` may still act.
272
+ current(channel) {
273
+ return !this.unloaded && channel === this.channel
274
+ }
275
+
276
+ async runCycle() {
277
+ const channel = this.channel
278
+ if (channel === null || this.unloaded) return
279
+ if (this.session === null) {
280
+ if (!(await this.handshake(channel))) return
281
+ } else {
282
+ const lease = await this.request(channel, 'lease')
283
+ if (!this.current(channel)) return
284
+ if (lease.kind !== 'sealed') {
285
+ // Whatever answered did not seal its answer with the session's key: the session ends here, and the next request
286
+ // starts a handshake, which only the service can answer. An unproven listener gets no second request of it.
287
+ this.session = null
288
+ if (!(lease.kind === 'refused' && (lease.error === 'session-unknown' || lease.statusCode === 401))) {
289
+ this.showRound(this.unreachable(lease))
290
+ return
291
+ }
292
+ // The service started again since, the vault's key changed, or the session is not accepted: shake hands at once.
293
+ if (!(await this.handshake(channel))) return
294
+ }
295
+ }
296
+ const status = await this.request(channel, 'status')
297
+ if (!this.current(channel)) return
298
+ if (status.kind !== 'sealed') this.session = null
299
+ this.showRound(status.kind === 'sealed' ? viewOfStatus(status.document) : this.unreachable(status))
300
+ }
301
+
302
+ // The handshake. Nothing that names this vault is sent before the listener proved it holds the vault's key.
303
+ async handshake(channel) {
304
+ let vaultPath = null
305
+ // As the file system stores the path, as the service proves it: the app may have the vault in another letter case.
306
+ // On Windows Node's own resolution, as the service's (the native one also expands short names).
307
+ const native = globalThis.process?.platform !== 'win32' && typeof this.fs.realpathSync.native === 'function'
308
+ try { vaultPath = native ? this.fs.realpathSync.native(this.app.vault.adapter.getBasePath()) : this.fs.realpathSync(this.app.vault.adapter.getBasePath()) } catch { vaultPath = null }
309
+ if (typeof vaultPath !== 'string') {
310
+ this.showRound({ state: 'not-set-up', reason: 'vault-path-unknown', report: null })
311
+ return false
312
+ }
313
+ const crypto = this.crypto
314
+ const clientNonce = crypto.randomBytes(32).toString('hex')
315
+ const issuedAt = Date.now()
316
+ const challenge = await post(this.http, channel, CHANNEL.routes.challenge, { protocol: CHANNEL.protocol, keyHint: hmacHex(crypto, channel.bearer, 'key-hint', [clientNonce, issuedAt]), clientNonce, issuedAt })
317
+ if (!this.current(channel)) return false
318
+ const offer = challenge.kind === 'response' && challenge.statusCode === 200 ? challenge.body : null
319
+ if (offer === null) {
320
+ // A 401 is a key the service does not know, unless the service says the challenge itself came too late or was
321
+ // answered already: the key is not in question then, and the next round's fresh challenge tries again.
322
+ const refusal = challenge.kind === 'response' && challenge.body ? challenge.body.error : null
323
+ const keyUnknown = challenge.kind === 'response' && challenge.statusCode === 401 && refusal !== 'challenge-stale' && refusal !== 'challenge-replayed'
324
+ this.showRound(keyUnknown ? { state: 'not-set-up', reason: 'key-not-known-to-the-service', report: null } : this.unreachable(challenge))
325
+ return false
326
+ }
327
+ const bound = [channel.scopeId, authorityOf(channel), clientNonce, offer.serverNonce, offer.handshakeId]
328
+ if (offer.protocol !== CHANNEL.protocol || typeof offer.handshakeId !== 'string' || !HANDSHAKE_ID.test(offer.handshakeId) || typeof offer.serverNonce !== 'string' || !NONCE.test(offer.serverNonce)
329
+ || !sameMac(offer.serverProof, hmacHex(crypto, channel.bearer, 'server-proof', bound))) {
330
+ // Whatever answers there does not hold this vault's key: it is told nothing more.
331
+ this.showRound({ state: 'unreachable', reason: 'listener-not-proven', report: null })
332
+ return false
333
+ }
334
+ const key = hmac(crypto, channel.bearer, 'session-key', bound)
335
+ const vaultProof = hmacHex(crypto, key, 'vault', [vaultPath])
336
+ const pluginVersion = String(this.manifest.version)
337
+ const clientProof = hmacHex(crypto, channel.bearer, 'client-proof', bound.concat([pluginVersion, this.appVersion, this.instanceId, vaultProof]))
338
+ const answer = await post(this.http, channel, CHANNEL.routes.hello, { handshakeId: offer.handshakeId, pluginVersion, appVersion: this.appVersion, instanceId: this.instanceId, vaultProof, clientProof })
339
+ const sealed = this.opened(answer, 'hello', { key, id: null }, 0)
340
+ if (sealed.kind === 'sealed') {
341
+ const session = { id: sealed.sessionId, key, counter: 0 }
342
+ if (!this.current(channel)) {
343
+ // Unloaded, or a new channel, while the hello was under way: the session it opened is let go at once.
344
+ void this.release(channel, session)
345
+ return false
346
+ }
347
+ this.session = session
348
+ return true
349
+ }
350
+ if (!this.current(channel)) return false
351
+ // A copy of a vault carries its data file, but the service maintains the original only.
352
+ if (sealed.kind === 'refused' && sealed.statusCode === 409 && sealed.error === 'wrong-vault') this.showRound({ state: 'not-set-up', reason: 'not-the-vault-atelier-maintains', report: null })
353
+ else this.showRound(this.unreachable(sealed))
354
+ return false
355
+ }
356
+
357
+ // One command of the session: a counter that only goes up, and a MAC over it under the session's key.
358
+ request(channel, command) {
359
+ const session = this.session
360
+ session.counter += 1
361
+ const counter = session.counter
362
+ return post(this.http, channel, CHANNEL.routes[command], { sessionId: session.id, counter, mac: hmacHex(this.crypto, session.key, 'request', [command, session.id, counter]) })
363
+ .then((answer) => this.opened(answer, command, session, counter))
364
+ }
365
+
366
+ release(channel, session) {
367
+ session.counter += 1
368
+ return post(this.http, channel, CHANNEL.routes.release, { sessionId: session.id, counter: session.counter, mac: hmacHex(this.crypto, session.key, 'request', ['release', session.id, session.counter]) })
369
+ }
370
+
371
+ // An answer counts only when it is sealed with the session's key over this very request:
372
+ // { kind: 'sealed', document, sessionId } the service's own answer
373
+ // { kind: 'refused', statusCode, error } an error; nobody vouches for it, so it only ever makes the plugin ask again
374
+ // { kind: 'unreachable', code } nothing usable came back
375
+ opened(answer, command, session, counter) {
376
+ if (answer.kind !== 'response') return answer
377
+ const body = answer.body
378
+ if (answer.statusCode !== 200) return { kind: 'refused', statusCode: answer.statusCode, error: body && typeof body.error === 'string' ? body.error : null }
379
+ let document = null
380
+ try { document = body && typeof body.payload === 'string' ? JSON.parse(body.payload) : null } catch { document = null }
381
+ // A hello's answer names the session it opened; the MAC over it is what vouches for that name.
382
+ const sessionId = command === 'hello' ? (isPlainObject(document) && typeof document.sessionId === 'string' && SESSION_ID.test(document.sessionId) ? document.sessionId : null) : session.id
383
+ if (!isPlainObject(document) || sessionId === null || !sameMac(body.mac, hmacHex(this.crypto, session.key, 'response', [command, sessionId, counter, body.payload]))) return { kind: 'unreachable', code: 'answer-not-authenticated' }
384
+ return { kind: 'sealed', document, sessionId }
385
+ }
386
+
387
+ unreachable(answer) {
388
+ const reason = answer.kind === 'unreachable' ? answer.code : typeof answer.error === 'string' ? answer.error : answer.body && typeof answer.body.error === 'string' ? answer.body.error : `answered-${answer.statusCode}`
389
+ return { state: 'unreachable', reason, report: null }
390
+ }
391
+
392
+ // What a round found. A failure other than the one shown ('not set up' where 'service unreachable' is shown, or the other
393
+ // way round) is shown only when the next round finds it too, so answers that alternate between them do not make the
394
+ // status bar flip. Anything else is shown at once.
395
+ showRound(view) {
396
+ if (ROUND_FAILURES.has(view.state) && ROUND_FAILURES.has(this.shownState) && view.state !== this.shownState && this.pendingFailure !== view.state) {
397
+ this.pendingFailure = view.state
398
+ return
399
+ }
400
+ this.setView(view)
401
+ }
402
+
403
+ setView(view) {
404
+ // Nothing is shown once the plugin unloaded.
405
+ if (this.unloaded) return
406
+ this.pendingFailure = null
407
+ const previous = this.shownState
408
+ this.view = view
409
+ this.statusBarEl.setText(labelOf(view))
410
+ this.statusBarEl.setAttr('aria-label', `${labelOf(view)} (${readable(view.reason)})`)
411
+ this.shownState = view.state
412
+ if (previous === view.state || previous === null) return
413
+ if (Object.prototype.hasOwnProperty.call(ATTENTION, view.state)) new obsidian.Notice(ATTENTION[view.state](view))
414
+ else if (view.state === 'current' && Object.prototype.hasOwnProperty.call(ATTENTION, previous)) new obsidian.Notice('Atelier: this view is current again.')
415
+ }
416
+
417
+ statusRows() {
418
+ const report = this.view.report || {}
419
+ const view = isPlainObject(report.view) ? report.view : {}
420
+ const count = (value) => (Number.isInteger(value) ? String(value) : 'unknown')
421
+ const channel = this.channel
422
+ return [
423
+ ['View', channel ? channel.scopeId : 'not set up'],
424
+ ['State', labelOf(this.view).replace(/^Atelier: /, '')],
425
+ ['Reason', readable(this.view.reason)],
426
+ ['Generation', typeof view.generationId === 'string' ? view.generationId : 'none yet'],
427
+ ['Prepared generation', typeof view.preparedGenerationId === 'string' ? view.preparedGenerationId : 'none yet'],
428
+ ['Checked at', typeof view.checkedAt === 'string' ? view.checkedAt : 'not yet'],
429
+ ['Held edits', count(view.heldNoteCount)],
430
+ ['Retained edits', count(view.retainedEdits)],
431
+ ['Pending edits', count(isPlainObject(report.pendingEdits) ? report.pendingEdits.open : null)],
432
+ ['Service', channel ? `${channel.host === '::1' ? '[::1]' : '127.0.0.1'}:${channel.port}, ${isPlainObject(report.service) && typeof report.service.status === 'string' ? report.service.status : 'no answer'}` : 'not set up'],
433
+ ['Plugin', String(this.manifest.version)],
434
+ ['Obsidian', this.appVersion || 'unknown'],
435
+ ]
436
+ }
437
+
438
+ showStatus() {
439
+ new AtelierStatusModal(this.app, this.statusRows()).open()
440
+ }
441
+ }
442
+
443
+ AtelierProjectionPlugin.channel = CHANNEL
444
+ module.exports = AtelierProjectionPlugin
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "atelier-projection",
3
+ "name": "Atelier",
4
+ "version": "1.1.1",
5
+ "minAppVersion": "1.13.7",
6
+ "description": "Shows whether this Atelier view is current and tells Atelier's maintenance service that the vault is open. Shipped by Atelier into the vaults it manages.",
7
+ "author": "MNSTRY",
8
+ "isDesktopOnly": true
9
+ }
@@ -0,0 +1,22 @@
1
+ /* Atelier's plugin: the status bar item and the status modal. */
2
+
3
+ .atelier-projection-status-bar {
4
+ cursor: pointer;
5
+ }
6
+
7
+ .atelier-projection-status {
8
+ display: grid;
9
+ grid-template-columns: max-content 1fr;
10
+ gap: var(--size-4-1) var(--size-4-4);
11
+ margin: 0;
12
+ }
13
+
14
+ .atelier-projection-status dt {
15
+ color: var(--text-muted);
16
+ }
17
+
18
+ .atelier-projection-status dd {
19
+ margin: 0;
20
+ font-variant-numeric: tabular-nums;
21
+ overflow-wrap: anywhere;
22
+ }
@@ -11,12 +11,14 @@ import { isoTime } from '../runtime/obsidian/documents.mjs'
11
11
  import { readObsidianEnablement } from '../runtime/obsidian/enablement.mjs'
12
12
  import { ObsidianMaintenanceRefusal, refuse } from '../runtime/obsidian/errors.mjs'
13
13
  import { BUILT_IN_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createObsidianRegistry } from '../runtime/obsidian/extension-points.mjs'
14
- import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
14
+ import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, requestServiceTick, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
15
15
  import {
16
16
  authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, installApplyPolicy, protectedRoots, readInstalledApplyPolicy, readMachineSettings,
17
17
  revokeApplyPolicy, writeMachineSettings,
18
18
  } from '../runtime/obsidian/machine-settings.mjs'
19
- import { APPLY_UNAVAILABLE, OPENING_OUTCOMES, OPENING_PRIMITIVES, nextStep, openScopeForOracleTests, resolveScope, scopeReport } from '../runtime/obsidian/opening.mjs'
19
+ import { APPLY_UNAVAILABLE, OPENING_OUTCOMES, OPENING_PRIMITIVES, REASON_NEXT, nextStep, openScopeForOracleTests, resolveScope, scopeReport } from '../runtime/obsidian/opening.mjs'
20
+ import { currentPluginChoice, writePluginChoice } from '../runtime/obsidian/plugin-choice.mjs'
21
+ import { pluginPresenceOf, turnPluginOnNext, withPluginReportedVersion } from '../runtime/obsidian/plugin-presence.mjs'
20
22
  import { serviceNameFor, servicePaths } from '../runtime/obsidian/service-record.mjs'
21
23
  import { resolveServiceWorkspace } from '../runtime/obsidian/service.mjs'
22
24
  import { buildStartupAdapter } from '../runtime/obsidian/startup-adapters.mjs'
@@ -33,7 +35,7 @@ import { buildStartupAdapter } from '../runtime/obsidian/startup-adapters.mjs'
33
35
  // that did not start, a stop that was refused).
34
36
  //
35
37
  // Reaching the installed app or the operating system goes through seams
36
- // (`appProbe`, `launcher`, `service`). A caller passes them. The production
38
+ // (`appProbe`, `launcher`, `registry`, `service`). A caller passes them. The production
37
39
  // seams are constructed only when this module runs as the real command-line
38
40
  // entry (`production: true`) AND `--adapter=obsidian-cli` was given, the same
39
41
  // explicit rule the service entry has; there is no default.
@@ -58,7 +60,12 @@ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.pro
58
60
  service unit --print --adapter=${PRODUCTION_ADAPTER}
59
61
  Print an operating-system startup unit. Writes and installs nothing.
60
62
  open [--scope ID] [--consent-actor ID] [--allow-stale] --adapter=${PRODUCTION_ADAPTER}
61
- Start or reconnect maintenance, verify the view, open it in Obsidian.
63
+ Start or reconnect maintenance, verify the view, add it to Obsidian and open it.
64
+ plugin show [--scope ID] Whether Atelier's plugin is on in a view's vault, and whether it holds it open.
65
+ plugin on [--scope ID] [--adapter=${PRODUCTION_ADAPTER}]
66
+ Offer Atelier's plugin again in a vault where it was turned off; a running
67
+ service publishes the view at once (with --adapter, one of an earlier release
68
+ is replaced first).
62
69
 
63
70
  Contributed operations, registered by the modules shipped under src/runtime/obsidian/contributions/:
64
71
  apply list | show EDIT | run EDIT [--actor ID] | recover
@@ -147,11 +154,20 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
147
154
  return { entryPath: SERVICE_ENTRY_PATH, entryArgs: [`--adapter=${PRODUCTION_ADAPTER}`] }
148
155
  }
149
156
  const appSeams = async () => {
150
- if (seams !== null) return { appProbe: seams.appProbe, launcher: seams.launcher }
157
+ if (seams !== null) return { appProbe: seams.appProbe, launcher: seams.launcher, registry: seams.registry }
151
158
  if (flags.adapter !== PRODUCTION_ADAPTER) refuse('app-adapter-not-selected', 'no editor adapter was selected; reaching the installed app is never a default')
152
159
  const { createProductionAppSeams } = await import('../runtime/obsidian/app-production-seams.mjs')
153
160
  return createProductionAppSeams({ env, platform })
154
161
  }
162
+ // Atelier's plugin as the running service sees it, for one view. A vault that turned it off says so from its own list
163
+ // and the private record too, whether or not the service runs.
164
+ const pluginView = (running, workspace, scopeId) => {
165
+ const presence = pluginPresenceOf(running, scopeId)
166
+ if (presence.present || workspace === null || currentPluginChoice({ ...workspace, scopeId }).state !== 'off') return presence
167
+ return { present: false, reason: 'turned-off-in-this-vault', next: turnPluginOnNext(scopeId) }
168
+ }
169
+ const pluginOf = async (scopeId) => pluginPresenceOf((await readServiceStatusDocument(lifecycle, lifecycleRules)).document, scopeId)
170
+ const pluginLine = (plugin) => (plugin.present ? `; plugin present (Obsidian ${plugin.appVersion})` : plugin.reason === 'turned-off-in-this-vault' ? '; plugin turned off in this vault' : `; plugin not present (${plugin.reason})`)
155
171
 
156
172
  const configured = () => {
157
173
  const project = loadProject()
@@ -193,7 +209,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
193
209
  ? enablement.scopes.map(({ scopeId }) => ({ scopeId, outcome: enablement.state === 'disabled' ? 'disabled' : 'not-prepared', reason: enablement.state === 'disabled' ? enablement.reason : 'workspace-not-prepared' }))
194
210
  : enablement.scopes.map(({ scopeId }) => {
195
211
  const { vaultRoot: _vault, summary: _summary, ...report } = scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: service.state, applyAvailable }, openingRules)
196
- return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next } : report
212
+ const plugin = pluginView(running, workspace, scopeId)
213
+ return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next, plugin } : { ...report, plugin }
197
214
  })
198
215
  const document = {
199
216
  enablement: { state: enablement.state, reason: enablement.reason, defaultScopeId: enablement.defaultScopeId }, workspace: { workspaceId, prepared: workspace !== null },
@@ -208,7 +225,11 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
208
225
  `service: ${service.state} (${service.reason ?? 'no reason'})${app ? `; app ${app.outcome} (${app.reason})` : ''}`,
209
226
  ...(app?.next ? [`Next for the app: ${app.next}`] : []),
210
227
  `apply: ${applyShown.state}`,
211
- ...scopes.map((scope) => `view ${scope.scopeId}: ${scope.outcome} (${scope.reason})${scope.pendingEdits?.open ? `; ${scope.pendingEdits.open} pending edit(s), apply ${scope.pendingEdits.apply}` : ''}`),
228
+ ...scopes.flatMap((scope) => [
229
+ `view ${scope.scopeId}: ${scope.outcome} (${scope.reason})${scope.pendingEdits?.open ? `; ${scope.pendingEdits.open} pending edit(s), apply ${scope.pendingEdits.apply}` : ''}${scope.plugin ? pluginLine(scope.plugin) : ''}`,
230
+ ...(scope.plugin?.next ? [` Next for the plugin: ${scope.plugin.next}`] : []),
231
+ ...(scope.diagnostics ?? []).map((item) => ` ${item.notePath ?? item.filePath ?? item.assetPath ?? item.nodeId ?? ''}: ${item.code}${item.rule ? ` (${item.rule})` : ''}`),
232
+ ]),
212
233
  ],
213
234
  }
214
235
  },
@@ -326,12 +347,70 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
326
347
 
327
348
  async open() {
328
349
  const seam = await serviceSeam()
350
+ const app = await appSeams()
351
+ const { appProbe } = app
352
+ // The view whose plugin may report the app version; an unknown one is refused by open itself.
353
+ const requested = () => { try { return resolveScope(readObsidianEnablement(loadProject()), flags.scope) } catch { return null } }
329
354
  const result = await openScopeForOracleTests({
330
- ...lifecycle, ...(await appSeams()), service: seam, scopeId: flags.scope, consent, allowStale: flags['allow-stale'] === true, extensions: registry.extensions,
355
+ ...lifecycle, ...app,
356
+ appProbe: typeof appProbe?.inspect === 'function' && typeof appProbe?.vaultState === 'function' ? withPluginReportedVersion(appProbe, async () => { const scopeId = requested(); return scopeId === null ? null : pluginOf(scopeId) }) : appProbe,
357
+ service: seam, scopeId: flags.scope, consent, allowStale: flags['allow-stale'] === true, extensions: registry.extensions,
331
358
  ...(flags['wait-ms'] === undefined ? {} : { tickTimeoutMs: Number(flags['wait-ms']) || undefined }), ...(options.open ?? {}),
332
359
  }, openingRules, lifecycleRules)
333
- const { ok: _ok, ...document } = result
334
- return { exit: result.ok ? EXIT.ok : EXIT.notSuccess, document, human: [`${result.outcome}: ${result.summary}${result.reason ? ` (${result.reason})` : ''}`, `Next: ${result.next}`, ...(result.pendingEdits?.open ? [`${result.pendingEdits.open} pending edit(s); apply ${result.pendingEdits.apply}`] : [])] }
360
+ const { ok: _ok, ...opened } = result
361
+ const plugin = typeof result.scopeId === 'string' ? await pluginOf(result.scopeId) : null
362
+ const document = plugin === null ? opened : { ...opened, plugin }
363
+ return {
364
+ exit: result.ok ? EXIT.ok : EXIT.notSuccess, document,
365
+ human: [`${result.outcome}: ${result.summary}${result.reason ? ` (${result.reason})` : ''}${plugin ? pluginLine(plugin) : ''}`, `Next: ${result.next}`, ...(result.service?.restarted ? [`service: restarted (${result.service.restarted})`] : []), ...(result.duplicates ? [`open in Obsidian as: ${result.duplicates.map((entry) => entry.path).join(', ')}`] : []), ...(result.pendingEdits?.open ? [`${result.pendingEdits.open} pending edit(s); apply ${result.pendingEdits.apply}`] : [])],
366
+ }
367
+ },
368
+
369
+ // Atelier's plugin in the vault of a view: the person's choice (as recorded, or as the vault shows it before the next
370
+ // publication records it), and whether a plugin holds the vault open. `on` records a request and asks a running
371
+ // maintenance service for a tick that names the view, whose publication brings the entry and the plugin files back;
372
+ // with no service running, the view's next publication does.
373
+ async plugin() {
374
+ if (sub !== undefined && sub !== 'show' && sub !== 'on') refuse('usage', 'plugin show [--scope ID] | plugin on [--scope ID]')
375
+ if (sub === 'on') {
376
+ const { enablement, workspace } = writable()
377
+ const scopeId = resolveScope(enablement, flags.scope)
378
+ const choice = writePluginChoice({ ...workspace, scopeId, state: 'requested', reason: 'requested-by-command', clock })
379
+ // The request is recorded either way; a service that cannot be asked leaves it to the view's next publication.
380
+ // With the installed entry to start (`--adapter`), a service of an earlier release still running after an upgrade
381
+ // is replaced before its tick, under the consent already recorded, as `open` does; without it, it is reported.
382
+ const replaceable = seams !== null || flags.adapter === PRODUCTION_ADAPTER
383
+ let asked
384
+ try { asked = await requestServiceTick({ ...lifecycle, scopeId, ...(replaceable ? { service: await serviceSeam() } : {}) }, lifecycleRules) } catch (error) { if (!isTyped(error)) throw error; asked = { requested: false, reason: error.code } }
385
+ const view = asked.tick?.scopes?.find((scope) => scope.scopeId === scopeId) ?? null
386
+ const takesEffect = view?.state === 'current' ? 'published' : asked.pending === true || view?.state === 'updating' ? 'publishing' : 'next-publication'
387
+ const service = {
388
+ asked: asked.requested === true, reason: asked.reason ?? asked.state ?? null, ...(asked.restarted ? { restarted: asked.restarted } : {}),
389
+ ...(view === null ? {} : { view: { state: view.state, reason: view.reason } }),
390
+ }
391
+ const human = takesEffect === 'published'
392
+ ? `view ${scopeId}: Atelier's plugin is requested and back: the maintenance service published the view again with its entry and its files; Obsidian runs it from the next time it opens the vault`
393
+ : takesEffect === 'publishing'
394
+ ? `view ${scopeId}: Atelier's plugin is requested; the maintenance service is publishing the view again, which brings its entry and its files back`
395
+ : `view ${scopeId}: Atelier's plugin is requested; its entry and its files come back with the view's next publication (the next change at its sources, or when the maintenance service next starts)${service.asked ? ` (the service's tick: ${view ? `${view.state}, ${view.reason}` : service.reason})` : ''}`
396
+ // A service this command could not ask: one of an earlier release it may replace (with `--adapter`), and one of a
397
+ // later release it never replaces, which gets open's next step.
398
+ const next = service.reason === 'service-outdated'
399
+ ? `the running maintenance service is of an earlier release; run \`atelier obsidian plugin on --scope ${scopeId} --adapter=${PRODUCTION_ADAPTER}\` to replace it`
400
+ : service.reason === 'service-other-release' ? REASON_NEXT['service-other-release'] : null
401
+ return { exit: EXIT.ok, document: { scopeId, choice, takesEffect, service, ...(next === null ? {} : { next }) }, human: [human, ...(service.restarted ? [`service: restarted (${service.restarted})`] : []), ...(next === null ? [] : [` Next: ${next}`])] }
402
+ }
403
+ const { enablement, workspace } = readable()
404
+ const scopeIds = flags.scope === undefined ? enablement.scopes.map((scope) => scope.scopeId) : [resolveScope(enablement, flags.scope)]
405
+ const service = enablement.reason === 'not-configured' ? { state: 'stopped' } : await serviceStatus(lifecycle, lifecycleRules)
406
+ const running = service.state === 'healthy' ? (await readServiceStatusDocument(lifecycle, lifecycleRules)).document : null
407
+ const plugins = scopeIds.map((scopeId) => ({
408
+ scopeId, choice: workspace === null ? { state: 'undecided', reason: null, since: null } : currentPluginChoice({ ...workspace, scopeId }), presence: pluginView(running, workspace, scopeId),
409
+ }))
410
+ return {
411
+ exit: EXIT.ok, document: { plugins },
412
+ human: plugins.flatMap(({ scopeId, choice, presence }) => [`view ${scopeId}: plugin ${choice.state}${choice.pending ? ' (as the vault shows it; recorded at the view\'s next publication)' : ''}${pluginLine(presence)}`, ...(presence.next ? [` Next: ${presence.next}`] : [])]),
413
+ }
335
414
  },
336
415
 
337
416
  // The placeholder that answers when no contribution registered an apply operation; the shipped source-apply contribution replaces it.