@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
|
@@ -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,
|
|
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
|
-
|
|
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.
|
|
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, ...
|
|
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, ...
|
|
334
|
-
|
|
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.
|