Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
3122 lines (2813 loc) · 143 KB
/
Copy pathmain.py
File metadata and controls
3122 lines (2813 loc) · 143 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Tern — FastAPI backend.
Wraps the existing Python pipeline (Whisper + SigLIP + Apple Vision + ChromaDB)
as HTTP endpoints. The HTML/CSS/JS frontend lives in ../app/ and is served
from the root path.
Run:
cd tern/api
uv run uvicorn main:app --host 127.0.0.1 --port 18765 --reload
Open:
http://localhost:18765
"""
from __future__ import annotations
import asyncio
import json
import mimetypes
import os
import subprocess
import sys
import time
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator
# Pull in the working tern pipeline.
# main.py is at tern/api/main.py — two parents up is tern/, then service_pipeline/.
# (Previously used .parent.parent.parent which pointed at the outer ./service_pipeline/
# that no longer exists. The `.pth` editable install masked this; when that
# breaks — e.g. macOS keeps UF_HIDDEN on the .pth — `uv run uvicorn` would fail.)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "service_pipeline"))
from tern.cli import default_config # type: ignore
from tern.ingest import Indexer, discover_files, SUPPORTED_IMAGE # type: ignore
from tern.audio import probe_media # type: ignore
from tern.models import IngestConfig, SearchQuery as PipelineSearchQuery # type: ignore
from tern.search import SearchEngine # type: ignore
from tern.storage import Store # type: ignore
from tern.vision import Embedder # type: ignore
from tern.clip import extract_clip, export_fcpxml, extract_audio_clip # type: ignore
# Trial quota + machine identity. Lives next to main.py; kept separate so
# the rules can be tested without standing up the API.
import licensing # noqa: E402
# ─── Configuration ───────────────────────────────────────────────────────
# Workspace = where the indexed DB lives. Default: demo workspace.
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_WORKSPACE = ROOT / "demo"
WORKSPACE_PATH = Path(os.environ.get("TERN_WORKSPACE", str(DEFAULT_WORKSPACE))).resolve()
APP_DIR = ROOT / "app"
# Vendored service_pipeline lives at tern/service_pipeline/ (canonical location
# since the outer /service_pipeline/ was removed). ROOT = tern/, so the path
# resolves to tern/service_pipeline/.
PIPELINE_DIR = ROOT / "service_pipeline"
# ─── Crash log writer ────────────────────────────────────────────
# Persistent log of unhandled exceptions + structured events to a file the
# developer can pull from a user's machine via ~/Library/Logs/tern-crash.log
# (or via the in-app "Send diagnostics" action when we ship it).
import logging
import traceback
from datetime import datetime, timezone
_CRASH_LOG = Path.home() / "Library" / "Logs" / "tern-crash.log"
_CRASH_LOG.parent.mkdir(parents=True, exist_ok=True)
# Rotation threshold. Previously, the crash log used `open("a")`
# with no rotation — fine for week-one customers, but a power user who
# runs Tern daily generates ~100-1000 INFO events per indexing pass and
# the file grows ~200 KB-2 MB per day. After a year: 70-700 MB. Real
# downstream pain:
# - /api/diagnostics typically tails the log and was returning multi-
# megabyte responses for power users
# - Console.app and `tail -f` choke on huge text files
# - Spotlight tries to index the file and pegs a CPU core
# 10 MB current + 10 MB single backup = 20 MB hard cap for the whole
# log story. Single .1 backup (not the usual N-file rotation) because
# the value is "we still have yesterday's logs if the customer's bug
# spans a Tern restart"; deeper history rarely helps and bloats the
# cap by N×.
_MAX_LOG_BYTES = 10 * 1024 * 1024 # 10 MB
def _rotate_log_if_needed() -> None:
"""Rotate _CRASH_LOG to .1 if it exceeds _MAX_LOG_BYTES.
Best-effort: any error during rotation (permissions, missing dir,
sibling .1 locked by another process) silently swallowed so a
rotation hiccup never blocks the actual log write that triggered
the check. Race-tolerant: if two threads stat() and both decide to
rotate, the second os.replace silently overwrites the first's .1
— worst case: lose the first's content. Acceptable trade vs. a
threading.Lock on every log line.
"""
try:
if not _CRASH_LOG.exists():
return
if _CRASH_LOG.stat().st_size <= _MAX_LOG_BYTES:
return
backup = _CRASH_LOG.with_suffix(_CRASH_LOG.suffix + ".1")
# os.replace is atomic; the next log_event() will create a fresh
# _CRASH_LOG via open("a").
import os as _os
_os.replace(_CRASH_LOG, backup)
except Exception:
pass
def _resolve_app_version() -> str:
"""Read the canonical version from pyproject.toml at startup.
Was hardcoded "0.1.1" in /api/diagnostics — drifted from the real
version (0.1.0 across pyproject.toml + tauri.conf.json + Cargo.toml)
and would have drifted further on every release bump. Customer
impact: support tickets reported wrong version, the auto-updater
banner ("v0.1.3 is available") + diagnostics ("I'm v0.1.1") looked
inconsistent.
Single source of truth = api/pyproject.toml. Caches the result so
/api/diagnostics doesn't re-parse on every call. Falls back to
"unknown" if the file isn't parseable (shouldn't happen — the API
can't have booted without it) so the endpoint never 500s on this
field alone.
"""
pyproject = Path(__file__).parent / "pyproject.toml"
try:
# tomllib is stdlib on 3.11+, which is our floor.
import tomllib
with pyproject.open("rb") as f:
data = tomllib.load(f)
return str(data.get("project", {}).get("version", "unknown"))
except Exception:
return "unknown"
_APP_VERSION = _resolve_app_version()
def log_event(category: str, msg: str, level: str = "INFO", **fields) -> None:
"""Append a structured one-line JSON event to the crash log.
`level` is one of INFO / WARN / ERROR / CRITICAL. Used so a customer's
diagnostic dump can be `grep '"level":"ERROR"' tern-crash.log` instead
of trying to infer severity from the category string.
"""
try:
# Rotate BEFORE writing so the new write lands in a fresh
# (post-rotation) file rather than the one we're about to retire.
_rotate_log_if_needed()
entry = {
# `datetime.utcnow()` is deprecated in 3.12+ — emits a noisy
# DeprecationWarning on every log line if pytest / the bundle's
# interpreter happens to run on 3.13. The replace-trick keeps
# the on-wire string byte-identical to before
# (`2026-05-21T10:00:00.123456Z`) so existing log readers /
# grep patterns still match — only the call shape changed.
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"level": level,
"category": category,
"msg": msg,
**fields,
}
with _CRASH_LOG.open("a") as f:
# ensure_ascii=False mirrors the storage.py behaviour;
# without it, log entries containing
# non-ASCII content (a folder path like ~/Документы/Подкасты,
# a Whisper-transcribed Cyrillic / CJK error message, a HEIC
# filename with accents) get escaped to `До...`,
# making tern-debug.log unreadable for support threads that
# ARE the primary use case for this log (the
# `cancel_indexing_writes_log_event_with_snapshot` etc. tests
# all assume human-readable msg strings). The log file is
# opened in text mode → UTF-8 on macOS, so the canonical
# form lands on disk as-is.
f.write(json.dumps(entry, default=str, ensure_ascii=False) + "\n")
except Exception:
# never let logging take down the request
pass
def log_exception(category: str, exc: BaseException, **fields) -> None:
"""Like log_event but captures the full traceback. Always logs at ERROR."""
log_event(
category=category,
msg=str(exc),
level="ERROR",
exception_type=type(exc).__name__,
traceback=traceback.format_exc(),
**fields,
)
# ─── Python-logging → crash-log bridge ───────────────────────────────────
# service_pipeline modules use stdlib logging
# (`logger.exception("search_transcript failed …")` in search.py's
# per-channel try/excepts, plus warnings from ingest/storage). Stdlib
# logging defaults to stderr — which the bundled Tauri launcher
# discards (Stdio::null). So every "one search channel silently died"
# traceback, the exact diagnostic the per-channel try/except design
# exists to capture, was INVISIBLE in production. Dev mode (./run.sh,
# terminal attached) saw them; customer bundles didn't — and customer
# bundles are where support needs them.
#
# Bridge: a logging.Handler that forwards WARNING+ records through
# log_event, so they land as structured JSON in tern-crash.log next
# to every other durable event. INFO/DEBUG stay stderr-only (dev
# noise; uvicorn access logs would flood the crash log). Guarded
# against recursion (a log_event failure can't re-enter logging) by
# log_event's own blanket try/except.
class _CrashLogBridge(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
extra = {}
if record.exc_info:
extra["traceback"] = "".join(traceback.format_exception(*record.exc_info))
log_event(
category=f"pylog.{record.name}",
msg=record.getMessage(),
level="ERROR" if record.levelno >= logging.ERROR else "WARN",
**extra,
)
except Exception:
pass
logging.getLogger().addHandler(_CrashLogBridge(level=logging.WARNING))
# ─── ChromaDB defensive patch ────────────────────────────────────────────
# Known chromadb bug (rust.py:131): RustBindingsAPI.stop() does
# `del self.bindings`
# but `self.bindings` is only set after a successful `_create_database()` —
# so any partially-initialised instance crashes on shutdown with
# AttributeError: 'RustBindingsAPI' object has no attribute 'bindings'
# This trashes both interpreter teardown (poisoning the next test in
# pytest) AND the next PersistentClient(path=…) call, because chromadb
# tries to release the cached half-failed system from a previous attempt.
# Wrap stop() so half-failed instances tear down silently.
try:
from chromadb.api.rust import RustBindingsAPI as _RustBindings
_orig_stop = _RustBindings.stop
def _safe_stop(self) -> None:
try:
_orig_stop(self)
except AttributeError:
pass # already torn down or never initialised — ignore
_RustBindings.stop = _safe_stop # type: ignore[method-assign]
except Exception:
# chromadb internals can change between versions; if the patch site
# moves, fall through silently — production is unaffected.
pass
# ─── Lifecycle ───────────────────────────────────────────────────────────
def _rewrite_stale_workspace_paths(workspace: Path) -> None:
"""Rewrite absolute paths in files.path + keyframes.thumbnail_path
so they reference the current workspace instead of whatever path
the bundled DB was built with. See lifespan() for the full
trial-flow rationale.
Conservative algorithm — only rewrites a row when ALL of:
1. Existing path doesn't already start with the workspace.
2. Path contains the `/demo/` segment (bundled-demo signature).
3. The workspace-relative rewrite target ACTUALLY EXISTS on
disk. Without this check, a user-indexed folder that happens
to contain `/demo/` in its path (e.g.,
`/Users/buyer/Documents/demo_recordings/`) could be falsely
flagged and rewritten to a non-existent workspace location.
The "target must exist" gate makes false positives essentially
impossible — a buyer-named "demo" folder wouldn't have a
coincidentally-matching workspace-relative file.
Defensive: catches all exceptions and logs to stderr but never
raises — worst-case the buyer sees the old "Couldn't load Audio"
failure, NOT a refusal to start the sidecar.
"""
db_path = workspace / "db" / "tern.db"
if not db_path.exists():
return # fresh workspace, no DB to rewrite
workspace_str = str(workspace)
marker = "/demo/"
try:
import sqlite3 as _sqlite3
conn = _sqlite3.connect(str(db_path))
try:
file_rows = conn.execute("SELECT id, path FROM files").fetchall()
file_updates: list[tuple[str, int]] = []
for row_id, path in file_rows:
if not path:
continue
if path.startswith(workspace_str + "/"):
continue # already correctly rooted
idx = path.find(marker)
if idx < 0:
continue # not a bundled-demo path
relative = path[idx + len(marker):]
target = workspace / relative
if not target.exists():
continue # safety gate — refuse to rewrite to a phantom path
file_updates.append((str(target), row_id))
kf_rows = conn.execute(
"SELECT id, thumbnail_path FROM keyframes WHERE thumbnail_path IS NOT NULL"
).fetchall()
kf_updates: list[tuple[str, int]] = []
for kf_id, kf_path in kf_rows:
if not kf_path or kf_path.startswith(workspace_str + "/"):
continue
idx = kf_path.find(marker)
if idx < 0:
continue
relative = kf_path[idx + len(marker):]
target = workspace / relative
if not target.exists():
continue
kf_updates.append((str(target), kf_id))
if not file_updates and not kf_updates:
return # nothing stale; idempotent re-launch path
conn.executemany(
"UPDATE files SET path = ? WHERE id = ?", file_updates,
)
conn.executemany(
"UPDATE keyframes SET thumbnail_path = ? WHERE id = ?", kf_updates,
)
conn.commit()
# Both print + log_event: print is visible in dev (run.sh
# captures stdout) AND survives stderr=Stdio::null in the
# Tauri bundle for free. log_event lands in tern-crash.log
# so /api/diagnostics' log_tail surfaces the rewrite in
# any support dump — important because a buyer who reports
# "Tern won't play my files after install" needs us to know
# whether the rewrite RAN (and how many rows it touched)
# to distinguish the path-rewrite-broken case from a
# different bug.
print(f" Demo-seed path rewrite: {len(file_updates)} files, "
f"{len(kf_updates)} keyframes re-rooted under {workspace_str}")
log_event(
"demo_path_rewrite",
f"{len(file_updates)} files, {len(kf_updates)} keyframes re-rooted under {workspace_str}",
level="INFO",
files=len(file_updates),
keyframes=len(kf_updates),
workspace=workspace_str,
)
finally:
conn.close()
except Exception as e:
# Tauri's bundled launcher sets the sidecar's stdout + stderr to
# Stdio::null (see tauri/src-tauri/src/main.rs), so a bare print
# to stderr on a real buyer install lands in /dev/null with no
# trail in tern-debug.log AND nothing for /api/diagnostics to
# surface. The buyer reports "Tern can't play my audio after
# install," support asks for diagnostics, the dump shows the
# workspace is set up correctly but the rewrite phase is
# invisible — looks like a different bug entirely. log_event
# writes to tern-crash.log directly so the failure traceback
# appears in /api/diagnostics' log_tail (and the "Copy
# diagnostics for support…" button picks it up). Keep the
# stderr print for dev-tree visibility — harmless duplicate in
# bundle (discarded by Stdio::null).
print(f" ⚠ demo-seed path rewrite failed: {e!r}", file=sys.stderr)
log_event(
"demo_path_rewrite_failed",
f"{type(e).__name__}: {e}",
level="ERROR",
exc=type(e).__name__,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
print(f"🦅 Tern API starting")
print(f" Workspace: {WORKSPACE_PATH}")
print(f" App dir: {APP_DIR}")
print(f" Pipeline: {PIPELINE_DIR}")
WORKSPACE_PATH.mkdir(parents=True, exist_ok=True)
(WORKSPACE_PATH / "db").mkdir(parents=True, exist_ok=True)
# CRITICAL trial-flow fix: the bundled demo workspace ships with
# absolute paths from the BUILD machine (the dev box where
# prepare_bundle.sh ran). When a buyer downloads the .app, Tauri
# seeds the workspace at ~/Library/Application Support/Tern/
# workspace/ — but the bundled tern.db still references the
# build-machine paths inside files.path and keyframes.thumbnail_
# path. A search would find hits via the FTS5 + Chroma tables
# (those use file_id, not path), the frontend would try to play
# the audio via /api/file?path=/Users/<build_user>/.../ep03.mp3,
# the path wouldn't exist on the buyer's Mac, and the audio
# element would surface "Couldn't load Audio." That single
# moment makes the bundled demo look broken on first launch.
#
# Detect + rewrite on every sidecar startup. Cheap (one SELECT,
# one UPDATE per stale row at most), idempotent (second run is
# a no-op since paths now match the workspace), and self-
# healing if a buyer ever moves their workspace dir.
_rewrite_stale_workspace_paths(WORKSPACE_PATH)
# Lazy-init the search engine on first request (loads SigLIP, takes ~2s)
app.state.engine = None
app.state.store = None
app.state.embedder = None
app.state.config = None
app.state.indexing = {
"running": False,
"paused": False,
# Initialized False so /api/index/status returns a stable shape
# whether or not an index has ever started in this session.
# Previously, the field was only inserted by /api/index
# when starting a run, so a fresh-launch poll returned a dict
# WITHOUT cancel_requested. The frontend used `?? false` to
# paper over the absence, but any test pinning the
# /api/index/status response shape (or a future client that
# doesn't use the `?? false` idiom) would trip on the dormant
# case. Stable contract beats defensive nullish-coalescing.
"cancel_requested": False,
"current_file": None,
"files_pending": 0,
"files_done": 0,
"files_errored": 0,
# Per-run count of already-up-to-date files (indexer.index_file
# returns False when the file's mtime matches the stored row and
# force=False). Without this counter, re-indexing a folder where
# most files were already done made the progress bar stall under
# 100% (pct = done / pending; skipped files counted in pending
# but not done) and the "Indexed N files" toast under-reported
# the actual work — confusing for any user re-running indexing
# after adding a few new files to an existing folder.
"files_skipped": 0,
"start_time": None,
"log": [],
# Sub-file pipeline stage published by Indexer.stage_cb. Lets the
# toast tell the user WHAT Tern is doing inside a long-running file
# (Whisper alone is 30+ s/file; "current_file: ep03.mp3" alone tells
# them nothing). Values: probe / transcribe / keyframes / embed /
# ocr / done; label is human-readable copy from ingest.py.
"stage": None,
"stage_label": None,
"stage_started_at": None,
# Sub-stage progress 0-100 published by Whisper's --print-progress
# parser (audio.py). null when no progress signal yet (e.g., probe/
# embed/ocr stages don't emit percent — only transcribe does).
"stage_progress": None,
}
# Pre-warm SigLIP + ChromaDB in the background so the FIRST /api/search is
# ~50 ms (warm-path) instead of ~5 s (cold-path SigLIP weights load + first
# forward pass). The Tauri sidecar reports "backend ready" the moment the
# listener binds, but without this thread the user could type a query and
# stare at a frozen results pane for 5 s. Daemon thread so it dies cleanly
# with the parent on shutdown.
import threading
def _warmup():
try:
get_engine(app)
print("🔥 search engine warm — first /api/search will be instant")
except Exception as e:
log_exception("warmup_failed", e)
print(f" (warmup failed: {e}; will lazy-load on first search)")
threading.Thread(target=_warmup, daemon=True, name="engine-warmup").start()
yield
print("🦅 Tern API stopping")
# Defensive teardown of ChromaDB. There's a known chromadb bug where if
# the System fails partway through init (warmup raced a parallel start,
# workspace path didn't exist yet, etc.) the `bindings` attribute never
# gets set — then component.stop() crashes with
# `AttributeError: 'RustBindingsAPI' object has no attribute 'bindings'`
# during interpreter teardown. In pytest this surfaces as the LAST test
# in the module failing with no useful trace. Swallow it here.
store = getattr(app.state, "store", None)
if store is not None and getattr(store, "chroma", None) is not None:
try:
# PersistentClient has _system; ask it to stop cleanly.
sys = getattr(store.chroma, "_system", None)
if sys is not None:
try:
sys.stop()
except Exception:
pass # chromadb's own teardown bug — ignore
except Exception:
pass
app = FastAPI(
title="Tern API",
description="Local AI search for podcast & video archives",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
# SECURITY: allow_origins must never include "*". With the no-auth
# loopback model, a wildcard lets any website the user visits in
# Safari/Chrome make cross-origin requests to 127.0.0.1:18765 and
# exfiltrate the full search index, file paths, and diagnostics. The
# path-traversal allowlist doesn't help
# against /api/search / /api/files / /api/stats which are by-design
# "return everything in the index" reads.
#
# Instead a regex covers only loopback. Any localhost
# or 127.0.0.1 port matches (the Rust shell auto-finds a free port from
# 18765 upward, so a fixed origin list would be brittle).
# Plus the tauri:// scheme used by the asset protocol (currently
# unused since we load via http://127.0.0.1, but harmless to grant).
# Public origins fail CORS preflight — browser-loaded malicious pages
# can no longer call our API.
allow_origin_regex=r"^(https?://(127\.0\.0\.1|localhost)(:\d+)?|tauri://localhost)$",
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Host-header guard (DNS rebinding) ───────────────────────────────────
# CORS only governs cross-origin reads. A DNS-rebinding page first resolves
# its own name to a public address, then re-points it at 127.0.0.1: the
# browser now treats the loopback API as SAME-origin with that page, so no
# CORS check ever fires, yet every request still carries the attacker's name
# in its Host header. Accepting only loopback names on the port this server
# is actually listening on closes that path.
#
# The desktop shell navigates its webview to http://127.0.0.1:<port>/ and the
# frontend uses relative URLs, so its Host is always 127.0.0.1:<port>.
# Browsers pointed at localhost (./run.sh, dev servers) use the other names.
_LOOPBACK_HOSTNAMES = frozenset({"127.0.0.1", "localhost", "[::1]"})
def _host_header_allowed(host_header: str | None, server_port: int | None) -> bool:
"""True when `Host` is a loopback name on the port this server listens on.
`server_port` comes from the ASGI scope (the socket the request arrived
on), not from configuration, so it stays right for any auto-picked port.
A Host without a port means the scheme default (80) and only matches a
server actually listening there. `None` (unix socket) skips the port
comparison and checks the name alone.
"""
if not host_header:
return False
host = host_header.strip().lower()
if host.startswith("["):
# IPv6 literal: "[::1]" or "[::1]:18765".
end = host.find("]")
if end == -1:
return False
name, rest = host[: end + 1], host[end + 1:]
if rest and not rest.startswith(":"):
return False
port_text = rest[1:]
else:
name, _, port_text = host.partition(":")
if name not in _LOOPBACK_HOSTNAMES:
return False
if port_text and not (port_text.isascii() and port_text.isdigit()):
return False
if server_port is None:
return True
return (int(port_text) if port_text else 80) == server_port
class _LoopbackHostGuard:
"""Pure-ASGI middleware: reject requests whose Host is not loopback.
Written by hand rather than with Starlette's TrustedHostMiddleware
because that one matches host names only and cannot bind the check to
the port the server is listening on (which is chosen at launch).
"""
def __init__(self, inner):
self.inner = inner
async def __call__(self, scope, receive, send):
if scope["type"] in ("http", "websocket"):
hosts = [v for k, v in scope["headers"] if k == b"host"]
server = scope.get("server")
port = server[1] if server else None
ok = len(hosts) == 1 and _host_header_allowed(
hosts[0].decode("latin-1"), port
)
if not ok:
if scope["type"] == "http":
resp = JSONResponse({"detail": "Invalid Host header"}, status_code=400)
await resp(scope, receive, send)
else:
await send({"type": "websocket.close", "code": 1008})
return
await self.inner(scope, receive, send)
# Added after CORSMiddleware so it is the outermost layer: a request with a
# foreign Host is refused before CORS (or any route) sees it.
app.add_middleware(_LoopbackHostGuard)
# Cap the in-memory indexing log. app.state.indexing["log"] is a plain
# list that grew unbounded — one append per indexed file + cancel/stage
# events. A 10k-file indexing run accumulates ~10k strings (500 KB-1 MB
# in RAM) that persist for the lifetime of the process; /api/index/status
# and /api/diagnostics already slice to the last 30 lines, so older
# entries serve no purpose. Trim in-place on append (cheap; del slice is
# C-impl) instead of switching to deque so the existing dict[**spread]
# JSON serialisation in /api/index/status keeps working without converters.
_INDEXING_LOG_MAX = 200
def _indexing_log_append(msg: str) -> None:
log = app.state.indexing["log"]
log.append(msg)
if len(log) > _INDEXING_LOG_MAX:
del log[: len(log) - _INDEXING_LOG_MAX]
# Global exception handler — log + re-raise. We do this rather than a
# pure middleware so HTTPException(404 etc) doesn't pollute the crash log.
@app.exception_handler(Exception)
async def _log_unhandled(request: Request, exc: Exception):
log_exception("api.unhandled", exc, path=str(request.url.path), method=request.method)
return JSONResponse(
status_code=500,
content={"detail": f"Internal error: {type(exc).__name__}", "type": "internal_error"},
)
# Diagnostics endpoint — power-user "Send diagnostics" button reads this.
_HOME_STR = str(Path.home())
def _redact_home(value):
"""Replace the user's literal home dir with `~` in any string. Returns
non-strings unchanged. Used for /api/diagnostics — the endpoint is
GET, unauthenticated, loopback — and customers regularly screencap
its response to send to support via screenshots, public Slack, or
GitHub issues. Without redaction, that exposes the Mac username to
anyone who sees the screenshot. The product's whole selling point
is "100% local · your audio never leaves this Mac"; leaking the
username through a diagnostics dump is a small but contradictory
privacy paper-cut. We rewrite recursively into list / dict shapes
so nested log entries get scrubbed too."""
if isinstance(value, str):
return value.replace(_HOME_STR, "~")
if isinstance(value, list):
return [_redact_home(v) for v in value]
if isinstance(value, dict):
return {k: _redact_home(v) for k, v in value.items()}
return value
def _tail_lines(path: Path, n: int = 100, max_read_bytes: int = 1_048_576) -> list[str]:
"""Return the last `n` lines from `path` without loading the whole file.
The previous /api/diagnostics implementation did `f.readlines()[-100:]`,
which allocates ALL lines of the (up-to-10 MB) crash log just to slice
off the tail. For a typical session with ~500 KB of accumulated log
entries, that's >1500 string allocations per "Send diagnostics" click,
dropped on the floor immediately after. Worse on the customer machine
that's been running Tern for weeks and is bumping the rotation cap.
Strategy: seek to (size - 64 KB), read forward, split, drop the partial
first line (we landed mid-line unless we already covered the whole
file), keep the last `n`. Double the window up to `max_read_bytes` if
we don't have enough lines yet (pathological stack-trace entries can
span KB each — the log_exception path embeds traceback.format_exc()).
Cap at 1 MiB so a 10 MB log with 50 KB-per-line entries doesn't
regress to the readlines() path we're trying to avoid.
Returns lines with trailing newlines preserved (matches readlines()
behaviour so the JSON wire shape stays byte-identical for callers
that have been grepping the field).
"""
try:
size = path.stat().st_size
except Exception:
return []
if size == 0:
return []
read_n = min(65_536, size)
try:
with path.open("rb") as f:
while True:
f.seek(max(0, size - read_n))
buf = f.read(read_n)
# JSONL is one entry per line. Plain ASCII for the framing
# (datetime + JSON delimiters); utf-8 with replace is safe
# for the embedded message bodies.
lines = buf.decode("utf-8", errors="replace").splitlines(keepends=True)
# If we started reading mid-file, the first "line" is
# almost certainly the tail of an earlier entry — drop it.
if read_n < size and lines:
lines = lines[1:]
if len(lines) >= n or read_n >= size or read_n >= max_read_bytes:
return lines[-n:]
read_n = min(read_n * 2, max_read_bytes, size)
except Exception:
return []
@app.get("/api/diagnostics")
async def diagnostics():
import asyncio
log_path = _CRASH_LOG
size = log_path.stat().st_size if log_path.exists() else 0
# Sync file IO offloaded to the thread pool so a 10 MB-log read
# (worst case: ~30 ms on warm cache, 200ms+ cold) doesn't block
# /api/health, /api/stats, the indexing-status poll while the
# user is clicking "Send diagnostics."
tail = await asyncio.to_thread(_tail_lines, log_path, 100) if log_path.exists() else []
# Cap indexing.log to the last 30 entries (matches what /api/index/status
# returns) — without this, a long-running indexing session bloats the
# diagnostics payload to many MB, slow for the "Send diagnostics" flow
# and risky if log lines ever capture sensitive paths.
indexing_snapshot = None
if hasattr(app.state, "indexing") and app.state.indexing is not None:
indexing_snapshot = {
**app.state.indexing,
"log": (app.state.indexing.get("log") or [])[-30:],
}
# Inline a workspace stats summary so support tickets that include
# /api/diagnostics output see what's indexed in one payload instead
# of needing a separate /api/stats round-trip. The most common
# support-thread questions are "how many files did you index?" and
# "how does Tern's idea of your library size compare to what's on
# disk?" — both answerable from this snapshot without another
# back-and-forth. Wrapped in try/except so a transient store
# failure (DB lock, mid-startup) doesn't break diagnostics — the
# whole point of this endpoint is to surface state when other
# things are misbehaving. On failure we return stats=None, and
# the rest of the diagnostics payload is still useful.
stats_snapshot = None
try:
store = get_store(app)
s = await asyncio.to_thread(store.stats)
stats_snapshot = {
"files_total": s.get("files_total", 0),
"files_done": s.get("files_done", 0),
"files_video": s.get("files_video", 0),
"files_audio": s.get("files_audio", 0),
"files_image": s.get("files_image", 0),
"total_duration_ms": s.get("total_duration_ms", 0),
"total_duration_hours": round(s.get("total_duration_ms", 0) / 3_600_000, 2),
"transcript_segments": s.get("transcript_segments", 0),
"ocr_segments": s.get("ocr_segments", 0),
"keyframes": s.get("keyframes", 0),
}
except Exception as e:
log_exception("diagnostics_stats_failed", e)
return _redact_home({
"version": _APP_VERSION,
"log_path": str(log_path),
"log_size_bytes": size,
"log_tail": tail,
"workspace": str(WORKSPACE_PATH),
"indexing": indexing_snapshot,
"stats": stats_snapshot,
})
# ─── License key validation ───────────────────────────────────────
# Wires the desktop app to the license server. Storage is a tiny JSON file
# in the user's app-support dir so the activation survives app re-installs
# and workspace changes.
def _state_dir() -> Path:
"""Where per-machine state (licence, trial counter) lives.
Resolved on every call rather than captured at import, so tests can
redirect it with TERN_STATE_DIR. Without that, the activation test
writes a licence into the developer's real Application Support folder
and every later run of the suite believes the machine is licensed.
"""
override = os.environ.get("TERN_STATE_DIR")
if override:
return Path(override)
return Path.home() / "Library" / "Application Support" / "Tern"
def _trial_file() -> Path:
return _state_dir() / "trial.json"
# Kept as a module-level name because existing tests monkeypatch it
# directly (see test_security_and_polish's license-write tests). New state
# should go through _state_dir() instead.
_LICENSE_FILE = _state_dir() / "license.json"
# The full URL of the validate endpoint, not a prefix. It is deliberately
# not baked into the source: a build points it at its own deployment of
# license_server/ (see license_server/README.md). Unset means activation
# answers with a clear "not configured" message instead of guessing a host.
_LICENSE_DEFAULT_SERVER = os.environ.get("TERN_LICENSE_SERVER", "").strip()
# Further endpoints an activation request may name in `server_url` (for
# example a customer's self-hosted deployment): comma-separated full URLs in
# TERN_LICENSE_ALLOWED_SERVERS. Together with the default above this is the
# whole allowlist; any other server_url is refused, so the API cannot be
# pointed at an arbitrary host by whatever can reach the loopback port.
def _parse_server_list(raw: str) -> tuple[str, ...]:
return tuple(u.strip() for u in raw.split(",") if u.strip())
_LICENSE_EXTRA_SERVERS = _parse_server_list(os.environ.get("TERN_LICENSE_ALLOWED_SERVERS", ""))
def _endpoint_key(url: str) -> tuple:
"""Comparable form of an endpoint URL: scheme and host are
case-insensitive, a trailing slash on the path is not significant."""
from urllib.parse import urlsplit
p = urlsplit(url.strip())
return (p.scheme.lower(), p.netloc.lower(), p.path.rstrip("/"), p.query)
def _license_server_allowed(url: str) -> bool:
"""True when `url` is the configured licence server or on the allowlist."""
allowed = [_LICENSE_DEFAULT_SERVER, *_LICENSE_EXTRA_SERVERS]
return _endpoint_key(url) in {_endpoint_key(u) for u in allowed if u}
def _is_licensed(cache: dict | None = None) -> bool:
"""True when this copy holds a licence the server has accepted."""
c = _load_license_cache() if cache is None else cache
return bool(c.get("license_key")) and bool(c.get("is_valid"))
def _load_trial() -> dict:
return licensing.load_trial(_trial_file())
def _current_trial_state() -> dict:
return licensing.trial_state(_load_trial(), licensed=_is_licensed())
def _quota_admit(file_path: Path) -> tuple[bool, str | None, int]:
"""Decide whether the trial can afford this file, before any work.
Returns (allowed, reason, duration_ms). The duration comes back so the
caller can charge exactly what it admitted rather than probing twice.
Fails open on a probe error: a corrupt file is an indexing problem, and
reporting it to the user as a billing problem would send them hunting
for a licence bug that isn't there. index_file will surface the real
failure a moment later.
"""
if _is_licensed():
return True, None, 0
if file_path.suffix.lower() in SUPPORTED_IMAGE:
return True, None, 0 # no timeline, nothing to meter, no ffprobe
try:
duration_ms = int(probe_media(file_path).get("duration_ms") or 0)
except Exception:
return True, None, 0
allowed, reason = licensing.check_quota(
_load_trial(), licensed=False, duration_ms=duration_ms
)
return allowed, reason, duration_ms
def _charge_trial(duration_ms: int) -> None:
"""Spend quota. Never raises: a state-file write failure must not take
down an indexing run the user has already been allowed to start."""
if _is_licensed() or not duration_ms or duration_ms <= 0:
return
try:
licensing.save_trial(_trial_file(),
licensing.record_usage(_load_trial(), duration_ms))
except Exception as e:
log_exception("trial_write_failed", e)
def _load_license_cache() -> dict:
"""Read the cached license. Bare except → return {} previously
swallowed both 'file not found' (expected on first launch) AND
'JSON corrupt' (which the user MUST know about — silent fallback
to {} flipped their sidebar from Licensed to Trial with no log
entry to debug from). Split the two cases."""
if not _LICENSE_FILE.exists():
return {}
try:
raw = _LICENSE_FILE.read_text()
if not raw.strip():
# Empty file = mid-write crash from before the atomic rename
# below shipped. Treat as missing, log so the user knows.
log_event(
"license_cache_empty",
f"license cache exists but is empty (size=0); treating as missing",
level="WARN", path=str(_LICENSE_FILE),
)
return {}
return json.loads(raw)
except json.JSONDecodeError as e:
log_event(
"license_cache_corrupt",
f"license cache is not valid JSON: {e}; treating as missing",
level="ERROR", path=str(_LICENSE_FILE), error=str(e),
)
return {}
except Exception as e:
log_exception("license_cache_read_failed", e)
return {}
def _save_license_cache(data: dict) -> None:
"""Atomic write + restrictive permissions.
Previously: `write_text` truncated the file then re-wrote. If
the process crashed (signal, OOM, force-quit) between truncate and
final flush, the file would be left empty — silent license loss
next launch. Now: write to a temp sibling, fsync, then os.replace
onto the target. POSIX guarantees os.replace is atomic on the
same filesystem, so the file either contains the old contents OR
the new contents — never a half-written state.
Previously: default file mode 644 — readable by any local
process. License keys are credentials. Now chmod 600 (owner-read
only) immediately after rename. Effective even when the file
didn't exist before (atomic-write codepath creates it 600).
"""
try:
_LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
# ensure_ascii=False mirrors the storage.py + log_event behaviour.
# The cached license dict
# holds an `email` field (RFC 6531 internationalized addresses
# may contain non-ASCII chars) and a `message` field
# (license-server-provided, may be localized). Default
# ASCII-escape would render those as `\uXXXX` in the on-disk
# license.json — fine for round-trip but ugly when the user
# inspects the file directly (a real debugging pattern when
# a license activation goes sideways). The file is UTF-8
# native (written via fdopen "w" without encoding= argument
# → platform default UTF-8 on macOS).
payload = json.dumps(data, indent=2, ensure_ascii=False)
# Sibling temp file in the SAME directory — os.replace requires
# same-filesystem move, and tempfile.mkstemp(dir=...) gives us
# one in the right place. Suffix is .tmp so a crash leaves a
# debuggable artifact rather than a mystery file.
import os as _os
import tempfile as _tempfile
fd, tmp_path = _tempfile.mkstemp(
prefix=".license-", suffix=".tmp",
dir=str(_LICENSE_FILE.parent),
)
try:
with _os.fdopen(fd, "w") as f:
f.write(payload)
f.flush()
_os.fsync(f.fileno())
# Tighten perms BEFORE rename — once it lands at the target
# path another process can read it. chmod-after-rename has
# a TOCTOU window where 644 leaks the key.
_os.chmod(tmp_path, 0o600)
_os.replace(tmp_path, _LICENSE_FILE)
tmp_path = None # consumed by replace; don't clean up
finally:
# Defensive cleanup if anything before the replace raised.
if tmp_path and Path(tmp_path).exists():
try: Path(tmp_path).unlink()
except Exception: pass
except Exception as e:
log_exception("license_cache_write_failed", e)
def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
"""Write `text` to `path` atomically via tempfile + fsync + os.replace.
Same pattern as _save_license_cache but reusable for
any file the user re-opens elsewhere. The three export endpoints
(CSV / SRT / FCPXML) previously used the plain `path.write_text(...)`
sequence: truncate-then-rewrite. If the process is killed mid-write
(signal, OOM, force-quit, host sleep at the wrong moment), the file
is left truncated. The customer then imports a half-SRT into
DaVinci → wrong subtitles burned into a 45-min film; opens a
half-CSV → missing rows with zero error indication; loads a
half-FCPXML into Final Cut → "corrupt project" alert.
With this helper, POSIX guarantees the target file contains EITHER
the old contents OR the new contents — never a half-written state.
"""
import os as _os
import tempfile as _tempfile
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = _tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=str(path.parent),
)
try:
with _os.fdopen(fd, "w", encoding=encoding) as f:
f.write(text)
f.flush()
_os.fsync(f.fileno())