@deepblend/dsh-blender-provider-local 0.2.4 → 0.2.5

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/lib/index.js CHANGED
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { spawnSync } from 'node:child_process'
23
23
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
24
+ import net from 'node:net'
24
25
  import { readFile } from 'node:fs/promises'
25
26
  import { delimiter, isAbsolute, join, resolve, sep } from 'node:path'
26
27
  import { randomUUID } from 'node:crypto'
@@ -154,6 +155,124 @@ export const ProviderConfig = z.object({
154
155
  * @property {string} workingDirectory
155
156
  */
156
157
 
158
+ /** How long an attached Blender gets to say hello before the attach is called a failure. */
159
+ const ATTACH_READY_MS = 10_000
160
+
161
+ /**
162
+ * Connect to a Unix socket, with a deadline.
163
+ *
164
+ * The add-on may be starting when the product asks — a user enables it and the product is already
165
+ * polling — so this retries inside one deadline rather than failing on the first refusal.
166
+ *
167
+ * @param {string} socketPath
168
+ * @param {number} timeoutMs
169
+ * @returns {Promise<import('node:net').Socket>}
170
+ */
171
+ async function connect(socketPath, timeoutMs) {
172
+ const deadline = Date.now() + Math.min(timeoutMs, 10_000)
173
+ let lastError = null
174
+ for (;;) {
175
+ const socket = await new Promise(resolve => {
176
+ const candidate = net.connect(socketPath)
177
+ candidate.once('connect', () => resolve(candidate))
178
+ candidate.once('error', error => {
179
+ lastError = error
180
+ candidate.destroy()
181
+ resolve(null)
182
+ })
183
+ })
184
+ if (socket !== null) return socket
185
+ if (Date.now() >= deadline) throw lastError ?? new Error('no answer')
186
+ await new Promise(settle => setTimeout(settle, 250))
187
+ }
188
+ }
189
+
190
+ /**
191
+ * A live Blender, presented as one shape whatever carries the bytes.
192
+ *
193
+ * WHY THE SEAM EXISTS. A session can be carried two ways, and both are real: the process this
194
+ * provider SPAWNS (`--background`, pipes on stdin/stdout) and the Blender a USER is looking at (the
195
+ * add-on's socket). The conversation — the handshake, the per-request deadline, matching an answer to
196
+ * its request — is identical, and the only thing that differs is who owns the other end. Writing it
197
+ * twice would mean two places to fix the next bug in the deadline, and the second one would be the
198
+ * one nobody runs.
199
+ *
200
+ * The subprocess adapter and the socket adapter below are the whole of the difference.
201
+ */
202
+ class SubprocessTransport {
203
+ /** @param {object} handle from `ctx.subprocess.spawn` */
204
+ constructor(handle) {
205
+ this.handle = handle
206
+ this.read = handle.stdout
207
+ this.onExit = callback => handle.done?.then?.(() => callback({}), () => callback({}))
208
+ this.pid = null
209
+ }
210
+
211
+ write(text) {
212
+ this.handle.stdin.write(text)
213
+ }
214
+
215
+ end() {
216
+ try {
217
+ this.handle.stdin.end()
218
+ } catch {
219
+ // Already gone; the exit callback is what decides.
220
+ }
221
+ }
222
+
223
+ /** Wait for the process to be gone, bounded. */
224
+ async settle(graceMs) {
225
+ try {
226
+ await this.handle.waitForExit?.(graceMs)
227
+ } catch {
228
+ // A process that had to be terminated is still a settled one.
229
+ }
230
+ }
231
+ }
232
+
233
+ class SocketTransport {
234
+ /**
235
+ * @param {import('node:net').Socket} socket
236
+ * @param {{ pid?: number|null }} [info]
237
+ */
238
+ constructor(socket, info = {}) {
239
+ this.socket = socket
240
+ this.read = socket
241
+ // The pid is the ATTACHED Blender's own, from its handshake — never this process's. A product that
242
+ // reported its own pid here would make "which Blender answered" unanswerable, which is the one
243
+ // question an attach exists to answer.
244
+ this.pid = info.pid ?? null
245
+ this.exitCallbacks = []
246
+ socket.on('close', () => {
247
+ for (const callback of this.exitCallbacks) callback({})
248
+ })
249
+ socket.on('error', () => {
250
+ // An error closes the socket, and the close handler reports it. Swallowing it here keeps a
251
+ // broken pipe from becoming an unhandled 'error' event, which would take the host down.
252
+ })
253
+ }
254
+
255
+ onExit(callback) {
256
+ this.exitCallbacks.push(callback)
257
+ }
258
+
259
+ write(text) {
260
+ this.socket.write(text)
261
+ }
262
+
263
+ end() {
264
+ try {
265
+ this.socket.end()
266
+ } catch {
267
+ // Already closed.
268
+ }
269
+ }
270
+
271
+ async settle() {
272
+ this.socket.destroy()
273
+ }
274
+ }
275
+
157
276
  /**
158
277
  * One live Blender process, serving requests until it is closed (SPEC §20 M6).
159
278
  *
@@ -170,10 +289,11 @@ export const ProviderConfig = z.object({
170
289
  */
171
290
  class BlenderSession {
172
291
  /**
173
- * @param {{ handle: object, directory: string, jobId: string, timeoutMs: number, onClose: () => void }} input
292
+ * @param {{ transport: SubprocessTransport|SocketTransport, directory: string|null, jobId: string, timeoutMs: number, onClose: () => void }} input
174
293
  */
175
294
  constructor(input) {
176
- this.handle = input.handle
295
+ this.transport = input.transport
296
+ this.handle = input.transport.handle ?? null
177
297
  this.directory = input.directory
178
298
  this.jobId = input.jobId
179
299
  this.timeoutMs = input.timeoutMs
@@ -182,7 +302,7 @@ class BlenderSession {
182
302
  // `done`, `terminate`, `terminateForHostExit`, `waitForExit`. So the pid comes from the process
183
303
  // itself, in the `ready` line bootstrap.py opens with — which is better evidence anyway, because
184
304
  // it is the pid the script believes it is rather than the one the spawn returned.
185
- this.pid = null
305
+ this.pid = input.transport.pid ?? null
186
306
  /** @type {Map<string, {resolve: Function, reject: Function, timer: object}>} */
187
307
  this.waiting = new Map()
188
308
  this.progress = []
@@ -193,15 +313,42 @@ class BlenderSession {
193
313
  this.readyPromise = null
194
314
  this.readyResolve = null
195
315
  this.readyReject = null
316
+ this.readySettled = false
196
317
  this._read()
197
318
  }
198
319
 
199
- /** Resolve when bootstrap.py has announced itself, reject if the process dies first. */
200
- ready() {
320
+ /**
321
+ * Resolve when the other end has announced itself — reject if it dies first, or says nothing.
322
+ *
323
+ * THE DEADLINE IS THE POINT, and a mutation found it missing: with the handshake removed from the
324
+ * bridge, `attachSession` connected successfully and then waited FOREVER, because the socket stayed
325
+ * open and simply never spoke. A hang is worse than either a success or a coded failure — it has no
326
+ * message, no code, and no end — and this repository's rule is that a failure has to be a branchable
327
+ * result. The same deadline that bounds a request bounds the opening of the conversation.
328
+ */
329
+ ready(timeoutMs = this.timeoutMs) {
201
330
  if (this.readyPromise === null) {
202
331
  this.readyPromise = new Promise((resolve, reject) => {
203
- this.readyResolve = resolve
204
- this.readyReject = reject
332
+ const timer = setTimeout(() => {
333
+ if (this.readySettled) return
334
+ this.readySettled = true
335
+ reject(new BlenderError(
336
+ BlenderErrorCode.TIMEOUT,
337
+ `The Blender session (pid ${this.pid ?? 'unknown'}) connected but never announced itself within ${timeoutMs} ms.`,
338
+ { detail: { pid: this.pid, timeoutMs } },
339
+ ))
340
+ }, timeoutMs)
341
+ if (typeof timer.unref === 'function') timer.unref()
342
+ this.readyResolve = value => {
343
+ clearTimeout(timer)
344
+ this.readySettled = true
345
+ resolve(value)
346
+ }
347
+ this.readyReject = error => {
348
+ clearTimeout(timer)
349
+ this.readySettled = true
350
+ reject(error)
351
+ }
205
352
  })
206
353
  }
207
354
  return this.readyPromise
@@ -209,7 +356,7 @@ class BlenderSession {
209
356
 
210
357
  /** Read stdout line by line for the life of the process. */
211
358
  _read() {
212
- const stream = this.handle?.stdout
359
+ const stream = this.transport.read
213
360
  if (stream === undefined || stream === null) {
214
361
  this._fail(new BlenderError(BlenderErrorCode.SPAWN_FAILED, 'the session has no stdout stream to read'))
215
362
  return
@@ -229,10 +376,10 @@ class BlenderSession {
229
376
  // Bounded: stderr is evidence for a failure, not a log to keep.
230
377
  this.stderr = (this.stderr + chunk.toString('utf8')).slice(-8192)
231
378
  })
232
- // THE HANDLE'S EXIT SIGNAL IS A PROMISE, NOT AN EVENT (`done`), and it carries nothing — so the
233
- // exit code is read from `waitForExit()` where one is needed, and the failure below says what is
234
- // knowable: the session ended without answering.
235
- this.handle?.done?.then?.(() => this._exited({}), () => this._exited({}))
379
+ // THE TRANSPORT REPORTS THE END, whatever the end means: for a spawned process the handle's
380
+ // `done` promise, for a socket its 'close'. Neither carries a code — the failure below says what
381
+ // is knowable, which is that the session ended without answering.
382
+ this.transport.onExit(info => this._exited(info))
236
383
  }
237
384
 
238
385
  _line(line) {
@@ -331,7 +478,7 @@ class BlenderSession {
331
478
  }
332
479
  })
333
480
 
334
- this.handle.stdin.write(`${JSON.stringify(document)}\n`)
481
+ this.transport.write(`${JSON.stringify(document)}\n`)
335
482
  return answered
336
483
  }
337
484
 
@@ -339,17 +486,12 @@ class BlenderSession {
339
486
  async close() {
340
487
  if (this.closed) return
341
488
  try {
342
- this.handle.stdin.write(`${JSON.stringify({ action: 'shutdown' })}\n`)
343
- this.handle.stdin.end()
489
+ this.transport.write(`${JSON.stringify({ action: 'shutdown' })}\n`)
490
+ this.transport.end()
344
491
  } catch {
345
- // The process may already be gone; the wait below is what decides.
346
- }
347
- try {
348
- // `waitForExit` is the handle's own way to wait, and it takes a deadline in milliseconds.
349
- await this.handle.waitForExit?.(TERMINATE_GRACE_MS)
350
- } catch {
351
- // A process that had to be terminated is still a closed session.
492
+ // The other end may already be gone; the settle below is what decides.
352
493
  }
494
+ await this.transport.settle(TERMINATE_GRACE_MS)
353
495
  this.closed = true
354
496
  this._fail(new BlenderError(
355
497
  BlenderErrorCode.ABORTED,
@@ -692,7 +834,7 @@ export default class LocalBlenderRuntime extends Service {
692
834
  })
693
835
 
694
836
  const session = new BlenderSession({
695
- handle,
837
+ transport: new SubprocessTransport(handle),
696
838
  directory,
697
839
  jobId,
698
840
  timeoutMs: this.config.timeoutMs,
@@ -702,6 +844,63 @@ export default class LocalBlenderRuntime extends Service {
702
844
  return session
703
845
  }
704
846
 
847
+ /**
848
+ * Attach to a Blender that is ALREADY RUNNING — the user's own, with the add-on (SPEC §20 M6).
849
+ *
850
+ * THE OTHER HALF OF THE LIVE BRIDGE. `openSession()` spawns a Blender nobody can see; this one talks
851
+ * to the Blender on the user's screen, so the operations happen in the window they are looking at.
852
+ * The conversation is identical — same protocol, same envelopes, same per-request deadline — because
853
+ * both go through `BlenderSession`; the only difference is who owns the other end of the bytes.
854
+ *
855
+ * The pid in the handshake is the ATTACHED Blender's own, and it is what makes "which Blender
856
+ * answered this" answerable. A product that reported its own pid here would make that question
857
+ * unanswerable, which is the one question an attach exists to answer.
858
+ *
859
+ * @param {string} socketPath
860
+ * @param {{ timeoutMs?: number }} [options]
861
+ * @returns {Promise<{ pid: number|null, run: Function, close: Function }>}
862
+ */
863
+ async attachSession(socketPath, options = {}) {
864
+ if (typeof socketPath !== 'string' || socketPath.length === 0) {
865
+ throw new BlenderError(
866
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
867
+ 'Attaching needs the socket path the Blender add-on is listening on.',
868
+ )
869
+ }
870
+ if (!existsSync(socketPath)) {
871
+ throw new BlenderError(
872
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
873
+ `Nothing is listening at ${socketPath}. Enable the DeepBlend bridge add-on in Blender ` +
874
+ '(View3D > Sidebar > DeepBlend), or start one headless with ' +
875
+ '`blender --background --python deepblend_bridge.py -- --socket <path>`.',
876
+ { detail: { socketPath } },
877
+ )
878
+ }
879
+
880
+ const socket = await connect(socketPath, options.timeoutMs ?? this.config.timeoutMs).catch(cause => {
881
+ throw new BlenderError(
882
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
883
+ `The Blender bridge at ${socketPath} could not be reached: ${cause?.message ?? 'unknown failure'}.`,
884
+ { detail: { socketPath } },
885
+ )
886
+ })
887
+
888
+ const session = new BlenderSession({
889
+ transport: new SocketTransport(socket),
890
+ directory: null,
891
+ jobId: `attach-${randomUUID()}`,
892
+ timeoutMs: this.config.timeoutMs,
893
+ onClose: () => {},
894
+ })
895
+ // A SHORTER DEADLINE THAN A SPAWN, and the difference is real rather than a preference: a spawned
896
+ // Blender legitimately takes a second or two to boot, while an add-on that is already serving
897
+ // answers the handshake immediately. MEASURED: with the provider's own 180 s timeout, a peer that
898
+ // connected and said nothing took 180 s to report — long enough that a user would conclude the
899
+ // product had hung.
900
+ await session.ready(ATTACH_READY_MS)
901
+ return session
902
+ }
903
+
705
904
  async runBootstrap(request, options = {}) {
706
905
  const action = request?.action
707
906
  if (typeof action !== 'string' || action.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.2.4"
14
+ "@deepblend/dsh-blender-contracts": "0.2.5"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -0,0 +1,306 @@
1
+ """DeepBlend Studio — the Blender-side bridge (SPEC §20 M6 "Blender Add-on" / "Live Bridge").
2
+
3
+ WHAT THIS IS
4
+ ------------
5
+ One file with two entry points, and that is deliberate:
6
+
7
+ * **As a Blender add-on** (the GUI case): the user installs it, enables it, and their own Blender
8
+ opens a local socket and shows a panel saying whether DeepBlend is attached. Every operation then
9
+ runs in the Blender they are LOOKING AT, which is the whole point of the M6 item — the batch round
10
+ trip stops being the only shape.
11
+ * **As a headless server** (the case a test can drive): `blender --background --python
12
+ deepblend_bridge.py -- --socket <path>` serves exactly the same protocol with no GUI at all.
13
+
14
+ Two entry points rather than two files, because the protocol and the dispatch are one thing: a second
15
+ copy for the headless case would be a second answer to "what does compile_scene do", and it would rot
16
+ first because nothing runs it.
17
+
18
+ WHAT IT REUSES
19
+ --------------
20
+ `bootstrap.py`'s dispatch, unchanged: the same `ACTIONS` table, the same protocol-version check, the
21
+ same envelope builder, the same per-request process identity. This file adds a TRANSPORT (a socket)
22
+ and a GUI (a panel), not a second implementation.
23
+
24
+ THE PROTOCOL is the session protocol, one JSON document per line: the product sends
25
+ `{protocolVersion, jobId, action, payload?}`, this answers with the envelope bootstrap.py builds.
26
+ `{"action": "shutdown"}` closes the connection.
27
+
28
+ Owner: DeepBlend Studio — SPEC §20 M6
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import socket
34
+ import sys
35
+ import threading
36
+
37
+ import bpy # noqa: F401 — present in Blender; the import is what makes this an add-on module
38
+
39
+ def _find_bootstrap():
40
+ """Import `bootstrap` from wherever it actually is, or say what to set.
41
+
42
+ MEASURED PROBLEM, and it is the user's case rather than the test's: Blender COPIES an add-on into
43
+ its own add-ons directory, so the file that lands there is this one ALONE — `bootstrap.py` is not
44
+ beside it, and `import bootstrap` fails. The headless entry point never sees this, because there
45
+ the file still sits in the package.
46
+
47
+ So the search is: beside me (headless), then the directory an operator points at, then the one a
48
+ user can paste into the add-on's preferences. When all three miss, the failure names the setting
49
+ instead of raising an ImportError into a panel that shows nothing.
50
+ """
51
+ candidates = [os.path.dirname(os.path.abspath(__file__))]
52
+ configured = os.environ.get("DEEPBLEND_BOOTSTRAP_DIR", "")
53
+ if configured:
54
+ candidates.append(configured)
55
+ try:
56
+ preferences = bpy.context.preferences.addons[__name__].preferences
57
+ if getattr(preferences, "bootstrap_dir", ""):
58
+ candidates.insert(0, preferences.bootstrap_dir)
59
+ except Exception:
60
+ # No preferences yet (headless, or before registration): the other two are enough.
61
+ pass
62
+
63
+ for candidate in candidates:
64
+ if candidate and os.path.exists(os.path.join(candidate, "bootstrap.py")):
65
+ if candidate not in sys.path:
66
+ sys.path.insert(0, candidate)
67
+ import bootstrap # noqa: F401
68
+ return sys.modules["bootstrap"]
69
+ return None
70
+
71
+
72
+ bootstrap = _find_bootstrap()
73
+ BOOTSTRAP_PROBLEM = None if bootstrap is not None else (
74
+ "bootstrap.py was not found. Set DEEPBLEND_BOOTSTRAP_DIR to the provider package's python "
75
+ "directory (…/provider-local/python), or paste it into this add-on's preferences."
76
+ )
77
+
78
+ bl_info = {
79
+ "name": "DeepBlend Studio Bridge",
80
+ "author": "DeepBlend Studio",
81
+ "version": (0, 2, 4),
82
+ "blender": (4, 0, 0),
83
+ "location": "View3D > Sidebar > DeepBlend",
84
+ "description": "Serve DeepBlend operations inside this Blender, so the product drives the window you are looking at",
85
+ "category": "Scene",
86
+ }
87
+
88
+ # The default socket path. One per workspace, so two stores on one machine do not collide — and the
89
+ # product passes its own path when it starts the headless server, so this default is only what the
90
+ # add-on uses when a user enables it with no configuration.
91
+ DEFAULT_SOCKET = os.path.join(os.path.expanduser("~"), ".deepblend-bridge.sock")
92
+
93
+
94
+ class _Bridge:
95
+ """The serving state: one socket, one thread, and the last thing that happened."""
96
+
97
+ def __init__(self, socket_path, options=None):
98
+ self.socket_path = socket_path
99
+ self.options = options or {}
100
+ self.server = None
101
+ self.thread = None
102
+ self.requests = 0
103
+ self.last_action = None
104
+ self.last_error = None
105
+ self.connected = False
106
+
107
+ # -- lifecycle ---------------------------------------------------------
108
+
109
+ def start(self):
110
+ if os.path.exists(self.socket_path):
111
+ # A socket file left by a process that died. Refusing to start would leave the user with
112
+ # no way forward except deleting a file they cannot see, so it is replaced — the same
113
+ # decision the render job's recovery path makes about a stale pid file.
114
+ os.unlink(self.socket_path)
115
+ self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
116
+ self.server.bind(self.socket_path)
117
+ self.server.listen(4)
118
+ self.thread = threading.Thread(target=self._serve, name="deepblend-bridge", daemon=True)
119
+ self.thread.start()
120
+
121
+ def stop(self):
122
+ try:
123
+ if self.server is not None:
124
+ self.server.close()
125
+ except Exception:
126
+ pass
127
+ self.server = None
128
+ try:
129
+ if os.path.exists(self.socket_path):
130
+ os.unlink(self.socket_path)
131
+ except Exception:
132
+ pass
133
+
134
+ # -- serving -----------------------------------------------------------
135
+
136
+ def _serve(self):
137
+ while self.server is not None:
138
+ try:
139
+ connection, _ = self.server.accept()
140
+ except Exception:
141
+ return
142
+ self.connected = True
143
+ try:
144
+ self._converse(connection)
145
+ except Exception as exc:
146
+ self.last_error = bootstrap.error_text(exc)
147
+ finally:
148
+ self.connected = False
149
+ try:
150
+ connection.close()
151
+ except Exception:
152
+ pass
153
+
154
+ def _converse(self, connection):
155
+ """One connection: a handshake, then a line in and an envelope out, until the peer stops.
156
+
157
+ THE HANDSHAKE IS THE SESSION PROTOCOL'S OWN, and it took a hang to notice it was missing: the
158
+ headless entry point printed `{"kind": "ready"}` to stdout at startup, and a CONNECTION got
159
+ nothing — so the product's `BlenderSession.ready()` waited forever on a peer that was already
160
+ serving. A transport that answers the same conversation has to open it the same way.
161
+ """
162
+ connection.sendall((json.dumps({"kind": "ready", "pid": os.getpid(), "socket": self.socket_path}) + "\n").encode("utf-8"))
163
+ buffer = b""
164
+ while True:
165
+ chunk = connection.recv(65536)
166
+ if not chunk:
167
+ return
168
+ buffer += chunk
169
+ while b"\n" in buffer:
170
+ line, buffer = buffer.split(b"\n", 1)
171
+ text = line.decode("utf-8", "replace").strip()
172
+ if text == "":
173
+ continue
174
+ try:
175
+ request = json.loads(text)
176
+ except Exception as exc:
177
+ answer = bootstrap.build_envelope(None, None, None, {
178
+ "code": "BLENDER_SCRIPT_ERROR",
179
+ "message": "the request line is not valid JSON: %s" % (bootstrap.error_text(exc),),
180
+ }, [], [])
181
+ else:
182
+ if isinstance(request, dict) and request.get("action") == "shutdown":
183
+ connection.sendall((json.dumps({"kind": "bye", "pid": os.getpid()}) + "\n").encode("utf-8"))
184
+ return
185
+ self.requests += 1
186
+ self.last_action = request.get("action") if isinstance(request, dict) else None
187
+ answer = bootstrap._session_dispatch(request, self.options)
188
+ answer["kind"] = "result"
189
+ connection.sendall((json.dumps(answer, ensure_ascii=False) + "\n").encode("utf-8"))
190
+
191
+ # -- what the panel shows ----------------------------------------------
192
+
193
+ def status(self):
194
+ if self.server is None:
195
+ return "off"
196
+ return "attached" if self.connected else "listening on %s" % (self.socket_path,)
197
+
198
+
199
+ _BRIDGE = None
200
+
201
+
202
+ class DeepBlendBridgePreferences(bpy.types.AddonPreferences):
203
+ """Where the user points the add-on at the package, because Blender copies add-ons alone."""
204
+
205
+ bl_idname = __name__
206
+
207
+ bootstrap_dir: bpy.props.StringProperty( # type: ignore[valid-type]
208
+ name="bootstrap.py directory",
209
+ description="The provider package's python directory (…/provider-local/python)",
210
+ default="",
211
+ subtype="DIR_PATH",
212
+ )
213
+
214
+ def draw(self, context):
215
+ layout = self.layout
216
+ layout.prop(self, "bootstrap_dir")
217
+ if BOOTSTRAP_PROBLEM is not None:
218
+ layout.label(text=BOOTSTRAP_PROBLEM, icon="ERROR")
219
+
220
+
221
+ class DEEPBLEND_PT_bridge(bpy.types.Panel):
222
+ """The panel, because an add-on a user cannot see is an add-on they will not trust."""
223
+
224
+ bl_label = "DeepBlend Studio"
225
+ bl_idname = "DEEPBLEND_PT_bridge"
226
+ bl_space_type = "VIEW_3D"
227
+ bl_region_type = "UI"
228
+ bl_category = "DeepBlend"
229
+
230
+ def draw(self, context):
231
+ layout = self.layout
232
+ if BOOTSTRAP_PROBLEM is not None:
233
+ layout.label(text="bootstrap.py not found", icon="ERROR")
234
+ layout.label(text="Set it in this add-on's preferences")
235
+ return
236
+ if _BRIDGE is None:
237
+ layout.label(text="Bridge is not running", icon="ERROR")
238
+ return
239
+ layout.label(text="Status: %s" % (_BRIDGE.status(),))
240
+ layout.label(text="Socket: %s" % (os.path.basename(_BRIDGE.socket_path),))
241
+ layout.label(text="Operations served: %d" % (_BRIDGE.requests,))
242
+ if _BRIDGE.last_action is not None:
243
+ layout.label(text="Last: %s" % (_BRIDGE.last_action,))
244
+ if _BRIDGE.last_error is not None:
245
+ layout.label(text="Last error: %s" % (_BRIDGE.last_error[:60],), icon="ERROR")
246
+
247
+
248
+ def register():
249
+ global _BRIDGE
250
+ socket_path = os.environ.get("DEEPBLEND_BRIDGE_SOCKET", DEFAULT_SOCKET)
251
+ _BRIDGE = _Bridge(socket_path)
252
+ try:
253
+ _BRIDGE.start()
254
+ except Exception as exc:
255
+ # A bridge that cannot listen must not stop Blender from starting: the user's session is more
256
+ # important than this panel, and the panel says what went wrong.
257
+ _BRIDGE.last_error = bootstrap.error_text(exc)
258
+ bpy.utils.register_class(DeepBlendBridgePreferences)
259
+ bpy.utils.register_class(DEEPBLEND_PT_bridge)
260
+
261
+
262
+ def unregister():
263
+ global _BRIDGE
264
+ bpy.utils.unregister_class(DEEPBLEND_PT_bridge)
265
+ bpy.utils.unregister_class(DeepBlendBridgePreferences)
266
+ if _BRIDGE is not None:
267
+ _BRIDGE.stop()
268
+ _BRIDGE = None
269
+
270
+
271
+ def main():
272
+ """Headless entry point: serve until the process is asked to stop."""
273
+ argv = sys.argv
274
+ options = {}
275
+ if "--" in argv:
276
+ tokens = argv[argv.index("--") + 1:]
277
+ index = 0
278
+ while index < len(tokens):
279
+ if tokens[index] == "--socket" and index + 1 < len(tokens):
280
+ options["socket"] = tokens[index + 1]
281
+ index += 2
282
+ continue
283
+ index += 1
284
+ socket_path = options.get("socket", DEFAULT_SOCKET)
285
+
286
+ if bootstrap is None:
287
+ print(json.dumps({"kind": "error", "message": BOOTSTRAP_PROBLEM}), flush=True)
288
+ return 2
289
+
290
+ global _BRIDGE
291
+ _BRIDGE = _Bridge(socket_path, {"proc": None})
292
+ _BRIDGE.start()
293
+ print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path}), flush=True)
294
+ try:
295
+ # The serve thread is a daemon, so this loop is what keeps the process alive. A SIGTERM (or
296
+ # the product closing the socket and asking for shutdown) ends it.
297
+ _BRIDGE.thread.join()
298
+ except KeyboardInterrupt:
299
+ pass
300
+ finally:
301
+ _BRIDGE.stop()
302
+ return 0
303
+
304
+
305
+ if __name__ == "__main__":
306
+ sys.exit(main())