fox_utils 0.1.7: clipboard, drag & drop, Android file saving
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dc16bba054
46 files changed
+4738
No files matched your search
Executable
+31
@@ -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/
|
||||||
@@ -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
|
||||||
Executable
+41
@@ -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.
|
||||||
@@ -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).
|
||||||
Executable
+13
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/libraries
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.cxx
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "fox_utils"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.foxyz.fox_utils">
|
||||||
|
<application>
|
||||||
|
<!-- Serves clipboard images to other apps (pasteboard clipboard engine).
|
||||||
|
Authority matches what the plugin computes at runtime:
|
||||||
|
context.getPackageName() + ".provider". -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Two channels:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code fox_utils/android_files} — permission-free saving to public
|
||||||
|
* directories (see AndroidFiles.save).</li>
|
||||||
|
* <li>{@code pasteboard} — clipboard image/files read/write, ported from
|
||||||
|
* one.mixin.pasteboard (Apache-2.0) with fixes: byte-faithful image
|
||||||
|
* writes (no Bitmap re-encode), correct async executor use, and a
|
||||||
|
* working writeFiles clip assembly.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Clipboard images are staged in the app cache dir. -->
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
</paths>
|
||||||
@@ -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 `<location>/<subPath>/<fileName>` 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<String> save(
|
||||||
|
Uint8List bytes, {
|
||||||
|
required String fileName,
|
||||||
|
AndroidPublicLocation location = AndroidPublicLocation.pictures,
|
||||||
|
String subPath = '',
|
||||||
|
bool scan = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final path = await _channel.invokeMethod<String>('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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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<String> _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<void> copyFiles(List<String> 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<String?> readText() => Pasteboard.text;
|
||||||
|
|
||||||
|
static Future<Uint8List?> readImage() => Pasteboard.image;
|
||||||
|
|
||||||
|
static Future<List<String>> 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<Uint8List?> 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<Uint8List?> _readFileBytes(String uri) async {
|
||||||
|
try {
|
||||||
|
return await _clipboardChannel.invokeMethod<Uint8List>('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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> pathsOf(List<DropItem> 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<Uint8List?> readImage(List<DropItem> 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<Uint8List?> _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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+211
@@ -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 '⛔';
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+115
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+9
@@ -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;
|
||||||
|
}
|
||||||
Executable
+420
@@ -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<Path> {
|
||||||
|
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<T> on T {
|
||||||
|
R let<R>(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<Side> 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<Side> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export 'src/drop_target.dart';
|
||||||
|
export 'src/drop_item.dart';
|
||||||
|
export 'src/channel.dart';
|
||||||
Vendored
+44
@@ -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<Uint8List?> 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<String?> get html => pasteboard.html;
|
||||||
|
|
||||||
|
/// available on iOS, Android, Web
|
||||||
|
///
|
||||||
|
/// set image data to system pasteboard.
|
||||||
|
static Future<void> 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<List<String>> files() => pasteboard.files();
|
||||||
|
|
||||||
|
/// Only available on desktop platforms.
|
||||||
|
///
|
||||||
|
/// Set files to system pasteboard.
|
||||||
|
static Future<bool> writeFiles(List<String> files) =>
|
||||||
|
pasteboard.writeFiles(files);
|
||||||
|
|
||||||
|
/// Available on all platforms.
|
||||||
|
/// Get text from system pasteboard.
|
||||||
|
static Future<String?> get text => pasteboard.text;
|
||||||
|
|
||||||
|
/// Available on all platforms.
|
||||||
|
/// Set text to system pasteboard.
|
||||||
|
static void writeText(String value) => pasteboard.writeText(value);
|
||||||
|
}
|
||||||
Vendored
+190
@@ -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 = <RawDropListener>{};
|
||||||
|
|
||||||
|
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<bool> startAccessingSecurityScopedResource(
|
||||||
|
{required Uint8List bookmark}) async {
|
||||||
|
if (bookmark.isEmpty) return false;
|
||||||
|
Map<String, dynamic> 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<bool> stopAccessingSecurityScopedResource(
|
||||||
|
{required Uint8List bookmark}) async {
|
||||||
|
if (bookmark.isEmpty) return true;
|
||||||
|
Map<String, dynamic> resultMap = {};
|
||||||
|
resultMap["apple-bookmark"] = bookmark;
|
||||||
|
final bool result = await _channel.invokeMethod(
|
||||||
|
"stopAccessingSecurityScopedResource", resultMap);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleMethodChannel(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case "entered":
|
||||||
|
final position = (call.arguments as List).cast<double>();
|
||||||
|
_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<double>();
|
||||||
|
_offset = Offset(position[0], position[1]);
|
||||||
|
_notifyEvent(DropEnterEvent(location: _offset!));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final position = (call.arguments as List).cast<double>();
|
||||||
|
_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<String>();
|
||||||
|
_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<Map>();
|
||||||
|
_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<dynamic>)[0] as String;
|
||||||
|
final offset = ((call.arguments as List<dynamic>)[1] as List<dynamic>)
|
||||||
|
.cast<double>();
|
||||||
|
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>()
|
||||||
|
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+104
@@ -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<DropItem> 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();
|
||||||
|
}
|
||||||
Vendored
+220
@@ -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<DropItem> 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<Detail> = 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<DropEventDetails>? onDragEntered;
|
||||||
|
|
||||||
|
/// Callback when drag exited target area.
|
||||||
|
final OnDragCallback<DropEventDetails>? onDragExited;
|
||||||
|
|
||||||
|
/// Callback when drag hover on target area.
|
||||||
|
final OnDragCallback<DropEventDetails>? 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<DropTarget> createState() => _DropTargetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _DragTargetStatus {
|
||||||
|
enter,
|
||||||
|
update,
|
||||||
|
idle,
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DropTargetState extends State<DropTarget> {
|
||||||
|
_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;
|
||||||
|
}
|
||||||
Vendored
+39
@@ -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<DropItem> files;
|
||||||
|
|
||||||
|
DropDoneEvent({
|
||||||
|
required Offset location,
|
||||||
|
required this.files,
|
||||||
|
}) : super(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return '$runtimeType($location, $files)';
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
abstract class PasteboardPlatform {
|
||||||
|
Future<Uint8List?> get image;
|
||||||
|
|
||||||
|
Future<String?> get html;
|
||||||
|
|
||||||
|
Future<void> writeImage(Uint8List? image);
|
||||||
|
|
||||||
|
Future<List<String>> files();
|
||||||
|
|
||||||
|
Future<bool> writeFiles(List<String> files);
|
||||||
|
|
||||||
|
Future<String?> get text;
|
||||||
|
|
||||||
|
void writeText(String value);
|
||||||
|
}
|
||||||
+105
@@ -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<List<String>> files() async {
|
||||||
|
final files = await _channel.invokeMethod<List>('files');
|
||||||
|
return files?.cast<String>() ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> get html async {
|
||||||
|
if (Platform.isWindows || Platform.isAndroid) {
|
||||||
|
return await _channel.invokeMethod<Object>('html') as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> get image async {
|
||||||
|
final image = await _channel.invokeMethod<Object>('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<bool> writeFiles(List<String> files) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<Object>('writeFiles', files);
|
||||||
|
return true;
|
||||||
|
} catch (error, stacktrace) {
|
||||||
|
debugPrint('$error\n$stacktrace');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> writeImage(Uint8List? image) async {
|
||||||
|
if (image == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
|
||||||
|
await _channel.invokeMethod<void>('writeImage', image);
|
||||||
|
} else if (Platform.isWindows) {
|
||||||
|
final file = await File(_getTempFileName()).create();
|
||||||
|
file.writeAsBytesSync(image);
|
||||||
|
await _channel
|
||||||
|
.invokeMethod<Object>('writeImage', {'fileName': file.path});
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> 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;
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:js_interop';
|
||||||
|
|
||||||
|
import 'package:web/web.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'pasteboard_platform.dart';
|
||||||
|
|
||||||
|
Future<String?> _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<Uint8List?> _readAsUint8List() async {
|
||||||
|
final buffer = await arrayBuffer().toDart;
|
||||||
|
return buffer.toDart.asUint8List();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PasteboardPlatform pasteboard = PasteboardPlatformWeb();
|
||||||
|
|
||||||
|
class PasteboardPlatformWeb implements PasteboardPlatform {
|
||||||
|
const PasteboardPlatformWeb();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> files() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> 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<Uint8List?> 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<bool> writeFiles(List<String> files) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<String?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+85
@@ -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<WebDropItem> children;
|
||||||
|
final Uint8List? data;
|
||||||
|
final String name;
|
||||||
|
final String type;
|
||||||
|
final int size;
|
||||||
|
final String? relativePath;
|
||||||
|
final DateTime lastModified;
|
||||||
|
|
||||||
|
factory WebDropItem.fromJson(Map<String, dynamic> json) => WebDropItem(
|
||||||
|
uri: json['uri'],
|
||||||
|
children: json['children'] == null
|
||||||
|
? []
|
||||||
|
: (json['children'] as List)
|
||||||
|
.cast<Map>()
|
||||||
|
.map((e) => WebDropItem.fromJson(e.cast<String, dynamic>()))
|
||||||
|
.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<String, dynamic> toJson() => <String, dynamic>{
|
||||||
|
'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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#include "include/fox_utils/desktop_drop_plugin.h"
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <sys/utsname.h>
|
||||||
|
|
||||||
|
#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<FlMethodChannel *>(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<FlMethodChannel *>(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<FlMethodChannel *>(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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
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_
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
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_
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
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_
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#include "include/fox_utils/pasteboard_plugin.h"
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<FlMethodCall *>(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<gchar **>(user_data_or_owner);
|
||||||
|
if (gtk_targets_include_uri(&target, 1)) {
|
||||||
|
gtk_selection_data_set_uris(selection_data, reinterpret_cast<gchar **>(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<gchar **>(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<FlMethodCall *>(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<const uint8_t *>(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);
|
||||||
|
}
|
||||||
Executable
+35
@@ -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
|
||||||
Executable
+12
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
"$<$<CXX_COMPILER_ID:MSVC>:/W3 /WX>;$<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra -Wpedantic -Werror>")
|
||||||
|
set_source_files_properties("desktop_drop_plugin.cpp"
|
||||||
|
PROPERTIES COMPILE_OPTIONS
|
||||||
|
"$<$<CXX_COMPILER_ID:MSVC>:/W3>")
|
||||||
|
|
||||||
|
# List of absolute paths to libraries that should be bundled with the plugin
|
||||||
|
set(fox_utils_bundled_libraries
|
||||||
|
""
|
||||||
|
PARENT_SCOPE
|
||||||
|
)
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
#include "include/fox_utils/desktop_drop_plugin.h"
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <flutter/method_channel.h>
|
||||||
|
#include <flutter/plugin_registrar_windows.h>
|
||||||
|
#include <flutter/standard_method_codec.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <sstream>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using FlutterMethodChannel = std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>>;
|
||||||
|
|
||||||
|
|
||||||
|
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<wchar_t>(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<wchar_t>(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<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||||
|
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<DesktopDropPlugin>(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::EncodableValue>(
|
||||||
|
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::EncodableValue>(
|
||||||
|
flutter::EncodableList{
|
||||||
|
flutter::EncodableValue(double(point.x)),
|
||||||
|
flutter::EncodableValue(double(point.y))
|
||||||
|
}
|
||||||
|
));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT DesktopDropTarget::DragLeave() {
|
||||||
|
channel_->InvokeMethod("exited", std::make_unique<flutter::EncodableValue>());
|
||||||
|
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<HDROP>(data), 0xFFFFFFFF, nullptr, 0);
|
||||||
|
for (unsigned int i = 0; i < files; ++i) {
|
||||||
|
TCHAR filename[MAX_PATH];
|
||||||
|
DragQueryFile(reinterpret_cast<HDROP>(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<flutter::EncodableValue>(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<flutter::PluginRegistrarWindows>(registrar));
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#ifndef FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_DESKTOP_DROP_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_plugin_registrar.h>
|
||||||
|
|
||||||
|
#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_
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#ifndef FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_FOX_UTILS_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_plugin_registrar.h>
|
||||||
|
|
||||||
|
#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_
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#ifndef FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_PASTEBOARD_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_plugin_registrar.h>
|
||||||
|
|
||||||
|
#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_
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
#include "include/fox_utils/pasteboard_plugin.h"
|
||||||
|
|
||||||
|
#include <flutter/method_channel.h>
|
||||||
|
#include <flutter/plugin_registrar_windows.h>
|
||||||
|
#include <flutter/standard_method_codec.h>
|
||||||
|
|
||||||
|
|
||||||
|
#include <Windows.h>
|
||||||
|
#include <ShlObj.h>
|
||||||
|
#include <gdiplus.h>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "strconv.h"
|
||||||
|
|
||||||
|
#pragma comment(lib, "GdiPlus")
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr STGMEDIUM kNullStorageMedium = {TYMED_NULL, nullptr, nullptr};
|
||||||
|
|
||||||
|
STGMEDIUM CreateStorageForFileNames(const std::vector<std::string> &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<wchar_t *>(
|
||||||
|
reinterpret_cast<BYTE *>(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<const BITMAPINFO *>(&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<flutter::EncodableValue> &method_call,
|
||||||
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||||
|
};
|
||||||
|
|
||||||
|
// static
|
||||||
|
void PasteboardPlugin::RegisterWithRegistrar(
|
||||||
|
flutter::PluginRegistrarWindows *registrar) {
|
||||||
|
auto channel =
|
||||||
|
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||||
|
registrar->messenger(), "pasteboard",
|
||||||
|
&flutter::StandardMethodCodec::GetInstance());
|
||||||
|
|
||||||
|
auto plugin = std::make_unique<PasteboardPlugin>();
|
||||||
|
|
||||||
|
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<flutter::EncodableValue> &method_call,
|
||||||
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> 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<BITMAPINFO *>(::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<const char *>(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<HBITMAP>(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<HDROP>(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<std::vector<flutter::EncodableValue>>(arguments);
|
||||||
|
if (!files) {
|
||||||
|
result->Error("0", "files is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::vector<std::string> paths;
|
||||||
|
for (const auto &item: *files) {
|
||||||
|
if (std::holds_alternative<std::string>(item)) {
|
||||||
|
paths.push_back(std::get<std::string>(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<flutter::EncodableMap>(method_call.arguments());
|
||||||
|
|
||||||
|
std::string fileName;
|
||||||
|
if (arguments) {
|
||||||
|
auto it = arguments->find(flutter::EncodableValue("fileName"));
|
||||||
|
if (it != arguments->end()) {
|
||||||
|
if (std::holds_alternative<std::string>(it->second)) {
|
||||||
|
fileName = std::get<std::string>(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<flutter::PluginRegistrarWindows>(registrar));
|
||||||
|
}
|
||||||
@@ -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 <windows.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#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<wchar_t> 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<char> 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<wchar_t> 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<char> 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<char> 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 <typename T>
|
||||||
|
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<char> &(*pf)(std::basic_ios<char> &)) // For manipulators...
|
||||||
|
{
|
||||||
|
(*m_ostrm) << pf;
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#define U8(X) ((const char *)u8##X)
|
||||||
|
#define WIDE(X) (L##X)
|
||||||
|
|
||||||
|
#endif /* STRCONV_H */
|
||||||
Reference in new issue
Block a user