@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,281 @@
|
|
|
1
|
+
import { createHash, randomBytes as cryptoRandomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow, realPathAsStored } from '../../project/private-state.mjs'
|
|
5
|
+
import {
|
|
6
|
+
PLUGIN_BEARER, PLUGIN_CHALLENGE_WINDOW_MS, PLUGIN_CHANNEL_PROTOCOL, PLUGIN_HANDSHAKE_TTL_MS, PLUGIN_LEASE_TTL_MS, PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE, PLUGIN_MAX_SESSION_AGE_MS, PLUGIN_MAX_SESSIONS_PER_SCOPE,
|
|
7
|
+
PLUGIN_RENEW_INTERVAL_MS, PLUGIN_STATUS_SCHEMA, pluginClientProof, pluginKeyHint, pluginRequestMac, pluginResponseMac, pluginServerProof, pluginSessionKey, pluginVaultProof,
|
|
8
|
+
} from '../../projection/obsidian/plugin-bridge/channel.mjs'
|
|
9
|
+
import { canonicalJson, compareText, isPlainObject, isoTime } from './documents.mjs'
|
|
10
|
+
|
|
11
|
+
// The service's half of the plugin channel (plugin-bridge/channel.mjs).
|
|
12
|
+
//
|
|
13
|
+
// bearers one random bearer per view, minted when the view's vault first
|
|
14
|
+
// receives the plugin, kept owner-only under state/plugin/ and
|
|
15
|
+
// written into that vault's plugin data file, nowhere else; it
|
|
16
|
+
// is the key of the handshake and never crosses the wire
|
|
17
|
+
// sessions who holds a view open right now: a plugin that proved it holds
|
|
18
|
+
// the vault's key and renews its lease; in memory, gone when the
|
|
19
|
+
// service stops
|
|
20
|
+
// commands challenge, hello, lease, release and status, answered from
|
|
21
|
+
// state the service already keeps; nothing here writes a vault, a
|
|
22
|
+
// source, a manifest or an edit, and no command decides anything
|
|
23
|
+
//
|
|
24
|
+
// A live session also tells the app version the plugin runs in, and an id of
|
|
25
|
+
// that launch of the plugin. That version counts as checked for app
|
|
26
|
+
// qualification (app-capability.mjs) while one launch alone holds the view:
|
|
27
|
+
// the plugin runs inside exactly that app.
|
|
28
|
+
|
|
29
|
+
export const PLUGIN_BEARER_SCHEMA = 'atelier-obsidian-plugin-bearer/v1'
|
|
30
|
+
export const PLUGIN_PRESENCE_SCHEMA = 'atelier-obsidian-plugin-presence/v1'
|
|
31
|
+
const MAX_BEARER_FILES = 256
|
|
32
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
33
|
+
|
|
34
|
+
export function pluginBearerDirectory(workspaceRoot) {
|
|
35
|
+
return path.join(workspaceRoot, 'state', 'plugin')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readBearerFile(file, workspaceId) {
|
|
39
|
+
let document
|
|
40
|
+
try { document = JSON.parse(readRegularTextNoFollow(file)) } catch { return null }
|
|
41
|
+
if (!isPlainObject(document) || Object.keys(document).sort().join(',') !== 'bearer,createdAt,schema,scopeId,workspaceId') return null
|
|
42
|
+
if (document.schema !== PLUGIN_BEARER_SCHEMA || document.workspaceId !== workspaceId || typeof document.scopeId !== 'string' || !PLUGIN_BEARER.test(document.bearer ?? '')) return null
|
|
43
|
+
return document
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The bearer of one view's vault, minted on first use. A file that does not
|
|
47
|
+
// read as this view's bearer is replaced: it cannot authenticate anything, and
|
|
48
|
+
// the vault receives the new bearer with its next publication.
|
|
49
|
+
export function ensurePluginBearer({ workspaceRoot, workspaceId, scopeId, randomBytes = cryptoRandomBytes, clock = () => new Date() }) {
|
|
50
|
+
const directory = ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginBearerDirectory(workspaceRoot), label: 'Obsidian plugin state' })
|
|
51
|
+
const file = path.join(directory, `${segment(scopeId)}.json`)
|
|
52
|
+
const existing = readBearerFile(file, workspaceId)
|
|
53
|
+
if (existing?.scopeId === scopeId) return existing.bearer
|
|
54
|
+
const bearer = randomBytes(32).toString('base64url')
|
|
55
|
+
atomicReplacePrivateText(file, canonicalJson({ schema: PLUGIN_BEARER_SCHEMA, workspaceId, scopeId, bearer, createdAt: isoTime(clock) }))
|
|
56
|
+
return bearer
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Every view's bearer this workspace has minted: scopeId -> bearer.
|
|
60
|
+
export function readPluginBearers({ workspaceRoot, workspaceId }) {
|
|
61
|
+
const directory = pluginBearerDirectory(workspaceRoot)
|
|
62
|
+
let names
|
|
63
|
+
try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return new Map(); throw error }
|
|
64
|
+
const bearers = new Map()
|
|
65
|
+
for (const name of names.filter((item) => item.endsWith('.json')).sort().slice(0, MAX_BEARER_FILES)) {
|
|
66
|
+
const document = readBearerFile(path.join(directory, name), workspaceId)
|
|
67
|
+
if (document !== null && name === `${segment(document.scopeId)}.json`) bearers.set(document.scopeId, document.bearer)
|
|
68
|
+
}
|
|
69
|
+
return bearers
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The bearers in memory. They are read again only when the directory changed
|
|
73
|
+
// (a bearer minted, replaced, or deleted to rotate it), so a request costs a
|
|
74
|
+
// stat and a listing of the directory, not a read of every file. The names
|
|
75
|
+
// are part of what is compared: not every platform changes a directory's times
|
|
76
|
+
// when an entry goes away. A bearer the service replaces under the same name
|
|
77
|
+
// is announced with `invalidate()`.
|
|
78
|
+
export function createPluginBearerCache({ workspaceRoot, workspaceId, read = readPluginBearers }) {
|
|
79
|
+
const directory = pluginBearerDirectory(workspaceRoot)
|
|
80
|
+
const signature = () => {
|
|
81
|
+
try {
|
|
82
|
+
const stat = fs.statSync(directory, { bigint: true })
|
|
83
|
+
return `${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${fs.readdirSync(directory).sort().join('/')}`
|
|
84
|
+
} catch (error) { if (error.code === 'ENOENT') return 'absent'; throw error }
|
|
85
|
+
}
|
|
86
|
+
let seen = null
|
|
87
|
+
let bearers = new Map()
|
|
88
|
+
return {
|
|
89
|
+
current() {
|
|
90
|
+
// Taken before the read: a change during the read is seen by the next call.
|
|
91
|
+
const now = signature()
|
|
92
|
+
if (now !== seen) { bearers = read({ workspaceRoot, workspaceId }); seen = now }
|
|
93
|
+
return bearers
|
|
94
|
+
},
|
|
95
|
+
invalidate() { seen = null },
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The decisions the channel's oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
|
|
100
|
+
export const PLUGIN_CHANNEL_PRIMITIVES = Object.freeze({
|
|
101
|
+
// Two HMACs in hex of validated shape, compared in constant time.
|
|
102
|
+
macMatches: (presented, expected) => typeof presented === 'string' && presented.length === expected.length && timingSafeEqual(Buffer.from(presented, 'hex'), Buffer.from(expected, 'hex')),
|
|
103
|
+
counterIsNew: (counter, last) => counter > last,
|
|
104
|
+
handshakeUsedOnce: true,
|
|
105
|
+
// A challenge is answered only within this long of the time it names, and each of its nonces once.
|
|
106
|
+
challengeWindowMs: PLUGIN_CHALLENGE_WINDOW_MS,
|
|
107
|
+
noncesAnsweredOnce: true,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Sessions of the plugin, per view. A session lives as long as its lease: a
|
|
111
|
+
// hello grants one, each renewal extends it by `ttlMs`, and a release, a lapse
|
|
112
|
+
// or `maxAgeMs` ends it. At most `maxPerScope` live sessions per view. A
|
|
113
|
+
// session holds the key its handshake derived; nothing it hands out carries it.
|
|
114
|
+
export function createPluginSessions({ now = () => Date.now(), ttlMs = PLUGIN_LEASE_TTL_MS, maxPerScope = PLUGIN_MAX_SESSIONS_PER_SCOPE, maxAgeMs = PLUGIN_MAX_SESSION_AGE_MS, randomBytes = cryptoRandomBytes } = {}) {
|
|
115
|
+
const sessions = new Map()
|
|
116
|
+
const prune = () => { const at = now(); for (const [sessionId, session] of sessions) if (session.expiresAt <= at || at - session.openedAt >= maxAgeMs) sessions.delete(sessionId) }
|
|
117
|
+
const liveFor = (scopeId) => { prune(); return [...sessions.values()].filter((session) => session.scopeId === scopeId) }
|
|
118
|
+
const shown = ({ sessionKey: _key, keyDigest: _digest, ...session }) => ({ ...session })
|
|
119
|
+
return {
|
|
120
|
+
open({ scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest }) {
|
|
121
|
+
if (liveFor(scopeId).length >= maxPerScope) return null
|
|
122
|
+
const at = now()
|
|
123
|
+
const session = { sessionId: `ps-${randomBytes(16).toString('hex')}`, scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest, counter: 0, openedAt: at, renewedAt: at, expiresAt: at + ttlMs }
|
|
124
|
+
sessions.set(session.sessionId, session)
|
|
125
|
+
return shown(session)
|
|
126
|
+
},
|
|
127
|
+
// The live session with this id, key included, for the channel only; null once it lapsed or was released.
|
|
128
|
+
held(sessionId) { prune(); return sessions.get(sessionId) ?? null },
|
|
129
|
+
renew(sessionId) {
|
|
130
|
+
const session = sessions.get(sessionId)
|
|
131
|
+
if (!session) return null
|
|
132
|
+
session.renewedAt = now()
|
|
133
|
+
session.expiresAt = session.renewedAt + ttlMs
|
|
134
|
+
return shown(session)
|
|
135
|
+
},
|
|
136
|
+
release: (sessionId) => sessions.delete(sessionId),
|
|
137
|
+
live: (scopeId) => liveFor(scopeId).map(shown),
|
|
138
|
+
// What the most recently renewed live session reports, or null when no plugin holds the view. `instances` counts
|
|
139
|
+
// the launches of the plugin behind the live sessions: more than one means more than one app holds the vault.
|
|
140
|
+
report(scopeId) {
|
|
141
|
+
const live = liveFor(scopeId).sort((left, right) => right.renewedAt - left.renewedAt)
|
|
142
|
+
if (live.length === 0) return null
|
|
143
|
+
const [newest] = live
|
|
144
|
+
return { scopeId, appVersion: newest.appVersion, pluginVersion: newest.pluginVersion, sessions: live.length, instances: new Set(live.map((session) => session.instanceId)).size, renewedAt: new Date(newest.renewedAt).toISOString() }
|
|
145
|
+
},
|
|
146
|
+
scopeIds() { prune(); return [...new Set([...sessions.values()].map((session) => session.scopeId))] },
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The commands of the plugin channel (plugin-bridge/channel.mjs). `statusOf(scopeId)`
|
|
151
|
+
// returns the view's freshness entry as the service reports it and its open
|
|
152
|
+
// pending edits; the vault a hello must prove is the view's vault under the
|
|
153
|
+
// workspace state. `authority` is the exact address the request reached.
|
|
154
|
+
export function createPluginChannelForOracleTests({
|
|
155
|
+
workspaceRoot, workspaceId, runtimeId, sessions, statusOf, serviceStatus, now = () => Date.now(), randomBytes = cryptoRandomBytes,
|
|
156
|
+
bearers = createPluginBearerCache({ workspaceRoot, workspaceId }),
|
|
157
|
+
// Told each time a plugin opened a session for a view: the app it runs in has the view's entry.
|
|
158
|
+
onSessionOpened = () => {},
|
|
159
|
+
}, primitives = PLUGIN_CHANNEL_PRIMITIVES) {
|
|
160
|
+
const rules = { ...PLUGIN_CHANNEL_PRIMITIVES, ...primitives }
|
|
161
|
+
// As the file system stores the path, as the store registers the vault in the app (realPathAsStored): a data root given
|
|
162
|
+
// in another letter case names the same vault.
|
|
163
|
+
const vaultRootOf = (scopeId) => { try { return realPathAsStored(path.join(workspaceRoot, 'vaults', segment(scopeId))) } catch { return null } }
|
|
164
|
+
const keyDigestOf = (bearer) => createHash('sha256').update(bearer, 'utf8').digest()
|
|
165
|
+
const handshakes = new Map()
|
|
166
|
+
const pruneHandshakes = () => { const at = now(); for (const [handshakeId, handshake] of handshakes) if (handshake.expiresAt <= at) handshakes.delete(handshakeId) }
|
|
167
|
+
// The nonces of the challenges each view's key answered, until their time has left the window: a challenge recorded
|
|
168
|
+
// by whoever squatted the address, sent later, is answered at most once and only while it is fresh.
|
|
169
|
+
const answered = new Map()
|
|
170
|
+
const seenBefore = (scopeId, clientNonce, issuedAt) => {
|
|
171
|
+
const at = now()
|
|
172
|
+
const nonces = answered.get(scopeId) ?? new Map()
|
|
173
|
+
for (const [nonce, until] of nonces) if (until <= at) nonces.delete(nonce)
|
|
174
|
+
const seen = nonces.has(clientNonce)
|
|
175
|
+
if (rules.noncesAnsweredOnce) nonces.set(clientNonce, issuedAt + rules.challengeWindowMs + 1)
|
|
176
|
+
answered.set(scopeId, nonces)
|
|
177
|
+
return seen
|
|
178
|
+
}
|
|
179
|
+
const answer = (statusCode, body) => ({ statusCode, body })
|
|
180
|
+
// An answer only the session's key can have made: the exact text of the document, and a MAC over it.
|
|
181
|
+
const sealed = ({ sessionKey, command, sessionId, counter }, document) => {
|
|
182
|
+
const payload = JSON.stringify(document)
|
|
183
|
+
return answer(200, { payload, mac: pluginResponseMac({ sessionKey, command, sessionId, counter, payload }) })
|
|
184
|
+
}
|
|
185
|
+
// A request of a session: its MAC verifies under the session's key and its counter was never seen. A session
|
|
186
|
+
// whose vault's bearer was rotated since its handshake ends: its key came from the old bearer.
|
|
187
|
+
const authenticated = (command, body) => {
|
|
188
|
+
const session = sessions.held(body.sessionId)
|
|
189
|
+
if (session === null) return { refusal: answer(409, { error: 'session-unknown' }) }
|
|
190
|
+
if (!rules.macMatches(body.mac, pluginRequestMac({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }))) return { refusal: answer(401, { error: 'request-not-authenticated' }) }
|
|
191
|
+
if (!rules.counterIsNew(body.counter, session.counter)) return { refusal: answer(401, { error: 'request-replayed' }) }
|
|
192
|
+
session.counter = body.counter
|
|
193
|
+
const bearer = bearers.current().get(session.scopeId)
|
|
194
|
+
if (typeof bearer !== 'string' || !timingSafeEqual(keyDigestOf(bearer), session.keyDigest)) {
|
|
195
|
+
sessions.release(session.sessionId)
|
|
196
|
+
return { refusal: answer(409, { error: 'session-unknown' }) }
|
|
197
|
+
}
|
|
198
|
+
return { session, seal: (document) => sealed({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }, document) }
|
|
199
|
+
}
|
|
200
|
+
const commands = {
|
|
201
|
+
challenge({ body, authority }) {
|
|
202
|
+
if (Math.abs(now() - body.issuedAt) > rules.challengeWindowMs) return answer(401, { error: 'challenge-stale' })
|
|
203
|
+
// The key the hint was made with: every key is tried, all of them, whichever matches.
|
|
204
|
+
let found = null
|
|
205
|
+
for (const [scopeId, bearer] of bearers.current()) {
|
|
206
|
+
if (rules.macMatches(body.keyHint, pluginKeyHint({ bearer, clientNonce: body.clientNonce, issuedAt: body.issuedAt })) && found === null) found = { scopeId, bearer }
|
|
207
|
+
}
|
|
208
|
+
if (found === null) return answer(401, { error: 'plugin-key-unknown' })
|
|
209
|
+
if (seenBefore(found.scopeId, body.clientNonce, body.issuedAt)) return answer(401, { error: 'challenge-replayed' })
|
|
210
|
+
pruneHandshakes()
|
|
211
|
+
// Bounded per view: one view's waiting handshakes never hold up another's.
|
|
212
|
+
if ([...handshakes.values()].filter((handshake) => handshake.scopeId === found.scopeId).length >= PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE) return answer(429, { error: 'too-many-handshakes' })
|
|
213
|
+
const handshake = { handshakeId: `ph-${randomBytes(16).toString('hex')}`, scopeId: found.scopeId, authority, clientNonce: body.clientNonce, serverNonce: randomBytes(32).toString('hex'), expiresAt: now() + PLUGIN_HANDSHAKE_TTL_MS }
|
|
214
|
+
handshakes.set(handshake.handshakeId, handshake)
|
|
215
|
+
return answer(200, { protocol: PLUGIN_CHANNEL_PROTOCOL, handshakeId: handshake.handshakeId, serverNonce: handshake.serverNonce, serverProof: pluginServerProof({ bearer: found.bearer, ...handshake }) })
|
|
216
|
+
},
|
|
217
|
+
hello({ body, authority }) {
|
|
218
|
+
pruneHandshakes()
|
|
219
|
+
const handshake = handshakes.get(body.handshakeId) ?? null
|
|
220
|
+
if (rules.handshakeUsedOnce) handshakes.delete(body.handshakeId)
|
|
221
|
+
const bearer = handshake === null ? undefined : bearers.current().get(handshake.scopeId)
|
|
222
|
+
if (handshake === null || handshake.authority !== authority || typeof bearer !== 'string') return answer(401, { error: 'handshake-unknown' })
|
|
223
|
+
const { scopeId } = handshake
|
|
224
|
+
const bound = { bearer, scopeId, authority, clientNonce: handshake.clientNonce, serverNonce: handshake.serverNonce, handshakeId: handshake.handshakeId }
|
|
225
|
+
const expected = pluginClientProof({ ...bound, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, vaultProof: body.vaultProof })
|
|
226
|
+
if (!rules.macMatches(body.clientProof, expected)) return answer(401, { error: 'plugin-not-authenticated' })
|
|
227
|
+
const sessionKey = pluginSessionKey(bound)
|
|
228
|
+
// Compared, never named: the vault the app has open, proven under this session's key.
|
|
229
|
+
const vaultRoot = vaultRootOf(scopeId)
|
|
230
|
+
if (vaultRoot === null || !rules.macMatches(body.vaultProof, pluginVaultProof({ sessionKey, vaultPath: vaultRoot }))) return answer(409, { error: 'wrong-vault' })
|
|
231
|
+
const session = sessions.open({ scopeId, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, sessionKey, keyDigest: keyDigestOf(bearer) })
|
|
232
|
+
if (session === null) return answer(429, { error: 'too-many-sessions' })
|
|
233
|
+
onSessionOpened(scopeId)
|
|
234
|
+
return sealed({ sessionKey, command: 'hello', sessionId: session.sessionId, counter: 0 }, {
|
|
235
|
+
schema: PLUGIN_CHANNEL_PROTOCOL, scopeId, sessionId: session.sessionId, runtimeId, leaseTtlMs: session.expiresAt - session.openedAt, renewEveryMs: PLUGIN_RENEW_INTERVAL_MS,
|
|
236
|
+
})
|
|
237
|
+
},
|
|
238
|
+
lease({ body }) {
|
|
239
|
+
const request = authenticated('lease', body)
|
|
240
|
+
if (request.refusal) return request.refusal
|
|
241
|
+
const session = sessions.renew(request.session.sessionId)
|
|
242
|
+
return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: session.scopeId, sessionId: session.sessionId, leaseTtlMs: session.expiresAt - session.renewedAt })
|
|
243
|
+
},
|
|
244
|
+
release({ body }) {
|
|
245
|
+
const request = authenticated('release', body)
|
|
246
|
+
if (request.refusal) return request.refusal
|
|
247
|
+
return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: request.session.scopeId, released: sessions.release(request.session.sessionId) })
|
|
248
|
+
},
|
|
249
|
+
status({ body }) {
|
|
250
|
+
const request = authenticated('status', body)
|
|
251
|
+
if (request.refusal) return request.refusal
|
|
252
|
+
const { scopeId } = request.session
|
|
253
|
+
const { view, pendingEdits } = statusOf(scopeId)
|
|
254
|
+
return request.seal({ schema: PLUGIN_STATUS_SCHEMA, scopeId, service: { status: serviceStatus() }, view, pendingEdits })
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
bearers: () => bearers.current(),
|
|
259
|
+
// The service minted a bearer: whatever the directory's times say, the next lookup reads the bearers again.
|
|
260
|
+
bearersChanged: () => bearers.invalidate?.(),
|
|
261
|
+
handle: (command, request) => commands[command](request),
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function createPluginChannel(options) {
|
|
266
|
+
return createPluginChannelForOracleTests(options, PLUGIN_CHANNEL_PRIMITIVES)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Which views a plugin holds open, for the service's status document, and
|
|
270
|
+
// whether the person wants the plugin in each vault (`entry`: undecided,
|
|
271
|
+
// requested, on or off; see plugin-choice.mjs). Counts, versions and states
|
|
272
|
+
// only: no vault path, no session identity.
|
|
273
|
+
export function pluginPresence({ sessions, scopeIds, entryOf = () => null }) {
|
|
274
|
+
const scopes = [...new Set([...scopeIds, ...sessions.scopeIds()])].sort(compareText).map((scopeId) => {
|
|
275
|
+
const report = sessions.report(scopeId)
|
|
276
|
+
const entry = entryOf(scopeId)
|
|
277
|
+
const presence = report === null ? { scopeId, present: false, sessions: 0 } : { scopeId, present: true, sessions: report.sessions, appVersion: report.appVersion, pluginVersion: report.pluginVersion, renewedAt: report.renewedAt }
|
|
278
|
+
return entry === null ? presence : { ...presence, entry }
|
|
279
|
+
})
|
|
280
|
+
return { schema: PLUGIN_PRESENCE_SCHEMA, leaseTtlMs: PLUGIN_LEASE_TTL_MS, scopes }
|
|
281
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
4
|
+
import { PLUGIN_ID } from '../../projection/obsidian/plugin-bridge/channel.mjs'
|
|
5
|
+
import { COMMUNITY_PLUGINS_PATH } from '../../projection/obsidian/materialize/settings.mjs'
|
|
6
|
+
import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
|
|
7
|
+
import { canonicalJson, isPlainObject, isoTime } from './documents.mjs'
|
|
8
|
+
|
|
9
|
+
// Whether the person wants Atelier's plugin in one vault, as the vault itself
|
|
10
|
+
// says it: the `atelier-projection` entry of .obsidian/community-plugins.json.
|
|
11
|
+
//
|
|
12
|
+
// (no record) nothing decided yet: Atelier offers the entry and the files
|
|
13
|
+
// requested `atelier obsidian plugin on` asked for it: offered again
|
|
14
|
+
// offered the entry is in place, but it was written while an app held
|
|
15
|
+
// the vault; that app keeps the list it read in memory and may
|
|
16
|
+
// write it back without the entry, so a list without it is no
|
|
17
|
+
// decision of the person's yet: the entry is offered again
|
|
18
|
+
// on the app has the entry: it was in place before an app next
|
|
19
|
+
// opened the vault (published while none ran), or the plugin
|
|
20
|
+
// ran in the app; from now on a list without it is the
|
|
21
|
+
// person's decision
|
|
22
|
+
// off the person removed the entry (turned the plugin off, or
|
|
23
|
+
// uninstalled it, in Obsidian): Atelier never adds it back and
|
|
24
|
+
// never creates a plugin file that is not there; it only keeps
|
|
25
|
+
// the files that are present current
|
|
26
|
+
//
|
|
27
|
+
// The decision is read before a view is prepared, from the bytes of the file
|
|
28
|
+
// on disk, and those bytes travel with the prepared view: the publisher writes
|
|
29
|
+
// the entry only over exactly those bytes, so a change the person makes while
|
|
30
|
+
// a publication runs is never written over, and the next preparation reads it.
|
|
31
|
+
// A file that is absent or is not a list decides nothing. A record that cannot
|
|
32
|
+
// be read counts as `off`: nothing is re-added on a guess. Status reads the
|
|
33
|
+
// same change without recording it (currentPluginChoice), so it says what the
|
|
34
|
+
// person did before the view's next preparation records it.
|
|
35
|
+
|
|
36
|
+
export const PLUGIN_CHOICE_SCHEMA = 'atelier-obsidian-plugin-choice/v1'
|
|
37
|
+
export const PLUGIN_CHOICE_STATES = Object.freeze(['requested', 'offered', 'on', 'off'])
|
|
38
|
+
const REASONS = Object.freeze(['requested-by-command', 'entry-offered-while-the-app-runs', 'entry-confirmed', 'entry-seen-by-the-app', 'entry-removed-by-person', 'entry-restored-by-person'])
|
|
39
|
+
// The states in which a list without the entry is not the person's decision: the entry is offered again.
|
|
40
|
+
const NOT_YET_CONFIRMED = Object.freeze(['undecided', 'requested', 'offered'])
|
|
41
|
+
const MAX_SETTINGS_BYTES = 256 * 1024
|
|
42
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
43
|
+
|
|
44
|
+
export const pluginChoiceDirectory = (workspaceRoot) => path.join(workspaceRoot, 'state', 'plugin', 'choices')
|
|
45
|
+
// Where the recovery store places a view's vault.
|
|
46
|
+
export const viewVaultRoot = (workspaceRoot, scopeId) => path.join(workspaceRoot, 'vaults', segment(scopeId))
|
|
47
|
+
const choiceFile = (workspaceRoot, scopeId) => path.join(pluginChoiceDirectory(workspaceRoot), `${segment(scopeId)}.json`)
|
|
48
|
+
|
|
49
|
+
function validChoice(document, { workspaceId, scopeId }) {
|
|
50
|
+
return isPlainObject(document) && Object.keys(document).sort().join(',') === 'reason,schema,scopeId,since,state,workspaceId'
|
|
51
|
+
&& document.schema === PLUGIN_CHOICE_SCHEMA && document.workspaceId === workspaceId && document.scopeId === scopeId
|
|
52
|
+
&& PLUGIN_CHOICE_STATES.includes(document.state) && REASONS.includes(document.reason) && typeof document.since === 'string'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// { state: 'undecided' | 'requested' | 'offered' | 'on' | 'off', reason, since, unreadable? }
|
|
56
|
+
export function readPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
|
|
57
|
+
let text
|
|
58
|
+
try { text = readRegularTextNoFollow(choiceFile(workspaceRoot, scopeId)) } catch (error) {
|
|
59
|
+
if (error.code === 'ENOENT') return { state: 'undecided', reason: null, since: null }
|
|
60
|
+
return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
|
|
61
|
+
}
|
|
62
|
+
let document = null
|
|
63
|
+
try { document = JSON.parse(text) } catch { document = null }
|
|
64
|
+
if (!validChoice(document, { workspaceId, scopeId })) return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
|
|
65
|
+
return { state: document.state, reason: document.reason, since: document.since }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function writePluginChoice({ workspaceRoot, workspaceId, scopeId, state, reason, clock = () => new Date() }) {
|
|
69
|
+
const document = { schema: PLUGIN_CHOICE_SCHEMA, workspaceId, scopeId, state, reason, since: isoTime(clock) }
|
|
70
|
+
if (!validChoice(document, { workspaceId, scopeId })) throw new TypeError('a plugin choice names a known state and reason')
|
|
71
|
+
ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginChoiceDirectory(workspaceRoot), label: 'Obsidian plugin state' })
|
|
72
|
+
atomicReplacePrivateText(choiceFile(workspaceRoot, scopeId), canonicalJson(document))
|
|
73
|
+
return { state, reason, since: document.since }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 'absent', 'unreadable' (a parent is a link or not a directory) or 'reachable'.
|
|
77
|
+
function parentsOf(vaultRoot, relativePath) {
|
|
78
|
+
let current = vaultRoot
|
|
79
|
+
for (const part of relativePath.split('/').slice(0, -1)) {
|
|
80
|
+
current = path.join(current, part)
|
|
81
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false })
|
|
82
|
+
if (!stat) return 'absent'
|
|
83
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
|
|
84
|
+
}
|
|
85
|
+
return 'reachable'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Whether a vault path holds a regular file under real directories.
|
|
89
|
+
export function vaultFilePresent(vaultRoot, relativePath) {
|
|
90
|
+
return parentsOf(vaultRoot, relativePath) === 'reachable' && fs.lstatSync(path.join(vaultRoot, relativePath), { throwIfNoEntry: false })?.isFile() === true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The bytes of a settings file of a vault, read without following a link and
|
|
94
|
+
// bounded: { state: 'absent' | 'present' | 'unreadable', bytes, digest }.
|
|
95
|
+
export function readVaultSettingsFile(vaultRoot, relativePath) {
|
|
96
|
+
const parents = parentsOf(vaultRoot, relativePath)
|
|
97
|
+
if (parents !== 'reachable') return { state: parents, bytes: null, digest: null }
|
|
98
|
+
let descriptor
|
|
99
|
+
try { descriptor = openRegularFileNoFollow(path.join(vaultRoot, relativePath)) } catch (error) {
|
|
100
|
+
return error.code === 'ENOENT' ? { state: 'absent', bytes: null, digest: null } : { state: 'unreadable', bytes: null, digest: null }
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (fs.fstatSync(descriptor).size > MAX_SETTINGS_BYTES) return { state: 'unreadable', bytes: null, digest: null }
|
|
104
|
+
const bytes = fs.readFileSync(descriptor)
|
|
105
|
+
return { state: 'present', bytes, digest: sha256Digest(bytes) }
|
|
106
|
+
} finally {
|
|
107
|
+
fs.closeSync(descriptor)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// What the vault's community plugin list says about Atelier's entry:
|
|
112
|
+
// 'listed', 'not-listed' (a list without it), or 'no-list' (absent, unreadable
|
|
113
|
+
// or not a list), with the bytes read.
|
|
114
|
+
export function readCommunityEntry(vaultRoot) {
|
|
115
|
+
const file = readVaultSettingsFile(vaultRoot, COMMUNITY_PLUGINS_PATH)
|
|
116
|
+
if (file.state !== 'present') return { entry: 'no-list', file }
|
|
117
|
+
let list = null
|
|
118
|
+
try { list = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(file.bytes)) } catch { list = null }
|
|
119
|
+
if (!Array.isArray(list)) return { entry: 'no-list', file }
|
|
120
|
+
return { entry: list.includes(PLUGIN_ID) ? 'listed' : 'not-listed', file }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A change of the decision the vault shows against the record, or null: an
|
|
124
|
+
// entry removed after it was confirmed is the person turning the plugin off; an
|
|
125
|
+
// entry the person put back turns it on again.
|
|
126
|
+
function changeShown(choice, entry) {
|
|
127
|
+
if (choice.state === 'on' && entry === 'not-listed') return { state: 'off', reason: 'entry-removed-by-person' }
|
|
128
|
+
if (choice.state === 'off' && !choice.unreadable && entry === 'listed') return { state: 'on', reason: 'entry-restored-by-person' }
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The decision for one view, from its record and what its vault says now, as
|
|
133
|
+
// the view is prepared: a change is recorded here.
|
|
134
|
+
export function decidePluginChoice({ workspaceRoot, workspaceId, scopeId, vaultRoot, clock }) {
|
|
135
|
+
const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
136
|
+
const community = readCommunityEntry(vaultRoot)
|
|
137
|
+
const change = changeShown(recorded, community.entry)
|
|
138
|
+
return { choice: change === null ? recorded : writePluginChoice({ workspaceRoot, workspaceId, scopeId, ...change, clock }), community }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The same decision for status, read only: a change not recorded yet is marked
|
|
142
|
+
// `pending` (the view's next preparation records it).
|
|
143
|
+
export function currentPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
|
|
144
|
+
const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
145
|
+
const change = changeShown(recorded, readCommunityEntry(viewVaultRoot(workspaceRoot, scopeId)).entry)
|
|
146
|
+
return change === null ? recorded : { ...change, since: null, pending: true }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// After a publication: an offered entry that is now in place. Published while
|
|
150
|
+
// no app ran, it is confirmed: an app reads the list, entry and all, when it
|
|
151
|
+
// next opens the vault. Published through a running app, it is only offered:
|
|
152
|
+
// that app may not have read it, and the plugin running there confirms it
|
|
153
|
+
// (confirmPluginSeen).
|
|
154
|
+
const IN_PLACE = new Set(['policy-satisfied', 'created', 'published', 'published-external-captured', 'already-current'])
|
|
155
|
+
export function confirmPluginEntry({ workspaceRoot, workspaceId, scopeId, result, clock }) {
|
|
156
|
+
const unit = (result?.notes ?? []).find((entry) => entry.path === COMMUNITY_PLUGINS_PATH)
|
|
157
|
+
if (!unit || !IN_PLACE.has(unit.outcome) || unit.changedAfterPublication === true) return null
|
|
158
|
+
const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
159
|
+
if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
|
|
160
|
+
if (result.mode === 'direct') return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-confirmed', clock })
|
|
161
|
+
if (choice.state === 'offered') return null
|
|
162
|
+
return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'offered', reason: 'entry-offered-while-the-app-runs', clock })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The plugin said hello from the vault: the app it runs in has the entry, so it is confirmed.
|
|
166
|
+
export function confirmPluginSeen({ workspaceRoot, workspaceId, scopeId, clock }) {
|
|
167
|
+
const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
168
|
+
if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
|
|
169
|
+
return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-seen-by-the-app', clock })
|
|
170
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
4
|
+
import { OBSIDIAN_EXT_KEY } from '../../projection/obsidian/contracts.mjs'
|
|
5
|
+
import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
|
|
6
|
+
import { readFileBytes, readFileDigest } from '../../projection/obsidian/recovery/store.mjs'
|
|
7
|
+
import { viewVaultRoot } from './plugin-choice.mjs'
|
|
8
|
+
|
|
9
|
+
// Whether the plugin files a view's committed generation pins are on disk as
|
|
10
|
+
// pinned. A person may remove one, change one, or repair a path Atelier had to
|
|
11
|
+
// leave (a folder where a file goes, say). The publisher then publishes the
|
|
12
|
+
// same generation again (publisher.mjs), but only when the view is prepared
|
|
13
|
+
// again, which nothing else asks for while its sources are unchanged.
|
|
14
|
+
//
|
|
15
|
+
// `observe(scopeIds)` looks at each view once per tick and returns the views
|
|
16
|
+
// to prepare again: those whose files differ from their pins and look
|
|
17
|
+
// different from the last look. A drift Atelier leaves for the person (a link
|
|
18
|
+
// where a file goes) is asked about once, not at every tick; a change to it is
|
|
19
|
+
// asked about again. Where the plugin is turned off in the vault, a pinned
|
|
20
|
+
// file that is gone is no drift: the publisher never makes one again.
|
|
21
|
+
|
|
22
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
23
|
+
const TURNED_OFF = 'turned-off-in-this-vault'
|
|
24
|
+
|
|
25
|
+
// The plugin record of the view's committed manifest, read as the store reads
|
|
26
|
+
// it: the pointer names the manifest and its digest. Null when there is none.
|
|
27
|
+
function pinnedPlugin(workspaceRoot, workspaceId, scopeId) {
|
|
28
|
+
const manifests = path.join(workspaceRoot, 'state', 'manifests', segment(scopeId))
|
|
29
|
+
try {
|
|
30
|
+
const pointer = JSON.parse(readRegularTextNoFollow(path.join(manifests, 'current.json')))
|
|
31
|
+
if (pointer?.workspaceId !== workspaceId || pointer?.scopeId !== scopeId || typeof pointer.manifestFile !== 'string' || pointer.manifestFile.includes('/')) return null
|
|
32
|
+
const bytes = readFileBytes(path.join(manifests, pointer.manifestFile))
|
|
33
|
+
if (sha256Digest(bytes) !== pointer.manifestDigest) return null
|
|
34
|
+
const owned = JSON.parse(bytes.toString('utf8'))?.ext?.[OBSIDIAN_EXT_KEY]?.settings?.pluginOwned
|
|
35
|
+
if (!Array.isArray(owned?.files)) return null
|
|
36
|
+
return { files: owned.files.filter((file) => typeof file?.path === 'string' && typeof file?.digest === 'string'), off: owned.withheldBecause === TURNED_OFF }
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// What one pinned path holds: its digest, 'absent', or 'unreadable' (a link, a
|
|
43
|
+
// folder where a file goes, a path nobody may read).
|
|
44
|
+
function onDisk(vaultRoot, relativePath) {
|
|
45
|
+
let current = vaultRoot
|
|
46
|
+
for (const part of relativePath.split('/').slice(0, -1)) {
|
|
47
|
+
current = path.join(current, part)
|
|
48
|
+
let stat
|
|
49
|
+
try { stat = fs.lstatSync(current, { throwIfNoEntry: false }) } catch { return 'unreadable' }
|
|
50
|
+
if (!stat) return 'absent'
|
|
51
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
|
|
52
|
+
}
|
|
53
|
+
try { return readFileDigest(path.join(vaultRoot, relativePath)) ?? 'absent' } catch { return 'unreadable' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createPluginDriftObserver({ workspaceRoot, workspaceId }) {
|
|
57
|
+
const looked = new Map()
|
|
58
|
+
return {
|
|
59
|
+
observe(scopeIds) {
|
|
60
|
+
const drifted = []
|
|
61
|
+
for (const scopeId of scopeIds) {
|
|
62
|
+
const pinned = pinnedPlugin(workspaceRoot, workspaceId, scopeId)
|
|
63
|
+
if (pinned === null || pinned.files.length === 0) { looked.delete(scopeId); continue }
|
|
64
|
+
const vaultRoot = viewVaultRoot(workspaceRoot, scopeId)
|
|
65
|
+
let root = 'absent'
|
|
66
|
+
try { const stat = fs.lstatSync(vaultRoot); root = stat.isSymbolicLink() ? 'link' : (stat.mode & 0o777).toString(8) } catch { root = 'absent' }
|
|
67
|
+
const disk = pinned.files.map((file) => onDisk(vaultRoot, file.path))
|
|
68
|
+
const differs = disk.some((found, index) => found !== pinned.files[index].digest && !(pinned.off && found === 'absent'))
|
|
69
|
+
const signature = JSON.stringify([root, pinned.files.map((file) => file.digest), disk])
|
|
70
|
+
if (differs && looked.get(scopeId) !== signature) drifted.push(scopeId)
|
|
71
|
+
looked.set(scopeId, signature)
|
|
72
|
+
}
|
|
73
|
+
return drifted
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isPlainObject } from './documents.mjs'
|
|
2
|
+
|
|
3
|
+
// What `status` and `open` say about Atelier's plugin, read from the status
|
|
4
|
+
// document of the running service (`plugins`, see plugin-channel.mjs). The
|
|
5
|
+
// plugin is present for a view while it holds a live lease on it.
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_PRESENCE_REASONS = Object.freeze(['live-lease', 'no-live-lease', 'turned-off-in-this-vault', 'service-not-running', 'service-reports-no-plugin-channel'])
|
|
8
|
+
|
|
9
|
+
// The two ways back, for a view whose vault turned the plugin off.
|
|
10
|
+
export const turnPluginOnNext = (scopeId) => `turn Atelier on in Obsidian (Settings, Community plugins), or run \`atelier obsidian plugin on --scope ${scopeId}\`, which restores it at the view's next publication`
|
|
11
|
+
|
|
12
|
+
export function pluginPresenceOf(statusDocument, scopeId) {
|
|
13
|
+
if (!isPlainObject(statusDocument)) return { present: false, reason: 'service-not-running' }
|
|
14
|
+
const scopes = isPlainObject(statusDocument.plugins) && Array.isArray(statusDocument.plugins.scopes) ? statusDocument.plugins.scopes : null
|
|
15
|
+
if (scopes === null) return { present: false, reason: 'service-reports-no-plugin-channel' }
|
|
16
|
+
const entry = scopes.find((item) => isPlainObject(item) && item.scopeId === scopeId)
|
|
17
|
+
if (entry?.present === true && typeof entry.appVersion === 'string') {
|
|
18
|
+
return { present: true, reason: 'live-lease', appVersion: entry.appVersion, pluginVersion: typeof entry.pluginVersion === 'string' ? entry.pluginVersion : null, sessions: Number.isInteger(entry.sessions) ? entry.sessions : 1 }
|
|
19
|
+
}
|
|
20
|
+
if (entry?.entry === 'off') return { present: false, reason: 'turned-off-in-this-vault', next: turnPluginOnNext(scopeId) }
|
|
21
|
+
return { present: false, reason: 'no-live-lease' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// An app probe that takes the version from the plugin when one holds the view
|
|
25
|
+
// and the command-line tool gives none: that plugin runs inside the app, so its
|
|
26
|
+
// version is the app's. A version the tool did give decides, since the tool may
|
|
27
|
+
// reach another app that holds the same vault. Whether the app is installed and
|
|
28
|
+
// has its command-line capability is still the probe's answer; that capability
|
|
29
|
+
// opens and coordinates the vault in this phase. So is whether the app runs: a
|
|
30
|
+
// lease outlives a crashed app by up to its lease time.
|
|
31
|
+
// The presence is asked at most once per `maxAgeMs`: open's wait loop asks the
|
|
32
|
+
// app far more often, and each answer about the plugin costs a round trip to
|
|
33
|
+
// the service.
|
|
34
|
+
export function withPluginReportedVersion(appProbe, readPresence, { maxAgeMs = 1000, now = () => Date.now() } = {}) {
|
|
35
|
+
let last = null
|
|
36
|
+
const presenceNow = () => {
|
|
37
|
+
if (last === null || now() - last.at >= maxAgeMs) last = { at: now(), presence: Promise.resolve().then(readPresence).catch(() => null) }
|
|
38
|
+
return last.presence
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
async inspect() {
|
|
42
|
+
const [observation, presence] = await Promise.all([appProbe.inspect(), presenceNow()])
|
|
43
|
+
if (presence?.present !== true || observation?.installed !== true || observation?.cli !== true || observation.running === false || typeof observation.version === 'string') return observation
|
|
44
|
+
return { ...observation, running: true, version: presence.appVersion, noVaultOpen: false, versionSource: 'plugin' }
|
|
45
|
+
},
|
|
46
|
+
vaultState: (input) => appProbe.vaultState(input),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -23,15 +23,27 @@ import { runMaintenanceService } from './service.mjs'
|
|
|
23
23
|
// selected them. The editor adapter is constructed only for an app that meets
|
|
24
24
|
// the minimum version; below it, or when the version cannot be read, the
|
|
25
25
|
// factory refuses, the engine records that reason and nothing is published.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// The app is asked for its version only when a view is published, without
|
|
27
|
+
// blocking the service. An adapter qualified while no app ran carries that
|
|
28
|
+
// qualification and does not coordinate with an app that starts before the
|
|
29
|
+
// next qualification. The engine is told what the app looks like from the
|
|
30
|
+
// process table and the app's vault list alone (whether it runs, which vaults
|
|
31
|
+
// its list shows open), so a view that did not settle is tried again as soon
|
|
32
|
+
// as that changes, and the app is not asked anything to find out; the list is
|
|
33
|
+
// only read here.
|
|
28
34
|
const ADAPTERS = Object.freeze({
|
|
29
35
|
'obsidian-cli': async () => {
|
|
30
|
-
const [{ createObsidianCliAdapter }, { createProductionAppProbe }, { createQualifiedAdapterFactory }] = await Promise.all([
|
|
36
|
+
const [{ createObsidianCliAdapter, defaultObsidianProcessProbe }, { createProductionAppProbe }, { createQualifiedAdapterFactory }, { obsidianUserDataDir, readObsidianSettings }, { appStateSignature }] = await Promise.all([
|
|
31
37
|
import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
|
|
38
|
+
import('../../projection/obsidian/publication/vault-list.mjs'), import('./app-registration.mjs'),
|
|
32
39
|
])
|
|
33
40
|
const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: ({ qualification }) => createObsidianCliAdapter({ qualification }) })
|
|
34
|
-
|
|
41
|
+
const userDataDir = obsidianUserDataDir()
|
|
42
|
+
const observeApp = () => appStateSignature({ processes: defaultObsidianProcessProbe(), settings: userDataDir === null ? null : readObsidianSettings({ userDataDir }) })
|
|
43
|
+
return {
|
|
44
|
+
adapterFactory, engineOptions: { observeApp },
|
|
45
|
+
appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } },
|
|
46
|
+
}
|
|
35
47
|
},
|
|
36
48
|
})
|
|
37
49
|
|
|
@@ -88,6 +100,7 @@ if (invokedDirectly) {
|
|
|
88
100
|
const { adapter, ...rest } = options
|
|
89
101
|
const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
|
|
90
102
|
const registry = createObsidianRegistry({ contributions: await loadContributions() })
|
|
91
|
-
|
|
103
|
+
const { engineOptions, ...seams } = await ADAPTERS[adapter]()
|
|
104
|
+
await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...seams, engineOptions: { ...engineOptions, extensions: registry.extensions } })
|
|
92
105
|
}
|
|
93
106
|
}
|