Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ tests/*.exe
tests/*.obj
tests/test_config.json
tests/test_config_reload.json
tests/migrate_*.json
# Rotation-test output: the harness rewrites these on every run, and the
# .1/.2 suffixes fall outside the *.log rule above.
tests/rotation_test.log.*
Expand Down
94 changes: 92 additions & 2 deletions VerseLinkWindows/ConfigManager.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
#include "ConfigManager.h"
#include "Logger.h"
#include "StringExtensions.h"
#include <fstream>
#include <sstream>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <shlobj.h>

// Declared here rather than on the link line so this file stays self-contained:
// the test harness compiles it with a bare cl invocation that has no library
// list of its own. SHGetKnownFolderPath needs shell32, CoTaskMemFree ole32.
#pragma comment(lib, "shell32.lib")
#pragma comment(lib, "ole32.lib")

namespace {
// Config paths are UTF-8 like every other string here, but MSVC's narrow
// fstream constructors interpret char* paths in the ANSI codepage. Going
// through a wide std::filesystem::path keeps a user whose profile name is
// not representable in that codepage working.
std::filesystem::path AsPath(const std::string& utf8Path) {
return std::filesystem::path(StringExtensions::Utf8ToWide(utf8Path));
}

std::string ExecutableDirectory() {
wchar_t buffer[MAX_PATH] = {};
const DWORD length = GetModuleFileNameW(nullptr, buffer, MAX_PATH);
if (length == 0 || length >= MAX_PATH) return "";

std::wstring path(buffer, length);
const size_t slash = path.find_last_of(L"\\/");
if (slash == std::wstring::npos) return "";
return StringExtensions::WideToUtf8(path.substr(0, slash));
}
}

// Static member definitions
std::unique_ptr<ConfigManager> ConfigManager::instance = nullptr;
Expand Down Expand Up @@ -32,6 +61,67 @@ void ConfigManager::setDefaults() {
config = VerseLinkConfig(); // Use default values from struct
}

std::string ConfigManager::userConfigPath() {
PWSTR roaming = nullptr;
if (FAILED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &roaming)) || !roaming) {
if (roaming) CoTaskMemFree(roaming);
return "";
}

std::filesystem::path directory(roaming);
CoTaskMemFree(roaming);
directory /= L"VerseLink";

std::error_code ec;
std::filesystem::create_directories(directory, ec);
if (ec) {
return "";
}

return StringExtensions::WideToUtf8((directory / L"config.json").wstring());
}

std::vector<std::string> ConfigManager::legacyConfigPaths() {
std::vector<std::string> candidates;

// Beside the exe first: that is where an unzipped copy kept its settings.
const std::string exeDir = ExecutableDirectory();
if (!exeDir.empty()) {
candidates.push_back(exeDir + "\\config.json");
}

// Then the working directory, which is what the app used to load from.
candidates.push_back("config.json");
return candidates;
}

std::string ConfigManager::migrateLegacyConfig(const std::string& targetPath,
const std::vector<std::string>& candidates) {
if (targetPath.empty()) return "";

std::error_code ec;
const std::filesystem::path target = AsPath(targetPath);
if (std::filesystem::exists(target, ec)) {
return ""; // never overwrite settings that are already in place
}

for (const auto& candidate : candidates) {
if (candidate.empty()) continue;

const std::filesystem::path source = AsPath(candidate);
if (std::filesystem::equivalent(source, target, ec)) continue;
if (!std::filesystem::is_regular_file(source, ec)) continue;

std::filesystem::copy_file(source, target,
std::filesystem::copy_options::overwrite_existing, ec);
if (!ec) {
return candidate;
}
}

return "";
}

std::string ConfigManager::expandPath(const std::string& path) {
// For now, just return the path as-is
// Could be expanded to handle environment variables, relative paths, etc.
Expand Down Expand Up @@ -114,7 +204,7 @@ bool ConfigManager::load() {
return false;
}

std::ifstream file(configFilePath);
std::ifstream file(AsPath(configFilePath));
if (!file.is_open()) {
LOG_WARNING("Config file not found: " + configFilePath + ", creating with defaults");
save(); // Create default config file
Expand Down Expand Up @@ -196,7 +286,7 @@ bool ConfigManager::save() {
}
const VerseLinkConfig& config = snapshot;

std::ofstream file(configFilePath);
std::ofstream file(AsPath(configFilePath));
if (!file.is_open()) {
LOG_ERROR("Failed to open config file for writing: " + configFilePath);
return false;
Expand Down
23 changes: 23 additions & 0 deletions VerseLinkWindows/ConfigManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <map>
#include <memory>
#include <mutex>
#include <vector>
#include <windows.h>

struct VerseLinkConfig {
Expand Down Expand Up @@ -78,6 +79,28 @@ class ConfigManager {
public:
static ConfigManager& getInstance();
static void initialize(const std::string& configFilePath = "config.json");

// Where an installed run keeps its settings: %APPDATA%\VerseLink\config.json,
// creating the directory if needed. Returns "" if the folder cannot be found.
//
// The install directory is not a safe place for this file. The app runs
// AsInvoker, so it cannot write under Program Files - saves would fail, or be
// silently redirected into VirtualStore where the app then reads a different
// file than the one on disk. A CWD-relative "config.json" is worse still:
// launched from a Start Menu shortcut the working directory is not the
// install folder, so settings would neither load nor save where expected.
static std::string userConfigPath();

// Config files an older version may have left beside the exe or in the
// working directory, in the order they should be preferred.
static std::vector<std::string> legacyConfigPaths();

// Copies the first candidate that exists to targetPath, but only when
// targetPath does not exist yet, so an upgrading user keeps their settings
// and a later run never overwrites them. Returns the path migrated from, or
// "" when nothing was migrated. Paths are UTF-8.
static std::string migrateLegacyConfig(const std::string& targetPath,
const std::vector<std::string>& candidates);

bool load();
bool save();
Expand Down
23 changes: 18 additions & 5 deletions VerseLinkWindows/SystemTray.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@
#include "Logger.h"
#include "SettingsDialog.h"
#include "StringExtensions.h"
#include "VerseLinkWindows.h" // DescribeHotkey
#include "VerseLinkWindows.h" // DescribeHotkey, ResolveLogPath
#include "Version.h"
#include <fstream>
#include <sstream>
#include <commctrl.h>

#pragma comment(lib, "comctl32.lib")

namespace {
// The icon compiled into the exe, falling back to the generic application
// icon if the resource is missing. Before VerseLink.rc existed the exe had
// no icon at all, so the tray showed the default executable glyph.
HICON DefaultAppIcon() {
if (HICON icon = LoadIconW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(IDI_VERSELINK))) {
return icon;
}
return LoadIcon(nullptr, IDI_APPLICATION);
}
}

SystemTray::SystemTray(HWND parentHwnd) : hwnd(parentHwnd), isVisible(false), hCustomIcon(nullptr) {
ZeroMemory(&nid, sizeof(NOTIFYICONDATA));
hPopupMenu = nullptr;
Expand All @@ -35,7 +48,7 @@ bool SystemTray::Initialize() {
nid.uID = 1;
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_TRAYICON;
nid.hIcon = hCustomIcon ? hCustomIcon : LoadIcon(nullptr, IDI_APPLICATION);
nid.hIcon = hCustomIcon ? hCustomIcon : DefaultAppIcon();
wcscpy_s(nid.szTip, sizeof(nid.szTip)/sizeof(WCHAR), L"VerseLink");

return true;
Expand Down Expand Up @@ -173,7 +186,7 @@ void SystemTray::ShowAboutDialog() {
// ASCII-only; literal Unicode here has been corrupted by a bad round-trip
// before, and CI now rejects non-ASCII bytes in sources.
const std::wstring aboutText =
L"VerseLink v1.0\n\n"
L"VerseLink " VERSELINK_VERSION_WIDE L"\n\n"
L"A Bible verse lookup and insertion tool.\n\n"
L"Features:\n"
L"\u2022 Hotkey-activated verse lookup (" + hotkey + L")\n"
Expand All @@ -192,7 +205,7 @@ void SystemTray::ShowLogDialog() {
// Read log file content
std::string logContent;
auto& config = ConfigManager::getInstance();
std::string logPath = config.getLogFilePath();
std::string logPath = ResolveLogPath(config.getLogFilePath());

std::ifstream logFile(logPath);
if (logFile.is_open()) {
Expand Down Expand Up @@ -308,7 +321,7 @@ void SystemTray::SetCustomIcon(const std::string& iconPath) {
}
} else {
// Fall back to default icon if custom failed
nid.hIcon = LoadIcon(nullptr, IDI_APPLICATION);
nid.hIcon = DefaultAppIcon();
if (isVisible) {
if (Shell_NotifyIcon(NIM_MODIFY, &nid)) {
LogMessage("Reverted to default system tray icon");
Expand Down
39 changes: 39 additions & 0 deletions VerseLinkWindows/VerseLink.rc
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#include <windows.h>
#include "Version.h"

// Embedded application icon. Without this the exe has no icon of its own, so
// Explorer, the taskbar and any installer-created shortcut fall back to the
// generic executable icon.
IDI_VERSELINK ICON "VerseLinkIcon.ico"

VS_VERSION_INFO VERSIONINFO
FILEVERSION VERSELINK_VERSION_MAJOR,VERSELINK_VERSION_MINOR,VERSELINK_VERSION_PATCH,0
PRODUCTVERSION VERSELINK_VERSION_MAJOR,VERSELINK_VERSION_MINOR,VERSELINK_VERSION_PATCH,0
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0" // U.S. English, Unicode
BEGIN
VALUE "CompanyName", "sethdtwigg"
VALUE "FileDescription", "VerseLink - Bible verse lookup and insertion"
VALUE "FileVersion", VERSELINK_VERSION_STRING
VALUE "InternalName", "VerseLinkWindows"
VALUE "OriginalFilename", "VerseLinkWindows.exe"
VALUE "ProductName", "VerseLink"
VALUE "ProductVersion", VERSELINK_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
Loading
Loading