Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion analysis/run_gaze_fusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def _analyse_video(video_path: Path, cam: _CameraInfo,
results: list[_AnalysedFrame] = []
frame_idx = 0
t_start = time.perf_counter()
progress_interval = max(1, total // 200)

while True:
ok, frame = cap.read()
Expand All @@ -221,7 +222,7 @@ def _analyse_video(video_path: Path, cam: _CameraInfo,
results.append(_AnalysedFrame(frame_id=frame_id, timestamp_ns=ts_ns, sample=sample))

frame_idx += 1
if frame_idx % 100 == 0:
if frame_idx % progress_interval == 0:
elapsed = time.perf_counter() - t_start
pct = frame_idx / max(total, 1) * 100
print(f" cam{cam.index} {pct:5.1f}% ({frame_idx}/{total}) {elapsed:.1f}s elapsed",
Expand Down
3 changes: 2 additions & 1 deletion analysis/run_pose3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ def _reconstruct_ticks(manifest: dict, cameras: dict, frames_by_camera: dict,

results: list[dict] = []
t_start = time.perf_counter()
progress_interval = max(1, total_ticks // 200)

for tick in range(0, total_ticks, max(1, args.skip)):
# Gather this tick's per-camera detections.
Expand Down Expand Up @@ -399,7 +400,7 @@ def _reconstruct_ticks(manifest: dict, cameras: dict, frames_by_camera: dict,
"people": people_entries,
})

if tick % 100 == 0:
if tick % progress_interval == 0:
elapsed = time.perf_counter() - t_start
print(f"[run_pose3d] tick {tick}/{total_ticks} "
f"({len(people_entries)} people) {elapsed:.1f}s elapsed", flush=True)
Expand Down
8 changes: 8 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ qt_add_executable(mosaic MACOSX_BUNDLE
utils/logger.cpp
utils/timestamp.hpp
utils/ring_buffer.hpp
utils/dpapi_crypt.hpp
utils/dpapi_crypt.cpp

# ── Core ──────────────────────────────────────────────────────────────
core/settings.hpp
Expand Down Expand Up @@ -225,6 +227,12 @@ target_link_libraries(mosaic PRIVATE
Qt6::Charts
)

if(WIN32)
# CryptProtectData/CryptUnprotectData (utils/dpapi_crypt.cpp) — protects
# the Hugging Face token at rest in settings.json.
target_link_libraries(mosaic PRIVATE Crypt32)
endif()

# ── Optional hardware subsystems ───────────────────────────────────────────

if(MOSAIC_HAVE_SERIAL)
Expand Down
47 changes: 41 additions & 6 deletions src/analysis/analysis_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,48 @@ void AnalysisManager::enqueue_or_launch(const QString& sessionPath, const QStrin
}

void AnalysisManager::stop() {
if (!d->process) { return; }
d->process->terminate();
if (!d->process->waitForFinished(3000)) {
d->process->kill();
// Looped, not a single pass: if the process actually finishes *during*
// waitForFinished() below, Qt delivers QProcess::finished() to
// on_process_finished() synchronously, re-entrantly, from inside that
// blocking call. on_process_finished() itself calls
// d->process->deleteLater()/d->process = nullptr — and, if a job was
// queued, immediately launches it too, replacing d->process with a
// brand-new QProcess for that queued job. A single-pass version of
// this function (tried first) only ever cleaned up the *original*
// process, silently leaving that reentrantly-launched queued job
// running unmanaged right as this object is destroyed — this loop
// keeps stopping whatever d->process now points to until there's
// nothing left, so "stop everything" actually means everything,
// including anything that got reentrantly started while stopping the
// job before it. Also confirmed via a fresh test harness (see
// tests/test_analysis_manager.cpp) that skipping this local-capture
// dance entirely crashes on a null d->process dereference in the
// single-job case — the loop preserves that original fix too.
while (d->process) {
QProcess* proc = d->process;
proc->terminate();
if (!proc->waitForFinished(3000)) {
proc->kill();
}

// Only clean up here if on_process_finished() didn't already do
// it re-entrantly above (in which case d->process now either
// points to a newly-launched queued job, handled by the next loop
// iteration, or is already null) — otherwise this would be a
// second deleteLater() on an object already scheduled for
// deletion.
if (d->process == proc) {
proc->deleteLater();
d->process = nullptr;
}
}
d->process->deleteLater();
d->process = nullptr;

// Anything still queued (e.g. the process above didn't finish
// reentrantly, so on_process_finished() never ran to drain the queue
// itself) is dropped rather than silently launched later — stop()
// means stop now, not "finish the current job and run the rest of the
// queue unattended."
d->queue.clear();
}

// ── Launch ─────────────────────────────────────────────────────────────────
Expand Down
5 changes: 3 additions & 2 deletions src/core/settings.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "core/settings.hpp"
#include "trigger/trigger_types.hpp"
#include "utils/dpapi_crypt.hpp"
#include "utils/logger.hpp"
#include <QDir>
#include <QFile>
Expand Down Expand Up @@ -356,13 +357,13 @@ std::optional<RecordSettings> RecordSettings::from_json(const QJsonObject& o) {

QJsonObject AnalysisSettings::to_json() const {
return {
{"hf_token", hfToken},
{"hf_token", dpapi_encrypt(hfToken)},
};
}

std::optional<AnalysisSettings> AnalysisSettings::from_json(const QJsonObject& o) {
AnalysisSettings s;
if (o.contains("hf_token")) s.hfToken = o["hf_token"].toString(s.hfToken);
if (o.contains("hf_token")) s.hfToken = dpapi_decrypt(o["hf_token"].toString());
return s;
}

Expand Down
14 changes: 11 additions & 3 deletions src/core/settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,17 @@ struct AnalysisSettings {
// Hugging Face access token for the gated pyannote speaker-diarization
// models (Analysis tab's Speaker Diarization plugin). Persisted here
// (rather than kept transient/re-entered per session) so the user
// doesn't have to re-paste it every run — the tradeoff being that it's
// stored in plaintext in this profile's settings.json, same as every
// other field in AppSettings.
// doesn't have to re-paste it every run. In memory this is always the
// plain token. On Windows builds it's DPAPI-encrypted at rest (see
// to_json()/from_json() and src/utils/dpapi_crypt.hpp) rather than
// stored in plaintext — a pre-existing plaintext value from before
// this protection existed still loads correctly and is silently
// upgraded on the next save. On non-Windows builds dpapi_crypt.cpp's
// functions are a plain pass-through (DPAPI is Windows-only), so the
// token remains plaintext on disk there, same as before this change.
// Note also that this value doesn't survive AdminPanelDialog's
// Export/Import Configuration feature across a different Windows
// account or machine — see that dialog's own handling.
QString hfToken;

[[nodiscard]] QJsonObject to_json() const;
Expand Down
35 changes: 32 additions & 3 deletions src/ui/auth/admin_panel_dialog.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "ui/auth/admin_panel_dialog.hpp"
#include "ui/anim_utils.hpp"
#include "utils/dpapi_crypt.hpp"
#include <QCheckBox>
#include <QDateTime>
#include <QDesktopServices>
Expand All @@ -10,6 +11,8 @@
#include <QGraphicsDropShadowEffect>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QJsonDocument>
#include <QJsonObject>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
Expand Down Expand Up @@ -654,10 +657,36 @@ void AdminPanelDialog::import_config() {
if (!QFile::copy(src, dst)) {
QMessageBox::critical(this, "Import failed",
"Could not write the settings file. Check file permissions.");
} else {
QMessageBox::information(this, "Imported",
QString("Configuration imported for @%1.").arg(d->detailUsername));
return;
}

// A Hugging Face diarization token (AnalysisSettings::hfToken) is
// DPAPI-encrypted at rest on Windows builds — tied to the Windows
// user account/machine that encrypted it (see settings.hpp's own doc
// comment on hfToken). This is a plain QFile::copy(), not a real
// AppSettings::from_json() round-trip, so an imported token that was
// encrypted elsewhere silently decrypts to empty (dpapi_decrypt()'s
// documented fail-closed behavior) with only a log warning — nothing
// in this dialog's own success message would otherwise say so. Detect
// that specific case here and mention it explicitly, rather than let
// the admin discover it later as "diarization stopped working" with
// no obvious cause.
QString note;
QFile importedFile(dst);
if (importedFile.open(QIODevice::ReadOnly)) {
const auto obj = QJsonDocument::fromJson(importedFile.readAll())
.object()["analysis"].toObject();
const QString storedToken = obj["hf_token"].toString();
if (!storedToken.isEmpty() && dpapi_decrypt(storedToken).isEmpty()) {
note = "\n\nNote: this file's Hugging Face diarization token was "
"encrypted on a different Windows account or machine and "
"could not be decrypted here — re-enter it in the "
"Diarization plugin if needed.";
}
}

QMessageBox::information(this, "Imported",
QString("Configuration imported for @%1.%2").arg(d->detailUsername, note));
}

void AdminPanelDialog::delete_profile() {
Expand Down
82 changes: 82 additions & 0 deletions src/utils/dpapi_crypt.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include "utils/dpapi_crypt.hpp"
#include "utils/logger.hpp"
#include <QByteArray>

#if defined(Q_OS_WIN)
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
# include <wincrypt.h>
#endif

namespace mosaic {

namespace {
const QLatin1String k_dpapi_marker("dpapi:v1:");
}

#if defined(Q_OS_WIN)

QString dpapi_encrypt(const QString& plaintext) {
if (plaintext.isEmpty()) { return {}; }

const QByteArray utf8 = plaintext.toUtf8();
DATA_BLOB in{};
in.cbData = static_cast<DWORD>(utf8.size());
in.pbData = reinterpret_cast<BYTE*>(const_cast<char*>(utf8.constData()));

DATA_BLOB out{};
const BOOL ok = CryptProtectData(&in, L"MOSAIC analysis token", nullptr, nullptr,
nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out);
if (!ok) {
log_warning("[dpapi_crypt] CryptProtectData failed — storing token unprotected.");
return plaintext;
}

const QByteArray cipher(reinterpret_cast<const char*>(out.pbData),
static_cast<int>(out.cbData));
LocalFree(out.pbData);

return k_dpapi_marker + QString::fromLatin1(cipher.toBase64());
}

QString dpapi_decrypt(const QString& stored) {
if (!stored.startsWith(k_dpapi_marker)) {
return stored; // legacy plaintext (or empty) — returned unchanged
}

const QByteArray cipher =
QByteArray::fromBase64(stored.mid(k_dpapi_marker.size()).toLatin1());
if (cipher.isEmpty()) {
log_warning("[dpapi_crypt] Stored token has the dpapi: marker but decodes to "
"empty ciphertext — treating as missing.");
return {};
}

DATA_BLOB in{};
in.cbData = static_cast<DWORD>(cipher.size());
in.pbData = reinterpret_cast<BYTE*>(const_cast<char*>(cipher.constData()));

DATA_BLOB out{};
const BOOL ok = CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr,
CRYPTPROTECT_UI_FORBIDDEN, &out);
if (!ok) {
log_warning("[dpapi_crypt] CryptUnprotectData failed (settings.json moved to a "
"different machine/user account, or the blob is corrupted) — token "
"cleared; re-enter it in the Diarization plugin's token field.");
return {};
}

const QString plaintext = QString::fromUtf8(reinterpret_cast<const char*>(out.pbData),
static_cast<int>(out.cbData));
LocalFree(out.pbData);
return plaintext;
}

#else // !Q_OS_WIN — DPAPI is Windows-only; pass through unprotected.

QString dpapi_encrypt(const QString& plaintext) { return plaintext; }
QString dpapi_decrypt(const QString& stored) { return stored; }

#endif

} // namespace mosaic
32 changes: 32 additions & 0 deletions src/utils/dpapi_crypt.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#pragma once
#include <QString>

namespace mosaic {

/// @brief Encrypts @p plaintext for storage in a JSON settings file, using
/// Windows DPAPI (CryptProtectData, current-user scope) on Windows builds.
///
/// Returns a `"dpapi:v1:<base64>"` marker string on success. Returns
/// @p plaintext unchanged (with a logged warning) if DPAPI itself fails, and
/// on non-Windows builds (a plain pass-through, since DPAPI is Windows-only)
/// — never silently drops the value, just leaves it unprotected rather than
/// lose it. An empty string returns an empty string (nothing to protect).
///
/// @see dpapi_decrypt
[[nodiscard]] QString dpapi_encrypt(const QString& plaintext);

/// @brief Reverses dpapi_encrypt().
///
/// A string that doesn't start with the `"dpapi:v1:"` marker is assumed to
/// be a pre-existing plaintext value from before this feature existed (or a
/// non-Windows build) and is returned unchanged — no migration step is
/// needed, since the very next save re-encrypts it via dpapi_encrypt().
///
/// If the marker IS present but decryption fails (e.g. the settings file
/// was copied to a different machine or user account, or the blob is
/// corrupted), returns an empty string with a logged warning — surfacing
/// the problem by making the field visibly empty rather than returning
/// silently wrong bytes.
[[nodiscard]] QString dpapi_decrypt(const QString& stored);

} // namespace mosaic
24 changes: 23 additions & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ find_package(GTest REQUIRED)
# (PBKDF2 password hashing) — lives in QtNetwork, not QtCore.
find_package(Qt6 REQUIRED COMPONENTS Core Network)

# Hermetic stand-in for the real Python interpreter, used only by
# test_analysis_manager.cpp (via AnalysisManager::set_python_path()) — see
# analysis_stub/main.cpp's own doc comment. Deliberately zero Qt
# dependency, built as a plain sibling executable in the same output
# directory as mosaic_tests itself.
add_executable(analysis_test_stub analysis_stub/main.cpp)

add_executable(mosaic_tests
test_main.cpp
test_ring_buffer.cpp
test_timestamp.cpp
test_sync_manifest.cpp
Expand All @@ -26,6 +34,8 @@ add_executable(mosaic_tests
test_realtime_metrics.cpp
test_speaker_palette.cpp
test_recording_access_control.cpp
test_dpapi_crypt.cpp
test_analysis_manager.cpp
"${CMAKE_SOURCE_DIR}/src/core/recording_access_control.cpp"
"${CMAKE_SOURCE_DIR}/src/ui/audio/speaker_palette.cpp"
"${CMAKE_SOURCE_DIR}/src/video/gige_action_command.cpp"
Expand All @@ -43,19 +53,31 @@ add_executable(mosaic_tests
"${CMAKE_SOURCE_DIR}/src/analysis/transcript_result.cpp"
"${CMAKE_SOURCE_DIR}/src/analysis/expression_result.cpp"
"${CMAKE_SOURCE_DIR}/src/analysis/realtime_metrics.cpp"
"${CMAKE_SOURCE_DIR}/src/analysis/analysis_manager.cpp"
"${CMAKE_SOURCE_DIR}/src/utils/logger.cpp"
"${CMAKE_SOURCE_DIR}/src/utils/dpapi_crypt.cpp"
"${CMAKE_SOURCE_DIR}/src/core/settings.cpp"
"${CMAKE_SOURCE_DIR}/src/auth/profile_manager.cpp"
# Add new test files here as subsystems grow.
)

# So `cmake --build . --target mosaic_tests` alone still produces the stub
# it needs at runtime, without requiring a separate "build everything" pass.
add_dependencies(mosaic_tests analysis_test_stub)

target_link_libraries(mosaic_tests PRIVATE
GTest::gtest_main
GTest::gtest # was GTest::gtest_main — test_main.cpp now provides main()
# itself, so QCoreApplication exists for QProcess's async
# signals (see test_analysis_manager.cpp's wait_for_signal()).
mosaic_compiler_options
Qt6::Core # needed for QString / QDateTime used in timestamp.hpp
Qt6::Network # needed for QPasswordDigestor used by profile_manager.cpp
)

if(WIN32)
target_link_libraries(mosaic_tests PRIVATE Crypt32) # utils/dpapi_crypt.cpp
endif()

target_include_directories(mosaic_tests PRIVATE
"${CMAKE_SOURCE_DIR}/src"
)
Expand Down
Loading
Loading