diff --git a/build.bat b/build.bat index cd2c157..a432659 100644 --- a/build.bat +++ b/build.bat @@ -48,65 +48,69 @@ echo Compiler: !CXX_PATH! echo Target: !TARGET! echo. -echo [1/13] Compiling capture.cpp +echo [1/14] Compiling capture.cpp "!CXX_PATH!" !COMMON! -c src\capture.cpp -o build\capture.o if errorlevel 1 goto :build_failed -echo [2/13] Compiling image_io.cpp +echo [2/14] Compiling image_io.cpp "!CXX_PATH!" !COMMON! -c src\image_io.cpp -o build\image_io.o if errorlevel 1 goto :build_failed -echo [3/13] Compiling gif_recorder.cpp +echo [3/14] Compiling clipboard_history.cpp +"!CXX_PATH!" !COMMON! -c src\clipboard_history.cpp -o build\clipboard_history.o +if errorlevel 1 goto :build_failed + +echo [4/14] Compiling gif_recorder.cpp "!CXX_PATH!" !COMMON! -c src\gif_recorder.cpp -o build\gif_recorder.o if errorlevel 1 goto :build_failed -echo [4/13] Compiling gif_options_window.cpp +echo [5/14] Compiling gif_options_window.cpp "!CXX_PATH!" !COMMON! -c src\gif_options_window.cpp -o build\gif_options_window.o if errorlevel 1 goto :build_failed -echo [5/13] Compiling overlay.cpp +echo [6/14] Compiling overlay.cpp "!CXX_PATH!" !COMMON! -c src\overlay.cpp -o build\overlay.o if errorlevel 1 goto :build_failed -echo [6/13] Compiling notifications.cpp +echo [7/14] Compiling notifications.cpp "!CXX_PATH!" !COMMON! -c src\notifications.cpp -o build\notifications.o if errorlevel 1 goto :build_failed -echo [7/13] Compiling settings.cpp +echo [8/14] Compiling settings.cpp "!CXX_PATH!" !COMMON! -c src\settings.cpp -o build\settings.o if errorlevel 1 goto :build_failed -echo [8/13] Compiling startup.cpp +echo [9/14] Compiling startup.cpp "!CXX_PATH!" !COMMON! -c src\startup.cpp -o build\startup.o if errorlevel 1 goto :build_failed -echo [9/13] Compiling settings_window.cpp +echo [10/14] Compiling settings_window.cpp "!CXX_PATH!" !COMMON! -c src\settings_window.cpp -o build\settings_window.o if errorlevel 1 goto :build_failed -echo [10/13] Compiling uploader.cpp +echo [11/14] Compiling uploader.cpp "!CXX_PATH!" !COMMON! -c src\uploader.cpp -o build\uploader.o if errorlevel 1 goto :build_failed -echo [11/13] Compiling main.cpp +echo [12/14] Compiling main.cpp "!CXX_PATH!" !COMMON! -c src\main.cpp -o build\main.o if errorlevel 1 goto :build_failed if defined WINDRES_PATH ( - echo [12/13] Compiling resources + echo [13/14] Compiling resources "!WINDRES_PATH!" -Iinclude -Ires res\app.rc -O coff -o build\app_res.o if errorlevel 1 goto :build_failed set "RES_OBJECT=build\app_res.o" ) else ( - echo [12/13] Resources skipped + echo [13/14] Resources skipped echo [WARN] Win64 windres was not found. The EXE will still build, but without the embedded icon/version/manifest. echo [WARN] Install the full MSYS2 mingw-w64-x86_64-binutils package to restore resources. ) -echo [13/13] Linking Win64 executable +echo [14/14] Linking Win64 executable "!CXX_PATH!" -municode -mwindows -static -static-libgcc -static-libstdc++ ^ -Wl,--gc-sections -Wl,--nxcompat -Wl,--dynamicbase -Wl,--high-entropy-va ^ - build\capture.o build\image_io.o build\gif_recorder.o build\gif_options_window.o ^ + build\capture.o build\image_io.o build\clipboard_history.o build\gif_recorder.o build\gif_options_window.o ^ build\overlay.o build\notifications.o ^ build\settings.o build\startup.o build\settings_window.o build\uploader.o ^ build\main.o !RES_OBJECT! ^ diff --git a/include/clipboard_history.hpp b/include/clipboard_history.hpp new file mode 100644 index 0000000..86429b0 --- /dev/null +++ b/include/clipboard_history.hpp @@ -0,0 +1,14 @@ +#pragma once +#include "config.hpp" +#include +#include + +namespace clipboard_history { + +// Publishes an encoded bitmap stream through Windows.ApplicationModel.DataTransfer +// so Windows 10/11 clipboard history can ingest it. This intentionally happens +// before the richer Win32 clipboard payload is installed. +[[nodiscard]] bool publishBitmap(const std::vector& encodedBitmap, + DWORD settleMilliseconds = 450); + +} // namespace clipboard_history diff --git a/include/image_io.hpp b/include/image_io.hpp index 3215a05..a075a6d 100644 --- a/include/image_io.hpp +++ b/include/image_io.hpp @@ -12,6 +12,7 @@ [[nodiscard]] bool saveBitmapAsJpeg(Gdiplus::Bitmap& bitmap, const std::wstring& path, int quality); [[nodiscard]] bool copyBitmapToClipboard(HWND owner, Gdiplus::Bitmap& bitmap); [[nodiscard]] bool encodeBitmapAsPng(Gdiplus::Bitmap& bitmap, std::vector& output); +[[nodiscard]] bool encodeBitmapForClipboardHistory(Gdiplus::Bitmap& bitmap, std::vector& output); // Reserves a non-existing incremented file name with CREATE_NEW. The returned // empty file belongs to the caller and may safely be filled/overwritten. diff --git a/res/app.rc b/res/app.rc index 1db7da0..f02958f 100644 --- a/res/app.rc +++ b/res/app.rc @@ -3,8 +3,8 @@ IDI_APP_ICON ICON "nightshot.ico" IDR_APP_MANIFEST RT_MANIFEST "app.manifest" 1 VERSIONINFO - FILEVERSION 0,19,0,0 - PRODUCTVERSION 0,19,0,0 + FILEVERSION 0,20,0,0 + PRODUCTVERSION 0,20,0,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS 0x40004L @@ -17,11 +17,11 @@ BEGIN BEGIN VALUE "CompanyName", "NightSync" VALUE "FileDescription", "NightShot Win64 screenshot utility" - VALUE "FileVersion", "0.19.0" + VALUE "FileVersion", "0.20.0" VALUE "InternalName", "NightShot" VALUE "OriginalFilename", "NightShot.exe" VALUE "ProductName", "NightShot" - VALUE "ProductVersion", "0.19.0" + VALUE "ProductVersion", "0.20.0" END END BLOCK "VarFileInfo" diff --git a/src/clipboard_history.cpp b/src/clipboard_history.cpp new file mode 100644 index 0000000..8d30801 --- /dev/null +++ b/src/clipboard_history.cpp @@ -0,0 +1,174 @@ +#include "clipboard_history.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace clipboard_history { +namespace { +using Microsoft::WRL::ComPtr; +using Microsoft::WRL::Wrappers::HStringReference; +using ABI::Windows::ApplicationModel::DataTransfer::IClipboardStatics; +using ABI::Windows::ApplicationModel::DataTransfer::IClipboardStatics2; +using ABI::Windows::ApplicationModel::DataTransfer::IClipboardContentOptions; +using ABI::Windows::ApplicationModel::DataTransfer::IDataPackage; +using ABI::Windows::Storage::Streams::IRandomAccessStream; +using ABI::Windows::Storage::Streams::IRandomAccessStreamReference; +using ABI::Windows::Storage::Streams::IRandomAccessStreamReferenceStatics; + +struct RuntimeScope { + bool uninitialize = false; + + RuntimeScope() { + const HRESULT result = RoInitialize(RO_INIT_SINGLETHREADED); + if (result == S_OK || result == S_FALSE) { + uninitialize = true; + } else if (result == RPC_E_CHANGED_MODE) { + // The caller already initialized COM/WinRT using another apartment. + // Static WinRT clipboard APIs remain usable on that thread. + } else { + initializationResult = result; + } + } + + ~RuntimeScope() { + if (uninitialize) RoUninitialize(); + } + + HRESULT initializationResult = S_OK; + [[nodiscard]] bool ready() const { + return SUCCEEDED(initializationResult); + } +}; + +HRESULT makeRandomAccessReference(const std::vector& bytes, + ComPtr& reference) { + if (bytes.empty()) return E_INVALIDARG; + + HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, bytes.size()); + if (!memory) return E_OUTOFMEMORY; + + void* destination = GlobalLock(memory); + if (!destination) { + const HRESULT error = HRESULT_FROM_WIN32(GetLastError()); + GlobalFree(memory); + return error; + } + std::memcpy(destination, bytes.data(), bytes.size()); + GlobalUnlock(memory); + + ComPtr stream; + HRESULT result = CreateStreamOnHGlobal(memory, TRUE, stream.ReleaseAndGetAddressOf()); + if (FAILED(result)) { + GlobalFree(memory); + return result; + } + + LARGE_INTEGER zero{}; + result = stream->Seek(zero, STREAM_SEEK_SET, nullptr); + if (FAILED(result)) return result; + + ComPtr randomAccessStream; + result = CreateRandomAccessStreamOverStream( + stream.Get(), BSOS_DEFAULT, + IID_PPV_ARGS(randomAccessStream.ReleaseAndGetAddressOf())); + if (FAILED(result)) return result; + + ComPtr referenceFactory; + result = RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_Storage_Streams_RandomAccessStreamReference).Get(), + IID_PPV_ARGS(referenceFactory.ReleaseAndGetAddressOf())); + if (FAILED(result)) return result; + + return referenceFactory->CreateFromStream( + randomAccessStream.Get(), reference.ReleaseAndGetAddressOf()); +} + +} // namespace + +bool publishBitmap(const std::vector& encodedBitmap, DWORD settleMilliseconds) { + if (encodedBitmap.empty()) return false; + + RuntimeScope runtime; + if (!runtime.ready()) return false; + + ComPtr streamReference; + HRESULT result = makeRandomAccessReference(encodedBitmap, streamReference); + if (FAILED(result)) return false; + + ComPtr inspectablePackage; + result = RoActivateInstance( + HStringReference(RuntimeClass_Windows_ApplicationModel_DataTransfer_DataPackage).Get(), + inspectablePackage.ReleaseAndGetAddressOf()); + if (FAILED(result)) return false; + + ComPtr package; + result = inspectablePackage.As(&package); + if (FAILED(result)) return false; + + result = package->SetBitmap(streamReference.Get()); + if (FAILED(result)) return false; + + ComPtr clipboard; + result = RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_ApplicationModel_DataTransfer_Clipboard).Get(), + IID_PPV_ARGS(clipboard.ReleaseAndGetAddressOf())); + if (FAILED(result)) return false; + + bool contentAccepted = false; + + // Windows 10 1809+ exposes SetContentWithOptions. Unlike SetContent, this + // API can be used when NightShot is no longer the foreground window (most + // importantly after an asynchronous GIF encode), and lets us explicitly + // mark the compact bitmap representation as history-eligible. + ComPtr clipboard2; + result = RoGetActivationFactory( + HStringReference(RuntimeClass_Windows_ApplicationModel_DataTransfer_Clipboard).Get(), + IID_PPV_ARGS(clipboard2.ReleaseAndGetAddressOf())); + if (SUCCEEDED(result) && clipboard2) { + ComPtr inspectableOptions; + result = RoActivateInstance( + HStringReference(RuntimeClass_Windows_ApplicationModel_DataTransfer_ClipboardContentOptions).Get(), + inspectableOptions.ReleaseAndGetAddressOf()); + if (SUCCEEDED(result) && inspectableOptions) { + ComPtr options; + result = inspectableOptions.As(&options); + if (SUCCEEDED(result) && options) { + result = options->put_IsAllowedInHistory(true); + if (SUCCEEDED(result)) { + boolean accepted = 0; + result = clipboard2->SetContentWithOptions( + package.Get(), options.Get(), &accepted); + contentAccepted = SUCCEEDED(result) && accepted != 0; + } + } + } + } + + // Compatibility fallback for Windows versions that do not expose the + // options API. SetContent requires the application to be foreground, so + // this path is intentionally second choice. + if (!contentAccepted) { + result = clipboard->SetContent(package.Get()); + if (FAILED(result)) return false; + contentAccepted = true; + } + + // Materialize the DataPackage now, while all stream objects are alive. + result = clipboard->Flush(); + if (FAILED(result)) return false; + + // Clipboard History consumes clipboard updates asynchronously. Keep this + // history-friendly item current long enough for cbdhsvc to observe it before + // NightShot replaces it with the richer Win32 PNG/DIB/GIF payload. + if (settleMilliseconds != 0) Sleep(settleMilliseconds); + return true; +} + +} // namespace clipboard_history diff --git a/src/gif_recorder.cpp b/src/gif_recorder.cpp index dfc3d94..262ff2b 100644 --- a/src/gif_recorder.cpp +++ b/src/gif_recorder.cpp @@ -1,6 +1,7 @@ #include "gif_recorder.hpp" #include "app_messages.hpp" +#include "clipboard_history.hpp" #include "image_io.hpp" #include "settings.hpp" @@ -262,13 +263,25 @@ bool copyGifToClipboard(const Session& session) { const std::vector gif = readAllBytes(session.path); if (gif.empty()) return false; + std::vector historyPreview; + bool historyPublished = false; + if (!session.firstFrame.empty() && session.width > 0 && session.height > 0 && + session.stride >= session.width * 4) { + Gdiplus::Bitmap firstFrame(session.width, session.height, session.stride, + PixelFormat32bppARGB, + const_cast(session.firstFrame.data())); + historyPublished = firstFrame.GetLastStatus() == Gdiplus::Ok && + encodeBitmapForClipboardHistory(firstFrame, historyPreview) && + clipboard_history::publishBitmap(historyPreview); + } + HGLOBAL gifGeneric = globalBytes(gif.data(), gif.size()); HGLOBAL gifMime = globalBytes(gif.data(), gif.size()); HGLOBAL files = dropFiles(session.path); HGLOBAL dibV5 = firstFrameDibV5(session); HGLOBAL dib = firstFrameDib24(session); HBITMAP bitmap = firstFrameBitmap(session); - HGLOBAL includeInHistory = serializedDword(1); + HGLOBAL includeInHistory = serializedDword(historyPublished ? 0u : 1u); if (!gifGeneric && !gifMime && !files && !dibV5 && !dib && !bitmap) { if (includeInHistory) GlobalFree(includeInHistory); return false; @@ -322,9 +335,9 @@ bool copyGifToClipboard(const Session& session) { transferred = true; } - // Explicitly ask Windows 10/11 clipboard monitoring to retain this item in - // Win+V. The first frame is also published as standard bitmap formats so - // history has a format it can display even when it ignores the custom GIF. + // The WinRT history proxy above carries the first frame. Keep the rich + // animated GIF replacement out of history when that succeeded; otherwise + // fall back to the documented Win32 history opt-in. const UINT historyFormat = RegisterClipboardFormatW(L"CanIncludeInClipboardHistory"); if (includeInHistory && historyFormat && SetClipboardData(historyFormat, includeInHistory)) { includeInHistory = nullptr; diff --git a/src/image_io.cpp b/src/image_io.cpp index 19c3caf..c8ffea6 100644 --- a/src/image_io.cpp +++ b/src/image_io.cpp @@ -1,4 +1,5 @@ #include "image_io.hpp" +#include "clipboard_history.hpp" #include #include #include @@ -223,6 +224,39 @@ bool openClipboardWithRetry(HWND owner) { return false; } + +bool makeClipboardHistoryPreview(Gdiplus::Bitmap& source, std::vector& output) { + output.clear(); + const UINT sourceWidth = source.GetWidth(); + const UINT sourceHeight = source.GetHeight(); + if (sourceWidth == 0 || sourceHeight == 0) return false; + + // Win+V has a small per-item budget. Keep the history representation at + // most 1280x720; the actual clipboard remains full resolution below. + constexpr UINT kMaximumWidth = 1280; + constexpr UINT kMaximumHeight = 720; + const double scale = std::min( + 1.0, std::min(static_cast(kMaximumWidth) / sourceWidth, + static_cast(kMaximumHeight) / sourceHeight)); + if (scale >= 0.999) return encodeBitmapAsPng(source, output); + + const UINT width = std::max(1, static_cast(sourceWidth * scale + 0.5)); + const UINT height = std::max(1, static_cast(sourceHeight * scale + 0.5)); + Gdiplus::Bitmap preview(static_cast(width), static_cast(height), + PixelFormat32bppARGB); + if (preview.GetLastStatus() != Gdiplus::Ok) return false; + + Gdiplus::Graphics graphics(&preview); + graphics.SetCompositingMode(Gdiplus::CompositingModeSourceCopy); + graphics.Clear(Gdiplus::Color(255, 255, 255, 255)); + graphics.SetCompositingMode(Gdiplus::CompositingModeSourceOver); + graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + graphics.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHighQuality); + if (graphics.DrawImage(&source, 0, 0, static_cast(width), + static_cast(height)) != Gdiplus::Ok) return false; + graphics.Flush(Gdiplus::FlushIntentionSync); + return encodeBitmapAsPng(preview, output); +} HGLOBAL serializedDword(DWORD value) { HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, sizeof(DWORD)); if (!memory) return nullptr; @@ -237,15 +271,28 @@ HGLOBAL serializedDword(DWORD value) { } } // namespace +bool encodeBitmapForClipboardHistory(Gdiplus::Bitmap& bitmap, std::vector& output) { + return makeClipboardHistoryPreview(bitmap, output); +} + bool copyBitmapToClipboard(HWND owner, Gdiplus::Bitmap& bitmap) { OpaqueBgraImage image = renderOpaqueBgra(bitmap); if (!image.valid()) return false; + std::vector historyPreview; + const bool historyPublished = + encodeBitmapForClipboardHistory(bitmap, historyPreview) && + clipboard_history::publishBitmap(historyPreview); + HGLOBAL png = makePng(image); HGLOBAL dibV5 = makeDibV5(image); HGLOBAL dib = makeDib24(image); HBITMAP nativeBitmap = makeClipboardBitmap(image); - HGLOBAL includeInHistory = serializedDword(1); + // If the WinRT history item was accepted, explicitly keep the following + // rich Win32 replacement out of history so it cannot create a duplicate or + // displace the compact preview. Fall back to the documented Win32 opt-in + // when WinRT is unavailable. + HGLOBAL includeInHistory = serializedDword(historyPublished ? 0u : 1u); if (!png && !dibV5 && !dib && !nativeBitmap) { if (includeInHistory) GlobalFree(includeInHistory); return false; diff --git a/src/uploader.cpp b/src/uploader.cpp index efd63e3..3d3e845 100644 --- a/src/uploader.cpp +++ b/src/uploader.cpp @@ -209,7 +209,7 @@ std::unique_ptr performUpload(const Job& job) { } InternetHandle session(WinHttpOpen( - L"NightShot/0.19", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + L"NightShot/0.20", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); if (!session.value) { result->message = L"WinHttpOpen: " + win32Error(GetLastError());