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.
- local_compile_for_overleaf-0.1.0/.gitignore +10 -0
- local_compile_for_overleaf-0.1.0/PKG-INFO +24 -0
- local_compile_for_overleaf-0.1.0/README.md +12 -0
- local_compile_for_overleaf-0.1.0/pyproject.toml +28 -0
- local_compile_for_overleaf-0.1.0/src/local_compile_for_overleaf/__init__.py +1 -0
- local_compile_for_overleaf-0.1.0/src/local_compile_for_overleaf/__main__.py +4 -0
- local_compile_for_overleaf-0.1.0/src/local_compile_for_overleaf/cli.py +89 -0
- local_compile_for_overleaf-0.1.0/src/local_compile_for_overleaf/install.py +479 -0
- local_compile_for_overleaf-0.1.0/src/local_compile_for_overleaf/native.py +145 -0
- local_compile_for_overleaf-0.1.0/src/local_compile_for_overleaf/server.py +1175 -0
- local_compile_for_overleaf-0.1.0/tests/test_cli.py +64 -0
- local_compile_for_overleaf-0.1.0/tests/test_install.py +239 -0
- local_compile_for_overleaf-0.1.0/tests/test_native.py +48 -0
- local_compile_for_overleaf-0.1.0/tests/test_server.py +632 -0
- local_compile_for_overleaf-0.1.0/tests/test_tex_fixtures.py +412 -0
|
@@ -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,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)
|