diff --git a/CMakeLists.txt b/CMakeLists.txt index f92b79b..02a99f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.1 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.8 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() @@ -36,7 +36,7 @@ if(MSVC) else() target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++) endif() -add_executable(KSwitcherOcrTests tests/ocr.cpp) +add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherOcrTests PRIVATE kscore) if(MSVC) set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") diff --git a/README.md b/README.md index 1b9eef9..798171d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ broker can load them even when the profile directory is inaccessible to it. Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to 40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot. In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window. -The editor starts with **Pointer (V)**: select objects, move them, and edit geometry, thickness and +The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and other properties using handles and controls. Clicking empty space clears selection; dragging empty space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them. The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing @@ -223,8 +223,24 @@ thumbnails and source labels aligned with the scaled fonts and spacing. stylesheet rules are not reproduced; preview text uses the list's colors for readability. - Consecutive screenshot copies are compared by image content, ignoring changing OLE transport metadata (including Snipping Tool's repeated clipboard publication). -- Windows OCR, entirely native and offline. Right-click an image in history and select - **Copy text from image**. Requires a Windows OCR language installed for the user profile. +- New images are recognized automatically in a separate background worker using native, offline + Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search, + without changing the image or clipboard. Completed results (including empty text) are reused; + pending work resumes after restart. Older images without OCR are processed once in the background, + starting with the newest entries. + Right-click an image and select **Copy text from image** to copy the saved text or explicitly + retry recognition. Requires a Windows OCR language installed for the user profile; automatic + failures are logged quietly. OCR sidecars count toward the history size limit and are removed + with their images. +- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains + the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package + separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable + path blank to detect a standard installation, or select `tesseract.exe`; its neighboring + `libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation** + validates the selected models. **Download Tesseract…** opens the Windows installer page in your + browser. No packages or models are downloaded automatically. + Tesseract processes screenshots locally in memory with a 30-second recognition deadline. + Changing engine, executable path or languages refreshes cached image text in the background. - History exclusion flags and excluded processes, pause/resume, count/size limits. - Per-user Windows DPAPI encryption for both history payloads and the preview index. - Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index e4c8370..2acac84 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 38632c1..54482aa 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,30 +1,30 @@ # Latest Windows release -## v0.6.7 — 2026-10-29 +## v0.6.8 — 2026-10-29 ## Changes -- Show a distinct cursor for each screenshot editor tool: pointer, text caret, pencil, and crosshairs with symbols for numbers, arrows, straight lines, rectangles, circles, ellipses and highlights. -- Preserve action cursor priority for moving, resizing, changing thickness and rotating objects. -- Scale custom cursors with display DPI and add contrasting outlines for light and dark backgrounds. Tool changes refresh the cursor immediately. +- Recognize clipboard images automatically in a background worker, including older images without OCR. Save recognized text encrypted alongside the original image and search it without rerunning OCR. +- Keep clipboard capture, previews and OCR from delaying history search. Skip unnecessary image-payload reads; ignore superseded search results. +- Add optional Tesseract 5 support in Settings → OCR options, with automatic installation detection, executable selection, combined languages such as ukr+eng+rus, installation validation and a Download Tesseract button linking to the Windows installer page. Windows OCR remains the default; no downloads are automatic. +- Refresh cached image text when the OCR engine or languages change. Reuse OCR for Copy text from image; preserve the clipboard during automatic recognition. +- Start the screenshot editor with Pencil / Freehand (F). Pointer remains available with V. ## Validation -176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. Cursor coverage verifies tool selection, action overrides, hotspots and masks at 32/48/64/96 pixels. Cursor previews were visually checked on light and dark backgrounds. +203 native checks, 445 input/layout checks, screenshot editor UI integration (including the initial pencil cursor), isolated notification registration, and 45 updater checks. Native Windows OCR and optional Tesseract tests verify background recognition, encrypted persistence, unchanged clipboard, mixed Ukrainian/English text, and engine-cache migration. OCR settings were also checked in the running configurator. -**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the -v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. -For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. -Keep personal `data/` and customized dictionaries. +**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. +For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. -Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains the resident, -configurator and screenshot executables, a release manifest, offline dictionaries, documentation -and license notices; no personal settings or clipboard history. +Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models. + +Documentation: [English](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard) · [Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard-uk). Build and package: ```powershell -.\build.ps1 -Stage -Test +.\build.ps1 -Test -Ocr +.\build\KSwitcherOcrTests.exe --tesseract .\package.ps1 -PublishRelease ``` -Commit the archive, checksum and sources together. Publish both assets on a stable -`release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag. +Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 942db35..7663e7b 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -3c5fd91f163e54440fb144b5ef7c301695eb9d8df36dea6f807521b9c171a489 KSwitcher-win-x64.zip +31c2c9923ff0f6d71eb421bcea890409cedf7a48999ebc513d0adedf5a2c9839 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index cf1036e..7f6667f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -69,7 +69,7 @@ try { if ($LASTEXITCODE) { throw 'Integration tests failed' } } if ($Ocr) { - & $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe + & $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe if ($LASTEXITCODE) { throw 'OCR test build failed' } & .\build\KSwitcherOcrTests.exe if ($LASTEXITCODE) { throw 'OCR tests failed' } diff --git a/resources/app.rc b/resources/app.rc index 2fbd3fc..aaf8001 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,6,7,0 - PRODUCTVERSION 0,6,7,0 + FILEVERSION 0,6,8,0 + PRODUCTVERSION 0,6,8,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -20,7 +20,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.6.7" + VALUE "FileVersion", "0.6.8" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index c4ca01b..14adfb3 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -269,8 +269,15 @@ App::~App() std::lock_guard lock(jobsMutex); stop = true; jobs.clear(); + pendingHistorySearch = {}; } jobsReady.notify_all(); + historySearchReady.notify_all(); + historyOcrReady.notify_all(); + if (historyOcrWorker.joinable()) + historyOcrWorker.join(); + if (historySearchWorker.joinable()) + historySearchWorker.join(); if (worker.joinable()) worker.join(); RemoveClipboardFormatListener(window); @@ -291,6 +298,83 @@ void App::enqueue(std::function job) } jobsReady.notify_one(); } +void App::enqueueHistorySearch(std::function job) +{ + { + std::lock_guard lock(jobsMutex); + if (stop) return; + // Only the latest query matters. Clipboard capture and OCR must not delay it. + pendingHistorySearch = std::move(job); + if (!historySearchWorker.joinable()) + historySearchWorker = std::thread([this] + { + for (;;) + { + std::function searchJob; + { + std::unique_lock lock(jobsMutex); + historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); }); + if (stop) return; + searchJob = std::move(pendingHistorySearch); + pendingHistorySearch = {}; + } + try { searchJob(); } + catch (const std::exception &e) { report(wide(e.what())); } + } + }); + } + historySearchReady.notify_one(); +} +void App::scheduleHistoryOcr(const Settings &config) +{ + { + std::lock_guard lock(jobsMutex); + if (stop || !history || !config.history) return; + historyOcrSettings = config; + historyOcrRequested = true; + if (!historyOcrWorker.joinable()) + historyOcrWorker = std::thread([this] + { + for (;;) + { + Settings config; + { + std::unique_lock lock(jobsMutex); + historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; }); + if (stop) return; + historyOcrRequested = false; + config = historyOcrSettings; + } + for (const auto &entry : history->entries()) + { + { std::lock_guard lock(jobsMutex); if (stop) return; } + auto profile = ocrProfile(config); + if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue; + try + { + auto text = recognizeText(history->load(entry.id), config); + std::lock_guard lock(jobsMutex); + if (stop || ocrProfile(historyOcrSettings) != profile) continue; + if (history->saveOcr(entry.id, text, config)) + PostMessageW(window, HistoryMessage, 0, 0); + } + catch (const std::exception &e) + { + // Automatic OCR never changes the clipboard or opens error dialogs. + log(L"History OCR: " + wide(e.what())); + try { + std::lock_guard lock(jobsMutex); + if (!stop && ocrProfile(historyOcrSettings) == profile) + history->saveOcr(entry.id, std::nullopt, config); + } + catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); } + } + } + } + }); + } + historyOcrReady.notify_one(); +} void App::report(const std::wstring &text) { log(text); @@ -333,6 +417,7 @@ void App::applySettings(const Settings &s) { bool autostartChanged = false; validateSettings(s); + validateTesseract(s); if (s.autoCorrect) { AutoCorrector probe; @@ -375,6 +460,7 @@ void App::applySettings(const Settings &s) } // Clear on the worker so the next check reloads edits to dictionary files. enqueue([this] { autoCorrector.configure({}, {}); }); + scheduleHistoryOcr(s); SetThreadExecutionState(ES_CONTINUOUS | (settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0)); } @@ -1051,6 +1137,7 @@ void App::captureHistory() !f.data[1] && !f.data[2] && !f.data[3]) return; history->add(clip, app, config); + scheduleHistoryOcr(config); PostMessageW(window, HistoryMessage, 0, 0); } catch (const std::exception &e) @@ -1078,7 +1165,9 @@ void App::recognizeEntry(std::wstring id) } reset{busy}; try { - auto text = recognizeText(history->load(id)); + auto cached = history->ocrText(id, ocrProfile(config)); + auto text = cached ? *cached : recognizeText(history->load(id), config); + if (!cached) history->saveOcr(id, text, config); if (text.empty()) { report(L"No text was found in the image."); @@ -1442,6 +1531,8 @@ int App::execute() if (!keyboardHook || !mouseHook) report(L"Input tracking could not start. Automatic recent-text conversion is unavailable."); AddClipboardFormatListener(window); + // Resume pending OCR, including older images without a saved OCR state. + scheduleHistoryOcr(settings); addTray(); SetTimer(window, 1, 60000, nullptr); SetThreadExecutionState(ES_CONTINUOUS | diff --git a/src/app.h b/src/app.h index 58b0362..acde1df 100644 --- a/src/app.h +++ b/src/app.h @@ -61,6 +61,7 @@ class App void recognizeEntry(std::wstring); void captureHistory(); void enqueue(std::function); + void enqueueHistorySearch(std::function); void report(const std::wstring &); void trayMenu(); void addTray(); @@ -83,6 +84,14 @@ class App std::condition_variable jobsReady; std::deque> jobs; std::thread worker; + std::thread historySearchWorker; + std::condition_variable historySearchReady; + std::function pendingHistorySearch; + std::thread historyOcrWorker; + std::condition_variable historyOcrReady; + bool historyOcrRequested{}; + Settings historyOcrSettings; + void scheduleHistoryOcr(const Settings &); std::thread updateWorker; bool updateRunning{}, installingUpdate{}; std::wstring availableUpdate, notifiedUpdate; diff --git a/src/core.h b/src/core.h index bcc7a53..d186e7f 100644 --- a/src/core.h +++ b/src/core.h @@ -49,6 +49,9 @@ struct Settings bool autoCorrect = false; bool writeLog = true; bool screenshotAutoOpenEditor = false; + bool useTesseract = false; + std::wstring tesseractPath; + std::wstring tesseractLanguages = L"ukr+eng+rus"; int autoMaxRank = 0; int autoMinLength = 3; std::wstring autoPrefixChars = L"([{«“‘\""; diff --git a/src/ocr.cpp b/src/ocr.cpp index 009e78e..7f8a2b8 100644 --- a/src/ocr.cpp +++ b/src/ocr.cpp @@ -271,4 +271,119 @@ std::wstring recognizeText(const Clip &clip) WindowsDeleteString(text); return out; } +std::filesystem::path tesseractExecutable(const Settings &s) +{ + if (!s.tesseractPath.empty()) + { + std::filesystem::path path(s.tesseractPath); + if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe"); + return path; + } + for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"}) + { + wchar_t base[32768]{}; + auto length = GetEnvironmentVariableW(variable, base, std::size(base)); + if (!length || length >= std::size(base)) continue; + auto root = std::filesystem::path(base); + for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"}) + if (std::filesystem::is_regular_file(root / relative)) return root / relative; + } + throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options."); +} +std::wstring ocrProfile(const Settings &s) +{ + return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows"; +} +namespace +{ +struct TessLibrary +{ + HMODULE module{}; + explicit TessLibrary(const Settings &s) + { + auto exe = tesseractExecutable(s); + if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist"); + auto library = exe.parent_path() / L"libtesseract-5.dll"; + module = LoadLibraryExW(library.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); + if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies."); + } + ~TessLibrary() { if (module) FreeLibrary(module); } + template T symbol(const char *name) + { + auto address = GetProcAddress(module, name); + if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name); + T result; static_assert(sizeof(result) == sizeof(address)); + memcpy(&result, &address, sizeof(result)); + return result; + } +}; +struct TessEngine +{ + TessLibrary library; + void *handle{}; + void (*destroy)(void *){}; + explicit TessEngine(const Settings &s) : library(s) + { + auto folder = tesseractExecutable(s).parent_path() / L"tessdata"; + size_t start = 0; + do + { + auto end = s.tesseractLanguages.find(L'+', start); + auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start); + if (!std::filesystem::is_regular_file(folder / (language + L".traineddata"))) + throw std::runtime_error("Tesseract language is not installed: " + utf8(language)); + if (end == std::wstring::npos) break; + start = end + 1; + } while (true); + auto create = library.symbol("TessBaseAPICreate"); + destroy = library.symbol("TessBaseAPIDelete"); + auto init = library.symbol("TessBaseAPIInit3"); + handle = create(); + if (!handle) throw std::runtime_error("Cannot create Tesseract engine"); + if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0) + { + destroy(handle); handle = nullptr; + throw std::runtime_error("Cannot initialize the selected Tesseract languages"); + } + } + ~TessEngine() { if (handle) destroy(handle); } +}; +} +void validateTesseract(const Settings &s) +{ + if (s.useTesseract) { TessEngine engine(s); } +} +std::wstring recognizeText(const Clip &clip, const Settings &s) +{ + if (!s.useTesseract) return recognizeText(clip); + auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult); + struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)}; + // Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs. + TessEngine engine(s); + auto &lib = engine.library; + auto image = lib.symbol("TessBaseAPISetImage"); + auto mode = lib.symbol("TessBaseAPISetPageSegMode"); + auto recognize = lib.symbol("TessBaseAPIRecognize"); + auto getText = lib.symbol("TessBaseAPIGetUTF8Text"); + auto freeText = lib.symbol("TessDeleteText"); + auto createMonitor = lib.symbol("TessMonitorCreate"); + auto freeMonitor = lib.symbol("TessMonitorDelete"); + auto deadline = lib.symbol("TessMonitorSetDeadlineMSecs"); + std::unique_ptr monitor(createMonitor(), freeMonitor); + if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor"); + auto pixels = decodeImage(clip); + Bytes gray(size_t(pixels.width) * pixels.height); + for (size_t i = 0; i < gray.size(); ++i) + gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8); + image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width); + mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs. + deadline(monitor.get(), 30000); + if (recognize(engine.handle, monitor.get()) != 0) + throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds"); + std::unique_ptr text(getText(engine.handle), freeText); + if (!text) throw std::runtime_error("Tesseract returned no result"); + return wide(text.get()); +} } // namespace ks diff --git a/src/ocr.h b/src/ocr.h index 00c4182..56d5d65 100644 --- a/src/ocr.h +++ b/src/ocr.h @@ -10,4 +10,8 @@ struct Pixels Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0); Format imageDib(const Pixels &); std::wstring recognizeText(const Clip &); +std::wstring recognizeText(const Clip &, const Settings &); +std::filesystem::path tesseractExecutable(const Settings &); +void validateTesseract(const Settings &); +std::wstring ocrProfile(const Settings &); } // namespace ks diff --git a/src/screenshot.cpp b/src/screenshot.cpp index 91429c8..d3568f0 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -169,7 +169,7 @@ struct Capture bool editing{}, dragging{}, arrow{}, owned{}, copied{}; bool autoOpenEditor; bool highlight{}; - bool pointer=true, freehand=false; + bool pointer=false, freehand=true; bool straight{}, textTool{}; Mark textDefaults{}; ScreenshotShape shape=ScreenshotShape::None; diff --git a/src/storage.cpp b/src/storage.cpp index f839d3a..ae8f252 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -44,6 +44,13 @@ bool validId(const std::wstring &id) } // namespace void validateSettings(const Settings &s) { + if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos) + throw std::runtime_error("Invalid Tesseract executable path"); + if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 || + s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' || + s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos || + s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos) + throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus"); std::vector used; std::vector keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot}; keys.insert(keys.end(), s.direct.begin(), s.direct.end()); @@ -125,6 +132,9 @@ Settings decodeSettings(const Bytes &bytes, const std::vector &layouts) s.switchLayout = j.value("switchSystemLayout", true); s.writeLog = j.value("writeLog", true); s.history = j.value("historyEnabled", true); + s.useTesseract = j.value("useTesseract", false); + s.tesseractPath = str(j, "tesseractPath"); + s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages); s.autostart = j.value("startWithSystem", false); s.elevatedAutostart = j.value("autostartAsAdministrator", false); s.awake = j.value("preventScreenLock", false); @@ -196,6 +206,9 @@ Bytes encodeSettings(const Settings &s) {"writeLog", s.writeLog}, {"autostartAsAdministrator", s.elevatedAutostart}, {"historyEnabled", s.history}, + {"useTesseract", s.useTesseract}, + {"tesseractPath", utf8(s.tesseractPath)}, + {"tesseractLanguages", utf8(s.tesseractLanguages)}, {"startWithSystem", s.autostart}, {"preventScreenLock", s.awake}, {"maxItems", s.maxItems}, @@ -297,7 +310,8 @@ History::History(const std::filesystem::path &folder) : folder_(folder) e.value("at", uint64_t{}), e.value("pinned", false), e.value("image", false), - e.value("files", false)}; + e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0), + str(e, "ocrProfile", L"windows")}; if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat"))) entries_.push_back(std::move(item)); } @@ -313,7 +327,7 @@ void History::saveIndex() {"at", e.at}, {"pinned", e.pinned}, {"image", e.image}, - {"files", e.files}}); + {"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}}); atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j))); } std::vector History::entries() const @@ -342,6 +356,7 @@ void History::prune(const Settings &s) total -= entries_[i].bytes; std::error_code ec; std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec); + std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec); entries_.erase(entries_.begin() + i); } } @@ -429,7 +444,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s) GetSystemTimeAsFileTime(&now); entries_.insert(entries_.begin(), Entry{id, text, app, encrypted.size(), - (uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files}); + (uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files, + image ? 1 : 0, ocrProfile(s)}); prune(s); saveIndex(); } @@ -445,6 +461,7 @@ void History::remove(const std::wstring &id) saveIndex(); std::error_code ec; std::filesystem::remove(folder_ / (id + L".dat"), ec); + std::filesystem::remove(folder_ / (id + L".ocr"), ec); } void History::pin(const std::wstring &id) { @@ -464,8 +481,49 @@ void History::clear() { std::error_code ec; std::filesystem::remove(folder_ / (e.id + L".dat"), ec); + std::filesystem::remove(folder_ / (e.id + L".ocr"), ec); } } +bool History::saveOcr(const std::wstring &id, const std::optional &text, const Settings &settings) +{ + std::lock_guard lock(mutex_); + auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; }); + // A late OCR completion must never recreate a deleted/pruned image. + auto profile = ocrProfile(settings); + if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false; + if (text) + { + auto utf = utf8(*text); + if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit"); + // A version byte also makes empty recognition results encryptable by DPAPI. + Bytes payload{1}; + payload.insert(payload.end(), utf.begin(), utf.end()); + auto encrypted = protect(payload); + auto path = folder_ / (id + L".ocr"); + std::error_code ec; + auto previousBytes = std::filesystem::file_size(path, ec); + if (ec) previousBytes = 0; + atomicWrite(path, encrypted); + it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size(); + it->ocrState = 2; + } + else it->ocrState = 3; + it->ocrProfile = profile; + prune(settings); + saveIndex(); + return true; +} +std::optional History::ocrText(const std::wstring &id, const std::wstring &profile) const +{ + { + std::lock_guard lock(mutex_); + auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; }); + if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt; + } + auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024)); + if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format"); + return wide(std::string(bytes.begin() + 1, bytes.end())); +} std::vector History::search(const std::wstring &query) const { auto items = entries(); @@ -477,6 +535,21 @@ std::vector History::search(const std::wstring &query) const { if (lower(e.preview + L" " + e.app).find(q) != q.npos) return false; + if (e.ocrState == 2) + { + try + { + auto text = ocrText(e.id); + if (text && lower(*text).find(q) != q.npos) return false; + } + catch (...) {} // Missing/corrupt OCR must not hide the original clip. + } + // Short previews contain the complete text (or a placeholder for + // textless clips). Do not decrypt image payloads just to rule them out. + // Newlines/tabs are flattened in previews, so those queries still + // need the original text. Exactly 240 characters may be truncated. + if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos) + return true; try { return lower(load(e.id).text()).find(q) == q.npos; diff --git a/src/storage.h b/src/storage.h index 1be636d..5715382 100644 --- a/src/storage.h +++ b/src/storage.h @@ -13,6 +13,8 @@ struct Entry std::wstring id, preview, app; uint64_t bytes{}, at{}; bool pinned{}, image{}, files{}; + int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed + std::wstring ocrProfile = L"windows"; }; class History { @@ -27,6 +29,8 @@ class History std::vector entries() const; Clip load(const std::wstring &id) const; void add(const Clip &, const std::wstring &app, const Settings &); + bool saveOcr(const std::wstring &id, const std::optional &, const Settings &); + std::optional ocrText(const std::wstring &id, const std::wstring &profile = L"") const; void remove(const std::wstring &); void pin(const std::wstring &); void clear(); diff --git a/src/ui.cpp b/src/ui.cpp index f7647e3..2b58c95 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -420,7 +420,7 @@ void search(HWND w, HistoryView *v) auto generation = ++v->generation; v->pending.clear(); auto *app = v->app; - app->enqueue( + app->enqueueHistorySearch( [w, app, q, generation] { try @@ -625,6 +625,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) int id = LOWORD(wp); if (id == 101 && HIWORD(wp) == EN_CHANGE) { + // Reject an older result even during the debounce interval. + ++v->generation; SetTimer(w, 1, 180, nullptr); return 0; } @@ -732,7 +734,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin"); AppendMenuW(menu, MF_STRING, 113, L"Delete"); AppendMenuW(menu, MF_STRING, 114, L"Open full item"); - AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (Windows OCR)"); + AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)"); POINT p; GetCursorPos(&p); int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr); @@ -1057,6 +1059,99 @@ void showAutoOptions(HWND parent, SettingsView *v) showRequestedWindow(w); SetForegroundWindow(w); } +struct OcrOptionsView { HWND parent; SettingsView *settings; }; +Settings ocrDraft(HWND w, OcrOptionsView *v) +{ + auto draft = v->settings->draft; + draft.useTesseract = checked(w, 501); + draft.tesseractPath = value(w, 502); + draft.tesseractLanguages = value(w, 503); + validateSettings(draft); + return draft; +} +LRESULT CALLBACK ocrOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) +{ + auto *v = reinterpret_cast(GetWindowLongPtrW(w, GWLP_USERDATA)); + if (!v) return DefWindowProcW(w, msg, wp, lp); + if (msg == WM_COMMAND) + { + int id = LOWORD(wp); + try + { + if (id == 507) + { + if ((INT_PTR)ShellExecuteW(w, L"open", L"https://github.com/UB-Mannheim/tesseract/wiki#tesseract-installer-for-windows", + nullptr, nullptr, SW_SHOWNORMAL) <= 32) + throw std::runtime_error("Could not open the Tesseract download page in your browser"); + return 0; + } + if (id == 504) + { + wchar_t path[32768]{}; + OPENFILENAMEW picker{sizeof(picker)}; + picker.hwndOwner = w; picker.lpstrFile = path; picker.nMaxFile = std::size(path); + picker.lpstrFilter = L"Tesseract executable (tesseract.exe)\0tesseract.exe\0Executables\0*.exe\0"; + picker.Flags = OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR; + if (GetOpenFileNameW(&picker)) value(w, 502, path); + return 0; + } + if (id == 505 || id == IDOK) + { + auto draft = ocrDraft(w, v); + if (id == 505) + { + draft.useTesseract = true; + validateTesseract(draft); + SetDlgItemTextW(w, 506, (L"Ready: " + draft.tesseractLanguages).c_str()); + return 0; + } + validateTesseract(draft); + v->settings->draft.useTesseract = draft.useTesseract; + v->settings->draft.tesseractPath = draft.tesseractPath; + v->settings->draft.tesseractLanguages = draft.tesseractLanguages; + DestroyWindow(w); + return 0; + } + if (id == IDCANCEL) { DestroyWindow(w); return 0; } + } + catch (const std::exception &e) { error(w, e); return 0; } + } + if (msg == WM_CLOSE) { DestroyWindow(w); return 0; } + if (msg == WM_NCDESTROY) + { + EnableWindow(v->parent, TRUE); + SetForegroundWindow(v->parent); + disposeFont(w); delete v; + SetWindowLongPtrW(w, GWLP_USERDATA, 0); + } + return DefWindowProcW(w, msg, wp, lp); +} +void showOcrOptions(HWND parent, SettingsView *v) +{ + HWND w = makeWindow(*v->app, L"KSwitcher.OcrOptions", L"Image text recognition", ocrOptionsProc, 640, 450); + SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent); + SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v}); + label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600); + checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600); + check(w, 501, v->draft.useTesseract); + label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600); + edit(w, 502, 18, 118, 484); + value(w, 502, v->draft.tesseractPath); + button(w, L"Browse…", 504, 514, 117, 106); + label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600); + edit(w, 503, 18, 188, 602); + value(w, 503, v->draft.tesseractLanguages); + button(w, L"Check installation", 505, 18, 228, 180); + control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24); + button(w, L"Download Tesseract…", 507, 18, 266, 180); + label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408); + label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602); + label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602); + button(w, L"OK", IDOK, 406, 390, 100); + button(w, L"Cancel", IDCANCEL, 520, 390, 100); + EnableWindow(parent, FALSE); + showRequestedWindow(w); SetForegroundWindow(w); +} LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { if (msg == WM_DPICHANGED) @@ -1092,6 +1187,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) showLogs(*v->app); return 0; } + if (id == 257) { showOcrOptions(w, v); return 0; } if (id == 230) { collectLayout(w, v); @@ -1685,6 +1781,7 @@ void showSettings(App &app, bool visible) edit(w, 243, 18, 544, 742); button(w, L"Auto-correction options…", 255, 18, 575, 240); button(w, L"View log…", 256, 278, 575, 220); + button(w, L"OCR options…", 257, 518, 575, 242); button(w, L"Import settings…", 250, 18, 606, 145); button(w, L"Export settings…", 251, 173, 606, 145); button(w, L"Snippets…", 252, 328, 606, 110); diff --git a/src/update.h b/src/update.h index c96abf2..7eaceec 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.7"; +inline constexpr wchar_t AppVersion[] = L"0.6.8"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; struct UpdateResult diff --git a/tests/input.cpp b/tests/input.cpp index 0bb9983..a67fe91 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -570,6 +570,34 @@ int main(int argc, char **argv) { ks::showHistory(app,false); auto history=app.historyWindow; require(history&&!IsWindowVisible(history),"history layout tested without changing focus"); + { + app.history=std::make_unique(temp/L"history-search"); + app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings); + app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings); + // This App has no general worker: a search on that queue can never finish. + auto originalFocus=GetFocus(); + auto pumpUntil=[&](int count) { + auto deadline=GetTickCount64()+3000; + do { + MSG message{}; + while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) { + TranslateMessage(&message); DispatchMessageW(&message); + } + if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true; + Sleep(5); + } while(GetTickCount64() -int main() +namespace ks { +struct InputRegressionAccess { + static void scheduleOcr(App &app) { app.scheduleHistoryOcr(app.settings); } +}; +} +int main(int argc, char **argv) { + const bool tesseract = argc == 2 && std::string(argv[1]) == "--tesseract"; CoInitializeEx(nullptr, COINIT_MULTITHREADED); try { @@ -24,7 +31,8 @@ int main() auto oldFont = SelectObject(dc, font); SetTextColor(dc, RGB(0, 0, 0)); SetBkColor(dc, RGB(255, 255, 255)); - TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15); + std::wstring fixture = tesseract ? L"Привіт Україно HELLO" : L"HELLO KSWITCHER"; + TextOutW(dc, 20, 40, fixture.c_str(), (int)fixture.size()); GdiFlush(); ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)}; SelectObject(dc, oldFont); @@ -36,11 +44,45 @@ int main() auto decoded = ks::decodeImage(clip); if (decoded.width != 800 || decoded.height != 150) throw std::runtime_error("WIC decode size mismatch"); - auto text = ks::recognizeText(clip); + ks::Settings config; + config.useTesseract = tesseract; + auto text = ks::recognizeText(clip, config); std::cout << "OCR: " << ks::utf8(text) << '\n'; if (ks::upper(text).find(L"HELLO") == std::wstring::npos) throw std::runtime_error("OCR fixture was not recognized"); - std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n"; + if (tesseract && ks::lower(text).find(L"привіт") == std::wstring::npos) + throw std::runtime_error("Ukrainian OCR fixture was not recognized"); + auto temp = std::filesystem::temp_directory_path() / (L"KSwitcher-auto-ocr-" + std::to_wstring(GetCurrentProcessId())); + auto sequence = GetClipboardSequenceNumber(); + { + ks::App app(GetModuleHandleW(nullptr), true, temp); + app.settings.history = true; + app.history->add(clip, L"OCR fixture", app.settings); + auto id = app.history->entries().front().id; + if (tesseract) + { + app.history->saveOcr(id, L"old Windows result", app.settings); + app.settings.useTesseract = true; + } + ks::InputRegressionAccess::scheduleOcr(app); + auto deadline = GetTickCount64() + 35000; + while ((app.history->entries().front().ocrState == 1 || + app.history->entries().front().ocrProfile != ks::ocrProfile(app.settings)) && + GetTickCount64() < deadline) Sleep(20); + if (app.history->search(L"HELLO").size() != 1) + throw std::runtime_error("Background OCR did not make the image searchable"); + ks::History restored(temp / L"history"); + if (!restored.ocrText(id) || restored.search(L"HELLO").size() != 1) + throw std::runtime_error("Background OCR did not persist its result"); + if (tesseract && (restored.search(L"привіт").size() != 1 || !restored.search(L"old Windows result").empty())) + throw std::runtime_error("Engine change did not replace cached Windows OCR with Ukrainian text"); + if (GetClipboardSequenceNumber() != sequence) + throw std::runtime_error("Background OCR changed the clipboard"); + std::cout << "PASS automatic background OCR, persisted search and unchanged clipboard\n"; + } + std::filesystem::remove_all(temp); + std::cout << (tesseract ? "PASS optional Tesseract, mixed Ukrainian/English and cache migration\n" : + "PASS native Windows OCR, DIB decoding and async result retrieval\n"); CoUninitialize(); return 0; } diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index 0bff004..b4513e5 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -22,6 +22,8 @@ int main() "editor is an independent Alt+Tab application window"); require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created"); require(c.pencilCursor!=nullptr,"pencil drawing cursor loads"); + require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor, + "new screenshot editor starts with the pencil tool"); for(int size:{32,48,64,96}) { auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0); ICONINFO info{}; diff --git a/tests/tests.cpp b/tests/tests.cpp index 98633c0..501d9e0 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -468,6 +468,75 @@ int main(int argc, char **argv) history.clear(); require(history.entries().empty(), "clear history"); } + { + ks::History history(temp / L"search"); + history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings); + history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings); + require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview"); + require(history.search(L"first\nsecond").size() == 1, "search retains original newlines"); + require(history.search(L"second\tthird").size() == 1, "search retains original tabs"); + require(history.search(L"absent").empty(), "short previews reject unmatched queries"); + require(history.search(L"FIRST").size() == 1, "short preview search ignores case"); + } + { + auto folder = temp / L"ocr-history"; + ks::History history(folder); + ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}}; + history.add(image, L"OCR test", settings); + auto id = history.entries().front().id; + require(history.entries().front().ocrState == 1, "new images await background OCR"); + require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart"); + auto original = ks::encodeClip(history.load(id)); + auto beforeBytes = history.entries().front().bytes; + require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR"); + require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size"); + require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats"); + require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten"); + ks::History restored(folder); + require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable"); + require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart"); + auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024); + require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted"); + restored.remove(id); + require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar"); + require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image"); + restored.add(image, L"OCR test", settings); + id = restored.entries().front().id; + restored.saveOcr(id, L"", settings); + require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result"); + restored.clear(); + require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars"); + restored.add(image, L"OCR test", settings); + id = restored.entries().front().id; + restored.saveOcr(id, std::nullopt, settings); + require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart"); + restored.saveOcr(id, L"manual retry", settings); + require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR"); + restored.add(ks::textClip(L"newer"), L"test", settings); + auto limited = settings; limited.maxItems = 1; + restored.add(ks::textClip(L"newest"), L"test", limited); + require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar"); + } + { + ks::Settings options; + require(!options.useTesseract, "Tesseract is opt-in by default"); + options.useTesseract = true; + options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe"; + options.tesseractLanguages = L"ukr+eng+rus"; + auto restored = ks::decodeSettings(ks::encodeSettings(options), {}); + require(restored.useTesseract && restored.tesseractPath == options.tesseractPath && + restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip"); + options.tesseractLanguages = L"ukr+../eng"; + rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected"); + options.tesseractLanguages = L"ukr++eng"; + rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected"); + options.tesseractLanguages = L"ukr+eng"; + options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe"; + rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure"); + options.useTesseract = false; + ks::validateTesseract(options); + require(true, "Windows OCR works without optional installation"); + } // Test-owned directory only; never touch the user's clipboard or stored history. std::filesystem::remove_all(temp); CoUninitialize();