// Sauna user settings — see settings.h. #include "app/settings.h" #include "nlohmann/json.hpp" #include #include #include #include using nlohmann::json; namespace sauna { namespace { std::string settingsDir() { const char* appdata = getenv("APPDATA"); if (!appdata || !*appdata) return ""; return std::string(appdata) + "\\sauna"; } } // namespace std::string SettingsPath() { std::string dir = settingsDir(); return dir.empty() ? "" : dir + "\\config.json"; } bool LoadSettings(Settings* out, std::string* err) { *out = Settings{}; err->clear(); const std::string path = SettingsPath(); if (path.empty()) { *err = "APPDATA not set — settings not persisted"; return false; } std::ifstream f(path, std::ios::binary); if (!f.is_open()) return true; // first run: defaults std::stringstream ss; ss << f.rdbuf(); json j = json::parse(ss.str(), nullptr, /*allow_exceptions=*/false); if (j.is_discarded() || !j.is_object()) { *err = path + ": malformed JSON — running on defaults"; return false; } // Per-key defaults: an old or hand-trimmed file stays valid. out->screen_distance_m = j.value("screen_distance_m", out->screen_distance_m); out->screen_scale = j.value("screen_scale", out->screen_scale); out->screen_curved = j.value("screen_curved", out->screen_curved); out->supersample = j.value("supersample", out->supersample); out->predict_auto = j.value("predict_auto", out->predict_auto); out->predict_ms = j.value("predict_ms", out->predict_ms); out->neck_enabled = j.value("neck_enabled", out->neck_enabled); out->neck_forward_mm = j.value("neck_forward_mm", out->neck_forward_mm); out->neck_up_mm = j.value("neck_up_mm", out->neck_up_mm); out->ipd_mm = j.value("ipd_mm", out->ipd_mm); out->recenter_hotkey = j.value("recenter_hotkey", out->recenter_hotkey); out->brightness = j.value("brightness", out->brightness); out->idle_timeout_min = j.value("idle_timeout_min", out->idle_timeout_min); out->refresh_hz = j.value("refresh_hz", out->refresh_hz); out->cursor_overlay = j.value("cursor_overlay", out->cursor_overlay); out->wake_on_motion = j.value("wake_on_motion", out->wake_on_motion); if (j.contains("monitor_exclude") && j["monitor_exclude"].is_array()) { out->monitor_exclude.clear(); for (const auto& v : j["monitor_exclude"]) if (v.is_number_integer()) out->monitor_exclude.push_back(v.get()); } return true; } bool SaveSettings(const Settings& s, std::string* err) { err->clear(); const std::string dir = settingsDir(); const std::string path = SettingsPath(); if (path.empty()) { *err = "APPDATA not set — cannot save settings"; return false; } CreateDirectoryA(dir.c_str(), nullptr); // exists = fine json j; j["screen_distance_m"] = s.screen_distance_m; j["screen_scale"] = s.screen_scale; j["screen_curved"] = s.screen_curved; j["supersample"] = s.supersample; j["predict_auto"] = s.predict_auto; j["predict_ms"] = s.predict_ms; j["neck_enabled"] = s.neck_enabled; j["neck_forward_mm"] = s.neck_forward_mm; j["neck_up_mm"] = s.neck_up_mm; j["ipd_mm"] = s.ipd_mm; j["recenter_hotkey"] = s.recenter_hotkey; j["monitor_exclude"] = s.monitor_exclude; j["brightness"] = s.brightness; j["idle_timeout_min"] = s.idle_timeout_min; j["refresh_hz"] = s.refresh_hz; j["cursor_overlay"] = s.cursor_overlay; j["wake_on_motion"] = s.wake_on_motion; const std::string tmp = path + ".tmp"; { std::ofstream f(tmp, std::ios::binary | std::ios::trunc); if (!f.is_open()) { *err = tmp + ": cannot write"; return false; } f << j.dump(2) << "\n"; if (!f.good()) { *err = tmp + ": write failed"; return false; } } if (!MoveFileExA(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { *err = path + ": replace failed (err " + std::to_string(GetLastError()) + ")"; DeleteFileA(tmp.c_str()); return false; } return true; } std::string GyroBiasPath(const std::string& serial) { const std::string dir = settingsDir(); if (dir.empty() || serial.empty()) return ""; return dir + "\\calib\\" + serial + ".json"; } bool LoadGyroBias(const std::string& serial, double biasLsb[3]) { const std::string path = GyroBiasPath(serial); if (path.empty()) return false; std::ifstream f(path, std::ios::binary); if (!f.is_open()) return false; std::stringstream ss; ss << f.rdbuf(); json j = json::parse(ss.str(), nullptr, /*allow_exceptions=*/false); if (j.is_discarded() || !j.is_object()) return false; if (!j.contains("gyro_bias_lsb") || !j["gyro_bias_lsb"].is_array() || j["gyro_bias_lsb"].size() != 3) return false; for (int a = 0; a < 3; a++) { const auto& v = j["gyro_bias_lsb"][a]; if (!v.is_number()) return false; biasLsb[a] = v.get(); } return true; } bool SaveGyroBias(const std::string& serial, const double biasLsb[3], std::string* err) { err->clear(); const std::string path = GyroBiasPath(serial); if (path.empty()) { *err = "APPDATA unset or no serial — bias not persisted"; return false; } CreateDirectoryA(settingsDir().c_str(), nullptr); CreateDirectoryA((settingsDir() + "\\calib").c_str(), nullptr); json j; j["serial"] = serial; j["gyro_bias_lsb"] = {biasLsb[0], biasLsb[1], biasLsb[2]}; const std::string tmp = path + ".tmp"; { std::ofstream f(tmp, std::ios::binary | std::ios::trunc); if (!f.is_open()) { *err = tmp + ": cannot write"; return false; } f << j.dump(2) << "\n"; if (!f.good()) { *err = tmp + ": write failed"; return false; } } if (!MoveFileExA(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { *err = path + ": replace failed (err " + std::to_string(GetLastError()) + ")"; DeleteFileA(tmp.c_str()); return false; } return true; } uint64_t SettingsFileTime() { const std::string path = SettingsPath(); if (path.empty()) return 0; WIN32_FILE_ATTRIBUTE_DATA fad{}; if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &fad)) return 0; return ((uint64_t)fad.ftLastWriteTime.dwHighDateTime << 32) | fad.ftLastWriteTime.dwLowDateTime; } bool ParseHotkey(const std::string& spec, uint32_t* mods, uint32_t* vk) { uint32_t m = 0, key = 0; std::string tok; std::stringstream ss(spec); while (std::getline(ss, tok, '+')) { // trim + lowercase size_t a = tok.find_first_not_of(" \t"); size_t b = tok.find_last_not_of(" \t"); if (a == std::string::npos) return false; tok = tok.substr(a, b - a + 1); for (auto& c : tok) c = (char)tolower((unsigned char)c); if (tok == "ctrl" || tok == "control") m |= MOD_CONTROL; else if (tok == "alt") m |= MOD_ALT; else if (tok == "shift") m |= MOD_SHIFT; else if (tok == "win") m |= MOD_WIN; else { if (key) return false; // two non-modifier tokens if (tok.size() == 1 && isalnum((unsigned char)tok[0])) key = (uint32_t)toupper((unsigned char)tok[0]); // VK_A..Z, VK_0..9 else if (tok.size() >= 2 && tok[0] == 'f' && tok.find_first_not_of("0123456789", 1) == std::string::npos) { int n = atoi(tok.c_str() + 1); if (n < 1 || n > 24) return false; key = VK_F1 + (n - 1); } else if (tok == "home") key = VK_HOME; else if (tok == "end") key = VK_END; else if (tok == "insert") key = VK_INSERT; else if (tok == "delete") key = VK_DELETE; else if (tok == "pageup") key = VK_PRIOR; else if (tok == "pagedown") key = VK_NEXT; else if (tok == "space") key = VK_SPACE; else if (tok == "pause") key = VK_PAUSE; else return false; } } if (!key) return false; *mods = m; *vk = key; return true; } } // namespace sauna