@@ -25,9 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
2525import prompts # noqa: E402
2626import validate # noqa: E402
2727
28−HOST_VERSION = "0.1.0"
28+HOST_VERSION = "0.1.0" # release builds stamp the extension version here (scripts/build.mjs)
2929HOST_NAME = "io.diagonal.host"
3030ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
31+# The Chrome Web Store gives the listed extension its own ID. Once the listing exists, put its origin here
32+# ("chrome-extension://<store id>/") so the store install and the unpacked one can both use the host.
33+STORE_ORIGIN = ""
3134FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
3235SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
3336SCHEMAS = os.path.join(SUPPORT, "schemas")
@@ -247,11 +250,18 @@ def op_ping(_payload, _opts):
247250 license_required, msg = True, LICENSE_MESSAGE
248251 except subprocess.TimeoutExpired:
249252 ok, msg = False, "fm available timed out"
253+ schema_message = None
250254 if ok:
251− ensure_schemas()
255+ try:
256+ ensure_schemas()
257+ except Fail as f:
258+ schema_message = f.message
252259 mode = organize_mode()
253− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
254− "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
260+ reply = {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
261+ "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
262+ if schema_message:
263+ reply["schemaMessage"] = schema_message
264+ return reply
255265
256266
257267def schemas_ok():
@@ -263,17 +273,22 @@ def ensure_schemas():
263273 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
264274 if schemas_ok():
265275 return
266− with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
276+ out = io.StringIO()
277+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
267278 rc = install_schemas()
268279 if rc == INSTALL_LICENSE_EXIT:
269280 raise Fail("LICENSE_REQUIRED", LICENSE_MESSAGE)
281+ if not schemas_ok():
282+ lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
283+ why = lines[-1] if lines else f"fm schema exited {rc}"
284+ raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
270285
271286
272287def op_name(payload, opts):
273288 ensure_schemas()
274289 schema = schema_path("name.json")
275290 if not schema:
276− raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "name.json"))
291+ raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
277292 prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
278293 check_budget(prompt, len(payload["items"]))
279294 out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
@@ -304,7 +319,7 @@ def op_organize(payload, opts):
304319 return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
305320 except validate.ValidationError as e:
306321 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
307− raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "organize.json"))
322+ raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'organize.json')}")
308323
309324
310325def handle(req):
@@ -508,7 +523,7 @@ def manifest_problems(path, host_path):
508523 problems.append(f"path is {m.get('path')!r}")
509524 elif not os.access(host_path, os.X_OK):
510525 problems.append(f"host {host_path} is missing or not executable")
511− if m.get("allowed_origins") != [ALLOWED_ORIGIN]:
526+ if m.get("allowed_origins") != allowed_origins():
512527 problems.append(f"allowed_origins is {m.get('allowed_origins')}")
513528 return problems
514529
@@ -569,11 +584,15 @@ def unregister():
569584
570585def manifest_for(path):
571586 return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
572− "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
587+ "path": os.path.abspath(path), "type": "stdio", "allowed_origins": allowed_origins()}
588+
589+
590+def allowed_origins():
591+ return [o for o in (ALLOWED_ORIGIN, STORE_ORIGIN) if o]
573592
574593
575594def origin_ok(arg):
576− return isinstance(arg, str) and arg.rstrip("/") + "/" == ALLOWED_ORIGIN
595+ return isinstance(arg, str) and arg.rstrip("/") + "/" in allowed_origins()
577596
578597
579598def main(argv=None):