commit dc16bba05491f54daf528ed83484ea9b6eab8825 Author: Foxyz Date: Fri Oct 9 06:31:15 2026 +0300 fox_utils 0.1.7: clipboard, drag & drop, Android file saving Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..1b415f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/.metadata b/.metadata new file mode 100755 index 0000000..41ebefc --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..361d746 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +## 0.1.7 + +* Add `DesktopDropX` + `DropTarget` — OS-level drag & drop of files and + images into the app, powered by a vendored desktop_drop engine (v0.7.1, + Apache-2.0): GTK uri-list drag destination on Linux, OLE `IDropTarget` + (`CF_HDROP`) on Windows, dead code on mobile and web. `DesktopDropX` + provides `pathsOf` (dropped file paths) and `readImage` (magic-byte + image sniffing, same table as `ClipboardX.pasteImage`). +* The Linux/Windows plugin entry is now a combined `FoxUtilsPlugin` + registration that wires the vendored pasteboard and desktop_drop engines + from one native entry per platform (the registered plugin class changed + from `PasteboardPlugin` to `FoxUtilsPlugin` — generated registrants + regenerate on the next build). +* New dependencies: `cross_file` (drop items are `XFile`s) and + `universal_platform` (web-safe platform checks), both required by the + vendored engine. + +## 0.1.6 + +* Add `ClipboardX.pasteImage` — reads an image from the clipboard whether it + was copied as image data (browser) or as a file reference (file manager), + on Windows, Linux and Android. Adds a `readFileBytes` channel method for + resolving Android content URIs. + +## 0.1.5 + +* Add `ClipboardX` — clipboard image/files/text copy via a vendored + pasteboard engine (MixinNetwork, Apache-2.0) with modernized builds: + the Android channel is Java (no KGP), the FileProvider is merged from + the plugin manifest (zero per-project setup), image writes are byte-faithful + with magic-byte MIME sniffing, and two upstream bugs are fixed. + +## 0.1.4 + +* Add `AndroidFiles.save` — permission-free file saving to public Android + directories (Pictures/Movies/Music/Download) with an optional media scan. + Android platform channel, no storage permissions required. + +## 0.0.1 + +* TODO: Describe initial release. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..97cfb48 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100755 index 0000000..0f2928d --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# fox_utils + +Reusable Flutter utilities shared across projects: permission-free Android +file saving, a cross-platform clipboard wrapper, and desktop drag & drop. + +## Features + +### `AndroidFiles.save` — save files to public Android storage + +Writes any bytes to a public Android directory (`Pictures`, `Movies`, +`Music`, `Download`) **without any storage permission**, with an optional +media scan so the file shows up in gallery apps. + +```dart +final path = await AndroidFiles.save( + bytes, // any file type + fileName: 'image.jpg', + location: AndroidPublicLocation.pictures, // pictures | movies | music | downloads + subPath: 'Imagen', // folder structure under the location + scan: true, // false = saved but not indexed (no gallery) +); +``` + +Notes: + +- Requires **no permissions** on Android 10+ (API 29+): creating new files + inside media collections is permission-free. Legacy devices (< API 29) may + fail — the exception carries the native message. +- `scan: false` writes the file without indexing it. On modern Android the + file can still be picked up by a later system rescan; for a hard + "never in gallery" guarantee, save outside a media collection or add a + `.nomedia` file to the folder. +- Throws `FileSaveException` on failure (logged with stack trace). + +### `ClipboardX` — clipboard copy + +```dart +await ClipboardX.copyImage(bytes); // image bytes as-is, MIME sniffed +await ClipboardX.copyFiles(['/path/a.png']); // desktop: files +ClipboardX.copyText('hello'); // text (Flutter built-in) +final text = await ClipboardX.readText(); +final image = await ClipboardX.readImage(); +final files = await ClipboardX.readFiles(); +``` + +Platform matrix: + +| Operation | Android | Windows | Linux | macOS | iOS | +|---|---|---|---|---|---| +| copy image | ✓ (FileProvider) | ✓ (GDI+) | ✓ (file reference) | ✓ | ✓ | +| copy files | – | ✓ (CF_HDROP) | ✓ (GNOME) | ✓ | – | +| copy/read text | ✓ | ✓ | ✓ | ✓ | ✓ | +| read image | ✓ | ✓ | ✓ | ✓ | ✓ | +| read files | ✓ (URIs) | ✓ | ✓ | ✓ | – | + +Notes: + +- Image bytes are placed **as-is** (no re-encode); the extension is sniffed + from magic bytes (PNG/JPEG/GIF/WebP/BMP), so animated GIFs survive. +- **Linux cannot hold raw image bytes** on the clipboard (GTK limitation), so + `copyImage` stages the bytes to a rotating temp file and copies it as a + file reference — pasted apps receive the image file. The staging file is + replaced on each copy. +- **Android requires no manifest setup**: the FileProvider is declared in + this plugin's manifest and merged automatically (`${applicationId}.provider`). + Images are staged in the app cache dir. +- Throws `ClipboardCopyException` on failure. + +### `ClipboardX.pasteImage` — paste an image, whatever the source + +```dart +final Uint8List? image = await ClipboardX.pasteImage(); +if (image != null) { + // send to server, display, whatever +} +``` + +Resolution order: image data on the clipboard first (browser "copy image"), +then the first file reference (file manager "copy"). The file's first bytes +are sniffed (PNG/JPEG/GIF/WebP/BMP) before the full read, so huge non-image +files are never loaded. Returns `null` when the clipboard holds no image. + +Source matrix: + +| Copied from | Windows | Linux | Android | +|---|---|---|---| +| Browser, "copy image" | CF_DIB → image bytes | `image/png` → image bytes | content URI (image mime) → image bytes | +| File manager, "copy" | CF_HDROP → first file's bytes | `text/uri-list` → first file's bytes | content URI → resolved bytes | + +Android notes: + +- Reading the clipboard works **only while the app is in the foreground** + (Android 10+); background reads return `null`. +- Android 12+ shows a system toast ("app pasted from your clipboard") on + read — cosmetic. +- File references come back as `content://` URIs; `pasteImage` resolves them + through the platform channel (`readFileBytes`), so no manifest setup or + permission is needed. + +### `DesktopDropX` + `DropTarget` — drag & drop files and images + +OS-level drag & drop into the app (desktop only). Wrap any widget in +`DropTarget` to get drag enter/exit/update/drop callbacks; `DesktopDropX` +turns the dropped items into plain paths or image bytes: + +```dart +DropTarget( + onDragEntered: (d) => setState(() => dragging = true), + onDragExited: (d) => setState(() => dragging = false), + onDragDone: (details) async { + final paths = DesktopDropX.pathsOf(details.files); // absolute paths + final Uint8List? image = await DesktopDropX.readImage(details.files); + // image == null when the drop contains no readable image + }, + child: myWidget, +) +``` + +Platform notes: + +- **Linux**: GTK drag destination on the whole window (`text/uri-list` from + GNOME/KDE file managers, browsers, …). Items are absolute paths, delivered + in the order dropped. +- **Windows**: OLE `IDropTarget` on the window (`CF_HDROP` from Explorer, + browsers, …). Same path semantics; `DropDoneDetails.localPosition` is + already DPI-corrected. +- **Android/iOS/web**: no native handler — the widget renders its child and + no drop events ever arrive. **macOS** is not vendored (no macOS target); + re-adding it is a copy of upstream's `macos/` folder behind the same API. +- `DropItem` extends `XFile`: `item.path`, `item.name`, `item.readAsBytes()`, + `item.openRead()`, … are available directly. +- Drop targets receive events for the **whole window**; an invisible + `DropTarget` keeps receiving them, so set `enable: false` when pushing a + page over it. Coordinate-based `DropTarget` bounds filtering is handled by + the widget. +- `readImage` never throws: unreadable files are logged with a stack trace + and skipped; `null` means "no image in this drop". + +## Vendored engines + +The clipboard channels (`pasteboard` method channel) are **vendored from +MixinNetwork's `pasteboard` package (v0.5.0, Apache-2.0)** because upstream's +build applies the legacy Kotlin Gradle Plugin (KGP), which future Flutter +versions will reject. The vendored copy lives in `lib/vendor/` (Dart) and the +native platform folders, with these changes: + +- **Android**: the Kotlin plugin was ported to Java and merged into + `FoxUtilsPlugin` (one plugin class, no KGP, no Kotlin in the module). +- **Byte-faithful image writes**: upstream decodes and re-encodes images as + PNG (destroying GIF animation and metadata); ours writes the original bytes + with magic-byte MIME sniffing. +- **Merged FileProvider**: declared in the plugin manifest — zero per-project + setup (upstream requires a manual manifest entry per app). +- **Two upstream bugs fixed**: clipboard image reads ran synchronously on the + calling thread (now on the executor), and `writeFiles` dropped the first + clip item (now assembles correctly). +- **Linux/Windows**: C++ sources byte-identical, include paths and CMake + targets renamed to the `fox_utils` package name. + +To diff against upstream: `git diff` the folders against +`packages/pasteboard/` at tag `v0.5.0` of +`https://github.com/MixinNetwork/flutter-plugins`. If upstream ever +modernizes their build, un-vendoring is a swap behind the same `ClipboardX` +API. + +The drag & drop channels (`desktop_drop` method channel) are **vendored from +MixinNetwork's `desktop_drop` package (v0.7.1, Apache-2.0)** for the same +reason: its `android/` folder applies KGP. Only the Dart sources and the +Linux/Windows native folders are vendored — the Android/Kotlin, macOS and +web bindings are omitted (Android DnD is out of scope, and there is no macOS +target in the app). Linux/Windows native sources are byte-identical to +upstream except the include path rename (`include/fox_utils/`). Both engines +are registered from one native entry per platform: +`fox_utils_plugin_register_with_registrar` (Linux) / +`FoxUtilsPluginRegisterWithRegistrar` (Windows) — the plugin class in +`pubspec.yaml` is no longer `PasteboardPlugin`. To diff against upstream, use +tag `desktop_drop-v0.7.1` of the same monorepo; un-vendoring is a swap +behind the same `DesktopDropX` API. + +## License + +MIT for this package's own code; the vendored engines are Apache-2.0 +© MixinNetwork (see `lib/vendor/` and the native platform folders). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100755 index 0000000..e4824a5 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,13 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..aad03b4 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,46 @@ +group = "com.foxyz.fox_utils" +version = "0.1.4" + +buildscript { + val kotlinVersion = "2.4.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.1.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "com.foxyz.fox_utils" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + minSdk = 24 + } +} + +dependencies { + // FileProvider for clipboard image sharing (pasteboard channel). + implementation("androidx.core:core:1.16.0") +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..93dd58d --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "fox_utils" diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0e02b16 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/android/src/main/java/com/foxyz/fox_utils/FoxUtilsPlugin.java b/android/src/main/java/com/foxyz/fox_utils/FoxUtilsPlugin.java new file mode 100644 index 0000000..3e1eaaa --- /dev/null +++ b/android/src/main/java/com/foxyz/fox_utils/FoxUtilsPlugin.java @@ -0,0 +1,306 @@ +package com.foxyz.fox_utils; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +/** + * Fox utils Android plugin. + * + *

Two channels: + *

+ */ +public class FoxUtilsPlugin implements FlutterPlugin, MethodCallHandler { + private static final String FILES_CHANNEL = "fox_utils/android_files"; + private static final String CLIPBOARD_CHANNEL = "pasteboard"; + private static final String CLIPBOARD_X_CHANNEL = "fox_utils/clipboard"; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private Context context; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + context = binding.getApplicationContext(); + new MethodChannel(binding.getBinaryMessenger(), FILES_CHANNEL).setMethodCallHandler(this); + new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_CHANNEL).setMethodCallHandler(this); + new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_X_CHANNEL).setMethodCallHandler(this); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + switch (call.method) { + // --- fox_utils/android_files --- + case "saveFile": + saveFile(call, result); + break; + // --- pasteboard (vendored) --- + case "image": + readImage(result); + break; + case "files": + readFiles(result); + break; + case "html": + readHtml(result); + break; + case "writeFiles": + writeFiles(call, result); + break; + case "writeImage": + writeImage(call, result); + break; + // --- fox_utils/clipboard --- + case "readFileBytes": + readFileBytes(call, result); + break; + default: + result.notImplemented(); + } + } + + // --- fox_utils/android_files --- + + private void saveFile(MethodCall call, Result result) { + final byte[] bytes = call.argument("bytes"); + final String fileName = call.argument("fileName"); + final String location = call.argument("location"); + final String subPath = call.argument("subPath"); + final Boolean scan = call.argument("scan"); + if (bytes == null || fileName == null || location == null || subPath == null || scan == null) { + result.error("INVALID_ARGUMENTS", "saveFile requires bytes, fileName, location, subPath and scan.", null); + return; + } + executor.execute(() -> { + try { + result.success(saveFileSync(bytes, fileName, location, subPath, scan)); + } catch (Exception e) { + result.error("SAVE_FAILED", e.getMessage(), null); + } + }); + } + + @SuppressWarnings("deprecation") // Still works permission-free on modern Android. + private String saveFileSync(byte[] bytes, String fileName, String location, String subPath, boolean scan) + throws IOException { + File base = new File(Environment.getExternalStoragePublicDirectory(directoryFor(location)), subPath); + if (!base.exists() && !base.mkdirs()) { + throw new IOException("Could not create directory " + base); + } + File file = new File(base, fileName); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(bytes); + } + if (scan) { + MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null); + } + return file.getAbsolutePath(); + } + + private static String directoryFor(String location) { + switch (location) { + case "movies": + return Environment.DIRECTORY_MOVIES; + case "music": + return Environment.DIRECTORY_MUSIC; + case "downloads": + return Environment.DIRECTORY_DOWNLOADS; + case "pictures": + default: + return Environment.DIRECTORY_PICTURES; + } + } + + // --- pasteboard channel (vendored from one.mixin.pasteboard, Apache-2.0) --- + + private ClipboardManager clipboard() { + return (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + } + + private void readImage(Result result) { + executor.execute(() -> { + try { + ClipData.Item first = firstClipItem(); + if (first == null || first.getUri() == null) { + result.success(null); + return; + } + Uri uri = first.getUri(); + String mime = context.getContentResolver().getType(uri); + if (mime == null || !mime.startsWith("image")) { + result.success(null); + return; + } + try (InputStream in = context.getContentResolver().openInputStream(uri)) { + if (in == null) { + result.success(null); + return; + } + result.success(readAll(in)); + } + } catch (Exception e) { + result.error("Error", "Failed to read image", e.getMessage()); + } + }); + } + + private void readFiles(Result result) { + ClipData clip = clipboard().getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + result.success(null); + return; + } + List files = new ArrayList<>(); + for (int i = 0; i < clip.getItemCount(); i++) { + Uri uri = clip.getItemAt(i).getUri(); + if (uri != null) { + files.add(uri.toString()); + } + } + result.success(files); + } + + private void readHtml(Result result) { + ClipData.Item first = firstClipItem(); + result.success(first == null ? null : first.getHtmlText()); + } + + private void writeFiles(MethodCall call, Result result) { + @SuppressWarnings("unchecked") + List args = call.arguments(); + if (args == null) { + result.error("NoArgs", "Missing Arguments", null); + return; + } + ClipboardManager manager = clipboard(); + ClipData clip = null; + for (String path : args) { + Uri uri = Uri.parse(path); + if (clip == null) { + clip = ClipData.newUri(context.getContentResolver(), "files", uri); + } else { + clip.addItem(new ClipData.Item(uri)); + } + } + if (clip != null) { + manager.setPrimaryClip(clip); + } + result.success(null); + } + + private void writeImage(MethodCall call, Result result) { + byte[] image = call.arguments(); + if (image == null) { + result.error("NoArgs", "Missing Arguments", null); + return; + } + // Bytes are staged as-is (no re-encode); the extension is sniffed from + // the magic bytes so receivers get the correct MIME type — animated + // GIFs and WebP survive copy/paste this way. + final File file = new File(context.getCacheDir(), + UUID.randomUUID().toString() + extensionFor(image)); + executor.execute(() -> { + try { + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(image); + } + Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", file); + clipboard().setPrimaryClip(ClipData.newUri(context.getContentResolver(), "image", uri)); + result.success(null); + } catch (Exception e) { + result.error("Error", "Failed to write image", e.getMessage()); + } + }); + } + + private ClipData.Item firstClipItem() { + ClipData clip = clipboard().getPrimaryClip(); + return clip == null || clip.getItemCount() == 0 ? null : clip.getItemAt(0); + } + + /** Resolves a content URI (e.g. a file manager's clipboard entry) to bytes. */ + private void readFileBytes(MethodCall call, Result result) { + String uri = call.arguments(); + if (uri == null) { + result.error("NoArgs", "Missing Arguments", null); + return; + } + executor.execute(() -> { + try (InputStream in = context.getContentResolver().openInputStream(Uri.parse(uri))) { + if (in == null) { + result.success(null); + return; + } + result.success(readAll(in)); + } catch (Exception e) { + result.error("Error", "Failed to read file", e.getMessage()); + } + }); + } + + /** Sniffs common image magic bytes so the pasted file carries the right MIME type. */ + private static String extensionFor(byte[] b) { + if (b.length >= 3 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8 && (b[2] & 0xFF) == 0xFF) { + return ".jpg"; + } + if (b.length >= 8 && (b[0] & 0xFF) == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) { + return ".png"; + } + if (b.length >= 6 && (b[0] & 0xFF) == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) { + return ".gif"; + } + if (b.length >= 12 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' + && b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') { + return ".webp"; + } + if (b.length >= 2 && b[0] == 'B' && b[1] == 'M') { + return ".bmp"; + } + return ".png"; + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + context = null; + executor.shutdown(); + } +} diff --git a/android/src/main/res/xml/file_paths.xml b/android/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d49e2c8 --- /dev/null +++ b/android/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/android_files.dart b/lib/android_files.dart new file mode 100644 index 0000000..997ba1b --- /dev/null +++ b/lib/android_files.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// A public Android storage location that needs no write permission. +enum AndroidPublicLocation { + pictures, + movies, + music, + downloads, +} + +/// Thrown when saving a file via [AndroidFiles.save] fails. +class FileSaveException implements Exception { + FileSaveException(this.message, {this.details}); + + final String message; + final String? details; + + @override + String toString() => + 'FileSaveException: $message${details != null ? ' — $details' : ''}'; +} + +/// Saves files to public Android directories without any storage permission. +/// +/// Files are written with plain file I/O, so on Android 10+ they are NOT +/// indexed by the media store. Pass [scan] = true to run the media scanner +/// and make the file show up in gallery apps. +class AndroidFiles { + AndroidFiles._(); + + static const MethodChannel _channel = MethodChannel('fox_utils/android_files'); + + /// Writes [bytes] to `//` on shared storage and + /// returns the absolute path. Requires no storage permission on Android 10+. + /// + /// When [scan] is true the file is indexed into the media store (gallery + /// visibility); when false it stays on disk without being indexed. + /// + /// Throws [FileSaveException] on failure (invalid arguments, I/O error, or + /// a platform rejection — e.g. pre-Android-10 devices without legacy + /// storage access). The native error message is carried in the exception. + static Future save( + Uint8List bytes, { + required String fileName, + AndroidPublicLocation location = AndroidPublicLocation.pictures, + String subPath = '', + bool scan = true, + }) async { + try { + final path = await _channel.invokeMethod('saveFile', { + 'bytes': bytes, + 'fileName': fileName, + 'location': location.name, + 'subPath': subPath, + 'scan': scan, + }); + if (path == null) { + throw FileSaveException('saveFile returned no path.'); + } + return path; + } on PlatformException catch (e, st) { + debugPrint('AndroidFiles.save failed (${e.code}): ${e.message}\n$st'); + throw FileSaveException(e.message ?? 'Failed to save file.', details: e.code); + } + } +} diff --git a/lib/clipboard_x.dart b/lib/clipboard_x.dart new file mode 100644 index 0000000..5171aa8 --- /dev/null +++ b/lib/clipboard_x.dart @@ -0,0 +1,152 @@ +import 'dart:io' show Directory, File, Platform; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'vendor/pasteboard.dart'; + +/// Thrown when a clipboard copy fails. +class ClipboardCopyException implements Exception { + ClipboardCopyException(this.message); + + final String message; + + @override + String toString() => 'ClipboardCopyException: $message'; +} + +/// Cross-project clipboard wrapper over the vendored pasteboard engine. +/// +/// Text goes through Flutter's built-in clipboard; images and files go +/// through the native channels (Android: FileProvider-served content URIs, +/// Windows: GDI+/CF_HDROP, Linux: GTK). +class ClipboardX { + ClipboardX._(); + + static const MethodChannel _clipboardChannel = MethodChannel('fox_utils/clipboard'); + + /// Copies [bytes] to the clipboard as an image. The bytes are placed as-is + /// (no re-encode) and the extension is sniffed from the magic bytes, so + /// PNG/JPEG/GIF/WebP/BMP all paste with the correct MIME type. + /// + /// Supported on Android, Windows, macOS and iOS. On Linux, raw image bytes + /// cannot be placed on the clipboard (GTK limitation), so the bytes are + /// staged to a temp file and copied as a file reference instead — pasted + /// apps receive the image file. + /// + /// Throws [ClipboardCopyException] on failure (e.g. invalid image bytes + /// rejected by the platform). + static Future copyImage(Uint8List bytes) async { + if (Platform.isLinux) { + await copyFiles([await _stageLinuxImage(bytes)]); + return; + } + await Pasteboard.writeImage(bytes); + } + + static File? _linuxStaged; + + /// Stages [bytes] to a rotating temp file. Linux clipboards hold file + /// references that receivers resolve at paste time, so the staged file must + /// outlive the copy call; the previous staging file is replaced on each + /// copy. + static Future _stageLinuxImage(Uint8List bytes) async { + final file = File('${Directory.systemTemp.path}/fox_clipboard_image.png'); + await file.writeAsBytes(bytes); + final old = _linuxStaged; + if (old != null && old.path != file.path) { + try { + await old.delete(); + } on Exception catch (e, st) { + // Stale staging file is harmless; log and move on. + debugPrint('Failed to delete stale clipboard staging file: $e\n$st'); + } + } + _linuxStaged = file; + return file.path; + } + + /// Copies [paths] (absolute file paths) to the clipboard as files. + /// + /// Throws [ClipboardCopyException] when the native write fails or reports + /// failure (pasteboard's `writeFiles` returns a bool that is converted to + /// an exception here). + static Future copyFiles(List paths) async { + final ok = await Pasteboard.writeFiles(paths); + if (!ok) { + throw ClipboardCopyException('Failed to copy files: $paths'); + } + } + + /// Copies [text] to the clipboard. + static void copyText(String text) => Pasteboard.writeText(text); + + static Future readText() => Pasteboard.text; + + static Future readImage() => Pasteboard.image; + + static Future> readFiles() => Pasteboard.files(); + + /// Reads an image from the clipboard regardless of how it was copied: + /// image data (a browser's "copy image") or a file reference (a file + /// manager's copy). Returns the image bytes, or null when the clipboard + /// holds no image. + /// + /// Resolution order: + /// 1. image data on the clipboard (browser copy) — all platforms; + /// 2. the first file reference (file manager copy) — desktop paths are + /// read directly, Android content URIs are resolved via the platform + /// channel. The file's first bytes are sniffed to confirm an image + /// before the full read, so huge non-image files are never loaded. + static Future pasteImage() async { + final image = await readImage(); + if (image != null) return image; + + final files = await readFiles(); + if (files.isEmpty) return null; + + final Uint8List? bytes; + if (Platform.isAndroid) { + bytes = await _readFileBytes(files.first); + } else { + final file = File(files.first); + try { + final raf = file.openSync(); + final head = raf.readSync(16); + raf.closeSync(); + if (!_looksLikeImage(head)) return null; + bytes = await file.readAsBytes(); + } on Exception catch (e, st) { + debugPrint('pasteImage: could not read clipboard file ${file.path}: $e\n$st'); + return null; + } + } + if (bytes == null || !_looksLikeImage(bytes)) return null; + return bytes; + } + + static Future _readFileBytes(String uri) async { + try { + return await _clipboardChannel.invokeMethod('readFileBytes', uri); + } on PlatformException catch (e, st) { + debugPrint('pasteImage: failed to read $uri: ${e.message}\n$st'); + return null; + } + } + + /// Sniffs common image magic bytes (PNG/JPEG/GIF/WebP/BMP). + static bool _looksLikeImage(Uint8List b) { + if (b.length < 4) return false; + if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return true; // jpeg + if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return true; // png + if (b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) return true; // gif + if (b.length >= 12 && + b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && + b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') { + return true; // webp + } + if (b[0] == 'B' && b[1] == 'M') return true; // bmp + return false; + } +} diff --git a/lib/desktop_drop_x.dart b/lib/desktop_drop_x.dart new file mode 100644 index 0000000..6d2e0ac --- /dev/null +++ b/lib/desktop_drop_x.dart @@ -0,0 +1,149 @@ +import 'dart:io' show File; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'vendor/desktop_drop.dart' show DropItem, DropItemDirectory; + +// The widget API and drop item model come from the vendored engine: +// DropTarget (drag-enter/exit/updated/done callbacks), DropItem / DropItemFile / +// DropItemDirectory, DropDoneDetails and DropEventDetails. +export 'vendor/desktop_drop.dart' + show + DropTarget, + DropItem, + DropItemFile, + DropItemDirectory, + DropDoneDetails, + DropEventDetails; + +/// Drag & drop of files and images from the OS into the app. +/// +/// The widget side of this API is [DropTarget] (re-exported from this +/// library). Wrap any widget in a [DropTarget] to receive drag-enter, +/// drag-exit, drag-update and drop callbacks for OS-level drags — anything +/// that can produce file URIs or file lists: file managers, browsers, +/// terminals, image editors. +/// +/// [DesktopDropX] provides the ergonomics the app needs on top of the raw +/// [DropItem] list delivered by [DropDoneDetails.files]: extracting plain +/// file paths and loading the first image among the dropped items. +/// +/// Platform behavior: +/// - **Linux**: the native side installs a GTK drag destination on the whole +/// Flutter window and delivers `text/uri-list` (GNOME/KDE file managers, +/// browsers, etc.). Dropped items are absolute filesystem paths. +/// - **Windows**: the native side registers an OLE `IDropTarget` on the +/// window and reads `CF_HDROP`, so drops work from Explorer and any +/// browser. Dropped items are absolute filesystem paths. +/// - **Android/iOS**: not implemented — this package does not vendor the +/// desktop_drop mobile bindings. [DropTarget] compiles and renders its +/// child, but no native handler answers the channel, so no drop events +/// ever arrive. +/// - **macOS**: not vendored (no macOS target in the app); re-adding it is +/// a copy of upstream's `macos/` folder behind the same API. +/// +/// Errors: nothing here throws. A drop that contains no readable image is an +/// expected outcome (returns `null`), and file reads that fail (stale drop, +/// permissions) are logged with a stack trace and skipped. +class DesktopDropX { + DesktopDropX._(); + + /// Returns one filesystem path per top-level dropped item. + /// + /// Each item in [items] maps to its path — files and directories alike; a + /// dropped folder yields its own path, its contents are not expanded + /// ([DropItemDirectory.children] is only populated on macOS, which is not + /// vendored). Items that carry no path (web data drops) yield `''`. + /// + /// Pass [DropDoneDetails.files] from a drop callback to get the paths the + /// app can hand to file I/O: + /// + /// ```dart + /// DropTarget( + /// onDragDone: (details) { + /// final paths = DesktopDropX.pathsOf(details.files); + /// // read, copy, upload… + /// }, + /// child: …, + /// ) + /// ``` + static List pathsOf(List items) => + items.map((item) => item.path).toList(); + + /// Reads the first image among [items] and returns its bytes. + /// + /// Resolution order follows [items]: for each item the first 16 bytes are + /// sniffed for image magic bytes (PNG/JPEG/GIF/WebP/BMP — same table as + /// `ClipboardX.pasteImage`); the first match is loaded in full. Huge + /// non-image files are never fully read. Returns `null` when none of the + /// dropped items is an image, or when none could be read. + /// + /// Directories are skipped ([DropItemDirectory] carries no file bytes). + /// Files that fail to open or read (deleted between drop and read, + /// permission denied) are logged with a stack trace and skipped — a later + /// item is still tried, and the method returns `null` only if nothing + /// readable remains. + /// + /// The returned bytes are as-dropped (no re-encode); use the bytes with + /// `Image.memory` or upload them as-is. + /// + /// ```dart + /// DropTarget( + /// onDragDone: (details) async { + /// final Uint8List? bytes = await DesktopDropX.readImage(details.files); + /// if (bytes == null) return; // drop contained no image + /// // display or upload + /// }, + /// child: …, + /// ) + /// ``` + static Future readImage(List items) async { + for (final item in items) { + if (item is DropItemDirectory) continue; + final path = item.path; + if (path.isEmpty) continue; + final head = await _readHead(path); + if (head == null || !_looksLikeImage(head)) continue; + try { + final bytes = await item.readAsBytes(); + if (bytes == null || !_looksLikeImage(bytes)) continue; + return bytes; + } on Exception catch (e, st) { + debugPrint( + 'DesktopDropX.readImage: failed reading ${item.path}: $e\n$st'); + } + } + return null; + } + + static Future _readHead(String path) async { + try { + final raf = File(path).openSync(); + final head = raf.readSync(16); + raf.closeSync(); + return head; + } on Exception catch (e, st) { + debugPrint('DesktopDropX.readImage: could not read $path: $e\n$st'); + return null; + } + } + + /// Sniffs common image magic bytes (PNG/JPEG/GIF/WebP/BMP). + /// + /// Shares the table used by `ClipboardX._looksLikeImage`; kept as a local + /// copy so the two APIs stay independent. + static bool _looksLikeImage(Uint8List b) { + if (b.length < 4) return false; + if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return true; // jpeg + if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return true; // png + if (b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) return true; // gif + if (b.length >= 12 && + b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && + b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') { + return true; // webp + } + if (b[0] == 'B' && b[1] == 'M') return true; // bmp + return false; + } +} \ No newline at end of file diff --git a/lib/dio_exceptions.dart b/lib/dio_exceptions.dart new file mode 100755 index 0000000..d88fc37 --- /dev/null +++ b/lib/dio_exceptions.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +extension DioExceptionX on DioException { + String get userMessage { + switch (type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return 'Connection timeout. Please check your internet.'; + case DioExceptionType.badResponse: + final statusCode = response?.statusCode; + if (statusCode == 401) return 'Unauthorized. Please log in again.'; + if (statusCode == 404) return 'Resource not found.'; + if (statusCode != null && statusCode >= 500) { + return 'Server error (Status $statusCode).'; + } + return 'Request failed with status $statusCode.'; + case DioExceptionType.cancel: + return 'Request was cancelled.'; + case DioExceptionType.unknown: + return 'An unexpected error occurred.'; + default: + return 'Request failed.'; + } + } + + String toDebugString() { + final buffer = StringBuffer(); + buffer.writeln('🚨 [DioException] ${type.name}'); + buffer.writeln('📤 Request: ${requestOptions.method} ${requestOptions.uri}'); + if (response != null) { + buffer.writeln('📥 Response: ${response!.statusCode}'); + if (response!.data != null) { + buffer.writeln(' Body: ${_formatData(response!.data)}'); + } + } else if (error != null) { + buffer.writeln('⚠️ Error: ${error.toString()}'); + } + return buffer.toString().trim(); + } + + String _formatData(dynamic data) { + if (data == null) return 'null'; + if (data is Map || data is List) { + try { + const encoder = JsonEncoder.withIndent(' '); + return encoder.convert(data); + } catch (_) { + return data.toString(); + } + } + return data.toString(); + } +} + +extension DiscordDioException on DioException { + /// Generates a Markdown formatted error report suitable for Discord messages. + String toDiscordMarkdown() { + final emoji = _getEmoji(); + final title = _getFriendlyTitle(); + final message = userMessage; + + final buffer = StringBuffer(); + buffer.writeln('$emoji **$title**'); + buffer.writeln(); + buffer.writeln(message); + buffer.writeln(); + buffer.writeln('`${requestOptions.method} ${requestOptions.uri}`'); + buffer.writeln(); + + // Technical details + buffer.writeln('```yaml'); + buffer.writeln('Type: ${type.name}'); + if (response != null) { + buffer.writeln('Status: ${response!.statusCode}'); + } + if (error != null) { + final errorStr = error.toString(); + buffer.writeln('Error: ${errorStr.length > 200 ? '${errorStr.substring(0, 200)}...' : errorStr}'); + } + buffer.writeln('```'); + + // Ensure whole message fits in Discord's 2000‑char limit + var result = buffer.toString().trim(); + const maxLength = 2000; + if (result.length > maxLength) { + result = '${result.substring(0, maxLength - 3)}...'; + } + return result; + } + + /// Human‑readable message, enhanced with the server’s own error message if available. + String get userMessage { + print("Server message: ${response?.data}"); + final base = _baseUserMessage(); + final server = _getServerMessage(); + if (server == null || server.isEmpty) return base; + if (base.contains(server)) return base; // avoid duplication + return '$base — $server'; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + String _baseUserMessage() { + switch (type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return 'The connection timed out.'; + + case DioExceptionType.connectionError: + final errorStr = error?.toString().toLowerCase() ?? ''; + if (errorStr.contains('semaphore timeout') || errorStr.contains('timed out')) { + return 'Connection timed out. The server took too long to respond.'; + } + if (errorStr.contains('connection refused')) { + return 'Connection refused. The server is likely offline.'; + } + if (errorStr.contains('failed host lookup')) { + return 'DNS Lookup Failed. Could not reach the host.'; + } + return 'A network connection error occurred.'; + + case DioExceptionType.badResponse: + final statusCode = response?.statusCode; + // Common client errors + if (statusCode == 400) return 'Bad Request (400).'; + if (statusCode == 401) return 'Unauthorized (401). Authentication failed.'; + if (statusCode == 403) return 'Forbidden (403). You lack permission.'; + if (statusCode == 404) return 'Not Found (404). The resource does not exist.'; + if (statusCode == 422) return 'Validation Error (422). Some fields are invalid.'; + if (statusCode == 429) return 'Too Many Requests (429). You are being rate‑limited.'; + // Server errors + if (statusCode != null && statusCode >= 500) { + if (statusCode == 503) return 'Service Unavailable (503).'; + return 'Server Error ($statusCode). Please try again later.'; + } + return 'Request failed with status $statusCode.'; + + case DioExceptionType.cancel: + return 'The request was cancelled.'; + + case DioExceptionType.unknown: + final errorStr = error?.toString().toLowerCase() ?? ''; + if (errorStr.contains('socketexception')) { + if (errorStr.contains('connection refused')) return 'Server connection refused.'; + if (errorStr.contains('failed host lookup')) return 'Could not find the server address.'; + if (errorStr.contains('semaphore timeout')) return 'Network timeout.'; + return 'Network connection unstable.'; + } + return 'An unexpected error occurred.'; + + default: + return 'Request failed.'; + } + } + + String? _getServerMessage() { + final data = response?.data; + if (data is Map) { + // Try common top‑level keys + for (final key in ['message', 'error', 'detail', 'description']) { + final val = data[key]; + if (val is String && val.trim().isNotEmpty) return val.trim(); + } + // Nested errors map (e.g. { "errors": { "email": ["is taken"] } }) + final errors = data['errors']; + if (errors is Map) { + for (final entry in errors.entries) { + final v = entry.value; + if (v is List && v.isNotEmpty) return '${entry.key}: ${v.first}'; + if (v is String && v.isNotEmpty) return '${entry.key}: $v'; + } + } + } else if (data is List && data.isNotEmpty) { + if (data.first is Map) { + return _getServerMessageFromMap(data.first as Map); + } + } else if (data is String && data.isNotEmpty) { + // Plain text body – use it directly, but keep it short + return data.length > 200 ? '${data.substring(0, 200)}...' : data; + } + return null; + } + + // Helper for when the first element of a list is a map + String? _getServerMessageFromMap(Map map) { + print("Server has map: ${map}"); + for (final key in ['message', 'error', 'detail']) { + final val = map[key]; + if (val is String && val.trim().isNotEmpty) { + return val.trim(); + } + } + return null; + } + + String _getFriendlyTitle() { + if (type == DioExceptionType.badResponse) return 'API Error'; + return 'Network Error'; + } + + String _getEmoji() { + if (type == DioExceptionType.badResponse) return '⚠️'; + return '⛔'; + } +} diff --git a/lib/error_window.dart b/lib/error_window.dart new file mode 100755 index 0000000..7877186 --- /dev/null +++ b/lib/error_window.dart @@ -0,0 +1,115 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'dio_exceptions.dart'; // Assuming they are in the same package + +class ErrorWindow extends StatelessWidget { + final Object? originalError; + final Widget? child; // For retry buttons, etc. + + const ErrorWindow(this.originalError, {this.child, super.key}); + + @override + Widget build(BuildContext context) { + final error = originalError; + + Widget errorContent = Text( + 'An unexpected error occurred:\n${error.toString()}', + style: Theme.of(context).textTheme.bodyMedium, + ); + + if (error is DioException) { + errorContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildFriendlyHeader(context, error), + const Divider(height: 30), + Text( + 'Technical Details:', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + error.toDebugString(), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.white70, + ), + ), + ), + ], + ); + } + + return Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 600, minHeight: 200), + padding: const EdgeInsets.all(20), + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3), + ), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + errorContent, + if (child != null) + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align(alignment: Alignment.bottomRight, child: child), + ), + ], + ), + ), + ), + ); + } + + Widget _buildFriendlyHeader(BuildContext context, DioException error) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_rounded, color: Colors.redAccent, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + error.userMessage, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + const SizedBox(height: 4), + Text( + 'Request: ${error.requestOptions.method} ${error.requestOptions.uri.path}', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/fox_utils.dart b/lib/fox_utils.dart new file mode 100755 index 0000000..b92a470 --- /dev/null +++ b/lib/fox_utils.dart @@ -0,0 +1,9 @@ +export 'android_files.dart'; +export 'clipboard_x.dart'; +export 'desktop_drop_x.dart'; + +/// A Calculator. +class Calculator { + /// Returns [value] plus 1. + int addOne(int value) => value + 1; +} diff --git a/lib/random_utils.dart b/lib/random_utils.dart new file mode 100755 index 0000000..04208d3 --- /dev/null +++ b/lib/random_utils.dart @@ -0,0 +1,420 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class GradientText extends StatelessWidget { + const GradientText(this.text, {super.key, required this.gradient}); + + final Widget text; + final Gradient gradient; + + @override + Widget build(BuildContext context) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), + child: text, + ); + } +} + +enum CornerSide { topLeft, topRight, bottomLeft, bottomRight } + +class SingleScoopedCornerClipper extends CustomClipper { + SingleScoopedCornerClipper({required this.radius, this.side = CornerSide.topRight}); + + final double radius; + final CornerSide side; + + @override + Path getClip(Size size) { + final path = Path(); + final w = size.width; + final h = size.height; + + // Start at top-left + path.moveTo(0, 0); + + switch (side) { + case CornerSide.topRight: + // Draw top edge to where the curve starts (right side) + path.lineTo(w - radius, 0); + // Concave arc + path.arcToPoint(Offset(w, radius), radius: Radius.circular(radius), clockwise: false); + // Draw remainder of edges + path.lineTo(w, h); + path.lineTo(0, h); + break; + + case CornerSide.bottomRight: + // Draw right edge to where the curve starts (bottom side) + path.lineTo(w, h - radius); + // Concave arc + path.arcToPoint(Offset(w - radius, h), radius: Radius.circular(radius), clockwise: false); + // Draw remainder of edges + path.lineTo(0, h); + break; + + case CornerSide.bottomLeft: + // Draw bottom edge to where the curve starts (left side) + path.lineTo(radius, h); + // Concave arc + path.arcToPoint(Offset(0, h - radius), radius: Radius.circular(radius), clockwise: false); + // Draw remainder of edges + path.lineTo(0, 0); // Already moved here, just closing path + break; + + case CornerSide.topLeft: + // Draw left edge to where the curve starts (top side) + path.lineTo(0, radius); + // Concave arc + path.arcToPoint(Offset(radius, 0), radius: Radius.circular(radius), clockwise: false); + // Draw remainder of edges + path.lineTo(w, 0); + path.lineTo(w, h); + path.lineTo(0, h); + break; + } + + path.close(); + return path; + } + + @override + bool shouldReclip(SingleScoopedCornerClipper oldClipper) => oldClipper.radius != radius || oldClipper.side != side; +} + +extension IntBytesExtension on int { + /// Converts to Kilobytes (1024 bytes) + int get kb => this * 1024; + + /// Converts to Megabytes (1024 * 1024 bytes) + int get mb => this * 1024 * 1024; + + /// Converts to Gigabytes (1024 * 1024 * 1024 bytes) + int get gb => this * 1024 * 1024 * 1024; + + /// Converts to Terabytes (1024^4 bytes) + int get tb => this * 1024 * 1024 * 1024 * 1024; +} + +String formatBytes(int bytes, {int decimals = 2, bool useBinary = true}) { + if (bytes <= 0) return "0 B"; + + // Choose between binary (1024) or decimal (1000) base + final base = useBinary ? 1024.0 : 1000.0; + final units = useBinary + ? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + : ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + final exp = (log(bytes) / log(base)).floor(); + final size = bytes / pow(base, exp); + + // Clamp the exponent to avoid index out of bounds + final unitIndex = exp.clamp(0, units.length - 1); + + return '${size.toStringAsFixed(decimals)} ${units[unitIndex]}'; +} + +// Extension method for easier usage +extension BytesFormatter on int { + String toReadableSize({int decimals = 2}) => formatBytes(this, decimals: decimals); +} + +extension DateTimeExtension on DateTime { + String timeAgo() { + final diff = DateTime.now().difference(this); + if (diff.inDays >= 1) return '${diff.inDays} days ago'; + if (diff.inHours >= 1) return '${diff.inHours} hours ago'; + if (diff.inMinutes >= 1) return '${diff.inMinutes} minutes ago'; + return 'Just now'; + } + + String toSmartDateWithTime() { + final now = DateTime.now(); + final diff = now.difference(this); + + // Helper for "14:30" format + final clockTime = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; + + String datePart; + + // 1. Just now + if (diff.inMinutes < 1) { + datePart = 'just now'; + } + // 2. Minute precision (up to 1 hour) + else if (diff.inHours < 1) { + datePart = '${diff.inMinutes}m ago'; + } + // 3. Hour precision (up to 24 hours) + else if (diff.inDays < 1) { + datePart = '${diff.inHours}h ago'; + } + // 4. Compact date (older than a day) + else if (year == now.year) { + datePart = '${_getMonth(month)} $day'; + } else { + datePart = '${_getMonth(month)} $day, $year'; + } + + // Combine them: "5m ago at 14:30" or "Jan 2 at 07:11" + return '$datePart at $clockTime'; + } + + String _getMonth(int month) { + return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month - 1]; + } + + String _getWeekday(int day) { + return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][day - 1]; + } +} + +extension CompactDateTime on DateTime { + String toCompactPostDate() { + final now = DateTime.now(); + final difference = now.difference(this); + + // 1. Less than a minute: "Just now" + if (difference.inMinutes < 1) { + return 'now'; + } + + // 2. Less than an hour: "5m", "59m" + if (difference.inHours < 1) { + return '${difference.inMinutes}m'; + } + + // 3. Less than a day: "2h", "23h" + if (difference.inDays < 1) { + return '${difference.inHours}h'; + } + + // 4. Less than a week: "Mon", "Tue" + if (difference.inDays < 7) { + return _getWeekday(weekday); + } + + // 5. This calendar year: "Mar 12" + if (year == now.year) { + return '${_getMonth(month)} $day'; + } + + // 6. Different year: "Mar 2024" + return '${_getMonth(month)} $year'; + } + + // Helper for short weekdays + String _getWeekday(int day) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return days[day - 1]; + } + + // Helper for short months + String _getMonth(int month) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months[month - 1]; + } +} + +extension TimeGradientColor on DateTime { + Color toSkyColor(Color baseColor) { + // Define the color transition for a full 24-hour cycle + const skyline = LinearGradient( + colors: [ + Color(0xFF0F172A), // 00:00 - Deep Night + Color(0xFF334155), // 06:00 - Dawn + Color(0xFF7DD3FC), // 12:00 - Noon + Color(0xFFFB923C), // 18:00 - Sunset + Color(0xFF0F172A), // 24:00 - Back to Night + ], + stops: [0.0, 0.25, 0.5, 0.75, 1.0], + ); + + // Calculate the day's progress (0.0 to 1.0) + final double dayProgress = (hour * 60 + minute) / (24 * 60); + + // Sample the color from the gradient + final Color sampledColor = _sampleGradient(skyline, dayProgress); + + // Blend it subtly with your theme's surface color (15% intensity) + return Color.lerp(baseColor, sampledColor, 0.15)!; + } + + // Helper to interpolate color from a LinearGradient + Color _sampleGradient(LinearGradient gradient, double t) { + if (t <= 0) return gradient.colors.first; + if (t >= 1) return gradient.colors.last; + + final int segmentCount = gradient.colors.length - 1; + final double segmentValue = t * segmentCount; + final int index = segmentValue.floor(); + final double weight = segmentValue - index; + + return Color.lerp(gradient.colors[index], gradient.colors[index + 1], weight)!; + } +} + +extension NullableProgress01 on ImageChunkEvent? { + /// 0.0 → 1.0 while loading; null when unknown. + double? get progress { + final chunk = this; + if (chunk == null) return null; + + final total = chunk.expectedTotalBytes; + return (total != null && total > 0) ? (chunk.cumulativeBytesLoaded / total).clamp(0.0, 1.0) : null; + } +} + +extension ObjectExt on T { + R let(R Function(T e) block) => block(this); +} + +extension ColorUtils on Color { + // --------------------------------------------------------- + // 1. Brightness Manipulation (HSL Based) + // --------------------------------------------------------- + + /// Darkens the color by a given [amount] (0.0 to 1.0). + /// + /// Defaults to 10% (0.1). + /// Uses HSL calculation to preserve color hue. + // Color darken([double amount = 0.1]) { + // assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1'); + + // final hsl = HSLColor.fromColor(this); + // final newLightness = (hsl.lightness - amount).clamp(0.0, 1.0); + + // return hsl.withLightness(newLightness).toColor(); + // } + + // /// Lightens the color by a given [amount] (0.0 to 1.0). + // /// + // /// Defaults to 10% (0.1). + // /// Uses HSL calculation to preserve color hue. + // Color lighten([double amount = 0.1]) { + // assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1'); + + // final hsl = HSLColor.fromColor(this); + // final newLightness = (hsl.lightness + amount).clamp(0.0, 1.0); + + // return hsl.withLightness(newLightness).toColor(); + // } + + // --------------------------------------------------------- + // 2. Opacity & Color Mixing + // --------------------------------------------------------- + + /// Returns a new color with the specified opacity. + /// Shortcut for `withOpacity`. + Color withOpacityValue(double opacity) { + return withOpacity(opacity); + } + + /// Blends this color with another color. + /// + /// [amount] defines how much of [color] to mix in (0.0 to 1.0). + /// 0.0 results in the original color, 1.0 results in the input color. + Color mix(Color color, [double amount = 0.5]) { + assert(amount >= 0 && amount <= 1, 'Amount must be between 0 and 1'); + + return Color.lerp(this, color, amount)!; + } + + // --------------------------------------------------------- + // 3. Color Properties & Conversion + // --------------------------------------------------------- + + /// Returns the color as a Hex string (e.g. "#FF0000"). + /// Includes alpha channel if [includeAlpha] is true. + String toHexString({bool includeAlpha = false}) { + if (includeAlpha) { + return '#${value.toRadixString(16).padLeft(8, '0').toUpperCase()}'; + } + return '#${value.toRadixString(16).substring(2).padLeft(6, '0').toUpperCase()}'; + } + + /// Calculates the relative luminance. + /// Useful for determining if the color is "light" or "dark". + bool get isDark => computeLuminance() < 0.5; + + bool get isLight => !isDark; + + /// Returns a contrasting text color (Black or White) + /// suitable for reading on top of this color. + Color get contrastingColor => isDark ? Colors.white : Colors.black; +} + +/// Optional: Extension on String to parse Hex colors easily. +extension StringColorUtils on String { + /// Parses a hex string to a Color. + /// Supports formats: "FF0000", "#FF0000", "0xFF0000", "#FFFF0000". + Color toColor() { + var hexColor = replaceAll('#', '').replaceAll('0x', ''); + + if (hexColor.length == 6) { + hexColor = 'FF$hexColor'; // Add full opacity if alpha is missing + } + + return Color(int.parse(hexColor, radix: 16)); + } +} + +enum Side { top, left, bottom, right } + +extension NumBorderRadiusExtension on num { + /// Creates a circular radius where specific sides are flattened (radius = 0). + /// + /// Example: + /// ``` + /// // A pill shape flattened on the left side + /// Container( + /// decoration: BoxDecoration( + /// borderRadius: 50.circularExcept({Side.left}), + /// color: Colors.blue, + /// ), + /// ) + /// ``` + BorderRadius circularExcept(Set excludedSides) { + final double r = toDouble(); + + // If a side is in the exclusion set, its corners become 0. + // Otherwise, they take the radius [r]. + + // Top-Left is affected by Top OR Left + final tl = (excludedSides.contains(Side.top) || excludedSides.contains(Side.left)) ? 0.0 : r; + + // Top-Right is affected by Top OR Right + final tr = (excludedSides.contains(Side.top) || excludedSides.contains(Side.right)) ? 0.0 : r; + + // Bottom-Left is affected by Bottom OR Left + final bl = (excludedSides.contains(Side.bottom) || excludedSides.contains(Side.left)) ? 0.0 : r; + + // Bottom-Right is affected by Bottom OR Right + final br = (excludedSides.contains(Side.bottom) || excludedSides.contains(Side.right)) ? 0.0 : r; + + return BorderRadius.only( + topLeft: Radius.circular(tl), + topRight: Radius.circular(tr), + bottomLeft: Radius.circular(bl), + bottomRight: Radius.circular(br), + ); + } + + BorderRadius get circular { + return BorderRadius.circular(this.toDouble()); + } +} + +// Optional: Extension on BorderRadius itself if you want to modify an existing one +extension BorderRadiusModifier on BorderRadius { + BorderRadius exclude(Set sides) { + return copyWith( + topLeft: sides.contains(Side.top) || sides.contains(Side.left) ? Radius.zero : topLeft, + topRight: sides.contains(Side.top) || sides.contains(Side.right) ? Radius.zero : topRight, + bottomLeft: sides.contains(Side.bottom) || sides.contains(Side.left) ? Radius.zero : bottomLeft, + bottomRight: sides.contains(Side.bottom) || sides.contains(Side.right) ? Radius.zero : bottomRight, + ); + } +} diff --git a/lib/vendor/desktop_drop.dart b/lib/vendor/desktop_drop.dart new file mode 100644 index 0000000..b0d30e1 --- /dev/null +++ b/lib/vendor/desktop_drop.dart @@ -0,0 +1,3 @@ +export 'src/drop_target.dart'; +export 'src/drop_item.dart'; +export 'src/channel.dart'; diff --git a/lib/vendor/pasteboard.dart b/lib/vendor/pasteboard.dart new file mode 100644 index 0000000..158504e --- /dev/null +++ b/lib/vendor/pasteboard.dart @@ -0,0 +1,44 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'src/pasteboard_platform_web.dart' + if (dart.library.io) 'src/pasteboard_platform_io.dart'; + +class Pasteboard { + /// Returns the image data of the pasteboard. + /// + /// available on iOS, Desktop, Web, Android + static Future get image => pasteboard.image; + + /// only available on Windows and the web. + /// Get "HTML format" from system pasteboard. + /// HTML format: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767917(v=vs.85) + static Future get html => pasteboard.html; + + /// available on iOS, Android, Web + /// + /// set image data to system pasteboard. + static Future writeImage(Uint8List? image) => + pasteboard.writeImage(image); + + /// available on Desktop, Android. + /// + /// Android: paths are content uris, must read by content resolver. + /// + /// Get files from system pasteboard. + static Future> files() => pasteboard.files(); + + /// Only available on desktop platforms. + /// + /// Set files to system pasteboard. + static Future writeFiles(List files) => + pasteboard.writeFiles(files); + + /// Available on all platforms. + /// Get text from system pasteboard. + static Future get text => pasteboard.text; + + /// Available on all platforms. + /// Set text to system pasteboard. + static void writeText(String value) => pasteboard.writeText(value); +} diff --git a/lib/vendor/src/channel.dart b/lib/vendor/src/channel.dart new file mode 100644 index 0000000..c0e4ee3 --- /dev/null +++ b/lib/vendor/src/channel.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; + +import 'package:fox_utils/vendor/src/drop_item.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'events.dart'; +import 'web_drop_item.dart'; + +typedef RawDropListener = void Function(DropEvent); + +class DesktopDrop { + static const MethodChannel _channel = MethodChannel('desktop_drop'); + + DesktopDrop._(); + + static final instance = DesktopDrop._(); + + final _listeners = {}; + + var _inited = false; + + Offset? _offset; + + void init() { + if (_inited) { + return; + } + _inited = true; + _channel.setMethodCallHandler((call) async { + try { + return await _handleMethodChannel(call); + } catch (e, s) { + debugPrint('_handleMethodChannel: $e $s'); + } + }); + } + + /// macOS: Attempt to start security-scoped access for a bookmarked URL. + /// + /// Pass the [DropItem.extraAppleBookmark] bytes from a dropped file that + /// originated outside the app container. Returns `true` if access began. + /// + /// If [bookmark] is empty, this function returns `false` and does not + /// invoke the platform call. Promise files written under your container do + /// not require security-scoped access. + Future startAccessingSecurityScopedResource( + {required Uint8List bookmark}) async { + if (bookmark.isEmpty) return false; + Map resultMap = {}; + resultMap["apple-bookmark"] = bookmark; + final bool? result = await _channel.invokeMethod( + "startAccessingSecurityScopedResource", resultMap); + if (result == null) return false; + return result; + } + + /// macOS: Stop security-scoped access previously started. + /// + /// If [bookmark] is empty, this function returns `true` and does not + /// invoke the platform call, acting as a no-op. + Future stopAccessingSecurityScopedResource( + {required Uint8List bookmark}) async { + if (bookmark.isEmpty) return true; + Map resultMap = {}; + resultMap["apple-bookmark"] = bookmark; + final bool result = await _channel.invokeMethod( + "stopAccessingSecurityScopedResource", resultMap); + return result; + } + + Future _handleMethodChannel(MethodCall call) async { + switch (call.method) { + case "entered": + final position = (call.arguments as List).cast(); + _offset = Offset(position[0], position[1]); + _notifyEvent(DropEnterEvent(location: _offset!)); + break; + case "updated": + if (_offset == null && UniversalPlatform.isLinux) { + final position = (call.arguments as List).cast(); + _offset = Offset(position[0], position[1]); + _notifyEvent(DropEnterEvent(location: _offset!)); + return; + } + final position = (call.arguments as List).cast(); + _offset = Offset(position[0], position[1]); + _notifyEvent(DropUpdateEvent(location: _offset!)); + break; + case "exited": + _notifyEvent(DropExitEvent(location: _offset ?? Offset.zero)); + _offset = null; + break; + case "performOperation": + final paths = (call.arguments as List).cast(); + _notifyEvent( + DropDoneEvent( + location: _offset ?? Offset.zero, + files: paths.map((e) => DropItemFile(e)).toList(), + ), + ); + _offset = null; + break; + case "performOperation_macos": + final items = (call.arguments as List).cast(); + _notifyEvent( + DropDoneEvent( + location: _offset ?? Offset.zero, + files: items.map((raw) { + final path = raw["path"] as String; + final bookmark = raw["apple-bookmark"] as Uint8List?; + final isDir = (raw["isDirectory"] as bool?) ?? false; + final fromPromise = (raw["fromPromise"] as bool?) ?? false; + if (isDir) { + return DropItemDirectory( + path, + const [], + extraAppleBookmark: bookmark, + fromPromise: fromPromise, + ); + } + return DropItemFile( + path, + extraAppleBookmark: bookmark, + fromPromise: fromPromise, + ); + }).toList(), + ), + ); + _offset = null; + break; + + case "performOperation_linux": + // gtk notify 'exit' before 'performOperation'. + final text = (call.arguments as List)[0] as String; + final offset = ((call.arguments as List)[1] as List) + .cast(); + final paths = const LineSplitter().convert(text).map((e) { + try { + final uri = Uri.tryParse(e); + if (uri == null) { + return ''; + } + if (uri.scheme == 'file') { + return uri.toFilePath(); + } + return e; + } catch (error, stacktrace) { + debugPrint('failed to parse linux path: $error $stacktrace'); + } + return ''; + }).where((e) => e.isNotEmpty); + _notifyEvent(DropDoneEvent( + location: Offset(offset[0], offset[1]), + files: paths.map((e) => DropItemFile(e)).toList(), + )); + break; + case "performOperation_web": + final results = (call.arguments as List) + .cast() + .map((e) => WebDropItem.fromJson(e.cast())) + .map((e) => e.toDropItem()) + .toList(); + _notifyEvent( + DropDoneEvent(location: _offset ?? Offset.zero, files: results), + ); + _offset = null; + break; + default: + throw UnimplementedError('${call.method} not implement.'); + } + } + + void _notifyEvent(DropEvent event) { + for (final listener in _listeners) { + listener(event); + } + } + + void addRawDropEventListener(RawDropListener listener) { + assert(!_listeners.contains(listener)); + _listeners.add(listener); + } + + void removeRawDropEventListener(RawDropListener listener) { + assert(_listeners.contains(listener)); + _listeners.remove(listener); + } +} diff --git a/lib/vendor/src/drop_item.dart b/lib/vendor/src/drop_item.dart new file mode 100644 index 0000000..306cf7f --- /dev/null +++ b/lib/vendor/src/drop_item.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; + +import 'package:cross_file/cross_file.dart'; + +/// A dropped item. +/// +/// On desktop, this is usually a filesystem path (file or directory). +/// +/// macOS specifics: +/// - If the drag source provided a real file URL (e.g. Finder/JetBrains), +/// [extraAppleBookmark] will typically be non-null and allow security-scoped +/// access when running sandboxed. +/// - If the drag source used a file promise (e.g. VS Code/Electron), the +/// system delivers bytes into a per-drop temporary folder inside your app's +/// container. In that case [fromPromise] is true and [extraAppleBookmark] +/// is usually null/empty. There is no original source path in this flow. +abstract class DropItem extends XFile { + /// Security-scoped bookmark bytes for the dropped item (macOS only). + /// + /// Use with [DesktopDrop.startAccessingSecurityScopedResource] to gain + /// temporary access to files outside your sandbox. When empty or null, + /// you typically don't need to call start/stop (e.g. promise files in + /// your app's container). + Uint8List? extraAppleBookmark; + + /// True when this item was delivered via a macOS file promise and was + /// written into your app's temporary Drops directory. + /// + /// In this case, the original source path is not available by design. + final bool fromPromise; + DropItem( + super.path, { + super.mimeType, + super.name, + super.length, + super.bytes, + super.lastModified, + this.extraAppleBookmark, + this.fromPromise = false, + }); + + DropItem.fromData( + super.bytes, { + super.mimeType, + super.name, + super.length, + super.lastModified, + super.path, + this.extraAppleBookmark, + this.fromPromise = false, + }) : super.fromData(); +} + +class DropItemFile extends DropItem { + DropItemFile( + super.path, { + super.mimeType, + super.name, + super.length, + super.bytes, + super.lastModified, + super.extraAppleBookmark, + super.fromPromise, + }); + + DropItemFile.fromData( + super.bytes, { + super.mimeType, + super.name, + super.length, + super.lastModified, + super.path, + super.fromPromise, + }) : super.fromData(); +} + +/// A dropped directory. +class DropItemDirectory extends DropItem { + final List children; + + DropItemDirectory( + super.path, + this.children, { + super.mimeType, + super.name, + super.length, + super.bytes, + super.lastModified, + super.extraAppleBookmark, + super.fromPromise, + }); + + DropItemDirectory.fromData( + super.bytes, + this.children, { + super.mimeType, + super.name, + super.length, + super.lastModified, + super.path, + super.extraAppleBookmark, + super.fromPromise, + }) : super.fromData(); +} diff --git a/lib/vendor/src/drop_target.dart b/lib/vendor/src/drop_target.dart new file mode 100644 index 0000000..1ebf467 --- /dev/null +++ b/lib/vendor/src/drop_target.dart @@ -0,0 +1,220 @@ +import 'package:flutter/widgets.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'channel.dart'; +import 'drop_item.dart'; +import 'events.dart'; + +@immutable +class DropDoneDetails { + const DropDoneDetails({ + required this.files, + required this.localPosition, + required this.globalPosition, + }); + + final List files; + final Offset localPosition; + final Offset globalPosition; +} + +class DropEventDetails { + DropEventDetails({ + required this.localPosition, + required this.globalPosition, + }); + + final Offset localPosition; + + final Offset globalPosition; +} + +typedef OnDragDoneCallback = void Function(DropDoneDetails details); + +typedef OnDragCallback = void Function(Detail details); + +/// A widget that accepts draggable files. +class DropTarget extends StatefulWidget { + const DropTarget({ + super.key, + required this.child, + this.onDragEntered, + this.onDragExited, + this.onDragDone, + this.onDragUpdated, + this.enable = true, + }); + + final Widget child; + + /// Callback when drag entered target area. + final OnDragCallback? onDragEntered; + + /// Callback when drag exited target area. + final OnDragCallback? onDragExited; + + /// Callback when drag hover on target area. + final OnDragCallback? onDragUpdated; + + /// Callback when drag dropped on target area. + final OnDragDoneCallback? onDragDone; + + /// Whether to enable drop target. + /// + /// ATTENTION: You should disable drop target when you push a new page/widget in + /// front of this drop target, since the drop target will still receive drag events + /// even it is invisible. + /// https://github.com/MixinNetwork/flutter-plugins/issues/2 + final bool enable; + + @override + State createState() => _DropTargetState(); +} + +enum _DragTargetStatus { + enter, + update, + idle, +} + +class _DropTargetState extends State { + _DragTargetStatus _status = _DragTargetStatus.idle; + + @override + void initState() { + super.initState(); + DesktopDrop.instance.init(); + if (widget.enable) { + DesktopDrop.instance.addRawDropEventListener(_onDropEvent); + } + } + + @override + void didUpdateWidget(DropTarget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.enable && !oldWidget.enable) { + DesktopDrop.instance.addRawDropEventListener(_onDropEvent); + } else if (!widget.enable && oldWidget.enable) { + DesktopDrop.instance.removeRawDropEventListener(_onDropEvent); + if (_status != _DragTargetStatus.idle) { + _updateStatus( + _DragTargetStatus.idle, + localLocation: Offset.zero, + globalLocation: Offset.zero, + ); + } + } + } + + void _onDropEvent(DropEvent event) { + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) { + return; + } + final globalPosition = _scaleHoverPoint(context, event.location); + final position = renderBox.globalToLocal(globalPosition); + bool inBounds = renderBox.paintBounds.contains(position); + if (event is DropEnterEvent) { + if (!inBounds) { + assert(_status == _DragTargetStatus.idle); + } else { + _updateStatus( + _DragTargetStatus.enter, + globalLocation: globalPosition, + localLocation: position, + ); + } + } else if (event is DropUpdateEvent) { + if (_status == _DragTargetStatus.idle && inBounds) { + _updateStatus( + _DragTargetStatus.enter, + globalLocation: globalPosition, + localLocation: position, + ); + } else if ((_status == _DragTargetStatus.enter || + _status == _DragTargetStatus.update) && + inBounds) { + _updateStatus( + _DragTargetStatus.update, + globalLocation: globalPosition, + localLocation: position, + debugRequiredStatus: false, + ); + } else if (_status != _DragTargetStatus.idle && !inBounds) { + _updateStatus( + _DragTargetStatus.idle, + globalLocation: globalPosition, + localLocation: position, + ); + } + } else if (event is DropExitEvent && _status != _DragTargetStatus.idle) { + _updateStatus( + _DragTargetStatus.idle, + globalLocation: globalPosition, + localLocation: position, + ); + } else if (event is DropDoneEvent && + (_status != _DragTargetStatus.idle || UniversalPlatform.isLinux) && + inBounds) { + _updateStatus( + _DragTargetStatus.idle, + debugRequiredStatus: false, + globalLocation: globalPosition, + localLocation: position, + ); + widget.onDragDone?.call(DropDoneDetails( + files: event.files, + localPosition: position, + globalPosition: globalPosition, + )); + } + } + + void _updateStatus( + _DragTargetStatus status, { + bool debugRequiredStatus = true, + required Offset localLocation, + required Offset globalLocation, + }) { + assert(!debugRequiredStatus || _status != status); + _status = status; + final details = DropEventDetails( + localPosition: localLocation, + globalPosition: globalLocation, + ); + switch (_status) { + case _DragTargetStatus.enter: + widget.onDragEntered?.call(details); + break; + case _DragTargetStatus.update: + widget.onDragUpdated?.call(details); + break; + case _DragTargetStatus.idle: + widget.onDragExited?.call(details); + break; + } + } + + @override + void dispose() { + if (widget.enable) { + DesktopDrop.instance.removeRawDropEventListener(_onDropEvent); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +Offset _scaleHoverPoint(BuildContext context, Offset point) { + if (UniversalPlatform.isWindows || UniversalPlatform.isAndroid) { + return point.scale( + 1 / MediaQuery.of(context).devicePixelRatio, + 1 / MediaQuery.of(context).devicePixelRatio, + ); + } + return point; +} diff --git a/lib/vendor/src/events.dart b/lib/vendor/src/events.dart new file mode 100644 index 0000000..5ba169d --- /dev/null +++ b/lib/vendor/src/events.dart @@ -0,0 +1,39 @@ +import 'package:fox_utils/vendor/src/drop_item.dart'; +import 'package:flutter/painting.dart'; + +abstract class DropEvent { + Offset location; + + DropEvent(this.location); + + @override + String toString() { + return '$runtimeType($location)'; + } +} + +class DropEnterEvent extends DropEvent { + DropEnterEvent({required Offset location}) : super(location); +} + +class DropExitEvent extends DropEvent { + DropExitEvent({required Offset location}) : super(location); +} + +class DropUpdateEvent extends DropEvent { + DropUpdateEvent({required Offset location}) : super(location); +} + +class DropDoneEvent extends DropEvent { + final List files; + + DropDoneEvent({ + required Offset location, + required this.files, + }) : super(location); + + @override + String toString() { + return '$runtimeType($location, $files)'; + } +} diff --git a/lib/vendor/src/pasteboard_platform.dart b/lib/vendor/src/pasteboard_platform.dart new file mode 100644 index 0000000..e597903 --- /dev/null +++ b/lib/vendor/src/pasteboard_platform.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; + +abstract class PasteboardPlatform { + Future get image; + + Future get html; + + Future writeImage(Uint8List? image); + + Future> files(); + + Future writeFiles(List files); + + Future get text; + + void writeText(String value); +} diff --git a/lib/vendor/src/pasteboard_platform_io.dart b/lib/vendor/src/pasteboard_platform_io.dart new file mode 100644 index 0000000..609feaa --- /dev/null +++ b/lib/vendor/src/pasteboard_platform_io.dart @@ -0,0 +1,105 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'pasteboard_platform.dart'; + +const PasteboardPlatform pasteboard = PasteboardPlatformIO(); + +class PasteboardPlatformIO implements PasteboardPlatform { + const PasteboardPlatformIO(); + + static const MethodChannel _channel = MethodChannel('pasteboard'); + + @override + Future> files() async { + final files = await _channel.invokeMethod('files'); + return files?.cast() ?? const []; + } + + @override + Future get html async { + if (Platform.isWindows || Platform.isAndroid) { + return await _channel.invokeMethod('html') as String?; + } + return null; + } + + @override + Future get image async { + final image = await _channel.invokeMethod('image'); + + if (image == null) { + return null; + } + if (Platform.isMacOS || + Platform.isLinux || + Platform.isIOS || + Platform.isAndroid) { + return image as Uint8List; + } else if (Platform.isWindows) { + final file = File(image as String); + final bytes = await file.readAsBytes(); + await file.delete(); + return bytes; + } + return null; + } + + @override + Future writeFiles(List files) async { + try { + await _channel.invokeMethod('writeFiles', files); + return true; + } catch (error, stacktrace) { + debugPrint('$error\n$stacktrace'); + return false; + } + } + + @override + Future writeImage(Uint8List? image) async { + if (image == null) { + return; + } + if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) { + await _channel.invokeMethod('writeImage', image); + } else if (Platform.isWindows) { + final file = await File(_getTempFileName()).create(); + file.writeAsBytesSync(image); + await _channel + .invokeMethod('writeImage', {'fileName': file.path}); + file.delete(); + } + } + + @override + Future get text async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + return data?.text; + } + + @override + void writeText(String value) { + Clipboard.setData(ClipboardData(text: value)); + } +} + +String _getTempFileName() { + final dir = Directory.systemTemp; + String tempFileName; + + const uuid = Uuid(); + + while (true) { + tempFileName = p.join(dir.path, uuid.v1().toString()); + if (!File(tempFileName).existsSync()) { + break; + } + } + + return tempFileName; +} diff --git a/lib/vendor/src/pasteboard_platform_web.dart b/lib/vendor/src/pasteboard_platform_web.dart new file mode 100644 index 0000000..be48cb3 --- /dev/null +++ b/lib/vendor/src/pasteboard_platform_web.dart @@ -0,0 +1,152 @@ +import 'dart:js_interop'; + +import 'package:web/web.dart'; +import 'package:flutter/foundation.dart'; + +import 'pasteboard_platform.dart'; + +Future _readBlobAsText(Blob blob) async { + final FileReader reader = FileReader(); + final future = + reader.onLoadEnd.first.then((ProgressEvent event) => reader.result); + + reader.readAsText(blob); + + final res = await future; + return res.toString(); +} + +Clipboard get _clipboard => window.navigator.clipboard; + +extension on Blob { + Future _readAsUint8List() async { + final buffer = await arrayBuffer().toDart; + return buffer.toDart.asUint8List(); + } +} + +const PasteboardPlatform pasteboard = PasteboardPlatformWeb(); + +class PasteboardPlatformWeb implements PasteboardPlatform { + const PasteboardPlatformWeb(); + + @override + Future> files() async => const []; + + @override + Future get html async { + try { + final clipboardItems = await _clipboard.read().toDart; + for (var clipboardItem in clipboardItems.toDart) { + if (clipboardItem.types.toDart.contains('text/html'.toJS)) { + final blob = await clipboardItem.getType('text/html').toDart; + return _readBlobAsText(blob); + } + } + } catch (e) { + return null; + } + return null; + } + + @override + Future get image async { + try { + final clipboardItems = await _clipboard.read().toDart; + for (var item in clipboardItems.toDart) { + for (var type in item.types.toDart) { + if (type.toDart.startsWith('image/')) { + final blob = await item.getType(type.toDart).toDart; + return blob._readAsUint8List(); + } + } + } + } catch (error, stacktrace) { + debugPrint('get image failed: $error $stacktrace'); + return null; + } + return null; + } + + @override + Future writeFiles(List files) async => false; + + @override + Future writeImage(Uint8List? image) async { + if (image == null) { + return; + } + try { + final blob = Blob([image.toJS].toJS, BlobPropertyBag(type: 'image/png')); + window.navigator.clipboard.write([ + ClipboardItem({'image/png': blob}.jsify()! as JSObject) + ].toJS); + } catch (e) { + debugPrint('Error writing image to clipboard: $e'); + return; + } + } + + @override + Future get text async { + final data = await _clipboard.readText().toDart; + return data.toDart; + } + + @override + void writeText(String value) { + final fakeElement = _createCopyFakeElement(value)..select(); + document.body?.append(fakeElement); + _select(fakeElement); + _copyCommand(); + fakeElement.remove(); + } +} + +void _select(HTMLTextAreaElement element) { + final isReadOnly = element.hasAttribute('readonly'); + + if (!isReadOnly) { + element.setAttribute('readonly', ''); + } + + element + ..select() + ..setSelectionRange(0, element.value.length); + + if (!isReadOnly) { + element.removeAttribute('readonly'); + } +} + +/// https://github.com/zenorocha/clipboard.js/blob/master/src/common/create-fake-element.js +HTMLTextAreaElement _createCopyFakeElement(String value) { + final isRtl = document.documentElement?.getAttribute('dir') == 'rtl'; + final fakeElement = HTMLTextAreaElement() + // Prevent zooming on iOS + ..style.fontSize = '12pt' + // Reset box model + ..style.border = '0' + ..style.padding = '0' + // Move element out of screen horizontally + ..style.position = 'absolute' + ..style.setProperty(isRtl ? 'right' : 'left', '-9999px'); + + // Move element to the same position vertically + final yPosition = document.documentElement?.scrollTop.toInt() ?? 0; + fakeElement + ..style.top = '${yPosition}px' + ..setAttribute('readonly', '') + ..value = value; + return fakeElement; +} + +bool _copyCommand() { + try { + return document.execCommand('copy'); + } catch (error, stack) { + window.alert('$error, $stack'); + // ignore + return false; + } +} diff --git a/lib/vendor/src/web_drop_item.dart b/lib/vendor/src/web_drop_item.dart new file mode 100644 index 0000000..4b75598 --- /dev/null +++ b/lib/vendor/src/web_drop_item.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; + +import 'package:fox_utils/vendor/src/drop_item.dart'; + +class WebDropItem { + WebDropItem({ + required this.uri, + required this.children, + this.data, + required this.name, + required this.type, + required this.size, + required this.relativePath, + required this.lastModified, + }); + + final String uri; + final List children; + final Uint8List? data; + final String name; + final String type; + final int size; + final String? relativePath; + final DateTime lastModified; + + factory WebDropItem.fromJson(Map json) => WebDropItem( + uri: json['uri'], + children: json['children'] == null + ? [] + : (json['children'] as List) + .cast() + .map((e) => WebDropItem.fromJson(e.cast())) + .toList(), + data: json['data'] != null ? Uint8List.fromList(json['data']) : null, + name: json['name'], + type: json['type'], + size: json['size'], + relativePath: json['relativePath'], + lastModified: DateTime.fromMillisecondsSinceEpoch(json['lastModified']), + ); + + Map toJson() => { + 'uri': uri, + 'children': children.map((e) => e.toJson()).toList(), + 'data': data?.toList(), + 'name': name, + 'type': type, + 'size': size, + 'relativePath': relativePath, + 'lastModified': lastModified.millisecondsSinceEpoch, + }; + + DropItem toDropItem() { + if (type == 'directory') { + return DropItemDirectory( + uri, + children.map((e) => e.toDropItem()).toList(), + name: name, + mimeType: type, + length: size, + lastModified: lastModified, + bytes: data, + ); + } else { + if (data != null) { + return DropItemFile.fromData( + data!, + name: name, + mimeType: type, + length: size, + lastModified: lastModified, + path: uri, + ); + } + return DropItemFile( + uri, + name: name, + mimeType: type, + length: size, + lastModified: lastModified, + bytes: data, + ); + } + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..84b5479 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "fox_utils") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "fox_utils_plugin") + +add_library(${PLUGIN_NAME} SHARED + "fox_utils_plugin.cc" + "pasteboard_plugin.cc" + "desktop_drop_plugin.cc" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +# List of absolute paths to libraries that should be bundled with the plugin +set(fox_utils_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/linux/desktop_drop_plugin.cc b/linux/desktop_drop_plugin.cc new file mode 100644 index 0000000..2eab3f3 --- /dev/null +++ b/linux/desktop_drop_plugin.cc @@ -0,0 +1,123 @@ +#include "include/fox_utils/desktop_drop_plugin.h" + +#include +#include +#include +#include + +#define DESKTOP_DROP_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_drop_plugin_get_type(), \ + DesktopDropPlugin)) + +struct _DesktopDropPlugin { + GObject parent_instance; +}; + +static gboolean isKDE = FALSE; +static gboolean ignoreNext = FALSE; + +G_DEFINE_TYPE(DesktopDropPlugin, desktop_drop_plugin, g_object_get_type()) + +void on_drag_data_received(GtkWidget *widget, GdkDragContext *drag_context, + gint x, gint y, GtkSelectionData *sdata, guint info, + guint time, gpointer user_data) { + auto *channel = static_cast(user_data); + auto *data = gtk_selection_data_get_data(sdata); + double point[] = {double(x), double(y)}; + auto args = fl_value_new_list(); + fl_value_append(args, fl_value_new_string((gchar *) data)); + fl_value_append(args, fl_value_new_float_list(point, 2)); + fl_method_channel_invoke_method(channel, "performOperation_linux", args, + nullptr, nullptr, nullptr); +} + +void on_drag_motion(GtkWidget *widget, GdkDragContext *drag_context, + gint x, gint y, guint time, gpointer user_data) { + if (ignoreNext) { + ignoreNext = FALSE; + return; + } + + auto *channel = static_cast(user_data); + double point[] = {double(x), double(y)}; + g_autoptr(FlValue) args = fl_value_new_float_list(point, 2); + fl_method_channel_invoke_method(channel, "updated", args, + nullptr, nullptr, nullptr); +} + +void on_drag_leave(GtkWidget *widget, GdkDragContext *drag_context, guint time, gpointer user_data) { + auto *channel = static_cast(user_data); + fl_method_channel_invoke_method(channel, "exited", nullptr, + nullptr, nullptr, nullptr); +} + +// Called when a method call is received from Flutter. +static void desktop_drop_plugin_handle_method_call( + DesktopDropPlugin *self, + FlMethodCall *method_call) { + fl_method_call_respond_not_implemented(method_call, nullptr); +} + +static void desktop_drop_plugin_dispose(GObject *object) { + G_OBJECT_CLASS(desktop_drop_plugin_parent_class)->dispose(object); +} + +static void desktop_drop_plugin_class_init(DesktopDropPluginClass *klass) { + G_OBJECT_CLASS(klass)->dispose = desktop_drop_plugin_dispose; +} + +static void desktop_drop_plugin_init(DesktopDropPlugin *self) { + const char * desktopEnv = getenv("XDG_CURRENT_DESKTOP"); + if (desktopEnv) { + const char * lowercaseDesktopEnv = g_ascii_strdown(desktopEnv, -1); + + if (strcmp(lowercaseDesktopEnv, "kde") == 0 || strcmp(lowercaseDesktopEnv, "plasma") == 0) { + isKDE = TRUE; + } + } +} + +static gboolean on_focus_in_event(GtkWidget *widget, GdkEventFocus *event, gpointer user_data) { + if (isKDE) { + ignoreNext = TRUE; + } + return FALSE; +} + +static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call, + gpointer user_data) { + DesktopDropPlugin *plugin = DESKTOP_DROP_PLUGIN(user_data); + desktop_drop_plugin_handle_method_call(plugin, method_call); +} + +void desktop_drop_plugin_register_with_registrar(FlPluginRegistrar *registrar) { + DesktopDropPlugin *plugin = DESKTOP_DROP_PLUGIN( + g_object_new(desktop_drop_plugin_get_type(), nullptr)); + + auto *fl_view = fl_plugin_registrar_get_view(registrar); + static GtkTargetEntry entries[] = { + {strdup("STRING"), GTK_TARGET_OTHER_APP, 0} + }; + gtk_drag_dest_set(GTK_WIDGET(fl_view), GTK_DEST_DEFAULT_ALL, entries, 1, GDK_ACTION_COPY); + gtk_drag_dest_add_uri_targets(GTK_WIDGET(fl_view)); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel *channel = + fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), + "desktop_drop", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(channel, method_call_cb, + g_object_ref(plugin), + g_object_unref); + + g_signal_connect(fl_view, "drag-motion", + G_CALLBACK(on_drag_motion), channel); + g_signal_connect(GTK_WIDGET(fl_view), "drag-data-received", + G_CALLBACK(on_drag_data_received), channel); + g_signal_connect(GTK_WIDGET(fl_view), "drag-leave", + G_CALLBACK(on_drag_leave), channel); + g_signal_connect(fl_view, "focus-in-event", + G_CALLBACK(on_focus_in_event), nullptr); + + g_object_unref(plugin); +} diff --git a/linux/fox_utils_plugin.cc b/linux/fox_utils_plugin.cc new file mode 100644 index 0000000..bad689d --- /dev/null +++ b/linux/fox_utils_plugin.cc @@ -0,0 +1,8 @@ +#include "include/fox_utils/fox_utils_plugin.h" +#include "include/fox_utils/pasteboard_plugin.h" +#include "include/fox_utils/desktop_drop_plugin.h" + +void fox_utils_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + pasteboard_plugin_register_with_registrar(registrar); + desktop_drop_plugin_register_with_registrar(registrar); +} \ No newline at end of file diff --git a/linux/include/fox_utils/desktop_drop_plugin.h b/linux/include/fox_utils/desktop_drop_plugin.h new file mode 100644 index 0000000..5a78560 --- /dev/null +++ b/linux/include/fox_utils/desktop_drop_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_ +#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _DesktopDropPlugin DesktopDropPlugin; +typedef struct { + GObjectClass parent_class; +} DesktopDropPluginClass; + +FLUTTER_PLUGIN_EXPORT GType desktop_drop_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void desktop_drop_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_ diff --git a/linux/include/fox_utils/fox_utils_plugin.h b/linux/include/fox_utils/fox_utils_plugin.h new file mode 100644 index 0000000..c84d367 --- /dev/null +++ b/linux/include/fox_utils/fox_utils_plugin.h @@ -0,0 +1,22 @@ +#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_ +#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +// Single native registration entry for the fox_utils package on Linux. +// The generated plugin registrant calls this function; it in turn registers +// every vendored engine in this package (pasteboard, desktop_drop). +FLUTTER_PLUGIN_EXPORT void fox_utils_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_ \ No newline at end of file diff --git a/linux/include/fox_utils/pasteboard_plugin.h b/linux/include/fox_utils/pasteboard_plugin.h new file mode 100644 index 0000000..c2db2d5 --- /dev/null +++ b/linux/include/fox_utils/pasteboard_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_ +#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _PasteboardPlugin PasteboardPlugin; +typedef struct { + GObjectClass parent_class; +} PasteboardPluginClass; + +FLUTTER_PLUGIN_EXPORT GType pasteboard_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void pasteboard_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_ diff --git a/linux/pasteboard_plugin.cc b/linux/pasteboard_plugin.cc new file mode 100644 index 0000000..4dbaa78 --- /dev/null +++ b/linux/pasteboard_plugin.cc @@ -0,0 +1,190 @@ +#include "include/fox_utils/pasteboard_plugin.h" + +#include +#include + +#include +#include + +#define GNOME_COPIED_FILES gdk_atom_intern_static_string ("x-special/gnome-copied-files") + +#define PASTEBOARD_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), pasteboard_plugin_get_type(), \ + PasteboardPlugin)) + +struct _PasteboardPlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(PasteboardPlugin, pasteboard_plugin, g_object_get_type()) + +static void gtk_clipboard_request_uris_callback( + GtkClipboard *clipboard, + gchar **uris, + gpointer user_data +) { + g_autoptr(FlMethodCall) method_call = static_cast(user_data); + + g_autoptr(FlValue) result = fl_value_new_list(); + for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) { + g_autoptr(GFile) file = g_file_new_for_uri(*uri); + auto *file_path = g_file_get_path(file); + if (file_path) { + fl_value_append(result, fl_value_new_string(file_path)); + } + } + fl_method_call_respond_success(method_call, result, nullptr); +} + +static void gtk_clipboard_get_file_uri(GtkClipboard *clipboard, + GtkSelectionData *selection_data, + guint info, + gpointer user_data_or_owner) { + auto target = gtk_selection_data_get_target(selection_data); + auto **uris = reinterpret_cast(user_data_or_owner); + if (gtk_targets_include_uri(&target, 1)) { + gtk_selection_data_set_uris(selection_data, reinterpret_cast(user_data_or_owner)); + } else if (gtk_targets_include_text(&target, 1)) { + g_autoptr(GString) string = g_string_new(nullptr); + bool should_insert_new_line = false; + for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) { + if (should_insert_new_line) { + g_string_append_c(string, '\n'); + } + g_string_append(string, *uri); + should_insert_new_line = true; + } + gtk_selection_data_set_text(selection_data, string->str, int(string->len)); + } else if (target == GNOME_COPIED_FILES) { + g_autoptr(GString) string = g_string_new("copy"); + for (auto uri = uris; uri != nullptr && *uri != nullptr; uri++) { + g_string_append(string, "\n"); + g_string_append(string, *uri); + } + gtk_selection_data_set(selection_data, target, 8, (const guchar *) string->str, int(string->len)); + } else { + g_critical("unsupported action: gdk_atom_name(target) = %s", gdk_atom_name(target)); + } + +} + +static void gtk_clipboard_clear(GtkClipboard *clipboard, + gpointer user_data_or_owner) { + auto **uris = reinterpret_cast(user_data_or_owner); + delete[] uris; +} + +static void clipboard_request_image_callback( + GtkClipboard *clipboard, + GdkPixbuf *pixbuf, + gpointer user_data) { + g_autoptr(FlMethodCall) method_call = static_cast(user_data); + + if (!pixbuf) { + fl_method_call_respond_success(method_call, nullptr, nullptr); + return; + } + + gchar *buffer = nullptr; + gsize buffer_size = 0; + GError *error = nullptr; + + gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", &error, nullptr); + if (error) { + fl_method_call_respond_error(method_call, "0", error->message, nullptr, nullptr); + return; + } + + if (!buffer) { + fl_method_call_respond_error(method_call, "0", "failed to get image", nullptr, nullptr); + return; + } + + fl_method_call_respond_success(method_call, + fl_value_new_uint8_list(reinterpret_cast(buffer), buffer_size), + nullptr); + +} + +// Called when a method call is received from Flutter. +static void pasteboard_plugin_handle_method_call( + PasteboardPlugin *self, + FlMethodCall *method_call) { + const gchar *method = fl_method_call_get_name(method_call); + if (strcmp(method, "files") == 0) { + auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default()); + gtk_clipboard_request_uris(clipboard, gtk_clipboard_request_uris_callback, g_object_ref(method_call)); + } else if (strcmp(method, "writeFiles") == 0) { + auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default()); + + auto args = fl_method_call_get_args(method_call); + auto size = fl_value_get_length(args); + + auto **paths = new gchar *[size + 1]; + paths[size] = nullptr; + for (unsigned int i = 0; i < size; ++i) { + auto path = fl_value_get_string(fl_value_get_list_value(args, i)); + paths[i] = g_strconcat("file://", path, nullptr); + } + + GtkTargetList *target_list = gtk_target_list_new(nullptr, 0); + gtk_target_list_add(target_list, GNOME_COPIED_FILES, 0, 0); + + gtk_target_list_add_uri_targets(target_list, 0); + gtk_target_list_add_text_targets(target_list, 0); + + int n_targets = 1; + GtkTargetEntry *target_table = gtk_target_table_new_from_list(target_list, &n_targets); + + gtk_clipboard_set_with_data(clipboard, + target_table, + n_targets, + gtk_clipboard_get_file_uri, + gtk_clipboard_clear, + paths); + + gtk_target_list_unref(target_list); + gtk_target_table_free(target_table, n_targets); + + fl_method_call_respond_success(method_call, nullptr, nullptr); + } else if (strcmp(method, "image") == 0) { + auto *clipboard = gtk_clipboard_get_default(gdk_display_get_default()); + + gtk_clipboard_request_image(clipboard, clipboard_request_image_callback, g_object_ref(method_call)); + + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static void pasteboard_plugin_dispose(GObject *object) { + G_OBJECT_CLASS(pasteboard_plugin_parent_class)->dispose(object); +} + +static void pasteboard_plugin_class_init(PasteboardPluginClass *klass) { + G_OBJECT_CLASS(klass)->dispose = pasteboard_plugin_dispose; +} + +static void pasteboard_plugin_init(PasteboardPlugin *self) {} + +static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call, + gpointer user_data) { + PasteboardPlugin *plugin = PASTEBOARD_PLUGIN(user_data); + pasteboard_plugin_handle_method_call(plugin, method_call); +} + +void pasteboard_plugin_register_with_registrar(FlPluginRegistrar *registrar) { + PasteboardPlugin *plugin = PASTEBOARD_PLUGIN( + g_object_new(pasteboard_plugin_get_type(), nullptr)); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + g_autoptr(FlMethodChannel) channel = + fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), + "pasteboard", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(channel, method_call_cb, + g_object_ref(plugin), + g_object_unref); + + g_object_unref(plugin); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100755 index 0000000..ebc1774 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,35 @@ +name: fox_utils +description: A collection of reusable Flutter widgets and extensions. +version: 0.1.7 + +environment: + sdk: ^3.9.2 + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + cross_file: ^0.3.4+2 # XFile base of DropItem (vendored desktop_drop engine) + dio: # Only non-vendored 3rd-party dependency + path: ^1.9.0 + universal_platform: ^1.1.0 # web-safe platform checks (vendored desktop_drop engine) + uuid: ^4.0.0 + web: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + plugin: + platforms: + android: + package: com.foxyz.fox_utils + pluginClass: FoxUtilsPlugin + linux: + pluginClass: FoxUtilsPlugin + windows: + pluginClass: FoxUtilsPlugin \ No newline at end of file diff --git a/test/fox_utils_test.dart b/test/fox_utils_test.dart new file mode 100755 index 0000000..bda3d03 --- /dev/null +++ b/test/fox_utils_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fox_utils/fox_utils.dart'; + +void main() { + test('adds one to input values', () { + final calculator = Calculator(); + expect(calculator.addOne(2), 3); + expect(calculator.addOne(-7), -6); + expect(calculator.addOne(0), 1); + }); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..5ddc42e --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "fox_utils") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "fox_utils_plugin") + +add_library(${PLUGIN_NAME} SHARED + "fox_utils_plugin.cpp" + "pasteboard_plugin.cpp" + "desktop_drop_plugin.cpp" + ) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# GdiPlus doesn't compile with /W4 warning level; strict flags are scoped to +# this package's own sources. The vendored desktop_drop engine is compiled +# as upstream ships it (no warnings-as-errors) since it is not ours to churn. +set_source_files_properties("fox_utils_plugin.cpp" "pasteboard_plugin.cpp" + PROPERTIES COMPILE_OPTIONS + "$<$:/W3 /WX>;$<$>:-Wall -Wextra -Wpedantic -Werror>") +set_source_files_properties("desktop_drop_plugin.cpp" + PROPERTIES COMPILE_OPTIONS + "$<$:/W3>") + +# List of absolute paths to libraries that should be bundled with the plugin +set(fox_utils_bundled_libraries + "" + PARENT_SCOPE + ) diff --git a/windows/desktop_drop_plugin.cpp b/windows/desktop_drop_plugin.cpp new file mode 100644 index 0000000..e8960cf --- /dev/null +++ b/windows/desktop_drop_plugin.cpp @@ -0,0 +1,285 @@ +#include "include/fox_utils/desktop_drop_plugin.h" + +#include + +#include +#include +#include + +#include +#include +#include + +namespace { + + using FlutterMethodChannel = std::unique_ptr>; + + + using namespace std::literals::string_literals; + + std::string ws2s(const std::wstring &wstr) { + if (wstr.empty()) { + return {}; + } + size_t pos; + size_t begin = 0; + std::string ret; + + int size; + pos = wstr.find(static_cast(0), begin); + while (pos != std::wstring::npos && begin < wstr.length()) { + std::wstring segment = std::wstring(&wstr[begin], pos - begin); + size = WideCharToMultiByte(CP_UTF8, + WC_ERR_INVALID_CHARS, + &segment[0], + (int) segment.size(), + nullptr, + 0, + nullptr, + nullptr); + std::string converted = std::string(size, 0); + WideCharToMultiByte(CP_UTF8, + WC_ERR_INVALID_CHARS, + &segment[0], + (int) segment.size(), + &converted[0], + (int) converted.size(), + nullptr, + nullptr); + ret.append(converted); + ret.append({0}); + begin = pos + 1; + pos = wstr.find(static_cast(0), begin); + } + if (begin <= wstr.length()) { + std::wstring segment = std::wstring(&wstr[begin], wstr.length() - begin); + size = + WideCharToMultiByte(CP_UTF8, + WC_ERR_INVALID_CHARS, + &segment[0], + (int) segment.size(), + nullptr, + 0, + nullptr, + nullptr); + std::string converted = std::string(size, 0); + WideCharToMultiByte(CP_UTF8, + WC_ERR_INVALID_CHARS, + &segment[0], + (int) segment.size(), + &converted[0], + (int) converted.size(), + nullptr, + nullptr); + ret.append(converted); + } + + return ret; + } + + + class DesktopDropTarget : public IDropTarget { + public: + + DesktopDropTarget(FlutterMethodChannel channel, HWND window_handle); + + HRESULT DragEnter(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override; + + HRESULT DragOver(DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override; + + HRESULT DragLeave() override; + + HRESULT Drop(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) override; + + HRESULT QueryInterface(const IID &riid, void **ppvObject) override; + + ULONG AddRef() override; + + ULONG Release() override; + + virtual ~DesktopDropTarget(); + + private: + FlutterMethodChannel channel_; + HWND window_handle_; + LONG ref_count_; + bool need_revoke_ole_initialize_; + }; + + class DesktopDropPlugin : public flutter::Plugin { + + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + explicit DesktopDropPlugin(DesktopDropTarget *target); + + ~DesktopDropPlugin() override; + + private: + + DesktopDropTarget *target_; + + }; + + DesktopDropPlugin::DesktopDropPlugin(DesktopDropTarget *target) : target_(target) { + target_->AddRef(); + } + + DesktopDropPlugin::~DesktopDropPlugin() { + target_->Release(); + } + + + // static + void DesktopDropPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "desktop_drop", + &flutter::StandardMethodCodec::GetInstance()); + + HWND hwnd = nullptr; + if (registrar->GetView()) { + hwnd = registrar->GetView()->GetNativeWindow(); + } + + if (hwnd == nullptr) { + // no window, no drop. + return; + } + + channel->SetMethodCallHandler([](const auto &call, auto result) { + result->NotImplemented(); + }); + + auto drop_target = new DesktopDropTarget(std::move(channel), hwnd); + + auto plugin = std::make_unique(drop_target); + + registrar->AddPlugin(std::move(plugin)); + } + + + DesktopDropTarget::DesktopDropTarget(FlutterMethodChannel channel, HWND window_handle) : channel_( + std::move(channel)), window_handle_(window_handle), ref_count_(0), need_revoke_ole_initialize_(false) { + auto ret = RegisterDragDrop(window_handle_, this); + if (ret == E_OUTOFMEMORY) { + OleInitialize(nullptr); + ret = RegisterDragDrop(window_handle_, this); + if (ret == 0) { + need_revoke_ole_initialize_ = true; + } + } + + if (ret != 0) { + std::cout << "RegisterDragDrop failed: " << ret << std::endl; + } + } + + HRESULT DesktopDropTarget::DragEnter(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) { + POINT point = {pt.x, pt.y}; + ScreenToClient(window_handle_, &point); + channel_->InvokeMethod("entered", std::make_unique( + flutter::EncodableList{ + flutter::EncodableValue(double(point.x)), + flutter::EncodableValue(double(point.y)) + } + )); + return 0; + } + + HRESULT DesktopDropTarget::DragOver(DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) { + POINT point = {pt.x, pt.y}; + ScreenToClient(window_handle_, &point); + channel_->InvokeMethod("updated", std::make_unique( + flutter::EncodableList{ + flutter::EncodableValue(double(point.x)), + flutter::EncodableValue(double(point.y)) + } + )); + return 0; + } + + HRESULT DesktopDropTarget::DragLeave() { + channel_->InvokeMethod("exited", std::make_unique()); + return 0; + } + + HRESULT DesktopDropTarget::Drop(IDataObject *pDataObj, DWORD grfKeyState, POINTL pt, DWORD *pdwEffect) { + + flutter::EncodableList list = {}; + + // construct a FORMATETC object + FORMATETC fmtetc = {CF_HDROP, nullptr, DVASPECT_CONTENT, -1, TYMED_HGLOBAL}; + STGMEDIUM stgmed; + + // See if the dataobject contains any TEXT stored as a HGLOBAL + if (pDataObj->QueryGetData(&fmtetc) == S_OK) { + // Yippie! the data is there, so go get it! + if (pDataObj->GetData(&fmtetc, &stgmed) == S_OK) { + // we asked for the data as a HGLOBAL, so access it appropriately + PVOID data = GlobalLock(stgmed.hGlobal); + if (data != nullptr) { + auto files = DragQueryFile(reinterpret_cast(data), 0xFFFFFFFF, nullptr, 0); + for (unsigned int i = 0; i < files; ++i) { + TCHAR filename[MAX_PATH]; + DragQueryFile(reinterpret_cast(data), i, filename, sizeof(TCHAR) * MAX_PATH); + std::wstring wide(filename); + std::string path = ws2s(wide); + std::cout << "done: " << path << std::endl; + list.push_back(flutter::EncodableValue(path)); + } + GlobalUnlock(stgmed.hGlobal); + } + + // release the data using the COM API + ReleaseStgMedium(&stgmed); + } + } + channel_->InvokeMethod("performOperation", std::make_unique(list)); + + return 0; + } + + HRESULT DesktopDropTarget::QueryInterface(const IID &iid, void **ppvObject) { + if (iid == IID_IDropTarget || iid == IID_IUnknown) { + AddRef(); + *ppvObject = this; + return S_OK; + } else { + *ppvObject = nullptr; + return E_NOINTERFACE; + } + } + + ULONG DesktopDropTarget::AddRef() { + return InterlockedIncrement(&ref_count_); + } + + ULONG DesktopDropTarget::Release() { + LONG count = InterlockedDecrement(&ref_count_); + + if (count == 0) { + delete this; + return 0; + } else { + return count; + } + } + + DesktopDropTarget::~DesktopDropTarget() { + RevokeDragDrop(window_handle_); + if (need_revoke_ole_initialize_) { + OleUninitialize(); + } + } + +} // namespace + +void DesktopDropPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + DesktopDropPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); + +} diff --git a/windows/fox_utils_plugin.cpp b/windows/fox_utils_plugin.cpp new file mode 100644 index 0000000..6f4d02a --- /dev/null +++ b/windows/fox_utils_plugin.cpp @@ -0,0 +1,9 @@ +#include "include/fox_utils/fox_utils_plugin.h" +#include "include/fox_utils/pasteboard_plugin.h" +#include "include/fox_utils/desktop_drop_plugin.h" + +void FoxUtilsPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + PasteboardPluginRegisterWithRegistrar(registrar); + DesktopDropPluginRegisterWithRegistrar(registrar); +} \ No newline at end of file diff --git a/windows/include/fox_utils/desktop_drop_plugin.h b/windows/include/fox_utils/desktop_drop_plugin.h new file mode 100644 index 0000000..17e3a27 --- /dev/null +++ b/windows/include/fox_utils/desktop_drop_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_ +#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void DesktopDropPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_ diff --git a/windows/include/fox_utils/fox_utils_plugin.h b/windows/include/fox_utils/fox_utils_plugin.h new file mode 100644 index 0000000..3f929c8 --- /dev/null +++ b/windows/include/fox_utils/fox_utils_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_ +#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +// Single native registration entry for the fox_utils package on Windows. +// The generated plugin registrant calls this function; it in turn registers +// every vendored engine in this package (pasteboard, desktop_drop). +FLUTTER_PLUGIN_EXPORT void FoxUtilsPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_ \ No newline at end of file diff --git a/windows/include/fox_utils/pasteboard_plugin.h b/windows/include/fox_utils/pasteboard_plugin.h new file mode 100644 index 0000000..67b75c0 --- /dev/null +++ b/windows/include/fox_utils/pasteboard_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_ +#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void PasteboardPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_ diff --git a/windows/pasteboard_plugin.cpp b/windows/pasteboard_plugin.cpp new file mode 100644 index 0000000..23fe922 --- /dev/null +++ b/windows/pasteboard_plugin.cpp @@ -0,0 +1,555 @@ +#include "include/fox_utils/pasteboard_plugin.h" + +#include +#include +#include + + +#include +#include +#include + +#include +#include + +#include "strconv.h" + +#pragma comment(lib, "GdiPlus") + +namespace { + +constexpr STGMEDIUM kNullStorageMedium = {TYMED_NULL, nullptr, nullptr}; + +STGMEDIUM CreateStorageForFileNames(const std::vector &filenames) { + // CF_HDROP clipboard format consists of DROPFILES structure, a series of file + // names including the terminating null character and the additional null + // character at the tail to terminate the array. + // For example, + //| DROPFILES | FILENAME 1 | NULL | ... | FILENAME n | NULL | NULL | + // For more details, please refer to + // https://docs.microsoft.com/en-us/windows/desktop/shell/clipboard#cf_hdrop + + if (filenames.empty()) + return kNullStorageMedium; + + const size_t kDropFilesHeaderSizeInBytes = sizeof(DROPFILES); + size_t total_bytes = kDropFilesHeaderSizeInBytes; + for (const auto &filename: filenames) { + // Allocate memory of the filename's length including the null + // character. + total_bytes += (filename.length() + 1) * sizeof(wchar_t); + } + // |data| needs to be terminated by an additional null character. + total_bytes += sizeof(wchar_t); + + // GHND combines GMEM_MOVEABLE and GMEM_ZEROINIT, and GMEM_ZEROINIT + // initializes memory contents to zero. + HANDLE hdata = GlobalAlloc(GHND, total_bytes); + + auto *drop_files = (DROPFILES *) GlobalLock(hdata); + drop_files->pFiles = sizeof(DROPFILES); + drop_files->fWide = TRUE; + + auto *data = reinterpret_cast( + reinterpret_cast(drop_files) + kDropFilesHeaderSizeInBytes); + + size_t next_filename_offset = 0; + for (const auto &filename: filenames) { + auto wide_filename = utf8_to_wide(filename); + wcsncpy_s(data + next_filename_offset, + wide_filename.length() + 1, + wide_filename.c_str(), + wide_filename.length() + 1); + // Skip the terminating null character of the filename. + next_filename_offset += wide_filename.length() + 1; + } + + STGMEDIUM storage; + storage.tymed = TYMED_HGLOBAL; + storage.hGlobal = hdata; + storage.pUnkForRelease = nullptr; + + GlobalUnlock(hdata); + return storage; +} + +PBITMAPINFO CreateBitmapInfoStruct(HBITMAP hBmp) { + BITMAP bmp; + PBITMAPINFO pbmi; + WORD cClrBits; + + // Retrieve the bitmap color format, width, and height. + GetObject(hBmp, sizeof(BITMAP), (LPSTR) &bmp); + + // Convert the color format to a count of bits. + cClrBits = (WORD) (bmp.bmPlanes * bmp.bmBitsPixel); + if (cClrBits == 1) + cClrBits = 1; + else if (cClrBits <= 4) + cClrBits = 4; + else if (cClrBits <= 8) + cClrBits = 8; + else if (cClrBits <= 16) + cClrBits = 16; + else if (cClrBits <= 24) + cClrBits = 24; + else + cClrBits = 32; + + // Allocate memory for the BITMAPINFO structure. (This structure + // contains a BITMAPINFOHEADER structure and an array of RGBQUAD + // data structures.) + + if (cClrBits < 24) + pbmi = (PBITMAPINFO) LocalAlloc( + LPTR, sizeof(BITMAPINFOHEADER) + sizeof(RGBQUAD) * int(1 << cClrBits)); + + // There is no RGBQUAD array for these formats: 24-bit-per-pixel or + // 32-bit-per-pixel + + else + pbmi = (PBITMAPINFO) LocalAlloc(LPTR, sizeof(BITMAPINFOHEADER)); + + // Initialize the fields in the BITMAPINFO structure. + + pbmi->bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + pbmi->bmiHeader.biWidth = bmp.bmWidth; + pbmi->bmiHeader.biHeight = bmp.bmHeight; + pbmi->bmiHeader.biPlanes = bmp.bmPlanes; + pbmi->bmiHeader.biBitCount = bmp.bmBitsPixel; + if (cClrBits < 24) pbmi->bmiHeader.biClrUsed = (1 << cClrBits); + + // If the bitmap is not compressed, set the BI_RGB flag. + pbmi->bmiHeader.biCompression = BI_RGB; + + // Compute the number of bytes in the array of color + // indices and store the result in biSizeImage. + // The width must be DWORD aligned unless the bitmap is RLE + // compressed. + pbmi->bmiHeader.biSizeImage = + ((pbmi->bmiHeader.biWidth * cClrBits + 31) & ~31) / 8 * + pbmi->bmiHeader.biHeight; + // Set biClrImportant to 0, indicating that all of the + // device colors are important. + pbmi->bmiHeader.biClrImportant = 0; + return pbmi; +} + +// The code was taken from this answer: https://stackoverflow.com/a/39201008/2134488 +// +bool CopyImageToClipboard(const wchar_t* filename) +{ + //initialize Gdiplus once: + Gdiplus::GdiplusStartupInput gdiplusStartupInput; + ULONG_PTR gdiplusToken; + Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL); + + bool result = false; + Gdiplus::Bitmap *gdibmp = Gdiplus::Bitmap::FromFile(filename); + if (gdibmp) + { + HBITMAP hbitmap; + gdibmp->GetHBITMAP(0, &hbitmap); + if (OpenClipboard(NULL)) + { + EmptyClipboard(); + DIBSECTION ds; + if (GetObject(hbitmap, sizeof(DIBSECTION), &ds)) + { + HDC hdc = GetDC(HWND_DESKTOP); + //create compatible bitmap (get DDB from DIB) + HBITMAP hbitmap_ddb = CreateDIBitmap(hdc, &ds.dsBmih, CBM_INIT, + ds.dsBm.bmBits, (BITMAPINFO*)&ds.dsBmih, DIB_RGB_COLORS); + ReleaseDC(HWND_DESKTOP, hdc); + SetClipboardData(CF_BITMAP, hbitmap_ddb); + DeleteObject(hbitmap_ddb); + result = true; + } + CloseClipboard(); + } + + //cleanup: + DeleteObject(hbitmap); + delete gdibmp; + } + + Gdiplus::GdiplusShutdown(gdiplusToken); + + return result; +} + +void CreateBMPFile(LPCTSTR pszFile, HBITMAP hBMP) { + HANDLE hf; // file handle + BITMAPFILEHEADER hdr; // bitmap file-header + PBITMAPINFOHEADER pbih; // bitmap info-header + LPBYTE lpBits; // memory pointer + DWORD cb; // incremental count of bytes + BYTE *hp; // byte pointer + DWORD dwTmp; + PBITMAPINFO pbi; + HDC hDC; + + hDC = CreateCompatibleDC(GetWindowDC(GetDesktopWindow())); + SelectObject(hDC, hBMP); + + pbi = CreateBitmapInfoStruct(hBMP); + + pbih = (PBITMAPINFOHEADER) pbi; + lpBits = (LPBYTE) GlobalAlloc(GMEM_FIXED, pbih->biSizeImage); + + assert(lpBits); + + // Retrieve the color table (RGBQUAD array) and the bits + // (array of palette indices) from the DIB. + GetDIBits(hDC, hBMP, 0, (WORD) pbih->biHeight, lpBits, pbi, + DIB_RGB_COLORS); + + // Create the .BMP file. + hf = CreateFile(pszFile, GENERIC_READ | GENERIC_WRITE, (DWORD) 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + assert(hf != INVALID_HANDLE_VALUE); + + hdr.bfType = 0x4d42; // 0x42 = "B" 0x4d = "M" + // Compute the size of the entire file. + hdr.bfSize = (DWORD) (sizeof(BITMAPFILEHEADER) + pbih->biSize + + pbih->biClrUsed * sizeof(RGBQUAD) + pbih->biSizeImage); + hdr.bfReserved1 = 0; + hdr.bfReserved2 = 0; + + // Compute the offset to the array of color indices. + hdr.bfOffBits = (DWORD) sizeof(BITMAPFILEHEADER) + pbih->biSize + + pbih->biClrUsed * sizeof(RGBQUAD); + + // Copy the BITMAPFILEHEADER into the .BMP file. + WriteFile(hf, (LPVOID) &hdr, sizeof(BITMAPFILEHEADER), (LPDWORD) &dwTmp, + nullptr); + + // Copy the BITMAPINFOHEADER and RGBQUAD array into the file. + WriteFile(hf, (LPVOID) pbih, + sizeof(BITMAPINFOHEADER) + pbih->biClrUsed * sizeof(RGBQUAD), + (LPDWORD) &dwTmp, (nullptr)); + + // Copy the array of color indices into the .BMP file. + cb = pbih->biSizeImage; + hp = lpBits; + WriteFile(hf, (LPSTR) hp, (int) cb, (LPDWORD) &dwTmp, nullptr); + + // Close the .BMP file. + CloseHandle(hf); + + // Free memory. + GlobalFree((HGLOBAL) lpBits); +} + +void CreateBitmapHeaderWithColorDepth(LONG width, LONG height, WORD color_depth, + BITMAPINFOHEADER *hdr) { + // These values are shared with gfx::PlatformDevice. + hdr->biSize = sizeof(BITMAPINFOHEADER); + hdr->biWidth = width; + hdr->biHeight = -height; // Minus means top-down bitmap. + hdr->biPlanes = 1; + hdr->biBitCount = color_depth; + hdr->biCompression = BI_RGB; // No compression. + hdr->biSizeImage = 0; + hdr->biXPelsPerMeter = 1; + hdr->biYPelsPerMeter = 1; + hdr->biClrUsed = 0; + hdr->biClrImportant = 0; +} + +HBITMAP CreateHBitmapXRGB8888(int width, int height, HANDLE shared_section, + void **data) { + if (width == 0 || height == 0) { + width = 1; + height = 1; + } + BITMAPINFOHEADER hdr = {0}; + CreateBitmapHeaderWithColorDepth(width, height, 32, &hdr); + HBITMAP hbitmap = + CreateDIBSection(nullptr, reinterpret_cast(&hdr), 0, + data, shared_section, 0); + return hbitmap; +} + +// A scoper to manage acquiring and automatically releasing the clipboard. +class ScopedClipboard { + public: + ScopedClipboard() : opened_(false) {} + + ~ScopedClipboard() { + if (opened_) Release(); + } + + bool Acquire(HWND owner) { + const int kMaxAttemptsToOpenClipboard = 5; + + if (opened_) { + return false; + } + + for (int attempts = 0; attempts < kMaxAttemptsToOpenClipboard; ++attempts) { + if (::OpenClipboard(owner)) { + opened_ = true; + return true; + } + + // If we didn't manage to open the clipboard, sleep a bit and be hopeful. + ::Sleep(5); + } + + // We failed to acquire the clipboard. + return false; + } + + void Release() { + if (opened_) { + ::CloseClipboard(); + opened_ = false; + } + } + + private: + bool opened_; +}; + +class PasteboardPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + PasteboardPlugin(); + + virtual ~PasteboardPlugin(); + + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); +}; + +// static +void PasteboardPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "pasteboard", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); +} + +PasteboardPlugin::PasteboardPlugin() {} + +PasteboardPlugin::~PasteboardPlugin() {} + +void PasteboardPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) { + if (method_call.method_name() == "image") { + if (!IsClipboardFormatAvailable(CF_DIB)) { + result->Success(); + return; + } + ScopedClipboard clipboard; + + if (!clipboard.Acquire(nullptr)) { + result->Error("0", "open clipboard failed"); + return; + } + // We use a DIB rather than a DDB here since ::GetObject() with the + // HBITMAP returned from ::GetClipboardData(CF_BITMAP) always reports a + // color depth of 32bpp. + auto *bitmap = static_cast(::GetClipboardData(CF_DIB)); + if (!bitmap) { + result->Success(); + return; + } + + int color_table_length = 0; + + // For more information on BITMAPINFOHEADER and biBitCount definition, + // see https://docs.microsoft.com/en-us/windows/win32/wmdm/-bitmapinfoheader + switch (bitmap->bmiHeader.biBitCount) { + case 1: + case 4: + case 8: + color_table_length = bitmap->bmiHeader.biClrUsed + ? int(bitmap->bmiHeader.biClrUsed) + : int(1 << bitmap->bmiHeader.biBitCount); + break; + case 16: + case 32: + if (bitmap->bmiHeader.biCompression == BI_BITFIELDS) + color_table_length = 3; + break; + case 24:break; + default:result->Success(); + return; + } + + const void *bitmap_bits = reinterpret_cast(bitmap) + + bitmap->bmiHeader.biSize + + color_table_length * sizeof(RGBQUAD); + + void *dst_bits; + auto dst_hbitmap = + CreateHBitmapXRGB8888(bitmap->bmiHeader.biWidth, + bitmap->bmiHeader.biHeight, nullptr, &dst_bits); + + auto hdc = CreateCompatibleDC(nullptr); + auto old_hbitmap = static_cast(SelectObject(hdc, dst_hbitmap)); + ::SetDIBitsToDevice( + hdc, 0, 0, bitmap->bmiHeader.biWidth, bitmap->bmiHeader.biHeight, 0, 0, + 0, bitmap->bmiHeader.biHeight, bitmap_bits, bitmap, DIB_RGB_COLORS); + SelectObject(hdc, old_hbitmap); + DeleteDC(hdc); + + TCHAR path[MAX_PATH]; + GetTempPath(MAX_PATH, path); + TCHAR name[MAX_PATH]; + GetTempFileName(path, L"pasteboard", false, name); + CreateBMPFile(name, dst_hbitmap); + + DeleteObject(dst_hbitmap); + + result->Success(flutter::EncodableValue(wide_to_utf8(name))); + } else if (method_call.method_name() == "files") { + if (!IsClipboardFormatAvailable(CF_HDROP)) { + result->Success(); + return; + } + if (!OpenClipboard(nullptr)) { + result->Error("0", "open clipboard failed"); + return; + } + auto handle = GetClipboardData(CF_HDROP); + flutter::EncodableList file_list; + if (handle) { + auto data = reinterpret_cast(GlobalLock(handle)); + if (data) { + auto files = DragQueryFile(data, 0xFFFFFFFF, nullptr, 0); + for (unsigned int i = 0; i < files; ++i) { + TCHAR filename[MAX_PATH]; + DragQueryFile(data, i, filename, sizeof(TCHAR) * MAX_PATH); + std::wstring wide_filename(filename); + file_list.emplace_back(wide_to_utf8(wide_filename)); + } + } + } + CloseClipboard(); + result->Success(flutter::EncodableValue(file_list)); + } else if (method_call.method_name() == "writeFiles") { + auto *arguments = method_call.arguments(); + auto files = std::get_if>(arguments); + if (!files) { + result->Error("0", "files is empty"); + return; + } + std::vector paths; + for (const auto &item: *files) { + if (std::holds_alternative(item)) { + paths.push_back(std::get(item)); + } + } + + if (paths.empty()) { + result->Error("0", "files is empty"); + return; + } + + ScopedClipboard clipboard; + if (!clipboard.Acquire(nullptr)) { + result->Error("0", "failed to open clipboard"); + return; + } + + auto storage = CreateStorageForFileNames(paths); + if (storage.tymed == TYMED_NULL) { + result->Error("0", "create storage failed"); + return; + } + SetClipboardData(CF_HDROP, storage.hGlobal); + result->Success(); + } + else if (method_call.method_name() == "writeImage") { + + + const auto* arguments = std::get_if(method_call.arguments()); + + std::string fileName; + if (arguments) { + auto it = arguments->find(flutter::EncodableValue("fileName")); + if (it != arguments->end()) { + if (std::holds_alternative(it->second)) { + fileName = std::get(it->second); + } + } + } + + if (fileName.size() == 0) { + result->Error("0", "File name is empty"); + return; + } + std::wstring wsFileName = cp_to_wide(fileName, CP_UTF8); + + if (!CopyImageToClipboard(wsFileName.c_str())) { + result->Error("0", "Failed to copy image to clipboard"); + return; + } + + result->Success(); +} + else if (method_call.method_name() == "html") { + + UINT CF_HTML = RegisterClipboardFormatA("HTML Format"); + bool isHTMLFormatAvailable = false; + + if (IsClipboardFormatAvailable(CF_HTML)) { + isHTMLFormatAvailable = true; + } + + if (!isHTMLFormatAvailable) { + result->Success(); + return; + } + + if (!OpenClipboard(nullptr)) { + result->Error("0", "open clipboard failed"); + return; + } + HANDLE hClipboardData = GetClipboardData(CF_HTML); + if (hClipboardData == NULL) { + result->Success(); + CloseClipboard(); + return; + } + else { + char* p = (char*)GlobalLock(hClipboardData); + SIZE_T size = GlobalSize(hClipboardData); + std::string str; + str.assign(p, size); + result->Success(flutter::EncodableValue(str)); + GlobalUnlock(hClipboardData); + } + CloseClipboard(); + } + else { + result->NotImplemented(); + } +} + +} // namespace + +void PasteboardPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + PasteboardPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/windows/strconv.h b/windows/strconv.h new file mode 100644 index 0000000..eddd012 --- /dev/null +++ b/windows/strconv.h @@ -0,0 +1,565 @@ +/* +------------------------------------------------------------------------------ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2019-2021 JavaCommons +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +----------------------------------------------------------------------------- + */ +/* strconv.h v1.8.10 */ +/* Last Modified: 2021/08/30 21:53 */ +#ifndef STRCONV_H +#define STRCONV_H + +#include +#include +#include +#include +#include + +#if __cplusplus >= 201103L && !defined(STRCONV_CPP98) +static inline std::wstring cp_to_wide(const std::string &s, UINT codepage) +{ + int in_length = (int)s.length(); + int out_length = MultiByteToWideChar(codepage, 0, s.c_str(), in_length, 0, 0); + std::wstring result(out_length, L'\0'); + if (out_length) + MultiByteToWideChar(codepage, 0, s.c_str(), in_length, &result[0], out_length); + return result; +} +static inline std::string wide_to_cp(const std::wstring &s, UINT codepage) +{ + int in_length = (int)s.length(); + int out_length = WideCharToMultiByte(codepage, 0, s.c_str(), in_length, 0, 0, 0, 0); + std::string result(out_length, '\0'); + if (out_length) + WideCharToMultiByte(codepage, 0, s.c_str(), in_length, &result[0], out_length, 0, 0); + return result; +} +#else /* __cplusplus < 201103L */ +static inline std::wstring cp_to_wide(const std::string &s, UINT codepage) +{ + int in_length = (int)s.length(); + int out_length = MultiByteToWideChar(codepage, 0, s.c_str(), in_length, 0, 0); + std::vector buffer(out_length); + if (out_length) + MultiByteToWideChar(codepage, 0, s.c_str(), in_length, &buffer[0], out_length); + std::wstring result(buffer.begin(), buffer.end()); + return result; +} +static inline std::string wide_to_cp(const std::wstring &s, UINT codepage) +{ + int in_length = (int)s.length(); + int out_length = WideCharToMultiByte(codepage, 0, s.c_str(), in_length, 0, 0, 0, 0); + std::vector buffer(out_length); + if (out_length) + WideCharToMultiByte(codepage, 0, s.c_str(), in_length, &buffer[0], out_length, 0, 0); + std::string result(buffer.begin(), buffer.end()); + return result; +} +#endif + +static inline std::string cp_to_utf8(const std::string &s, UINT codepage) +{ + if (codepage == CP_UTF8) + return s; + std::wstring wide = cp_to_wide(s, codepage); + return wide_to_cp(wide, CP_UTF8); +} +static inline std::string utf8_to_cp(const std::string &s, UINT codepage) +{ + if (codepage == CP_UTF8) + return s; + std::wstring wide = cp_to_wide(s, CP_UTF8); + return wide_to_cp(wide, codepage); +} + +static inline std::wstring ansi_to_wide(const std::string &s) +{ + return cp_to_wide(s, CP_ACP); +} +static inline std::string wide_to_ansi(const std::wstring &s) +{ + return wide_to_cp(s, CP_ACP); +} + +static inline std::wstring sjis_to_wide(const std::string &s) +{ + return cp_to_wide(s, 932); +} +static inline std::string wide_to_sjis(const std::wstring &s) +{ + return wide_to_cp(s, 932); +} + +static inline std::wstring utf8_to_wide(const std::string &s) +{ + return cp_to_wide(s, CP_UTF8); +} +static inline std::string wide_to_utf8(const std::wstring &s) +{ + return wide_to_cp(s, CP_UTF8); +} + +static inline std::string ansi_to_utf8(const std::string &s) +{ + return cp_to_utf8(s, CP_ACP); +} +static inline std::string utf8_to_ansi(const std::string &s) +{ + return utf8_to_cp(s, CP_ACP); +} + +static inline std::string sjis_to_utf8(const std::string &s) +{ + return cp_to_utf8(s, 932); +} +static inline std::string utf8_to_sjis(const std::string &s) +{ + return utf8_to_cp(s, 932); +} + +#ifdef __cpp_char8_t +static inline std::u8string utf8_to_char8(const std::string &s) +{ + return std::u8string(s.begin(), s.end()); +} +static inline std::string char8_to_utf8(const std::u8string &s) +{ + return std::string(s.begin(), s.end()); +} + +static inline std::wstring char8_to_wide(const std::u8string &s) +{ + return cp_to_wide(char8_to_utf8(s), CP_UTF8); +} +static inline std::u8string wide_to_char8(const std::wstring &s) +{ + return utf8_to_char8(wide_to_cp(s, CP_UTF8)); +} + +static inline std::u8string cp_to_char8(const std::string &s, UINT codepage) +{ + return utf8_to_char8(cp_to_utf8(s, codepage)); +} +static inline std::string char8_to_cp(const std::u8string &s, UINT codepage) +{ + return utf8_to_cp(char8_to_utf8(s), codepage); +} + +static inline std::u8string ansi_to_char8(const std::string &s) +{ + return cp_to_char8(s, CP_ACP); +} +static inline std::string char8_to_ansi(const std::u8string &s) +{ + return char8_to_cp(s, CP_ACP); +} + +static inline std::u8string sjis_to_char8(const std::string &s) +{ + return cp_to_char8(s, 932); +} +static inline std::string char8_to_sjis(const std::u8string &s) +{ + return char8_to_cp(s, 932); +} +#endif + +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4996) +#endif + +static inline std::wstring vformat(const wchar_t *format, va_list args) +{ + int len = _vsnwprintf(0, 0, format, args); + if (len < 0) + return L""; + std::vector buffer(len + 1); + len = _vsnwprintf(&buffer[0], len, format, args); + if (len < 0) + return L""; + buffer[len] = L'\0'; + return &buffer[0]; +} +static inline std::string vformat(const char *format, va_list args) +{ + int len = _vsnprintf(0, 0, format, args); + if (len < 0) + return ""; + std::vector buffer(len + 1); + len = _vsnprintf(&buffer[0], len, format, args); + if (len < 0) + return ""; + buffer[len] = '\0'; + return &buffer[0]; +} +#ifdef __cpp_char8_t +static inline std::u8string vformat(const char8_t *format, va_list args) +{ + int len = _vsnprintf(0, 0, (const char *)format, args); + if (len < 0) + return u8""; + std::vector buffer(len + 1); + len = _vsnprintf(&buffer[0], len, (const char *)format, args); + if (len < 0) + return u8""; + buffer[len] = '\0'; + return (char8_t *)&buffer[0]; +} +#endif + +#if defined(_MSC_VER) +#pragma warning(pop) +#endif + +static inline std::wstring format(const wchar_t *format, ...) +{ + va_list args; + va_start(args, format); + std::wstring s = vformat(format, args); + va_end(args); + return s; +} +static inline std::string format(const char *format, ...) +{ + va_list args; + va_start(args, format); + std::string s = vformat(format, args); + va_end(args); + return s; +} +#ifdef __cpp_char8_t +static inline std::u8string format(const char8_t *format, ...) +{ + va_list args; + va_start(args, format); + std::u8string s = vformat(format, args); + va_end(args); + return s; +} +#endif + +static inline void format(std::ostream &ostrm, const wchar_t *format, ...) +{ + va_list args; + va_start(args, format); + std::wstring s = vformat(format, args); + va_end(args); + ostrm << wide_to_utf8(s) << std::flush; +} +static inline void format(std::ostream &ostrm, const char *format, ...) +{ + va_list args; + va_start(args, format); + std::string s = vformat(format, args); + va_end(args); + ostrm << s << std::flush; +} +#ifdef __cpp_char8_t +static inline void format(std::ostream &ostrm, const char8_t *format, ...) +{ + va_list args; + va_start(args, format); + std::u8string s = vformat(format, args); + va_end(args); + ostrm << char8_to_utf8(s) << std::flush; +} +#endif + +static inline std::string formatA(const wchar_t *format, ...) +{ + va_list args; + va_start(args, format); + std::wstring s = vformat(format, args); + va_end(args); + return wide_to_ansi(s); +} +static inline std::string formatA(const char *format, ...) +{ + va_list args; + va_start(args, format); + std::string s = vformat(format, args); + va_end(args); + return utf8_to_ansi(s); +} +#ifdef __cpp_char8_t +static inline std::string formatA(const char8_t *format, ...) +{ + va_list args; + va_start(args, format); + std::u8string s = vformat(format, args); + va_end(args); + return char8_to_ansi(s); +} +#endif + +static inline void formatA(std::ostream &ostrm, const wchar_t *format, ...) +{ + va_list args; + va_start(args, format); + std::wstring s = vformat(format, args); + va_end(args); + ostrm << wide_to_ansi(s) << std::flush; +} +static inline void formatA(std::ostream &ostrm, const char *format, ...) +{ + va_list args; + va_start(args, format); + std::string s = vformat(format, args); + va_end(args); + ostrm << utf8_to_ansi(s) << std::flush; +} +#ifdef __cpp_char8_t +static inline void formatA(std::ostream &ostrm, const char8_t *format, ...) +{ + va_list args; + va_start(args, format); + std::u8string s = vformat(format, args); + va_end(args); + ostrm << char8_to_ansi(s) << std::flush; +} +#endif + +static inline void dbgmsg(const wchar_t *title, const wchar_t *format, ...) +{ + va_list args; + va_start(args, format); + std::wstring s = vformat(format, args); + va_end(args); + MessageBoxW(0, s.c_str(), title, MB_OK); +} +static inline void dbgmsg(const char *title, const char *format, ...) +{ + va_list args; + va_start(args, format); + std::string s = vformat(format, args); + va_end(args); + MessageBoxW(0, utf8_to_wide(s).c_str(), utf8_to_wide(title).c_str(), MB_OK); +} +#ifdef __cpp_char8_t +static inline void dbgmsg(const char8_t *title, const char8_t *format, ...) +{ + va_list args; + va_start(args, format); + std::u8string s = vformat(format, args); + va_end(args); + MessageBoxW(0, char8_to_wide(s).c_str(), char8_to_wide(title).c_str(), MB_OK); +} +#endif + +static inline HANDLE handle_for_ostream(std::ostream &ostrm) +{ + if (&ostrm == &std::cout) + { + return GetStdHandle(STD_OUTPUT_HANDLE); + } + else if (&ostrm == &std::cerr) + { + return GetStdHandle(STD_ERROR_HANDLE); + } + return INVALID_HANDLE_VALUE; +} +static inline void dbgout(std::ostream &ostrm, const wchar_t *format, ...) +{ + va_list args; + va_start(args, format); + std::wstring ws = vformat(format, args); + va_end(args); + HANDLE h = handle_for_ostream(ostrm); + if (h == INVALID_HANDLE_VALUE) + { + return; + } + DWORD dwNumberOfCharsWrite; + if (GetFileType(h) != FILE_TYPE_CHAR) + { + std::string s = wide_to_cp(ws, GetConsoleOutputCP()); + WriteFile(h, s.c_str(), (DWORD)s.size(), &dwNumberOfCharsWrite, NULL); + } + else + { + WriteConsoleW(h, + ws.c_str(), + (DWORD)ws.size(), + &dwNumberOfCharsWrite, + NULL); + } +} +static inline void dbgout(std::ostream &ostrm, const char *format, ...) +{ + va_list args; + va_start(args, format); + std::string s = vformat(format, args); + va_end(args); + HANDLE h = handle_for_ostream(ostrm); + if (h == INVALID_HANDLE_VALUE) + { + return; + } + DWORD dwNumberOfCharsWrite; + if (GetFileType(h) != FILE_TYPE_CHAR) + { + s = utf8_to_cp(s, GetConsoleOutputCP()); + WriteFile(h, s.c_str(), (DWORD)s.size(), &dwNumberOfCharsWrite, NULL); + } + else + { + std::wstring ws = utf8_to_wide(s); + WriteConsoleW(h, + ws.c_str(), + (DWORD)ws.size(), + &dwNumberOfCharsWrite, + NULL); + } +} +#ifdef __cpp_char8_t +static inline void dbgout(std::ostream &ostrm, const char8_t *format, ...) +{ + va_list args; + va_start(args, format); + std::u8string s = vformat(format, args); + va_end(args); + HANDLE h = handle_for_ostream(ostrm); + if (h == INVALID_HANDLE_VALUE) + { + return; + } + DWORD dwNumberOfCharsWrite; + if (GetFileType(h) != FILE_TYPE_CHAR) + { + std::string str = char8_to_cp(s, GetConsoleOutputCP()); + WriteFile(h, (const char *)str.c_str(), (DWORD)str.size(), &dwNumberOfCharsWrite, NULL); + } + else + { + std::wstring ws = char8_to_wide(s); + WriteConsoleW(h, + ws.c_str(), + (DWORD)ws.size(), + &dwNumberOfCharsWrite, + NULL); + } +} +#endif + +class unicode_ostream +{ + private: + std::ostream *m_ostrm; + UINT m_target_cp; + bool is_ascii(const std::string &s) + { + for (std::size_t i = 0; i < s.size(); i++) + { + unsigned char c = (unsigned char)s[i]; + if (c > 0x7f) + return false; + } + return true; + } + + public: + unicode_ostream(std::ostream &ostrm, UINT target_cp = CP_ACP) : m_ostrm(&ostrm), m_target_cp(target_cp) {} + std::ostream &stream() { return *m_ostrm; } + void stream(std::ostream &ostrm) { m_ostrm = &ostrm; } + UINT target_cp() { return m_target_cp; } + void target_cp(UINT cp) { m_target_cp = cp; } + template + unicode_ostream &operator<<(const T &x) + { + std::ostringstream oss; + oss << x; + std::string output = oss.str(); + if (is_ascii(output)) + { + (*m_ostrm) << x; + } + else + { + (*m_ostrm) << utf8_to_cp(output, m_target_cp); + } + return *this; + } + unicode_ostream &operator<<(const std::wstring &x) + { + (*m_ostrm) << wide_to_cp(x, m_target_cp); + return *this; + } + unicode_ostream &operator<<(const wchar_t *x) + { + (*m_ostrm) << wide_to_cp(x, m_target_cp); + return *this; + } + unicode_ostream &operator<<(const std::string &x) + { + (*m_ostrm) << utf8_to_cp(x, m_target_cp); + return *this; + } + unicode_ostream &operator<<(const char *x) + { + (*m_ostrm) << utf8_to_cp(x, m_target_cp); + return *this; + } +#ifdef __cpp_char8_t + unicode_ostream &operator<<(const std::u8string &x) + { + (*m_ostrm) << char8_to_cp(x, m_target_cp); + return *this; + } + unicode_ostream &operator<<(const char8_t *x) + { + (*m_ostrm) << char8_to_cp(x, m_target_cp); + return *this; + } +#endif + unicode_ostream &operator<<(std::ostream &(*pf)(std::ostream &)) // For manipulators... + { + (*m_ostrm) << pf; + return *this; + } + unicode_ostream &operator<<(std::basic_ios &(*pf)(std::basic_ios &)) // For manipulators... + { + (*m_ostrm) << pf; + return *this; + } +}; + +#define U8(X) ((const char *)u8##X) +#define WIDE(X) (L##X) + +#endif /* STRCONV_H */ \ No newline at end of file