diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index a081452a..3ec89e94 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -1453,7 +1453,7 @@ class _SettingsScreenState extends State { .first; final suggestedName = 'CopyPaste_Backup_$ts'; - final path = await FilePicker.platform.saveFile( + final path = await FilePicker.saveFile( dialogTitle: 'Save Backup', fileName: '$suggestedName.zip', type: FileType.custom, @@ -1494,7 +1494,7 @@ class _SettingsScreenState extends State { Future _restoreBackup() async { final l = AppLocalizations.of(context); - final result = await FilePicker.platform.pickFiles( + final result = await FilePicker.pickFiles( dialogTitle: l.restoreDialogTitle, type: FileType.custom, allowedExtensions: ['zip'], diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 040e64d7..dae1174a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: hotkey_manager: ^0.2.0 window_manager: ^0.5.1 ffi: ^2.2.0 - file_picker: ^10.0.0 + file_picker: ^11.0.0 flutter_acrylic: ^1.1.4 cryptography: ^2.7.0 path: ^1.9.0 diff --git a/core/lib/services/image_processor.dart b/core/lib/services/image_processor.dart index 9ddc7c52..5f9b45a6 100644 --- a/core/lib/services/image_processor.dart +++ b/core/lib/services/image_processor.dart @@ -45,6 +45,21 @@ class ImageProcessor { } if (decoded == null) return null; + if (decoded.numChannels == 4 && decoded.bitsPerChannel == 8) { + var allTransparent = true; + for (final px in decoded) { + if (px.a != 0) { + allTransparent = false; + break; + } + } + if (allTransparent) { + for (final px in decoded) { + px.a = 255; + } + } + } + // Encoding and file-write errors (disk full, permissions) are NOT silenced — // they propagate out of the Isolate and are caught + logged by the caller. final pngBytes = img.encodePng(decoded); diff --git a/core/test/image_processor_test.dart b/core/test/image_processor_test.dart index c57d39af..515ad263 100644 --- a/core/test/image_processor_test.dart +++ b/core/test/image_processor_test.dart @@ -136,4 +136,123 @@ void main() { expect(result.fileSize, equals(savedSize)); }); }); + + group('alpha normalization', () { + test('all-transparent RGBA8 pixels are opaquified with RGB preserved', () { + final src = img.Image(width: 4, height: 4, numChannels: 4); + for (final px in src) { + px.r = 100; + px.g = 150; + px.b = 200; + px.a = 0; + } + final inputBytes = Uint8List.fromList(img.encodePng(src)); + + final result = ImageProcessor.processSync( + imageBytes: inputBytes, + id: 'all-transparent', + imagesDir: tempDir.path, + ); + + expect(result, isNotNull); + final decoded = img.decodeImage( + File(result!.imagePath).readAsBytesSync(), + ); + expect(decoded, isNotNull); + for (final px in decoded!) { + expect(px.a, equals(255)); + expect(px.r, equals(100)); + expect(px.g, equals(150)); + expect(px.b, equals(200)); + } + }); + + test('partial-transparency RGBA8 alpha distribution is preserved', () { + final src = img.Image(width: 4, height: 4, numChannels: 4); + var i = 0; + for (final px in src) { + px.r = 80; + px.g = 80; + px.b = 80; + px.a = switch (i % 3) { + 0 => 0, + 1 => 128, + _ => 255, + }; + i++; + } + final inputBytes = Uint8List.fromList(img.encodePng(src)); + + final result = ImageProcessor.processSync( + imageBytes: inputBytes, + id: 'partial-transparent', + imagesDir: tempDir.path, + ); + + expect(result, isNotNull); + final decoded = img.decodeImage( + File(result!.imagePath).readAsBytesSync(), + ); + expect(decoded, isNotNull); + final alphas = decoded!.map((px) => px.a).toList(); + expect(alphas.where((a) => a == 0).length, greaterThan(0)); + expect(alphas.where((a) => a == 128).length, greaterThan(0)); + expect(alphas.where((a) => a == 255).length, greaterThan(0)); + }); + + test( + 'all-transparent RGBA16 alpha is not mutated (bitsPerChannel guard)', + () { + final src = img.Image( + width: 4, + height: 4, + numChannels: 4, + format: img.Format.uint16, + ); + for (final px in src) { + px.r = 1000; + px.g = 2000; + px.b = 3000; + px.a = 0; + } + final inputBytes = Uint8List.fromList(img.encodePng(src)); + + final result = ImageProcessor.processSync( + imageBytes: inputBytes, + id: 'rgba16-transparent', + imagesDir: tempDir.path, + ); + + expect(result, isNotNull); + final decoded = img.decodeImage( + File(result!.imagePath).readAsBytesSync(), + ); + expect(decoded, isNotNull); + for (final px in decoded!) { + expect(px.a, equals(0)); + } + }, + ); + + test('RGB8 (no alpha channel) round-trips with correct dimensions', () { + final src = img.Image(width: 4, height: 4); + for (final px in src) { + px.r = 10; + px.g = 20; + px.b = 30; + } + final inputBytes = Uint8List.fromList(img.encodePng(src)); + + final result = ImageProcessor.processSync( + imageBytes: inputBytes, + id: 'rgb-no-alpha', + imagesDir: tempDir.path, + ); + + expect(result, isNotNull); + expect(result!.width, equals(4)); + expect(result.height, equals(4)); + expect(File(result.imagePath).existsSync(), isTrue); + }); + }); } diff --git a/listener/windows/listener_plugin.cpp b/listener/windows/listener_plugin.cpp index 46ad1adf..1588cdad 100644 --- a/listener/windows/listener_plugin.cpp +++ b/listener/windows/listener_plugin.cpp @@ -878,31 +878,36 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) { std::wstring wpath = Utf8ToWide(imagePath); - // Use GDI+ to load any image format (PNG, BMP, JPEG, etc.) Gdiplus::Bitmap bitmap(wpath.c_str()); if (bitmap.GetLastStatus() != Gdiplus::Ok) return false; HBITMAP hBitmap = nullptr; - Gdiplus::Color bg(0, 255, 255, 255); + Gdiplus::Color bg(255, 255, 255, 255); if (bitmap.GetHBITMAP(bg, &hBitmap) != Gdiplus::Ok || !hBitmap) return false; BITMAP bm = {}; GetObject(hBitmap, sizeof(bm), &bm); - BITMAPINFOHEADER bih = {}; - bih.biSize = sizeof(BITMAPINFOHEADER); - bih.biWidth = bm.bmWidth; - bih.biHeight = bm.bmHeight; - bih.biPlanes = 1; - bih.biBitCount = 32; - bih.biCompression = BI_RGB; - size_t rowBytes = static_cast(bm.bmWidth) * 4; size_t imgSize = rowBytes * bm.bmHeight; - bih.biSizeImage = static_cast(imgSize); - size_t dibSize = sizeof(BITMAPINFOHEADER) + imgSize; + BITMAPV5HEADER bv5 = {}; + bv5.bV5Size = sizeof(BITMAPV5HEADER); + bv5.bV5Width = bm.bmWidth; + bv5.bV5Height = bm.bmHeight; + bv5.bV5Planes = 1; + bv5.bV5BitCount = 32; + bv5.bV5Compression = BI_BITFIELDS; + bv5.bV5SizeImage = static_cast(imgSize); + bv5.bV5RedMask = 0x00FF0000; + bv5.bV5GreenMask = 0x0000FF00; + bv5.bV5BlueMask = 0x000000FF; + bv5.bV5AlphaMask = 0xFF000000; + bv5.bV5CSType = LCS_WINDOWS_COLOR_SPACE; + bv5.bV5Intent = LCS_GM_IMAGES; + + size_t dibSize = sizeof(BITMAPV5HEADER) + imgSize; HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, dibSize); if (!hMem) { DeleteObject(hBitmap); @@ -916,14 +921,32 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) { return false; } - memcpy(ptr, &bih, sizeof(BITMAPINFOHEADER)); + std::memcpy(ptr, &bv5, sizeof(BITMAPV5HEADER)); + + BITMAPINFOHEADER bih = {}; + bih.biSize = sizeof(BITMAPINFOHEADER); + bih.biWidth = bm.bmWidth; + bih.biHeight = bm.bmHeight; + bih.biPlanes = 1; + bih.biBitCount = 32; + bih.biCompression = BI_RGB; + bih.biSizeImage = static_cast(imgSize); HDC hDC = GetDC(nullptr); - auto* bi = reinterpret_cast(ptr); - int scanLines = GetDIBits(hDC, hBitmap, 0, bm.bmHeight, - static_cast(ptr) + sizeof(BITMAPINFOHEADER), - bi, DIB_RGB_COLORS); + auto* bi = reinterpret_cast(&bih); + int scanLines = GetDIBits( + hDC, hBitmap, 0, bm.bmHeight, + static_cast(ptr) + sizeof(BITMAPV5HEADER), + bi, DIB_RGB_COLORS); ReleaseDC(nullptr, hDC); + + if (scanLines > 0) { + uint8_t* pixels = static_cast(ptr) + sizeof(BITMAPV5HEADER); + for (size_t i = 3; i < imgSize; i += 4) { + pixels[i] = 0xFF; + } + } + GlobalUnlock(hMem); DeleteObject(hBitmap); @@ -941,34 +964,9 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) { } EmptyClipboard(); - bool ok = SetClipboardData(CF_DIB, hMem) != nullptr; + bool ok = SetClipboardData(CF_DIBV5, hMem) != nullptr; if (!ok) GlobalFree(hMem); - if (ok) { - DWORD attr = GetFileAttributesW(wpath.c_str()); - if (attr != INVALID_FILE_ATTRIBUTES) { - size_t pathBytes = (wpath.size() + 1) * sizeof(wchar_t); - size_t dropSize = sizeof(DROPFILES) + pathBytes + sizeof(wchar_t); - HGLOBAL hDrop = GlobalAlloc(GHND, dropSize); - if (hDrop) { - auto* df = static_cast(GlobalLock(hDrop)); - if (df) { - df->pFiles = sizeof(DROPFILES); - df->fWide = TRUE; - auto* dest = reinterpret_cast( - reinterpret_cast(df) + sizeof(DROPFILES)); - memcpy(dest, wpath.c_str(), pathBytes); - GlobalUnlock(hDrop); - if (!SetClipboardData(CF_HDROP, hDrop)) { - GlobalFree(hDrop); - } - } else { - GlobalFree(hDrop); - } - } - } - } - CloseClipboard(); if (ok) last_write_tick_ = GetTickCount64(); return ok; diff --git a/pubspec.lock b/pubspec.lock index 5562c8ac..8c12f5a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: build_runner - sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.15.0" built_collection: dependency: transitive description: @@ -169,14 +169,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" collection: dependency: transitive description: @@ -301,10 +293,10 @@ packages: dependency: transitive description: name: file_picker - sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 url: "https://pub.dev" source: hosted - version: "10.3.10" + version: "11.0.2" fixnum: dependency: transitive description: