local-compile-for-overleaf 0.1.0__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ /overleaf-ce-source
2
+ node_modules/
3
+ extension/dist/
4
+ extension/dist-firefox/
5
+ playwright-report/
6
+ test-results/
7
+ .pytest_cache/
8
+ __pycache__/
9
+ *.pyc
10
+ .DS_Store
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: local-compile-for-overleaf
3
+ Version: 0.1.0
4
+ Summary: Unofficial Native Messaging host for compiling Overleaf projects locally.
5
+ Project-URL: Homepage, https://github.com/DominikPeters/local-compile-for-overleaf
6
+ Project-URL: Source, https://github.com/DominikPeters/local-compile-for-overleaf
7
+ Project-URL: Issues, https://github.com/DominikPeters/local-compile-for-overleaf/issues
8
+ Author: Dominik Peters
9
+ License-Expression: AGPL-3.0-only
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Local Compile for Overleaf Native Host
14
+
15
+ This package provides the Native Messaging host for Local Compile for Overleaf. It starts a loopback HTTP server used by the browser extension and invokes local TeX tooling such as `latexmk`.
16
+
17
+ Install:
18
+
19
+ ```sh
20
+ python3 -m pip install --user --upgrade local-compile-for-overleaf
21
+ python3 -m local_compile_for_overleaf
22
+ ```
23
+
24
+ The project is unofficial and is not affiliated with Overleaf.
@@ -0,0 +1,12 @@
1
+ # Local Compile for Overleaf Native Host
2
+
3
+ This package provides the Native Messaging host for Local Compile for Overleaf. It starts a loopback HTTP server used by the browser extension and invokes local TeX tooling such as `latexmk`.
4
+
5
+ Install:
6
+
7
+ ```sh
8
+ python3 -m pip install --user --upgrade local-compile-for-overleaf
9
+ python3 -m local_compile_for_overleaf
10
+ ```
11
+
12
+ The project is unofficial and is not affiliated with Overleaf.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "local-compile-for-overleaf"
7
+ version = "0.1.0"
8
+ description = "Unofficial Native Messaging host for compiling Overleaf projects locally."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Dominik Peters" }]
12
+ license = "AGPL-3.0-only"
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/DominikPeters/local-compile-for-overleaf"
17
+ Source = "https://github.com/DominikPeters/local-compile-for-overleaf"
18
+ Issues = "https://github.com/DominikPeters/local-compile-for-overleaf/issues"
19
+
20
+ [project.scripts]
21
+ local-compile-for-overleaf = "local_compile_for_overleaf.cli:main"
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ pythonpath = ["src"]
26
+ markers = [
27
+ "tex: tests that run real TeX tools such as latexmk, bibtex, makeindex, or makeglossaries",
28
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .install import FIREFOX_EXTENSION_ID, format_report, install_chrome_host, install_manifests
7
+ from .native import run_native_host
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ argv = list(sys.argv[1:] if argv is None else argv)
12
+ if is_native_messaging_invocation(argv):
13
+ run_native_host()
14
+ return 0
15
+
16
+ parser = argparse.ArgumentParser(prog="local-compile-for-overleaf")
17
+ subparsers = parser.add_subparsers(dest="command")
18
+
19
+ install = subparsers.add_parser("install")
20
+ add_install_arguments(install)
21
+
22
+ install = subparsers.add_parser("install-chrome-host")
23
+ install.add_argument("--extension-id", required=True, action="append")
24
+ install.add_argument("--host-path")
25
+
26
+ doctor = subparsers.add_parser("doctor")
27
+ add_install_arguments(doctor)
28
+
29
+ args = parser.parse_args(argv)
30
+ if args.command is None:
31
+ report = install_manifests()
32
+ print(format_report(report))
33
+ return 0
34
+ if args.command == "install":
35
+ report = install_manifests(
36
+ browsers=args.browser,
37
+ extension_ids=args.extension_id,
38
+ only_detected=args.only_detected,
39
+ host_path=args.host_path,
40
+ )
41
+ print(format_report(report))
42
+ return 0
43
+ if args.command == "doctor":
44
+ report = install_manifests(
45
+ browsers=args.browser,
46
+ extension_ids=args.extension_id,
47
+ only_detected=args.only_detected,
48
+ host_path=args.host_path,
49
+ )
50
+ print(format_report(report))
51
+ return 0
52
+ if args.command == "install-chrome-host":
53
+ for extension_id in args.extension_id:
54
+ install_chrome_host(extension_id, args.host_path)
55
+ return 0
56
+
57
+ parser.error(f"Unknown command: {args.command}")
58
+ return 2
59
+
60
+
61
+ def add_install_arguments(parser: argparse.ArgumentParser) -> None:
62
+ parser.add_argument(
63
+ "--browser",
64
+ action="append",
65
+ choices=["chrome", "chromium", "chrome-for-testing", "edge", "brave", "firefox"],
66
+ help="Install only for this browser. Can be passed more than once.",
67
+ )
68
+ parser.add_argument(
69
+ "--extension-id",
70
+ action="append",
71
+ help="Chrome-family extension ID to allow. For dev/unpacked installs.",
72
+ )
73
+ parser.add_argument(
74
+ "--only-detected",
75
+ action="store_true",
76
+ help="Skip browser manifests unless the extension is detected in a profile.",
77
+ )
78
+ parser.add_argument("--host-path")
79
+
80
+
81
+ def is_native_messaging_invocation(argv: list[str]) -> bool:
82
+ return bool(
83
+ argv
84
+ and (
85
+ argv[0].startswith("chrome-extension://")
86
+ or argv[0].startswith("moz-extension://")
87
+ or (len(argv) >= 2 and argv[1] == FIREFOX_EXTENSION_ID)
88
+ )
89
+ )
@@ -0,0 +1,479 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import sys
10
+ import sysconfig
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .server import find_executable
15
+
16
+ HOST_NAME = "de.dominik_peters.local_compile_for_overleaf"
17
+ PRODUCT_NAME = "Local Compile for Overleaf"
18
+ DESCRIPTION = "Unofficial local compile helper for Overleaf projects"
19
+ PYTHON_MODULE = "local_compile_for_overleaf"
20
+ CLI_NAME = "local-compile-for-overleaf"
21
+ FIREFOX_EXTENSION_ID = "local-compile-for-overleaf@dominik-peters.de"
22
+
23
+ # Fill these once the store listings exist. Until then, dev installs can pass
24
+ # --extension-id or rely on best-effort detection from browser profile data.
25
+ PUBLISHED_CHROME_EXTENSION_IDS: tuple[str, ...] = ()
26
+ PUBLISHED_EDGE_EXTENSION_IDS: tuple[str, ...] = ()
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BrowserTarget:
31
+ key: str
32
+ display_name: str
33
+ family: str
34
+ profile_root: Path | None
35
+ manifest_dir: Path | None
36
+ windows_registry_key: str | None = None
37
+ default_extension_ids: tuple[str, ...] = ()
38
+
39
+
40
+ @dataclass
41
+ class ManifestInstall:
42
+ browser: str
43
+ path: Path
44
+ extension_ids: list[str]
45
+ status: str
46
+ detected: bool = False
47
+
48
+
49
+ @dataclass
50
+ class InstallReport:
51
+ host_name: str = HOST_NAME
52
+ launcher: Path | None = None
53
+ installed: list[ManifestInstall] = field(default_factory=list)
54
+ skipped: list[str] = field(default_factory=list)
55
+ warnings: list[str] = field(default_factory=list)
56
+ latexmk_path: str | None = None
57
+
58
+
59
+ def install_manifests(
60
+ *,
61
+ browsers: list[str] | None = None,
62
+ extension_ids: list[str] | None = None,
63
+ only_detected: bool = False,
64
+ host_path: str | None = None,
65
+ ) -> InstallReport:
66
+ selected = set(browsers or [])
67
+ report = InstallReport()
68
+ launcher = ensure_launcher(host_path)
69
+ report.launcher = launcher
70
+
71
+ for target in browser_targets():
72
+ if selected and target.key not in selected and target.family not in selected:
73
+ continue
74
+ detected_ids = detect_extension_ids(target)
75
+ if target.family == "firefox":
76
+ ids = [] if only_detected else [FIREFOX_EXTENSION_ID]
77
+ else:
78
+ ids = list(dict.fromkeys([*(extension_ids or []), *detected_ids]))
79
+ if not ids and not only_detected:
80
+ if target.profile_root and not target.profile_root.exists():
81
+ report.skipped.append(f"{target.display_name}: browser profile not found")
82
+ continue
83
+ ids = list(target.default_extension_ids)
84
+ if not ids:
85
+ report.skipped.append(
86
+ f"{target.display_name}: extension not detected and no published ID configured"
87
+ )
88
+ continue
89
+
90
+ if target.family == "firefox":
91
+ path = write_firefox_manifest(target, launcher, ids)
92
+ else:
93
+ path = write_chromium_manifest(target, launcher, ids)
94
+ report.installed.append(
95
+ ManifestInstall(
96
+ browser=target.display_name,
97
+ path=path,
98
+ extension_ids=ids,
99
+ status="installed",
100
+ detected=bool(detected_ids),
101
+ )
102
+ )
103
+
104
+ report.latexmk_path = find_executable("latexmk")
105
+ if not report.latexmk_path:
106
+ report.warnings.append("latexmk was not found on PATH or in common TeX locations")
107
+ return report
108
+
109
+
110
+ def install_chrome_host(extension_id: str, host_path: str | None = None) -> Path:
111
+ report = install_manifests(
112
+ browsers=["chrome"],
113
+ extension_ids=[extension_id],
114
+ host_path=host_path,
115
+ )
116
+ if not report.installed:
117
+ raise SystemExit("No Chrome Native Messaging manifest was installed")
118
+ path = report.installed[0].path
119
+ print(path)
120
+ return path
121
+
122
+
123
+ def ensure_launcher(host_path: str | None = None) -> Path:
124
+ if sys.platform == "win32":
125
+ launcher = resolve_windows_launcher(host_path)
126
+ if launcher is None:
127
+ raise SystemExit(
128
+ "Could not find local-compile-for-overleaf.exe. "
129
+ "Install with pip so the console script is created, or pass --host-path."
130
+ )
131
+ return launcher
132
+ return write_posix_launcher(host_path)
133
+
134
+
135
+ def resolve_windows_launcher(host_path: str | None = None) -> Path | None:
136
+ if host_path:
137
+ return Path(host_path).resolve()
138
+ executable = shutil.which(CLI_NAME)
139
+ if executable:
140
+ return Path(executable).resolve()
141
+ scripts_dir = Path(sysconfig.get_path("scripts"))
142
+ for name in [f"{CLI_NAME}.exe", f"{CLI_NAME}.cmd", CLI_NAME]:
143
+ candidate = scripts_dir / name
144
+ if candidate.exists():
145
+ return candidate.resolve()
146
+ return None
147
+
148
+
149
+ def write_posix_launcher(host_path: str | None = None) -> Path:
150
+ directory = app_support_dir()
151
+ directory.mkdir(parents=True, exist_ok=True)
152
+ wrapper = directory / f"{HOST_NAME}.sh"
153
+ if host_path:
154
+ command = shlex.quote(str(Path(host_path).resolve()))
155
+ else:
156
+ command = f"{shlex.quote(sys.executable)} -m {PYTHON_MODULE}"
157
+ wrapper.write_text(
158
+ "\n".join(
159
+ [
160
+ "#!/bin/sh",
161
+ "LOG_DIR=\"$HOME/Library/Logs/local-compile-for-overleaf\"",
162
+ "mkdir -p \"$LOG_DIR\"",
163
+ "PATH=\"/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH\"",
164
+ "export PATH",
165
+ "{",
166
+ " printf '%s wrapper starting target=%s pwd=%s PATH=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" "
167
+ + f"{shlex.quote(command)} \"$PWD\" \"$PATH\"",
168
+ "} >> \"$LOG_DIR/host-launch.log\" 2>&1",
169
+ f"exec {command} \"$@\" 2>> \"$LOG_DIR/host-launch.log\"",
170
+ "status=$?",
171
+ "{",
172
+ " printf '%s wrapper exec failed status=%s target=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$status\" "
173
+ + f"{shlex.quote(command)}",
174
+ "} >> \"$LOG_DIR/host-launch.log\" 2>&1",
175
+ "exit \"$status\"",
176
+ "",
177
+ ]
178
+ ),
179
+ encoding="utf-8",
180
+ )
181
+ wrapper.chmod(0o755)
182
+ return wrapper
183
+
184
+
185
+ def write_chromium_manifest(
186
+ target: BrowserTarget,
187
+ launcher: Path,
188
+ extension_ids: list[str],
189
+ ) -> Path:
190
+ manifest = {
191
+ "name": HOST_NAME,
192
+ "description": DESCRIPTION,
193
+ "path": str(launcher),
194
+ "type": "stdio",
195
+ "allowed_origins": [
196
+ f"chrome-extension://{extension_id}/" for extension_id in extension_ids
197
+ ],
198
+ }
199
+ if sys.platform == "win32":
200
+ path = windows_manifest_dir(target) / f"{HOST_NAME}.json"
201
+ write_json(path, manifest)
202
+ if target.windows_registry_key:
203
+ write_windows_registry_key(target.windows_registry_key, path)
204
+ return path
205
+ if target.manifest_dir is None:
206
+ raise ValueError(f"{target.display_name} has no manifest directory")
207
+ path = target.manifest_dir / f"{HOST_NAME}.json"
208
+ write_json(path, manifest)
209
+ return path
210
+
211
+
212
+ def write_firefox_manifest(
213
+ target: BrowserTarget,
214
+ launcher: Path,
215
+ extension_ids: list[str],
216
+ ) -> Path:
217
+ manifest = {
218
+ "name": HOST_NAME,
219
+ "description": DESCRIPTION,
220
+ "path": str(launcher),
221
+ "type": "stdio",
222
+ "allowed_extensions": extension_ids,
223
+ }
224
+ if sys.platform == "win32":
225
+ path = windows_manifest_dir(target) / f"{HOST_NAME}.json"
226
+ write_json(path, manifest)
227
+ if target.windows_registry_key:
228
+ write_windows_registry_key(target.windows_registry_key, path)
229
+ return path
230
+ if target.manifest_dir is None:
231
+ raise ValueError(f"{target.display_name} has no manifest directory")
232
+ path = target.manifest_dir / f"{HOST_NAME}.json"
233
+ write_json(path, manifest)
234
+ return path
235
+
236
+
237
+ def write_json(path: Path, payload: dict[str, Any]) -> None:
238
+ path.parent.mkdir(parents=True, exist_ok=True)
239
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
240
+
241
+
242
+ def write_windows_registry_key(registry_key: str, manifest_path: Path) -> None:
243
+ import winreg
244
+
245
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, registry_key) as key:
246
+ winreg.SetValueEx(key, None, 0, winreg.REG_SZ, str(manifest_path))
247
+
248
+
249
+ def browser_targets() -> list[BrowserTarget]:
250
+ home = Path.home()
251
+ if sys.platform == "darwin":
252
+ app = home / "Library/Application Support"
253
+ return [
254
+ chromium_target("chrome", "Google Chrome", app / "Google/Chrome", chrome_extension_ids()),
255
+ chromium_target("chromium", "Chromium", app / "Chromium", chrome_extension_ids()),
256
+ chromium_target(
257
+ "chrome-for-testing",
258
+ "Chrome for Testing",
259
+ app / "Google/ChromeForTesting",
260
+ chrome_extension_ids(),
261
+ ),
262
+ chromium_target("edge", "Microsoft Edge", app / "Microsoft Edge", edge_extension_ids()),
263
+ chromium_target(
264
+ "brave",
265
+ "Brave",
266
+ app / "BraveSoftware/Brave-Browser",
267
+ chrome_extension_ids(),
268
+ ),
269
+ firefox_target(app / "Mozilla/NativeMessagingHosts"),
270
+ ]
271
+ if sys.platform == "win32":
272
+ local = Path(os.environ.get("LOCALAPPDATA", str(home / "AppData/Local")))
273
+ roaming = Path(os.environ.get("APPDATA", str(home / "AppData/Roaming")))
274
+ return [
275
+ chromium_target(
276
+ "chrome",
277
+ "Google Chrome",
278
+ local / "Google/Chrome/User Data",
279
+ chrome_extension_ids(),
280
+ r"Software\Google\Chrome\NativeMessagingHosts" + "\\" + HOST_NAME,
281
+ ),
282
+ chromium_target(
283
+ "chromium",
284
+ "Chromium",
285
+ local / "Chromium/User Data",
286
+ chrome_extension_ids(),
287
+ r"Software\Chromium\NativeMessagingHosts" + "\\" + HOST_NAME,
288
+ ),
289
+ chromium_target(
290
+ "edge",
291
+ "Microsoft Edge",
292
+ local / "Microsoft/Edge/User Data",
293
+ edge_extension_ids(),
294
+ r"Software\Microsoft\Edge\NativeMessagingHosts" + "\\" + HOST_NAME,
295
+ ),
296
+ chromium_target(
297
+ "brave",
298
+ "Brave",
299
+ local / "BraveSoftware/Brave-Browser/User Data",
300
+ chrome_extension_ids(),
301
+ r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts" + "\\" + HOST_NAME,
302
+ ),
303
+ firefox_target(
304
+ roaming / "Mozilla/NativeMessagingHosts",
305
+ r"Software\Mozilla\NativeMessagingHosts" + "\\" + HOST_NAME,
306
+ ),
307
+ ]
308
+ config = Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config")))
309
+ return [
310
+ chromium_target("chrome", "Google Chrome", config / "google-chrome", chrome_extension_ids()),
311
+ chromium_target(
312
+ "chrome-for-testing",
313
+ "Chrome for Testing",
314
+ config / "google-chrome-for-testing",
315
+ chrome_extension_ids(),
316
+ ),
317
+ chromium_target("chromium", "Chromium", config / "chromium", chrome_extension_ids()),
318
+ chromium_target("edge", "Microsoft Edge", config / "microsoft-edge", edge_extension_ids()),
319
+ chromium_target("brave", "Brave", config / "BraveSoftware/Brave-Browser", chrome_extension_ids()),
320
+ firefox_target(home / ".mozilla/native-messaging-hosts"),
321
+ ]
322
+
323
+
324
+ def chromium_target(
325
+ key: str,
326
+ display_name: str,
327
+ profile_root: Path,
328
+ default_extension_ids: tuple[str, ...],
329
+ windows_registry_key: str | None = None,
330
+ ) -> BrowserTarget:
331
+ return BrowserTarget(
332
+ key=key,
333
+ display_name=display_name,
334
+ family="chromium",
335
+ profile_root=profile_root,
336
+ manifest_dir=profile_root / "NativeMessagingHosts",
337
+ windows_registry_key=windows_registry_key,
338
+ default_extension_ids=default_extension_ids,
339
+ )
340
+
341
+
342
+ def firefox_target(
343
+ manifest_dir: Path,
344
+ windows_registry_key: str | None = None,
345
+ ) -> BrowserTarget:
346
+ return BrowserTarget(
347
+ key="firefox",
348
+ display_name="Firefox",
349
+ family="firefox",
350
+ profile_root=None,
351
+ manifest_dir=manifest_dir,
352
+ windows_registry_key=windows_registry_key,
353
+ default_extension_ids=(FIREFOX_EXTENSION_ID,),
354
+ )
355
+
356
+
357
+ def windows_manifest_dir(target: BrowserTarget) -> Path:
358
+ base = Path(
359
+ os.environ.get(
360
+ "LOCALAPPDATA",
361
+ str(Path.home() / "AppData/Local"),
362
+ )
363
+ )
364
+ return base / PRODUCT_NAME / "NativeMessagingHosts" / target.key
365
+
366
+
367
+ def app_support_dir() -> Path:
368
+ home = Path.home()
369
+ if sys.platform == "darwin":
370
+ return home / "Library/Application Support/local-compile-for-overleaf"
371
+ if sys.platform == "win32":
372
+ return Path(os.environ.get("LOCALAPPDATA", str(home / "AppData/Local"))) / PRODUCT_NAME
373
+ return Path(os.environ.get("XDG_DATA_HOME", str(home / ".local/share"))) / "local-compile-for-overleaf"
374
+
375
+
376
+ def detect_extension_ids(target: BrowserTarget) -> list[str]:
377
+ if target.family != "chromium" or target.profile_root is None:
378
+ return []
379
+ ids: list[str] = []
380
+ for preferences in preference_files(target.profile_root):
381
+ ids.extend(extension_ids_from_preferences(preferences))
382
+ return list(dict.fromkeys(ids))
383
+
384
+
385
+ def preference_files(profile_root: Path) -> list[Path]:
386
+ if not profile_root.exists():
387
+ return []
388
+ return sorted(
389
+ path
390
+ for path in profile_root.glob("*/Preferences")
391
+ if path.is_file()
392
+ )
393
+
394
+
395
+ def extension_ids_from_preferences(path: Path) -> list[str]:
396
+ try:
397
+ data = json.loads(path.read_text(encoding="utf-8"))
398
+ except Exception:
399
+ return []
400
+ settings = data.get("extensions", {}).get("settings", {})
401
+ if not isinstance(settings, dict):
402
+ return []
403
+ ids: list[str] = []
404
+ for extension_id, record in settings.items():
405
+ if is_local_compile_extension_record(record):
406
+ ids.append(str(extension_id))
407
+ return ids
408
+
409
+
410
+ def is_local_compile_extension_record(record: Any) -> bool:
411
+ if not isinstance(record, dict):
412
+ return False
413
+ manifest = record.get("manifest")
414
+ if isinstance(manifest, dict):
415
+ name = str(manifest.get("name", ""))
416
+ description = str(manifest.get("description", ""))
417
+ if name == PRODUCT_NAME or "compile Overleaf projects locally" in description:
418
+ return True
419
+ path = str(record.get("path", ""))
420
+ return bool(re.search(r"(extension/dist|local-compile-for-overleaf)", path))
421
+
422
+
423
+ def env_extension_ids(name: str) -> tuple[str, ...]:
424
+ configured = os.environ.get(name, "")
425
+ values = [
426
+ value.strip()
427
+ for value in re.split(r"[,:\s]+", configured)
428
+ if value.strip()
429
+ ]
430
+ return tuple(dict.fromkeys(values))
431
+
432
+
433
+ def chrome_extension_ids() -> tuple[str, ...]:
434
+ return tuple(
435
+ dict.fromkeys([*PUBLISHED_CHROME_EXTENSION_IDS, *env_extension_ids("LCFO_CHROME_EXTENSION_IDS")])
436
+ )
437
+
438
+
439
+ def edge_extension_ids() -> tuple[str, ...]:
440
+ return tuple(
441
+ dict.fromkeys([*PUBLISHED_EDGE_EXTENSION_IDS, *env_extension_ids("LCFO_EDGE_EXTENSION_IDS")])
442
+ )
443
+
444
+
445
+ def format_report(report: InstallReport) -> str:
446
+ lines = [f"{PRODUCT_NAME} native host installer", ""]
447
+ lines.append(f"Native host: {report.host_name}")
448
+ if report.launcher:
449
+ lines.append(f"Launcher: {report.launcher}")
450
+ lines.append("")
451
+
452
+ if report.installed:
453
+ lines.append("Installed manifests:")
454
+ for item in report.installed:
455
+ source = "detected" if item.detected else "configured"
456
+ lines.append(f" {item.browser}: {item.path}")
457
+ lines.append(f" extensions ({source}): {', '.join(item.extension_ids)}")
458
+ else:
459
+ lines.append("Installed manifests: none")
460
+
461
+ if report.skipped:
462
+ lines.append("")
463
+ lines.append("Skipped:")
464
+ lines.extend(f" {item}" for item in report.skipped)
465
+
466
+ lines.append("")
467
+ if report.latexmk_path:
468
+ lines.append(f"TeX: latexmk found at {report.latexmk_path}")
469
+ else:
470
+ lines.append("TeX: latexmk not found")
471
+
472
+ if report.warnings:
473
+ lines.append("")
474
+ lines.append("Warnings:")
475
+ lines.extend(f" {item}" for item in report.warnings)
476
+
477
+ lines.append("")
478
+ lines.append("Return to the browser and click Retry.")
479
+ return "\n".join(lines)