diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index f5160b05..a4d6455f 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -1,212 +1,354 @@ -name: Release (Linux) - -on: - workflow_call: - inputs: - version: - required: true - type: string - workflow_dispatch: - inputs: - version: - description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" - required: false - default: "2.0.0-dev" - -permissions: - contents: read - -jobs: - build-linux: - runs-on: ubuntu-22.04 - timeout-minutes: 45 - name: Build Linux (AppImage, deb, rpm) - - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.ref_name }} - - - name: Resolve version - id: get_version - run: | - VERSION="${{ inputs.version }}" - - IS_PRERELEASE="false" - if [[ "$VERSION" == *-* ]]; then - IS_PRERELEASE="true" - fi - - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION PreRelease: $IS_PRERELEASE" - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v5 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - clang \ - cmake \ - desktop-file-utils \ - libayatana-appindicator3-dev \ - libfuse2 \ - libgtk-3-dev \ - libkeybinder-3.0-dev \ - liblzma-dev \ - libx11-dev \ - libxtst-dev \ - lld-14 \ - ninja-build \ - patchelf \ - pkg-config \ - rpm - - - name: Verify Linux toolchain preflight - run: | - set -euo pipefail - test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld - pkg-config --modversion gtk+-3.0 - pkg-config --modversion keybinder-3.0 - pkg-config --modversion ayatana-appindicator3-0.1 - pkg-config --modversion x11 - pkg-config --modversion xtst - - - name: Install Fastforge - run: dart pub global activate fastforge - - - name: Install appimagetool - run: | - wget -qO /usr/local/bin/appimagetool \ - "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x /usr/local/bin/appimagetool - - - name: Install Melos - run: dart pub global activate melos - - - name: Get dependencies - run: melos bootstrap - - - name: Update pubspec version from tag - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - sed -i "s/^version:.*/version: $VERSION/" app/pubspec.yaml - echo "Updated pubspec.yaml version to: $VERSION" - - - name: Package AppImage - env: - APPIMAGE_EXTRACT_AND_RUN: "1" - run: | - cd app - fastforge package \ - --platform linux \ - --targets appimage \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - - - name: Rename AppImage - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) - if [[ -z "$APPIMAGE" ]]; then - echo "::error::No AppImage generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - mv "$APPIMAGE" "$DEST" - chmod +x "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Package deb - run: | - cd app - fastforge package \ - --platform linux \ - --targets deb \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - - - name: Rename deb - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) - if [[ -z "$DEB" ]]; then - echo "::error::No deb package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" - mv "$DEB" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Normalize version for RPM - id: rpm_version - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - RPM_VERSION="${VERSION%%-*}" - echo "RPM_VERSION=$RPM_VERSION" >> "$GITHUB_OUTPUT" - echo "RPM version (normalized): $RPM_VERSION" - - - name: Package rpm - run: | - RPM_VERSION="${{ steps.rpm_version.outputs.RPM_VERSION }}" - cd app - sed -i "s/^version:.*/version: $RPM_VERSION/" pubspec.yaml - fastforge package \ - --platform linux \ - --targets rpm \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - VERSION="${{ steps.get_version.outputs.VERSION }}" - sed -i "s/^version:.*/version: $VERSION/" pubspec.yaml - - - name: Rename rpm - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) - if [[ -z "$RPM" ]]; then - echo "::error::No rpm package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" - mv "$RPM" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Publish deb to Cloudsmith - if: github.event_name == 'push' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - run: | - pip install cloudsmith-cli - cloudsmith push deb rgdevment/copypaste/any-distro/any-version \ - app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_amd64.deb --republish - - - name: Publish rpm to Cloudsmith - if: github.event_name == 'push' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - run: | - cloudsmith push rpm rgdevment/copypaste/any-distro/any-version \ - app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_x86_64.rpm --republish - - - name: Upload artifact - uses: actions/upload-artifact@v6 - with: - name: release-linux - path: | - app/dist/*.AppImage - app/dist/*.deb - app/dist/*.rpm - retention-days: 5 +name: Release (Linux) + +on: + workflow_call: + inputs: + version: + required: true + type: string + workflow_dispatch: + inputs: + version: + description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" + required: false + default: "2.0.0-dev" + +permissions: + contents: read + +jobs: + build-linux: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + name: Build Linux (AppImage, deb, rpm) + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Resolve version + id: get_version + run: | + VERSION="${{ inputs.version }}" + + IS_PRERELEASE="false" + if [[ "$VERSION" == *-* ]]; then + IS_PRERELEASE="true" + fi + + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION PreRelease: $IS_PRERELEASE" + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v5 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + desktop-file-utils \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libkeybinder-3.0-dev \ + liblzma-dev \ + libx11-dev \ + libxtst-dev \ + lld-14 \ + ninja-build \ + patchelf \ + pkg-config \ + rpm + + - name: Verify Linux toolchain preflight + run: | + set -euo pipefail + test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld + pkg-config --modversion gtk+-3.0 + pkg-config --modversion keybinder-3.0 + pkg-config --modversion ayatana-appindicator3-0.1 + pkg-config --modversion x11 + pkg-config --modversion xtst + + - name: Install Fastforge + run: dart pub global activate fastforge + + - name: Install appimagetool + run: | + wget -qO /usr/local/bin/appimagetool \ + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x /usr/local/bin/appimagetool + + - name: Install Melos + run: dart pub global activate melos + + - name: Get dependencies + run: melos bootstrap + + - name: Update pubspec version from tag + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + sed -i "s/^version:.*/version: $VERSION/" app/pubspec.yaml + echo "Updated pubspec.yaml version to: $VERSION" + + - name: Package AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + cd app + fastforge package \ + --platform linux \ + --targets appimage \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" + + - name: Rename AppImage + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) + if [[ -z "$APPIMAGE" ]]; then + echo "::error::No AppImage generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + mv "$APPIMAGE" "$DEST" + chmod +x "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Repack AppImage with AppImageUpdate metadata + if: env.STORE_BUILD != 'true' + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + set -euo pipefail + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE="$GITHUB_WORKSPACE/app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + BASENAME="$(basename "$APPIMAGE")" + WORK="$(mktemp -d)" + (cd "$WORK" && "$APPIMAGE" --appimage-extract >/dev/null) + UPDATE_INFO="gh-releases-zsync|rgdevment|CopyPaste|latest|CopyPaste_*_x86_64.AppImage.zsync" + ( + cd "$WORK" + ARCH=x86_64 appimagetool \ + --updateinformation "$UPDATE_INFO" \ + squashfs-root \ + "$BASENAME" + ) + mv "$WORK/$BASENAME" "$APPIMAGE" + mv "$WORK/${BASENAME}.zsync" "$GITHUB_WORKSPACE/app/dist/${BASENAME}.zsync" + chmod +x "$APPIMAGE" + echo "Embedded update info: $UPDATE_INFO" + ls -la "$GITHUB_WORKSPACE/app/dist/${BASENAME}.zsync" + + - name: Package deb + run: | + cd app + fastforge package \ + --platform linux \ + --targets deb \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ + --skip-clean + + - name: Rename deb + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) + if [[ -z "$DEB" ]]; then + echo "::error::No deb package generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" + mv "$DEB" "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Normalize version for RPM + id: rpm_version + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + RPM_VERSION="${VERSION%%-*}" + echo "RPM_VERSION=$RPM_VERSION" >> "$GITHUB_OUTPUT" + echo "RPM version (normalized): $RPM_VERSION" + + - name: Package rpm + run: | + RPM_VERSION="${{ steps.rpm_version.outputs.RPM_VERSION }}" + cd app + sed -i "s/^version:.*/version: $RPM_VERSION/" pubspec.yaml + fastforge package \ + --platform linux \ + --targets rpm \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ + --skip-clean + VERSION="${{ steps.get_version.outputs.VERSION }}" + sed -i "s/^version:.*/version: $VERSION/" pubspec.yaml + + - name: Rename rpm + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) + if [[ -z "$RPM" ]]; then + echo "::error::No rpm package generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" + mv "$RPM" "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Validate bundled .desktop file + run: | + set -euo pipefail + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + WORKDIR="$(mktemp -d)" + (cd "$WORKDIR" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract '*.desktop' >/dev/null) + DESKTOP=$(find "$WORKDIR/squashfs-root" -maxdepth 2 -name '*.desktop' | head -n 1) + if [[ -z "$DESKTOP" ]]; then + echo "::error::No .desktop file inside AppImage" + exit 1 + fi + desktop-file-validate "$DESKTOP" + echo "✓ desktop-file-validate passed" + + - name: Build portable tarball for OBS + run: | + set -euo pipefail + VERSION="${{ steps.get_version.outputs.VERSION }}" + BUNDLE="app/build/linux/x64/release/bundle" + if [[ ! -d "$BUNDLE" ]]; then + echo "::error::Flutter bundle not found at $BUNDLE" + exit 1 + fi + STAGE="$(mktemp -d)/CopyPaste-${VERSION}-linux-x64" + mkdir -p "$STAGE/bundle" "$STAGE/packaging" + cp -a "$BUNDLE"/. "$STAGE/bundle/" + cp LICENSE "$STAGE/LICENSE" + cp app/assets/icons/icon_app_256.png "$STAGE/packaging/icon_app_256.png" + APPIMAGE="$GITHUB_WORKSPACE/app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + EXTRACT="$(mktemp -d)" + (cd "$EXTRACT" && "$APPIMAGE" --appimage-extract '*.desktop' >/dev/null) + DESKTOP=$(find "$EXTRACT/squashfs-root" -maxdepth 2 -name '*.desktop' | head -n 1) + cp "$DESKTOP" "$STAGE/packaging/com.rgdevment.copypaste.desktop" + tar -czf "app/dist/CopyPaste-${VERSION}-linux-x64.tar.gz" \ + -C "$(dirname "$STAGE")" "$(basename "$STAGE")" + ls -la "app/dist/CopyPaste-${VERSION}-linux-x64.tar.gz" + + - name: Compute SHA-256 checksums + run: | + cd app/dist + sha256sum *.AppImage *.AppImage.zsync *.deb *.rpm *.tar.gz > SHA256SUMS 2>/dev/null || \ + sha256sum *.AppImage *.deb *.rpm *.tar.gz > SHA256SUMS + cat SHA256SUMS + + - name: Publish deb to Cloudsmith + if: github.event_name == 'push' + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + pip install cloudsmith-cli + cloudsmith push deb rgdevment/copypaste/any-distro/any-version \ + app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_amd64.deb --republish + + - name: Publish rpm to Cloudsmith + if: github.event_name == 'push' + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + cloudsmith push rpm rgdevment/copypaste/any-distro/any-version \ + app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_x86_64.rpm --republish + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: release-linux + path: | + app/dist/*.AppImage + app/dist/*.AppImage.zsync + app/dist/*.deb + app/dist/*.rpm + app/dist/*.tar.gz + app/dist/SHA256SUMS + retention-days: 5 + + publish-obs: + runs-on: ubuntu-22.04 + needs: build-linux + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + timeout-minutes: 15 + name: Publish to OpenSUSE Build Service + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Resolve version + id: get_version + run: | + VERSION="${{ inputs.version }}" + DATE_RFC="$(date -R)" + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "DATE_RFC=$DATE_RFC" >> "$GITHUB_OUTPUT" + + - name: Install osc + run: | + sudo apt-get update + sudo apt-get install -y osc + + - name: Configure osc credentials + env: + OBS_USERNAME: ${{ secrets.OBS_USERNAME }} + OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} + run: | + if [[ -z "$OBS_USERNAME" || -z "$OBS_PASSWORD" ]]; then + echo "::warning::OBS credentials missing — skipping OBS publish" + echo "SKIP=true" >> "$GITHUB_ENV" + exit 0 + fi + mkdir -p ~/.config/osc + cat > ~/.config/osc/oscrc </dev/null || true + STAGE="$(mktemp -d)" + cp -a "$GITHUB_WORKSPACE/packaging/obs/." "$STAGE/" + find "$STAGE" -type f \( -name '_service' -o -name '*.spec' -o -name '*.dsc' -o -name 'changelog' \) \ + -exec sed -i "s/@VERSION@/$VERSION/g; s/Thu, 23 Apr 2026 00:00:00 +0000/$DATE_RFC/g" {} + + cp "$STAGE/_service" "$OBS_DIR/_service" + cp "$STAGE/copypaste.spec" "$OBS_DIR/copypaste.spec" + cp "$STAGE/copypaste.dsc" "$OBS_DIR/copypaste.dsc" + tar -C "$STAGE" -cJf "$OBS_DIR/debian.tar.xz" debian + cd "$OBS_DIR" + osc addremove + osc commit -m "Release v$VERSION (automated from GitHub Actions)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56dd8a49..53914969 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,8 +93,10 @@ jobs: artifacts/release-windows/**/*_store.msix* artifacts/release-macos/*.dmg artifacts/release-linux/*.AppImage + artifacts/release-linux/*.AppImage.zsync artifacts/release-linux/*.deb artifacts/release-linux/*.rpm + artifacts/release-linux/*.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3499b5fc..dfe5fd54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,79 +1,83 @@ -# ── Dart / Flutter ── -.dart_tool/ -.packages -build/ -*.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -*.iml - -# Generated files -*.g.dart -*.freezed.dart -*.mocks.dart - -# Coverage -coverage/ -*.lcov - -# Pub (keep workspace root lockfile) -.pub-cache/ -.pub/ -**/pubspec.lock -!/pubspec.lock - -# ── IDE ── -.vs/ -.idea/ -*.code-workspace - -# VS Code (keep shared config) -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# ── Build outputs ── -dist/ - -# ── Environment ── -.env -.venv/ - -# ── Python ── -__pycache__/ -*.pyc - -# ── OS: macOS ── -.DS_Store -.AppleDouble -.LSOverride -Icon -._* -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# ── OS: Windows ── -Thumbs.db -ehthumbs.db -ehthumbs_vista.db -*.stackdump -[Dd]esktop.ini -$RECYCLE.BIN/ -*.lnk - -# ── OS: Linux ── -*~ -*.swp -last_cleanup.txt +# ── Dart / Flutter ── +.dart_tool/ +.packages +build/ +*.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml + +# Generated files +*.g.dart +*.freezed.dart +*.mocks.dart + +# Coverage +coverage/ +*.lcov + +# Pub (keep workspace root lockfile) +.pub-cache/ +.pub/ +**/pubspec.lock +!/pubspec.lock + +# ── IDE ── +.vs/ +.idea/ +*.code-workspace + +# VS Code (keep shared config) +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# ── Build outputs ── +dist/ + +# ── OBS (osc local checkouts) ── +.osc/ +/home:rgdevment/ + +# ── Environment ── +.env +.venv/ + +# ── Python ── +__pycache__/ +*.pyc + +# ── OS: macOS ── +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# ── OS: Windows ── +Thumbs.db +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.lnk + +# ── OS: Linux ── +*~ +*.swp +last_cleanup.txt diff --git a/PRIVACY.md b/PRIVACY.md index f4a857b0..c6535130 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,363 +1,363 @@ -# Privacy Policy - -**Last updated:** April 24, 2026 - ---- - -## The Short Version - -**Everything stays on your computer.** CopyPaste does not collect, transmit, or share any of your data. There's no cloud, no accounts, no telemetry, no analytics, no tracking — nothing leaves your machine, ever. - -**"Everything local" is not a feature — it's the foundation.** It's a technical fact you can verify yourself: the entire source code is [open and public](https://github.com/rgdevment/CopyPaste). Read the code, run a network monitor, check for yourself. - ---- - -## Our Privacy Philosophy: Everything Local - -CopyPaste was built with a **privacy-first** mindset from day one. This isn't an afterthought or a feature — it's a core design principle. **Everything stays on your machine.** - -- 🔒 **Local-only by design** — Your data never leaves your computer -- 🚫 **No telemetry** — We don't measure, track, or analyze your usage -- 🚫 **No analytics** — No Google Analytics, no App Insights, no Sentry, nothing -- 🚫 **No accounts** — No sign-up, no login, no user profiles -- 🚫 **No cloud sync** — Your clipboard history is yours alone -- 🚫 **No automatic reporting** — Errors are logged locally only; nothing is sent anywhere without your action -- 🔍 **Fully auditable** — Every line of code is open source under [GPLv3](LICENSE) - -**The data on your computer is yours.** I built CopyPaste to respect that boundary completely — not just in policy, but in code. - ---- - -## What Data Does CopyPaste Store? - -CopyPaste monitors your system clipboard to maintain a local history. The following data is stored **exclusively on your computer**: - -### Clipboard Content - -| Type | What's Stored | Where | -| :--- | :--- | :--- | -| **Text** | The copied text content | SQLite database | -| **Images** | Image files (PNG) | Local `images` folder | -| **Files & Folders** | File/folder paths (not the files themselves) | SQLite database | -| **Links** | URL text | SQLite database | -| **Audio & Video** | File paths only | SQLite database | -| **Thumbnails** | Small preview images (`_thumb.png`) generated by the OS shell for images, video and audio entries | Local `images` folder | - -### Metadata - -For each clipboard item, CopyPaste also stores: - -- **Timestamp** — When the item was copied -- **Content type** — Text, Image, File, Folder, Link, Audio, or Video -- **Source application** — The name of the app where you copied from (_window title_) -- **User labels** — Custom labels you assign to items (optional) -- **Color tags** — Color categories you assign (optional) -- **Pin status** — Whether you pinned the item -- **Paste count** — How many times you have pasted the item -- **Media metadata** — Duration, dimensions, or codec info for audio and video files (stored as JSON) -- **Image thumbnails** — Smaller preview versions of copied images -- **Broken-since timestamp** (`broken_since`) — When the referenced file/image stopped being available on disk (set to `null` while the file exists). Used to keep the entry visible during the configured retention window so reconnecting an external drive restores the preview instead of losing it. - -### Configuration - -Your settings are stored locally: - -- Hotkey preferences -- Theme selection -- Language preference -- Panel width -- Retention period -- Filter behavior -- Startup preferences -- **Image quota (MB)** (`imagesQuotaMB`) — Maximum disk space copied images may use; `0` means unlimited (default). When the cap is reached, the oldest non-pinned images inside the app's own `images` folder are evicted (LRU). Pinned items and any path that does not live under that folder are never touched. -- **Broken-item retention days** (`keepBrokenItemsDays`) — Number of days an entry whose referenced file is missing is preserved before being purged (default 30). -- **Thumbnail generation toggles** — Independent on/off switches for image, video and audio thumbnails, plus a maximum image processing size (MB) to skip very large files. -- **Onboarding completion flag** (`hasCompletedOnboarding`) — Remembers that the first-launch walkthrough has already been shown. - -### Windows System Integration (Startup) - -When you enable **Start with Windows** in Settings, CopyPaste registers itself as a startup application using the appropriate mechanism for each distribution channel — and removes the registration when you disable it: - -| Distribution | Mechanism | What is written | -| :--- | :--- | :--- | -| **Standalone installer (.exe)** | Windows registry key | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\CopyPaste` | -| **Microsoft Store (MSIX)** | Windows StartupTask API | System startup catalog (no registry write) | - -Neither mechanism requires administrator rights. On uninstall, the standalone installer automatically removes the registry entry. The MSIX version is cleaned up by Windows when the app is uninstalled through the Store or Settings → Apps. - -If you never enable "Start with Windows," nothing is written to the registry or the startup catalog. - -### Logs - -Application logs are stored locally for troubleshooting: - -- **Windows:** `%LOCALAPPDATA%\CopyPaste\logs\` -- **macOS:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` -- **Linux:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` -- **Content:** Application events, errors, and diagnostic information only -- **No personal data:** Logs do **not** contain clipboard content — your copied text, images, or file paths are never written to log files - -### Crash Log - -If the app fails to start or crashes during initialization, a single `crash.log` file is written next to the data folder so the failure is recoverable on the next launch. This file: - -- Lives at `/crash.log` on every platform (e.g. `%LOCALAPPDATA%\CopyPaste\crash.log` on Windows) -- Is **capped at 512 KB** — older content is overwritten automatically -- Contains: timestamp (UTC), OS name and version, Dart runtime version, the failing operation, and the stack trace -- Has **automatic redaction applied at write time**: your Windows/macOS/Linux user name, full home folder path, and any email addresses found in stack traces are replaced with ``, ``, and `` placeholders before being written to disk -- **Never contains clipboard content** — clipboard data does not flow through error paths -- **Is never sent anywhere automatically** — same rule as the regular logs - ---- - -## Support & Log Export — What Happens and What Doesn't - -CopyPaste includes a **Support** section in Settings → About that lets you export a diagnostic log bundle. Here is exactly what this does and doesn't do: - -### What the Export Does - -- Collects recent `.log` files from the local logs folder -- Includes the `crash.log` file if one exists -- Applies an **additional redaction pass** before zipping: user name, home folder path and email addresses are replaced with ``, `` and `` in every file added to the archive -- Adds a `device_info.txt` with your OS version, OS build, system locale, and CopyPaste app version -- Packages everything into a single `.zip` file saved to a location **you choose** on your computer - -### What the Export Does NOT Do - -- **Does not send anything anywhere automatically** — the zip stays on your disk until you manually share it -- **Does not include clipboard content** — your copied text, images, or file paths are never in the logs and never in the export -- **Does not connect to the internet** — the export is a local file operation only -- **Does not run in the background** — it only happens when you explicitly click "Export Logs" - -### How to Share Safely - -If you want to attach logs to a GitHub issue: - -1. Open the exported zip and review it before sharing — you can read the log files in any text editor -2. Redact anything you're uncomfortable sharing (though there should be no personal data) -3. Attach the zip manually to your bug report - -**You are in control at every step.** Nothing goes anywhere without your explicit action. - ---- - -## Where Is Everything Stored? - -All data is stored locally under your user profile: - -**Windows:** - -| Data | Location | -| :--- | :--- | -| **Database** | `%LOCALAPPDATA%\CopyPaste\clipboard.db` | -| **Images** | `%LOCALAPPDATA%\CopyPaste\images\` | -| **Configuration** | `%LOCALAPPDATA%\CopyPaste\config\` | -| **Logs** | `%LOCALAPPDATA%\CopyPaste\logs\` | -| **Crash log** | `%LOCALAPPDATA%\CopyPaste\crash.log` | - -**macOS:** - -| Data | Location | -| :--- | :--- | -| **Database** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/clipboard.db` | -| **Images** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images/` | -| **Configuration** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config/` | -| **Logs** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` | -| **Crash log** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/crash.log` | - -**Linux:** - -| Data | Location | -| :--- | :--- | -| **Database** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/clipboard.db` | -| **Images** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/images/` | -| **Configuration** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/config/` | -| **Logs** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` | -| **Crash log** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/crash.log` | - -These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. - ---- - -## What CopyPaste Does NOT Do - -To be absolutely clear: - -- ❌ **Does not send data to any server** — No clipboard content, no metadata, no usage data -- ❌ **Does not use cookies or tracking technologies** -- ❌ **Does not create user accounts or profiles** -- ❌ **Does not share data with third parties** -- ❌ **Does not use advertising or ad networks** -- ❌ **Does not use AI or machine learning** on your data -- ❌ **Does not sync across devices** -- ❌ **Does not upload crash reports** — Crashes are written to a local `crash.log` (with PII redacted at write time); log export is always manual and user-initiated -- ❌ **Does not phone home** — No background network calls except the update checker described below (all platforms) - ---- - -## Network Requests - -CopyPaste makes **one type of network request** for update checking: - -### Update Checker - -| Detail | Value | -| :--- | :--- | -| **Purpose** | Check if a newer version of CopyPaste is available and enforce blocks for versions with known critical issues | -| **URL (all platforms)** | `https://github.com/rgdevment/CopyPaste/releases/latest/download/release-manifest.json` (and its `.sig` signature file) | -| **Method** | `GET` (read-only) | -| **Data sent** | Standard HTTP headers only — **no user data** | -| **Data received** | A small signed JSON file listing the latest version, minimum supported version, any blocked versions, and per-channel install info. An accompanying Ed25519 signature is verified locally before the manifest is trusted | -| **Frequency** | Every 24 hours, plus once at startup | -| **Cached locally** | Yes — last successfully verified manifest is cached for up to 15 days so the app works offline | - -**Important notes:** - -- This request is **read-only** — it only downloads two small public files; no data is ever uploaded -- **No clipboard content, no usage data, no personal information** is ever sent -- The manifest is **cryptographically signed** with an Ed25519 key. If the signature does not verify, the manifest is discarded and no update indicator is shown -- **All platforms:** If an update is found, a non-invasive indicator appears in the app's footer bar — no popups or dialogs interrupt your workflow. You can click the indicator to see details -- **Standalone builds (Windows / macOS / Linux):** Clicking the indicator opens the GitHub release page (or shows a Homebrew / Snap command). Nothing is downloaded or installed automatically -- **Microsoft Store version:** Clicking the indicator opens a dialog explaining that Microsoft Store delivers updates on its own schedule. The app is never blocked on Store builds, since update delivery is outside our control -- **Blocked versions:** If the manifest flags the installed version as having a critical issue (for example, a severe security bug or data-corruption fix), standalone builds show a full-screen prompt with direct install/download instructions. This mechanism is disabled on Microsoft Store builds - -### User-Initiated Browser Navigation - -When you explicitly click certain UI buttons, CopyPaste opens URLs in your default browser: - -- **"Report issue"** button → Opens `https://github.com/rgdevment/CopyPaste/issues` -- **"Download update"** indicator → Opens the GitHub release page - -These are standard browser navigations initiated by your action — CopyPaste does not make these requests itself. - ---- - -## Sensitive Data Protection - -CopyPaste includes built-in protections for sensitive content: - -### Password Manager Exclusion - -Clipboard content from recognized password managers is **automatically excluded** from history. Supported password managers include: - -- 1Password -- Bitwarden -- LastPass -- KeePass -- And others that use standard clipboard security flags - -### How It Works - -- Password managers typically set a clipboard format flag indicating sensitive content -- CopyPaste detects these flags and **skips storing** the content entirely -- The sensitive data is never written to the database or disk - -### Windows Clipboard History - -CopyPaste operates independently from Windows' built-in clipboard history (`Win+V`). Your CopyPaste settings do not affect Windows clipboard behavior, and vice versa. - ---- - -## Data Retention & Deletion - -### Automatic Cleanup - -- CopyPaste automatically deletes unpinned items older than your configured retention period (default: **30 days**) -- Cleanup runs periodically in the background -- **Pinned items are preserved** regardless of the retention setting - -### Manual Deletion - -You can delete any clipboard item at any time: - -- Select an item and press `Delete` -- Right-click and choose "Delete" - -### Clean Install & Reset (In-App) - -CopyPaste includes in-app reset options at **Settings → About → Reset & Clean Install**: - -- **Soft Reset** — Resets all settings to defaults. Your clipboard history is preserved. -- **Hard Reset** — Deletes everything: history, images, settings, and logs. The app restarts completely clean. **This cannot be undone.** - -These options work on all platforms including the Microsoft Store version. - -### Complete Data Removal (Uninstall) - -To completely remove all CopyPaste data when uninstalling: - -**Windows:** - -1. Uninstall CopyPaste (via Settings → Apps or the standalone uninstaller) -2. Delete the data folder: `%LOCALAPPDATA%\CopyPaste\` - -**macOS:** - -1. Move CopyPaste to Trash from Applications -2. Delete the data folder: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` - -**Linux:** - -1. Uninstall CopyPaste (via your package manager or remove the binary) -2. Delete the data folder: `~/.local/share/com.rgdevment.copypaste/CopyPaste/` - -After these steps, no CopyPaste data remains on your system. - ---- - -## Children's Privacy - -CopyPaste does not knowingly collect any personal information from anyone, including children under 13. The application does not collect personal information from any user — it has no accounts, no registration, and no data transmission. - ---- - -## Microsoft Store Distribution - -CopyPaste is available through the [Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856). The Store version: - -- **Follows the same privacy principles** as the standalone version -- **Makes one read-only network request** — queries the GitHub Releases API every 24 hours to check if a newer version exists. If found, a non-invasive indicator appears in the footer bar. No download link is shown and nothing is installed automatically — updates are delivered through the Microsoft Store -- **Uses MSIX packaging** — installs/uninstalls cleanly with Windows standard mechanisms -- **Microsoft Store policies** apply to distribution, but CopyPaste itself does not share any data with Microsoft beyond what the Store platform requires for installation and updates - -For Microsoft's own privacy practices regarding the Store, refer to [Microsoft's Privacy Statement](https://privacy.microsoft.com/privacystatement). - ---- - -## Open Source Transparency - -The best privacy policy is one you can verify. CopyPaste is **100% open source** under the [GNU General Public License v3.0](LICENSE): - -- 📂 **Full source code:** [github.com/rgdevment/CopyPaste](https://github.com/rgdevment/CopyPaste) -- 🔍 **Audit the code yourself** — every network request, every database write, every file operation -- 🐛 **Report concerns** — [open an issue](https://github.com/rgdevment/CopyPaste/issues) or [email us](mailto:github@apirest.cl) - -We encourage security researchers and privacy advocates to inspect the code. See our [Security Policy](SECURITY.md) for responsible disclosure guidelines. - ---- - -## Changes to This Policy - -If we ever change this privacy policy, the changes will be: - -- Committed to the public repository with a clear commit message -- Reflected in the "Last updated" date at the top -- Documented in the release notes - -Since CopyPaste is open source, any change to privacy behavior would also be visible as a code change in the public repository before it reaches you. - ---- - -## Contact - -If you have questions or concerns about this privacy policy: - -- 📧 **Email:** [github@apirest.cl](mailto:github@apirest.cl) -- 💬 **GitHub Discussions:** [github.com/rgdevment/CopyPaste/discussions](https://github.com/rgdevment/CopyPaste/discussions) -- 🐛 **Issues:** [github.com/rgdevment/CopyPaste/issues](https://github.com/rgdevment/CopyPaste/issues) - ---- - -
-

Everything local. Your clipboard is yours — I built CopyPaste to keep it that way.

-
+# Privacy Policy + +**Last updated:** April 24, 2026 + +--- + +## The Short Version + +**Everything stays on your computer.** CopyPaste does not collect, transmit, or share any of your data. There's no cloud, no accounts, no telemetry, no analytics, no tracking — nothing leaves your machine, ever. + +**"Everything local" is not a feature — it's the foundation.** It's a technical fact you can verify yourself: the entire source code is [open and public](https://github.com/rgdevment/CopyPaste). Read the code, run a network monitor, check for yourself. + +--- + +## Our Privacy Philosophy: Everything Local + +CopyPaste was built with a **privacy-first** mindset from day one. This isn't an afterthought or a feature — it's a core design principle. **Everything stays on your machine.** + +- 🔒 **Local-only by design** — Your data never leaves your computer +- 🚫 **No telemetry** — We don't measure, track, or analyze your usage +- 🚫 **No analytics** — No Google Analytics, no App Insights, no Sentry, nothing +- 🚫 **No accounts** — No sign-up, no login, no user profiles +- 🚫 **No cloud sync** — Your clipboard history is yours alone +- 🚫 **No automatic reporting** — Errors are logged locally only; nothing is sent anywhere without your action +- 🔍 **Fully auditable** — Every line of code is open source under [GPLv3](LICENSE) + +**The data on your computer is yours.** I built CopyPaste to respect that boundary completely — not just in policy, but in code. + +--- + +## What Data Does CopyPaste Store? + +CopyPaste monitors your system clipboard to maintain a local history. The following data is stored **exclusively on your computer**: + +### Clipboard Content + +| Type | What's Stored | Where | +| :--- | :--- | :--- | +| **Text** | The copied text content | SQLite database | +| **Images** | Image files (PNG) | Local `images` folder | +| **Files & Folders** | File/folder paths (not the files themselves) | SQLite database | +| **Links** | URL text | SQLite database | +| **Audio & Video** | File paths only | SQLite database | +| **Thumbnails** | Small preview images (`_thumb.png`) generated by the OS shell for images, video and audio entries | Local `images` folder | + +### Metadata + +For each clipboard item, CopyPaste also stores: + +- **Timestamp** — When the item was copied +- **Content type** — Text, Image, File, Folder, Link, Audio, or Video +- **Source application** — The name of the app where you copied from (_window title_) +- **User labels** — Custom labels you assign to items (optional) +- **Color tags** — Color categories you assign (optional) +- **Pin status** — Whether you pinned the item +- **Paste count** — How many times you have pasted the item +- **Media metadata** — Duration, dimensions, or codec info for audio and video files (stored as JSON) +- **Image thumbnails** — Smaller preview versions of copied images +- **Broken-since timestamp** (`broken_since`) — When the referenced file/image stopped being available on disk (set to `null` while the file exists). Used to keep the entry visible during the configured retention window so reconnecting an external drive restores the preview instead of losing it. + +### Configuration + +Your settings are stored locally: + +- Hotkey preferences +- Theme selection +- Language preference +- Panel width +- Retention period +- Filter behavior +- Startup preferences +- **Image quota (MB)** (`imagesQuotaMB`) — Maximum disk space copied images may use; `0` means unlimited (default). When the cap is reached, the oldest non-pinned images inside the app's own `images` folder are evicted (LRU). Pinned items and any path that does not live under that folder are never touched. +- **Broken-item retention days** (`keepBrokenItemsDays`) — Number of days an entry whose referenced file is missing is preserved before being purged (default 30). +- **Thumbnail generation toggles** — Independent on/off switches for image, video and audio thumbnails, plus a maximum image processing size (MB) to skip very large files. +- **Onboarding completion flag** (`hasCompletedOnboarding`) — Remembers that the first-launch walkthrough has already been shown. + +### Windows System Integration (Startup) + +When you enable **Start with Windows** in Settings, CopyPaste registers itself as a startup application using the appropriate mechanism for each distribution channel — and removes the registration when you disable it: + +| Distribution | Mechanism | What is written | +| :--- | :--- | :--- | +| **Standalone installer (.exe)** | Windows registry key | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\CopyPaste` | +| **Microsoft Store (MSIX)** | Windows StartupTask API | System startup catalog (no registry write) | + +Neither mechanism requires administrator rights. On uninstall, the standalone installer automatically removes the registry entry. The MSIX version is cleaned up by Windows when the app is uninstalled through the Store or Settings → Apps. + +If you never enable "Start with Windows," nothing is written to the registry or the startup catalog. + +### Logs + +Application logs are stored locally for troubleshooting: + +- **Windows:** `%LOCALAPPDATA%\CopyPaste\logs\` +- **macOS:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` +- **Linux:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` +- **Content:** Application events, errors, and diagnostic information only +- **No personal data:** Logs do **not** contain clipboard content — your copied text, images, or file paths are never written to log files + +### Crash Log + +If the app fails to start or crashes during initialization, a single `crash.log` file is written next to the data folder so the failure is recoverable on the next launch. This file: + +- Lives at `/crash.log` on every platform (e.g. `%LOCALAPPDATA%\CopyPaste\crash.log` on Windows) +- Is **capped at 512 KB** — older content is overwritten automatically +- Contains: timestamp (UTC), OS name and version, Dart runtime version, the failing operation, and the stack trace +- Has **automatic redaction applied at write time**: your Windows/macOS/Linux user name, full home folder path, and any email addresses found in stack traces are replaced with ``, ``, and `` placeholders before being written to disk +- **Never contains clipboard content** — clipboard data does not flow through error paths +- **Is never sent anywhere automatically** — same rule as the regular logs + +--- + +## Support & Log Export — What Happens and What Doesn't + +CopyPaste includes a **Support** section in Settings → About that lets you export a diagnostic log bundle. Here is exactly what this does and doesn't do: + +### What the Export Does + +- Collects recent `.log` files from the local logs folder +- Includes the `crash.log` file if one exists +- Applies an **additional redaction pass** before zipping: user name, home folder path and email addresses are replaced with ``, `` and `` in every file added to the archive +- Adds a `device_info.txt` with your OS version, OS build, system locale, and CopyPaste app version +- Packages everything into a single `.zip` file saved to a location **you choose** on your computer + +### What the Export Does NOT Do + +- **Does not send anything anywhere automatically** — the zip stays on your disk until you manually share it +- **Does not include clipboard content** — your copied text, images, or file paths are never in the logs and never in the export +- **Does not connect to the internet** — the export is a local file operation only +- **Does not run in the background** — it only happens when you explicitly click "Export Logs" + +### How to Share Safely + +If you want to attach logs to a GitHub issue: + +1. Open the exported zip and review it before sharing — you can read the log files in any text editor +2. Redact anything you're uncomfortable sharing (though there should be no personal data) +3. Attach the zip manually to your bug report + +**You are in control at every step.** Nothing goes anywhere without your explicit action. + +--- + +## Where Is Everything Stored? + +All data is stored locally under your user profile: + +**Windows:** + +| Data | Location | +| :--- | :--- | +| **Database** | `%LOCALAPPDATA%\CopyPaste\clipboard.db` | +| **Images** | `%LOCALAPPDATA%\CopyPaste\images\` | +| **Configuration** | `%LOCALAPPDATA%\CopyPaste\config\` | +| **Logs** | `%LOCALAPPDATA%\CopyPaste\logs\` | +| **Crash log** | `%LOCALAPPDATA%\CopyPaste\crash.log` | + +**macOS:** + +| Data | Location | +| :--- | :--- | +| **Database** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/clipboard.db` | +| **Images** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images/` | +| **Configuration** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config/` | +| **Logs** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` | +| **Crash log** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/crash.log` | + +**Linux:** + +| Data | Location | +| :--- | :--- | +| **Database** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/clipboard.db` | +| **Images** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/images/` | +| **Configuration** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/config/` | +| **Logs** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` | +| **Crash log** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/crash.log` | + +These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. + +--- + +## What CopyPaste Does NOT Do + +To be absolutely clear: + +- ❌ **Does not send data to any server** — No clipboard content, no metadata, no usage data +- ❌ **Does not use cookies or tracking technologies** +- ❌ **Does not create user accounts or profiles** +- ❌ **Does not share data with third parties** +- ❌ **Does not use advertising or ad networks** +- ❌ **Does not use AI or machine learning** on your data +- ❌ **Does not sync across devices** +- ❌ **Does not upload crash reports** — Crashes are written to a local `crash.log` (with PII redacted at write time); log export is always manual and user-initiated +- ❌ **Does not phone home** — No background network calls except the update checker described below (all platforms) + +--- + +## Network Requests + +CopyPaste makes **one type of network request** for update checking: + +### Update Checker + +| Detail | Value | +| :--- | :--- | +| **Purpose** | Check if a newer version of CopyPaste is available and enforce blocks for versions with known critical issues | +| **URL (all platforms)** | `https://github.com/rgdevment/CopyPaste/releases/latest/download/release-manifest.json` (and its `.sig` signature file) | +| **Method** | `GET` (read-only) | +| **Data sent** | Standard HTTP headers only — **no user data** | +| **Data received** | A small signed JSON file listing the latest version, minimum supported version, any blocked versions, and per-channel install info. An accompanying Ed25519 signature is verified locally before the manifest is trusted | +| **Frequency** | Every 24 hours, plus once at startup | +| **Cached locally** | Yes — last successfully verified manifest is cached for up to 15 days so the app works offline | + +**Important notes:** + +- This request is **read-only** — it only downloads two small public files; no data is ever uploaded +- **No clipboard content, no usage data, no personal information** is ever sent +- The manifest is **cryptographically signed** with an Ed25519 key. If the signature does not verify, the manifest is discarded and no update indicator is shown +- **All platforms:** If an update is found, a non-invasive indicator appears in the app's footer bar — no popups or dialogs interrupt your workflow. You can click the indicator to see details +- **Standalone builds (Windows / macOS / Linux):** Clicking the indicator opens the GitHub release page (or shows the Homebrew / apt / dnf upgrade command). Nothing is downloaded or installed automatically +- **Microsoft Store version:** Clicking the indicator opens a dialog explaining that Microsoft Store delivers updates on its own schedule. The app is never blocked on Store builds, since update delivery is outside our control +- **Blocked versions:** If the manifest flags the installed version as having a critical issue (for example, a severe security bug or data-corruption fix), standalone builds show a full-screen prompt with direct install/download instructions. This mechanism is disabled on Microsoft Store builds + +### User-Initiated Browser Navigation + +When you explicitly click certain UI buttons, CopyPaste opens URLs in your default browser: + +- **"Report issue"** button → Opens `https://github.com/rgdevment/CopyPaste/issues` +- **"Download update"** indicator → Opens the GitHub release page + +These are standard browser navigations initiated by your action — CopyPaste does not make these requests itself. + +--- + +## Sensitive Data Protection + +CopyPaste includes built-in protections for sensitive content: + +### Password Manager Exclusion + +Clipboard content from recognized password managers is **automatically excluded** from history. Supported password managers include: + +- 1Password +- Bitwarden +- LastPass +- KeePass +- And others that use standard clipboard security flags + +### How It Works + +- Password managers typically set a clipboard format flag indicating sensitive content +- CopyPaste detects these flags and **skips storing** the content entirely +- The sensitive data is never written to the database or disk + +### Windows Clipboard History + +CopyPaste operates independently from Windows' built-in clipboard history (`Win+V`). Your CopyPaste settings do not affect Windows clipboard behavior, and vice versa. + +--- + +## Data Retention & Deletion + +### Automatic Cleanup + +- CopyPaste automatically deletes unpinned items older than your configured retention period (default: **30 days**) +- Cleanup runs periodically in the background +- **Pinned items are preserved** regardless of the retention setting + +### Manual Deletion + +You can delete any clipboard item at any time: + +- Select an item and press `Delete` +- Right-click and choose "Delete" + +### Clean Install & Reset (In-App) + +CopyPaste includes in-app reset options at **Settings → About → Reset & Clean Install**: + +- **Soft Reset** — Resets all settings to defaults. Your clipboard history is preserved. +- **Hard Reset** — Deletes everything: history, images, settings, and logs. The app restarts completely clean. **This cannot be undone.** + +These options work on all platforms including the Microsoft Store version. + +### Complete Data Removal (Uninstall) + +To completely remove all CopyPaste data when uninstalling: + +**Windows:** + +1. Uninstall CopyPaste (via Settings → Apps or the standalone uninstaller) +2. Delete the data folder: `%LOCALAPPDATA%\CopyPaste\` + +**macOS:** + +1. Move CopyPaste to Trash from Applications +2. Delete the data folder: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` + +**Linux:** + +1. Uninstall CopyPaste (via your package manager or remove the binary) +2. Delete the data folder: `~/.local/share/com.rgdevment.copypaste/CopyPaste/` + +After these steps, no CopyPaste data remains on your system. + +--- + +## Children's Privacy + +CopyPaste does not knowingly collect any personal information from anyone, including children under 13. The application does not collect personal information from any user — it has no accounts, no registration, and no data transmission. + +--- + +## Microsoft Store Distribution + +CopyPaste is available through the [Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856). The Store version: + +- **Follows the same privacy principles** as the standalone version +- **Makes one read-only network request** — queries the GitHub Releases API every 24 hours to check if a newer version exists. If found, a non-invasive indicator appears in the footer bar. No download link is shown and nothing is installed automatically — updates are delivered through the Microsoft Store +- **Uses MSIX packaging** — installs/uninstalls cleanly with Windows standard mechanisms +- **Microsoft Store policies** apply to distribution, but CopyPaste itself does not share any data with Microsoft beyond what the Store platform requires for installation and updates + +For Microsoft's own privacy practices regarding the Store, refer to [Microsoft's Privacy Statement](https://privacy.microsoft.com/privacystatement). + +--- + +## Open Source Transparency + +The best privacy policy is one you can verify. CopyPaste is **100% open source** under the [GNU General Public License v3.0](LICENSE): + +- 📂 **Full source code:** [github.com/rgdevment/CopyPaste](https://github.com/rgdevment/CopyPaste) +- 🔍 **Audit the code yourself** — every network request, every database write, every file operation +- 🐛 **Report concerns** — [open an issue](https://github.com/rgdevment/CopyPaste/issues) or [email us](mailto:github@apirest.cl) + +We encourage security researchers and privacy advocates to inspect the code. See our [Security Policy](SECURITY.md) for responsible disclosure guidelines. + +--- + +## Changes to This Policy + +If we ever change this privacy policy, the changes will be: + +- Committed to the public repository with a clear commit message +- Reflected in the "Last updated" date at the top +- Documented in the release notes + +Since CopyPaste is open source, any change to privacy behavior would also be visible as a code change in the public repository before it reaches you. + +--- + +## Contact + +If you have questions or concerns about this privacy policy: + +- 📧 **Email:** [github@apirest.cl](mailto:github@apirest.cl) +- 💬 **GitHub Discussions:** [github.com/rgdevment/CopyPaste/discussions](https://github.com/rgdevment/CopyPaste/discussions) +- 🐛 **Issues:** [github.com/rgdevment/CopyPaste/issues](https://github.com/rgdevment/CopyPaste/issues) + +--- + +
+

Everything local. Your clipboard is yours — I built CopyPaste to keep it that way.

+
diff --git a/README.md b/README.md index a5852a59..bc5d3f4e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Latest Release - Platform: Windows, macOS, Linux + Platform: Windows, macOS, Linux License GPL-3.0 @@ -74,13 +74,13 @@ This isn't a company product. I'm a developer who needed a better **copy paste** - **100% local** — your clipboard history never leaves your computer. No cloud, no servers, no accounts. - **Truly free** — no premium tiers, no feature gates, no "free trial" tricks. GPL v3, forever. -- **Cross-platform** — same native copy-paste experience on Windows, macOS, and Linux (beta). +- **Cross-platform** — same native copy-paste experience on Windows, macOS, and Linux. - **Fast and light** — starts in milliseconds, uses minimal resources. You'll forget it's running. - **Beautiful** — follows your OS theme (light/dark), with Mica effect on Windows and native materials on macOS. > I use CopyPaste every day on Windows 11 and macOS. If something feels off, [let me know](#found-a-bug-have-feedback) — this project keeps improving because of real-world use. > -> **Linux is in beta.** It works, but there are edge cases across different desktop environments. If you're on Linux and want to help, [your feedback matters](#found-a-bug-have-feedback). +> **Linux:** standalone builds for **X11 sessions** (Ubuntu / Fedora / RHEL-compatible). Wayland is not supported yet — global hotkey and auto-paste rely on X11 APIs. --- @@ -370,64 +370,134 @@ If "Return to Content mode on open" is enabled, the other clear options are auto ## Getting Started -### Microsoft Store — Windows +| OS | Recommended | Alternatives | +| :---------- | :-------------------------------- | :------------------------------------------------- | +| **Windows** | Microsoft Store | Standalone `.exe` | +| **macOS** | Homebrew | Standalone `.dmg` | +| **Linux** | `apt` / `dnf` (OBS repo) | Homebrew · self-updating AppImage · `.deb`/`.rpm` | -The simplest way on Windows — one click, auto-updates, no security warnings. +After installing, open CopyPaste with **Ctrl+Shift+V** (default on every platform — customizable in Settings → Shortcuts). On Linux/X11, if `Ctrl+Shift+V` is taken by another app or desktop shortcut, CopyPaste falls back to **Ctrl+Shift+Shift+V** for that session and shows a warning. -

- - Get CopyPaste clipboard manager from Microsoft Store - -

+### Windows + +**Microsoft Store** (recommended) — one click, automatic updates, no security warnings. + +> [Install from the Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856) + +**Standalone `.exe`** — direct download from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest). The installer is self-signed; see the [security note](#standalone-downloads) below. --- -### Homebrew +### macOS -**macOS:** +**Homebrew** (recommended) — installs the universal binary (Apple Silicon + Intel) and tracks updates with `brew upgrade`: ```sh brew tap rgdevment/tap && brew install --cask copypaste ``` +**Standalone `.dmg`** — direct download from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest). Same universal binary, manual updates. + --- -### Linux — apt / dnf +### Linux + +> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, the global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (OBS apt/dnf, Homebrew, AppImage). There is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling, and opening of arbitrary file paths from the history. -> **Linux support is in beta.** Core clipboard manager features work well across tested distributions, but you may encounter issues depending on your desktop environment, display server, or distro. [Please report anything unusual](https://github.com/rgdevment/CopyPaste/issues/new) — your reports directly shape stability improvements. +#### 1. Native packages via the openSUSE Build Service (recommended) -Packages are hosted on [Cloudsmith](https://cloudsmith.io/~rgdevment/repos/copypaste/) — set up the repository once, then get updates through your system package manager. +Native `.deb` and `.rpm` packages are built and hosted on the [openSUSE Build Service](https://build.opensuse.org/package/show/home:rgdevment/copypaste) (project `home:rgdevment`). Add the repo once, then get updates through your system package manager just like any other system package. -**Debian, Ubuntu, Pop!\_OS and derivatives:** +
+Debian 12 / 13 ```sh -curl -1sLf 'https://dl.cloudsmith.io/public/rgdevment/copypaste/setup.deb.sh' | sudo -E bash +DIST=Debian_13 # or: Debian_12 +echo "deb http://download.opensuse.org/repositories/home:/rgdevment/${DIST}/ /" \ + | sudo tee /etc/apt/sources.list.d/home_rgdevment.list +curl -fsSL "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/Release.key" \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null +sudo apt update sudo apt install copypaste ``` -**Fedora, RHEL, CentOS Stream and derivatives:** +
+ +
+Ubuntu 22.04 / 24.04 ```sh -curl -1sLf 'https://dl.cloudsmith.io/public/rgdevment/copypaste/setup.rpm.sh' | sudo -E bash +DIST=xUbuntu_24.04 # or: xUbuntu_22.04 +echo "deb http://download.opensuse.org/repositories/home:/rgdevment/${DIST}/ /" \ + | sudo tee /etc/apt/sources.list.d/home_rgdevment.list +curl -fsSL "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/Release.key" \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null +sudo apt update +sudo apt install copypaste +``` + +
+ +
+Fedora 40 / 41 + +```sh +DIST=Fedora_41 # or: Fedora_40 +sudo dnf config-manager --add-repo \ + "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/home:rgdevment.repo" sudo dnf install copypaste ``` -> **Note:** Requires an **X11 session**. On Wayland, global hotkey and auto-paste are unavailable — a warning is shown at startup. -> **Permissions note:** apt/dnf installation writes to system locations, so sudo is required. If your user cannot use sudo, those commands will fail with permission errors. -> **No-sudo alternatives:** Use **Homebrew (Linux)** if available for your user, or run the .AppImage from your home directory (chmod +x CopyPaste-\*.AppImage && ./CopyPaste-\*.AppImage). -> **Runtime note:** On standard desktop installs, apt/dnf resolve required libraries automatically. Very minimal VMs/containers may need additional desktop runtime libraries. +
-**Alternative Linux (requires Homebrew installed):** +
+openSUSE Tumbleweed + +```sh +sudo zypper addrepo \ + https://download.opensuse.org/repositories/home:/rgdevment/openSUSE_Tumbleweed/home:rgdevment.repo +sudo zypper refresh +sudo zypper install copypaste +``` + +
+ +> Repository signing is handled by the OBS project key; `apt`/`dnf`/`zypper` verify every package automatically. Installation requires `sudo` because system paths are written. If you cannot use `sudo`, use Homebrew or the AppImage below. + +#### 2. Homebrew + +If you already use Homebrew, or you cannot use `sudo`, this is the fastest path: ```sh brew tap rgdevment/tap && brew install copypaste ``` ---- +Updates land via `brew upgrade copypaste`. + +#### 3. Self-updating AppImage + +A single portable file — no install, runs from your home directory. Once launched, the AppImage **updates itself** through [AppImageUpdate](https://github.com/AppImage/AppImageUpdate): each release embeds a `.zsync` URL pointing to the latest GitHub Release, so the binary delta-updates in place. + +```sh +wget https://github.com/rgdevment/CopyPaste/releases/latest/download/CopyPaste__x86_64.AppImage +chmod +x CopyPaste__x86_64.AppImage +./CopyPaste__x86_64.AppImage +``` + +To refresh without redownloading the whole file, install AppImageUpdate from your distro and run: -After installing, open CopyPaste with **Ctrl+Alt+V** (default on all platforms — customizable in Settings → Shortcuts). +```sh +appimageupdate ./CopyPaste__x86_64.AppImage +``` + +#### 4. Standalone `.deb` / `.rpm` (manual) -If Ctrl+Alt+V is already taken on Linux/X11 by another app or desktop shortcut, CopyPaste temporarily uses **Ctrl+Alt+Shift+V** for that session and shows a warning. +If you don't want a repo and don't want the AppImage, grab the standalone packages directly from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): + +- `CopyPaste__amd64.deb` for Debian / Ubuntu and derivatives +- `CopyPaste__x86_64.rpm` for Fedora / RHEL and derivatives + +These are the same artifacts the OBS repo ships, but installed with `dpkg -i` / `rpm -i` they don't get system-managed updates — you'd need to redownload manually for each release. For day-to-day use, prefer the OBS repo above. ### Compatibility @@ -435,21 +505,19 @@ If Ctrl+Alt+V is already taken on Linux/X11 by another app or desktop shortcut, | :---------- | :------------------------------------------- | :-------------------------------- | | **Windows** | Windows 10 (1809+), Windows 11 | x64 | | **macOS** | Ventura (13.0+) | Universal (Apple Silicon + Intel) | -| **Linux** | Ubuntu 22.04+ · Fedora 38+ · RHEL-compatible | x64 | - ---- +| **Linux** | Ubuntu 22.04+ · Fedora 40+ · openSUSE Tumbleweed · RHEL-compatible | x86_64 | ### Standalone Downloads -Not a fan of package managers? Direct packages are on [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest). +Direct packages live on [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): -| Platform | Download | Notes | -| :---------- | :-------------------------------------------------------------------- | :---------------------------------------------- | -| **Windows** | [.exe](https://github.com/rgdevment/CopyPaste/releases/latest) | Self-signed installer — see security note below | -| **macOS** | [.dmg](https://github.com/rgdevment/CopyPaste/releases/latest) | Universal binary (Apple Silicon + Intel) | -| **Linux** | [.AppImage](https://github.com/rgdevment/CopyPaste/releases/latest) | No install — chmod +x and run | -| **Linux** | [.deb](https://github.com/rgdevment/CopyPaste/releases/latest) | Debian, Ubuntu and derivatives | -| **Linux** | [.rpm](https://github.com/rgdevment/CopyPaste/releases/latest) | Fedora, RHEL and derivatives | +| Platform | File | Notes | +| :---------- | :------------------------- | :-------------------------------------------------------------------------- | +| **Windows** | `*_Setup.exe` | Self-signed installer — see security note below | +| **macOS** | `*.dmg` | Universal binary (Apple Silicon + Intel) | +| **Linux** | `*.AppImage` + `.zsync` | Self-updating via AppImageUpdate | +| **Linux** | `*.deb` | Debian/Ubuntu — manual updates | +| **Linux** | `*.rpm` | Fedora/RHEL — manual updates |
Windows standalone: security warnings @@ -488,7 +556,7 @@ For apt/dnf, yes — they install to system paths. If you cannot use sudo, use H Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` — Linux: `~/.local/share/com.rgdevment.copypaste/CopyPaste/`. Each folder contains the database, images, config, and logs. **What platforms does this copy-paste tool support?** -Windows 10/11, macOS (Ventura+), and Linux (Ubuntu 22.04+ · Fedora 38+ via apt/dnf · any distro via Homebrew or direct .deb, .rpm, .AppImage). Linux is in beta — see the [Getting Started](#getting-started) section for details. +Windows 10/11, macOS (Ventura+), and Linux on **X11 sessions** (Ubuntu 22.04+ · Fedora 40+ via OBS apt/dnf · openSUSE Tumbleweed · any distro via Homebrew or the self-updating .AppImage). Wayland-only sessions are not supported yet — see the [Getting Started](#getting-started) section for details. **Does it start automatically with Windows?** Optionally, yes. Enable it in Settings → General → Start with Windows. On the Microsoft Store version it uses the Windows StartupTask system; on the standalone installer it registers through the standard Windows startup mechanism. No administrator rights are required for either. @@ -741,14 +809,6 @@ Distributed under the **GNU General Public License v3.0**. See LICENSE for more --- -## Acknowledgments - -Linux package hosting (.deb and .rpm) is provided by [Cloudsmith](https://cloudsmith.com) — a cloud-native universal package management solution. - -[![Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-Cloudsmith-003F72?style=flat-square&logo=cloudsmith&logoColor=white)](https://cloudsmith.com) - ---- - I built CopyPaste because I was tired of the alternatives — bloated, resource-hungry, or disrespectful of my privacy. This is a personal copy paste productivity tool, built from a real need, shared because others might need a better clipboard manager too. Free to use, free to inspect, free forever. No analytics, no subscription, no upsell. If you find it useful, I'm glad. If you want to help make it better, even better. diff --git a/SECURITY.md b/SECURITY.md index 3cb2e684..fb7fcb44 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,235 +1,237 @@ -# Security Policy - -## Security Matters - -**CopyPaste** handles your clipboard history—that can include sensitive stuff. I take security seriously because you're trusting this tool with content that might be personal or confidential. - -**This isn't corporate security theater.** This is a personal project shared with the community. It's built on trust—transparency in the code, responsibility when issues come up, and treating security researchers as partners. - -I'm not protecting a brand or business. I'm protecting _you_ and everyone using this tool. - ---- - -## 🔒 What We Do to Keep You Safe - -### Privacy by Design - -- **100% Local Storage** — Your clipboard history never leaves your machine. No cloud sync, no telemetry, no remote servers. -- **Sensitive Data Exclusion** — Password manager content (1Password, Bitwarden, etc.) is automatically excluded from history. -- **No Tracking** — I don't collect anything. No analytics, no usage data, nothing. - -### Security Features - -- **Local SQLite Database** — Your clipboard history is stored in a local database on your machine, not in the cloud. -- **Configurable Retention** — Automatically delete old clipboard items based on your retention settings. -- **Open Source** — Every line of code is public. You can inspect, audit, and verify what we're doing. -- **Signed Release Manifest** — The update notifier fetches a small JSON file signed with an Ed25519 key. The signature is verified locally before the file is trusted, so a compromised mirror cannot inject a fake "latest version" or a malicious install URL. If the signature fails, the manifest is discarded. -- **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS / Linux) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. - -### Development Practices - -- **Modern Flutter Stack** — Built with Flutter and Dart, with dependencies regularly audited and updated. -- **Dependency Updates** — We regularly update dependencies to patch known vulnerabilities. -- **Code Reviews** — All contributions go through review before merging. - ---- - -## 🚨 Supported Versions - -Security updates are provided for: - -| Version | Supported | -| :--- | :--- | -| Latest Release | ✅ Actively Supported | -| Beta Versions | ✅ Actively Supported | -| Older Releases | ❌ Not Supported (please update) | - -**We strongly recommend always using the latest version** from the [Releases Page](https://github.com/rgdevment/CopyPaste/releases/latest). - ---- - -## 🐛 Reporting a Vulnerability - -If you discover a security vulnerability in **CopyPaste**, please help us protect our users by reporting it responsibly. - -### What Qualifies as a Security Vulnerability? - -**Please report:** - -- ✅ Unauthorized access to clipboard history -- ✅ Privilege escalation issues -- ✅ Data leakage or unintended storage of sensitive information -- ✅ Injection attacks (SQL, command, etc.) -- ✅ Bypass of sensitive data exclusion mechanisms -- ✅ Critical bugs that could lead to data loss or corruption - -**Not security issues:** - -- ❌ Feature requests or enhancements -- ❌ General bugs that don't have security implications -- ❌ Issues with third-party dependencies (report those upstream) -- ❌ Windows SmartScreen warnings (see [README](README.md) for explanation) - -### How to Report Securely - -**DO NOT** open a public GitHub issue for security vulnerabilities. Instead, use one of these private channels: - -#### 📧 Email (Simplest & Direct) - -Send an email to: **** - -**Subject:** `[SECURITY] Brief description of the issue` - -This is the fastest way to reach us. We check email daily and will respond within 48 hours. - -#### 🔒 GitHub Security Advisory (Alternative) - -1. Go to the [Security tab](https://github.com/rgdevment/CopyPaste/security) in the repository -2. Click **"Report a vulnerability"** -3. Fill in the details using the template provided -4. Submit privately — only maintainers will see it - -**Choose whichever method is most comfortable for you.** What matters is that we hear from you. - -**Include in your report:** - -- **Description** — Clear explanation of the vulnerability -- **Impact** — What could an attacker do? Who is affected? -- **Steps to Reproduce** — How can we reproduce the issue? -- **CopyPaste Version** — Which version is affected? -- **OS and version** — e.g., Windows 11 23H2, macOS Sequoia 15.1, Ubuntu 24.04 -- **Proof of Concept** (optional) — Code or screenshots demonstrating the issue -- **Suggested Fix** (optional) — If you have ideas on how to fix it - -### What Happens Next? - -1. **Acknowledgment (Within 48 Hours)** - - I'll confirm I received your report - - I'll let you know if I need more information - -2. **Investigation (1-7 Days)** - - I'll reproduce and analyze the issue - - Assess severity and impact - - Develop a fix - -3. **Resolution** - - Create a patch and test it thoroughly - - Coordinate a release timeline with you - - Credit you in the release notes (if you want) - -4. **Disclosure (After Fix is Released)** - - Publish a security advisory - - Notify users to update - - You can publicly disclose (coordinated disclosure) - -### Response Time Expectations - -| Severity | Response Time | Fix Target | -| :--- | :---: | :---: | -| **Critical** (Remote code execution, data breach) | 24 hours | 1-3 days | -| **High** (Privilege escalation, significant data leak) | 48 hours | 3-7 days | -| **Medium** (Limited scope, requires user interaction) | 3 days | 1-2 weeks | -| **Low** (Minimal impact, edge cases) | 1 week | Next release | - -**I'm one person** (with community help), but I take security seriously. If you don't hear back within the expected timeframe, please follow up—things might've gotten lost. - ---- - -## 🤝 Responsible Disclosure - -I believe in **coordinated disclosure** to protect users: - -- **Please give me reasonable time to fix the issue** before publicly disclosing it -- I aim to release fixes within 7 days for critical issues -- I'll work with you on a disclosure timeline that protects users -- I'll credit you in the release notes (unless you prefer to remain anonymous) - -### My Promise to Security Researchers - -**I WILL:** - -- ✅ Treat you with respect and gratitude—you're helping protect users -- ✅ Respond promptly to your report (within 48 hours) -- ✅ Keep you updated throughout the investigation and fix process -- ✅ Credit your work publicly (if you want) -- ✅ Be transparent about the timeline and progress - -**I will NEVER:** - -- ❌ Threaten legal action against good-faith security researchers -- ❌ Ignore or dismiss legitimate reports -- ❌ Retaliate against reporters in any way -- ❌ Use intimidation tactics or silence critics -- ❌ Blame you for finding vulnerabilities in the code - -**Security research makes everyone safer.** I'm grateful for your work and will treat you as a valued partner in protecting the community. - ---- - -## 🏆 Security Researchers Hall of Fame - -We're grateful to the security researchers who help make **CopyPaste** safer: - - - -- _No security issues reported yet. Help us stay secure!_ - -**Want to be listed here?** Report a verified security vulnerability and choose to be credited. We'll add your name (or handle) and a link to your profile if you'd like. - ---- - -## 📚 Additional Security Resources - -### For Users - -- **Keep CopyPaste Updated** — Enable automatic updates or check for new releases regularly -- **Review Clipboard History** — Periodically check what's being stored and delete sensitive items -- **Configure Retention** — Set shorter retention periods if you handle highly sensitive data -- **Use Password Managers** — Their clipboard content is automatically excluded from history - -### For Developers - -- **Read the Code** — The entire codebase is open source: [CopyPaste Repository](https://github.com/rgdevment/CopyPaste) -- **Review Dependencies** — Check `pubspec.yaml` for third-party packages we use -- **Security Best Practices** — Follow secure coding guidelines when contributing - ---- - -## 🔐 Cryptographic Disclosure - -**CopyPaste does not currently use cryptographic functions for data storage.** - -- Clipboard history is stored in **plaintext** in a local SQLite database -- Database files are protected by **OS-level file system permissions** (Windows, macOS, and Linux) -- No encryption is applied to stored clipboard data - -**Why?** - -- The database is local-only and protected by your OS user account -- Encryption would add complexity and potential key management issues -- Performance and startup time would be impacted -- You control physical access to your machine - -**Future Consideration:** -If there's community demand for at-rest encryption, we're open to discussing it. Open an issue if this is important to you. - ---- - -## 💬 Questions or Concerns? - -We're here to help and answer questions: - -- **Security Questions:** Email us at **** — we're happy to discuss concerns privately -- **General Questions:** [Open a Discussion](https://github.com/rgdevment/CopyPaste/discussions) — ask publicly, we'll answer openly -- **Vulnerability Reports:** Use the private channels above — never post security issues publicly -- **Policy Feedback:** [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — help us improve this policy - -**Security is everyone's responsibility.** Thank you for helping keep **CopyPaste** safe for everyone using it. - -**Remember:** If you're unsure whether something is a security issue, reach out anyway. I'd rather have a conversation than miss a real problem. - ---- - -
-

Built securely, transparently, and with ❤️.

-
+# Security Policy + +## Security Matters + +**CopyPaste** handles your clipboard history—that can include sensitive stuff. I take security seriously because you're trusting this tool with content that might be personal or confidential. + +**This isn't corporate security theater.** This is a personal project shared with the community. It's built on trust—transparency in the code, responsibility when issues come up, and treating security researchers as partners. + +I'm not protecting a brand or business. I'm protecting _you_ and everyone using this tool. + +--- + +## 🔒 What We Do to Keep You Safe + +### Privacy by Design + +- **100% Local Storage** — Your clipboard history never leaves your machine. No cloud sync, no telemetry, no remote servers. +- **Sensitive Data Exclusion** — Password manager content (1Password, Bitwarden, etc.) is automatically excluded from history. +- **No Tracking** — I don't collect anything. No analytics, no usage data, nothing. + +### Security Features + +- **Local SQLite Database** — Your clipboard history is stored in a local database on your machine, not in the cloud. +- **Configurable Retention** — Automatically delete old clipboard items based on your retention settings. +- **Open Source** — Every line of code is public. You can inspect, audit, and verify what we're doing. +- **Signed Release Manifest** — The update notifier fetches a small JSON file signed with an Ed25519 key. The signature is verified locally before the file is trusted, so a compromised mirror cannot inject a fake "latest version" or a malicious install URL. If the signature fails, the manifest is discarded. +- **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS / Linux) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. +- **Signed Linux Repositories** — Native `.deb` and `.rpm` packages are built and signed by the [openSUSE Build Service](https://build.opensuse.org/project/show/home:rgdevment) project key. `apt`, `dnf` and `zypper` verify every package against the OBS GPG key before installation, the same trust chain used by upstream openSUSE and Fedora repositories. +- **Delta-Updated AppImage** — The Linux AppImage embeds an [AppImageUpdate](https://github.com/AppImage/AppImageUpdate) `.zsync` URL pointing back to GitHub Releases. Updates are fetched as binary deltas over HTTPS and verified against the published `SHA256SUMS` file shipped with each release. + +### Development Practices + +- **Modern Flutter Stack** — Built with Flutter and Dart, with dependencies regularly audited and updated. +- **Dependency Updates** — We regularly update dependencies to patch known vulnerabilities. +- **Code Reviews** — All contributions go through review before merging. + +--- + +## 🚨 Supported Versions + +Security updates are provided for: + +| Version | Supported | +| :--- | :--- | +| Latest Release | ✅ Actively Supported | +| Pre-releases (`-rc`, `-beta`) | ✅ Actively Supported | +| Older Releases | ❌ Not Supported (please update) | + +**We strongly recommend always using the latest version** from the [Releases Page](https://github.com/rgdevment/CopyPaste/releases/latest). + +--- + +## 🐛 Reporting a Vulnerability + +If you discover a security vulnerability in **CopyPaste**, please help us protect our users by reporting it responsibly. + +### What Qualifies as a Security Vulnerability? + +**Please report:** + +- ✅ Unauthorized access to clipboard history +- ✅ Privilege escalation issues +- ✅ Data leakage or unintended storage of sensitive information +- ✅ Injection attacks (SQL, command, etc.) +- ✅ Bypass of sensitive data exclusion mechanisms +- ✅ Critical bugs that could lead to data loss or corruption + +**Not security issues:** + +- ❌ Feature requests or enhancements +- ❌ General bugs that don't have security implications +- ❌ Issues with third-party dependencies (report those upstream) +- ❌ Windows SmartScreen warnings (see [README](README.md) for explanation) + +### How to Report Securely + +**DO NOT** open a public GitHub issue for security vulnerabilities. Instead, use one of these private channels: + +#### 📧 Email (Simplest & Direct) + +Send an email to: **** + +**Subject:** `[SECURITY] Brief description of the issue` + +This is the fastest way to reach us. We check email daily and will respond within 48 hours. + +#### 🔒 GitHub Security Advisory (Alternative) + +1. Go to the [Security tab](https://github.com/rgdevment/CopyPaste/security) in the repository +2. Click **"Report a vulnerability"** +3. Fill in the details using the template provided +4. Submit privately — only maintainers will see it + +**Choose whichever method is most comfortable for you.** What matters is that we hear from you. + +**Include in your report:** + +- **Description** — Clear explanation of the vulnerability +- **Impact** — What could an attacker do? Who is affected? +- **Steps to Reproduce** — How can we reproduce the issue? +- **CopyPaste Version** — Which version is affected? +- **OS and version** — e.g., Windows 11 23H2, macOS Sequoia 15.1, Ubuntu 24.04 +- **Proof of Concept** (optional) — Code or screenshots demonstrating the issue +- **Suggested Fix** (optional) — If you have ideas on how to fix it + +### What Happens Next? + +1. **Acknowledgment (Within 48 Hours)** + - I'll confirm I received your report + - I'll let you know if I need more information + +2. **Investigation (1-7 Days)** + - I'll reproduce and analyze the issue + - Assess severity and impact + - Develop a fix + +3. **Resolution** + - Create a patch and test it thoroughly + - Coordinate a release timeline with you + - Credit you in the release notes (if you want) + +4. **Disclosure (After Fix is Released)** + - Publish a security advisory + - Notify users to update + - You can publicly disclose (coordinated disclosure) + +### Response Time Expectations + +| Severity | Response Time | Fix Target | +| :--- | :---: | :---: | +| **Critical** (Remote code execution, data breach) | 24 hours | 1-3 days | +| **High** (Privilege escalation, significant data leak) | 48 hours | 3-7 days | +| **Medium** (Limited scope, requires user interaction) | 3 days | 1-2 weeks | +| **Low** (Minimal impact, edge cases) | 1 week | Next release | + +**I'm one person** (with community help), but I take security seriously. If you don't hear back within the expected timeframe, please follow up—things might've gotten lost. + +--- + +## 🤝 Responsible Disclosure + +I believe in **coordinated disclosure** to protect users: + +- **Please give me reasonable time to fix the issue** before publicly disclosing it +- I aim to release fixes within 7 days for critical issues +- I'll work with you on a disclosure timeline that protects users +- I'll credit you in the release notes (unless you prefer to remain anonymous) + +### My Promise to Security Researchers + +**I WILL:** + +- ✅ Treat you with respect and gratitude—you're helping protect users +- ✅ Respond promptly to your report (within 48 hours) +- ✅ Keep you updated throughout the investigation and fix process +- ✅ Credit your work publicly (if you want) +- ✅ Be transparent about the timeline and progress + +**I will NEVER:** + +- ❌ Threaten legal action against good-faith security researchers +- ❌ Ignore or dismiss legitimate reports +- ❌ Retaliate against reporters in any way +- ❌ Use intimidation tactics or silence critics +- ❌ Blame you for finding vulnerabilities in the code + +**Security research makes everyone safer.** I'm grateful for your work and will treat you as a valued partner in protecting the community. + +--- + +## 🏆 Security Researchers Hall of Fame + +We're grateful to the security researchers who help make **CopyPaste** safer: + + + +- _No security issues reported yet. Help us stay secure!_ + +**Want to be listed here?** Report a verified security vulnerability and choose to be credited. We'll add your name (or handle) and a link to your profile if you'd like. + +--- + +## 📚 Additional Security Resources + +### For Users + +- **Keep CopyPaste Updated** — Enable automatic updates or check for new releases regularly +- **Review Clipboard History** — Periodically check what's being stored and delete sensitive items +- **Configure Retention** — Set shorter retention periods if you handle highly sensitive data +- **Use Password Managers** — Their clipboard content is automatically excluded from history + +### For Developers + +- **Read the Code** — The entire codebase is open source: [CopyPaste Repository](https://github.com/rgdevment/CopyPaste) +- **Review Dependencies** — Check `pubspec.yaml` for third-party packages we use +- **Security Best Practices** — Follow secure coding guidelines when contributing + +--- + +## 🔐 Cryptographic Disclosure + +**CopyPaste does not currently use cryptographic functions for data storage.** + +- Clipboard history is stored in **plaintext** in a local SQLite database +- Database files are protected by **OS-level file system permissions** (Windows, macOS, and Linux) +- No encryption is applied to stored clipboard data + +**Why?** + +- The database is local-only and protected by your OS user account +- Encryption would add complexity and potential key management issues +- Performance and startup time would be impacted +- You control physical access to your machine + +**Future Consideration:** +If there's community demand for at-rest encryption, we're open to discussing it. Open an issue if this is important to you. + +--- + +## 💬 Questions or Concerns? + +We're here to help and answer questions: + +- **Security Questions:** Email us at **** — we're happy to discuss concerns privately +- **General Questions:** [Open a Discussion](https://github.com/rgdevment/CopyPaste/discussions) — ask publicly, we'll answer openly +- **Vulnerability Reports:** Use the private channels above — never post security issues publicly +- **Policy Feedback:** [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — help us improve this policy + +**Security is everyone's responsibility.** Thank you for helping keep **CopyPaste** safe for everyone using it. + +**Remember:** If you're unsure whether something is a security issue, reach out anyway. I'd rather have a conversation than miss a real problem. + +--- + +
+

Built securely, transparently, and with ❤️.

+
diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 52d86ee5..aa03f2a7 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -541,6 +541,34 @@ } }, + "linuxHotkeyGrabFailedWarning": "The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.", + "@linuxHotkeyGrabFailedWarning": { + "description": "Shown when XGrabKey fails because another app already owns the shortcut", + "placeholders": { + "hotkey": { "type": "String" } + } + }, + + "linuxPasteFocusTimeoutWarning": "The clipboard has your content. Paste manually with Ctrl+V.", + "@linuxPasteFocusTimeoutWarning": { + "description": "Shown when the X11 paste flow could not regain focus on the previous window in time" + }, + + "linuxAppindicatorBannerTitle": "System tray icon unavailable", + "@linuxAppindicatorBannerTitle": { "description": "Title of the AppIndicator missing banner" }, + + "linuxAppindicatorBannerBody": "Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.", + "@linuxAppindicatorBannerBody": { "description": "Body of the AppIndicator missing banner" }, + + "linuxXtestBannerTitle": "Automatic paste-back disabled", + "@linuxXtestBannerTitle": { "description": "Title of the missing XTest banner" }, + + "linuxXtestBannerBody": "The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.", + "@linuxXtestBannerBody": { "description": "Body of the missing XTest banner" }, + + "linuxBannerDismiss": "Dismiss", + "@linuxBannerDismiss": { "description": "Action to dismiss a Linux capability banner" }, + "wakeupHint": "CopyPaste runs in the background — press {hotkey} or click the tray icon to open it anytime.", "@wakeupHint": { "description": "In-app snackbar shown inside the window when it is raised by a second launch attempt", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index f3a544f4..bb063c15 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -247,6 +247,13 @@ "waylandUnsupportedClose": "Cerrar", "linuxHotkeyFallbackWarning": "El atajo {requested} no est\u00e1 disponible en este escritorio X11. CopyPaste est\u00e1 usando temporalmente {fallback}. Puedes cambiarlo en Configuraci\u00f3n.", "linuxHotkeyConflictWarning": "El atajo {requested} no est\u00e1 disponible en este escritorio X11 y el fallback temporal {fallback} tambi\u00e9n fall\u00f3. Abre Configuraci\u00f3n para elegir otro atajo.", + "linuxHotkeyGrabFailedWarning": "El atajo {hotkey} est\u00e1 siendo usado por otra aplicaci\u00f3n. C\u00e1mbialo en Configuraci\u00f3n \u2192 Atajos.", + "linuxPasteFocusTimeoutWarning": "El portapapeles tiene tu contenido. P\u00e9galo manualmente con Ctrl+V.", + "linuxAppindicatorBannerTitle": "\u00cdcono de bandeja no disponible", + "linuxAppindicatorBannerBody": "Tu escritorio no expone un host de AppIndicator, por lo que el \u00edcono de CopyPaste no aparecer\u00e1 en la bandeja. Instala una extensi\u00f3n de bandeja para tu distribuci\u00f3n y reinicia CopyPaste.", + "linuxXtestBannerTitle": "Pegado autom\u00e1tico deshabilitado", + "linuxXtestBannerBody": "La extensi\u00f3n XTest de X11 no est\u00e1 disponible, por lo que CopyPaste no puede inyectar Ctrl+V autom\u00e1ticamente. Los elementos siguen copi\u00e1ndose al portapapeles \u2014 p\u00e9galos manualmente con Ctrl+V.", + "linuxBannerDismiss": "Descartar", "wakeupHint": "CopyPaste se ejecuta en segundo plano \u2014 presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo cuando quieras.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 8cc5961a..2b08b4d3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1346,6 +1346,48 @@ abstract class AppLocalizations { /// **'The shortcut {requested} is unavailable on this X11 desktop, and the temporary fallback {fallback} also failed. Open Settings to choose another shortcut.'** String linuxHotkeyConflictWarning(String requested, String fallback); + /// Shown when XGrabKey fails because another app already owns the shortcut + /// + /// In en, this message translates to: + /// **'The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.'** + String linuxHotkeyGrabFailedWarning(String hotkey); + + /// Shown when the X11 paste flow could not regain focus on the previous window in time + /// + /// In en, this message translates to: + /// **'The clipboard has your content. Paste manually with Ctrl+V.'** + String get linuxPasteFocusTimeoutWarning; + + /// Title of the AppIndicator missing banner + /// + /// In en, this message translates to: + /// **'System tray icon unavailable'** + String get linuxAppindicatorBannerTitle; + + /// Body of the AppIndicator missing banner + /// + /// In en, this message translates to: + /// **'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'** + String get linuxAppindicatorBannerBody; + + /// Title of the missing XTest banner + /// + /// In en, this message translates to: + /// **'Automatic paste-back disabled'** + String get linuxXtestBannerTitle; + + /// Body of the missing XTest banner + /// + /// In en, this message translates to: + /// **'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'** + String get linuxXtestBannerBody; + + /// Action to dismiss a Linux capability banner + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get linuxBannerDismiss; + /// In-app snackbar shown inside the window when it is raised by a second launch attempt /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 36ae0039..c470986c 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -685,6 +685,32 @@ class AppLocalizationsEn extends AppLocalizations { return 'The shortcut $requested is unavailable on this X11 desktop, and the temporary fallback $fallback also failed. Open Settings to choose another shortcut.'; } + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; + } + + @override + String get linuxPasteFocusTimeoutWarning => + 'The clipboard has your content. Paste manually with Ctrl+V.'; + + @override + String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; + + @override + String get linuxAppindicatorBannerBody => + 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; + + @override + String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; + + @override + String get linuxXtestBannerBody => + 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Dismiss'; + @override String wakeupHint(String hotkey) { return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 4b929b84..a6ba6d8d 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -689,6 +689,32 @@ class AppLocalizationsEs extends AppLocalizations { return 'El atajo $requested no está disponible en este escritorio X11 y el fallback temporal $fallback también falló. Abre Configuración para elegir otro atajo.'; } + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; + } + + @override + String get linuxPasteFocusTimeoutWarning => + 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; + + @override + String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; + + @override + String get linuxAppindicatorBannerBody => + 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; + + @override + String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; + + @override + String get linuxXtestBannerBody => + 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Descartar'; + @override String wakeupHint(String hotkey) { return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; diff --git a/app/lib/main.dart b/app/lib/main.dart index 4fece463..e47aa3ee 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -14,6 +14,7 @@ import 'package:window_manager/window_manager.dart'; import 'services/auto_update_service.dart'; import 'services/install_channel.dart'; +import 'services/linux_capabilities.dart'; import 'services/release_manifest_service.dart'; import 'shell/app_window.dart'; @@ -27,7 +28,7 @@ import 'shell/startup_helper.dart'; import 'shell/tray_icon.dart'; import 'shell/win_known_folders.dart'; import 'shell/win_package_context.dart'; -import 'shell/windows_balloon.dart'; +import 'shell/desktop_notifier.dart'; import 'screens/main_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/wayland_unsupported_screen.dart'; @@ -35,7 +36,7 @@ import 'theme/compact_theme.dart'; import 'theme/theme_provider.dart'; import 'l10n/app_localizations.dart'; import 'screens/permission_gate_screen.dart'; -import 'screens/windows_onboarding_screen.dart'; +import 'screens/desktop_onboarding_screen.dart'; import 'screens/blocked_version_screen.dart'; // Re-exported so existing tests can import isWaylandSession from main.dart. @@ -119,6 +120,8 @@ Future _run() async { ? WindowsNativeThumbnailProvider() : Platform.isMacOS ? MacOSNativeThumbnailProvider() + : Platform.isLinux + ? LinuxNativeThumbnailProvider() : null; final clipboardService = ClipboardService( repo, @@ -165,6 +168,15 @@ Future _run() async { AppLogger.warn('main: Window.setEffect failed (non-fatal): $e'); } + if (Platform.isLinux) { + try { + final caps = await LinuxCapabilitiesService.detect(); + AppLogger.info('main: linux capabilities $caps'); + } catch (e) { + AppLogger.warn('main: LinuxCapabilities.detect failed (non-fatal): $e'); + } + } + runApp( CopyPasteApp( storage: storage, @@ -215,7 +227,7 @@ class _CopyPasteAppState extends State StreamSubscription? _listenerSubscription; String? _lastTrayLocale; bool _showPermissionGate = false; - bool _showWindowsOnboarding = false; + bool _showOnboarding = false; bool _showWaylandUnsupported = false; bool _linuxPrefersDark = false; String? _availableUpdateVersion; @@ -319,15 +331,19 @@ class _CopyPasteAppState extends State final isUpdate = _config.lastRunVersion != AppConfig.appVersion; final windowsNeedsOnboarding = - Platform.isWindows && (!_config.hasSeenWindowsOnboarding || isUpdate); + Platform.isWindows && (!_config.hasSeenOnboarding || isUpdate); + final linuxNeedsOnboarding = + Platform.isLinux && (!_config.hasCompletedOnboarding || isUpdate); + final desktopNeedsOnboarding = + windowsNeedsOnboarding || linuxNeedsOnboarding; final showOnStart = isFirstRun && (Platform.isLinux || (Platform.isMacOS && macosGranted) || Platform.isWindows) || - windowsNeedsOnboarding; + desktopNeedsOnboarding; await _appWindow.init(startVisible: showOnStart); - if (showOnStart && Platform.isWindows) { + if (showOnStart && (Platform.isWindows || linuxNeedsOnboarding)) { try { await _appWindow.enterGateMode(); } catch (e) { @@ -356,7 +372,7 @@ class _CopyPasteAppState extends State AppLogger.error('trayIcon.init failed: $e'); } - if (Platform.isWindows && !isFirstRun && _config.hasSeenWindowsOnboarding) { + if (Platform.isWindows && !isFirstRun && _config.hasSeenOnboarding) { WidgetsBinding.instance.addPostFrameCallback( (_) => unawaited(_showStartupBalloon()), ); @@ -380,10 +396,10 @@ class _CopyPasteAppState extends State } } } else { - final shouldShowOnboarding = windowsNeedsOnboarding; + final shouldShowOnboarding = desktopNeedsOnboarding; if (shouldShowOnboarding) { if (isFirstRun) widget.storage.markAsInitialized(); - if (mounted) setState(() => _showWindowsOnboarding = true); + if (mounted) setState(() => _showOnboarding = true); if (!_appWindow.isGateMode) { try { await _appWindow.enterGateMode(); @@ -451,13 +467,20 @@ class _CopyPasteAppState extends State '${result.requestedBinding.label()} -> ' '${result.effectiveBinding?.label()}', ); - _showLinuxNotice( - (l) => l.linuxHotkeyFallbackWarning( - result.requestedBinding.label(), - result.effectiveBinding?.label() ?? - kLinuxTemporaryFallbackHotkey.label(), - ), - ); + if (result.failureReason == HotkeyFailureReason.grabFailed) { + _showLinuxNotice( + (l) => + l.linuxHotkeyGrabFailedWarning(result.requestedBinding.label()), + ); + } else { + _showLinuxNotice( + (l) => l.linuxHotkeyFallbackWarning( + result.requestedBinding.label(), + result.effectiveBinding?.label() ?? + kLinuxTemporaryFallbackHotkey.label(), + ), + ); + } return; } @@ -465,12 +488,19 @@ class _CopyPasteAppState extends State AppLogger.error( 'Linux hotkey registration failed for ${result.requestedBinding.label()}', ); - _showLinuxNotice( - (l) => l.linuxHotkeyConflictWarning( - result.requestedBinding.label(), - kLinuxTemporaryFallbackHotkey.label(), - ), - ); + if (result.failureReason == HotkeyFailureReason.grabFailed) { + _showLinuxNotice( + (l) => + l.linuxHotkeyGrabFailedWarning(result.requestedBinding.label()), + ); + } else { + _showLinuxNotice( + (l) => l.linuxHotkeyConflictWarning( + result.requestedBinding.label(), + kLinuxTemporaryFallbackHotkey.label(), + ), + ); + } } } @@ -622,14 +652,14 @@ class _CopyPasteAppState extends State } Future _showOnboardingFromWakeup() async { - if (_showWindowsOnboarding || _appWindow.isSettingsMode) { + if (_showOnboarding || _appWindow.isSettingsMode) { try { await windowManager.show(); await windowManager.focus(); } catch (_) {} return; } - setState(() => _showWindowsOnboarding = true); + setState(() => _showOnboarding = true); try { await _appWindow.enterGateMode(); } catch (e) { @@ -659,7 +689,7 @@ class _CopyPasteAppState extends State ); final ctx = _navigatorKey.currentContext; final l = ctx != null && ctx.mounted ? AppLocalizations.of(ctx) : null; - await WindowsBalloon.show( + await DesktopNotifier.show( title: l?.balloonWakeupTitle ?? 'CopyPaste is already open', body: l?.balloonWakeupBody(binding.label()) ?? @@ -678,7 +708,7 @@ class _CopyPasteAppState extends State ); final ctx = _navigatorKey.currentContext; final l = ctx != null && ctx.mounted ? AppLocalizations.of(ctx) : null; - await WindowsBalloon.show( + await DesktopNotifier.show( title: 'CopyPaste', body: l?.balloonStartupBody(binding.label()) ?? @@ -704,6 +734,14 @@ class _CopyPasteAppState extends State if (mounted) setState(() {}); } + Future _updateLinuxConfig(AppConfig Function(AppConfig) update) async { + final next = update(_config); + if (identical(next, _config)) return; + _config = next; + if (mounted) setState(() {}); + await _config.save('${widget.storage.configPath}/${AppConfig.fileName}'); + } + Future _toggleWindow() async { _programmaticRestore = true; await _appWindow.toggle(); @@ -738,11 +776,14 @@ class _CopyPasteAppState extends State if (!ok) return; await _appWindow.hide(); try { - await _focusManager.restoreAndPaste( + final response = await _focusManager.restoreAndPaste( delayBeforeFocusMs: _config.delayBeforeFocusMs, maxFocusVerifyAttempts: _config.maxFocusVerifyAttempts, delayBeforePasteMs: _config.delayBeforePasteMs, ); + if (Platform.isLinux && response.isFocusTimeout) { + _showLinuxNotice((l) => l.linuxPasteFocusTimeoutWarning); + } } on PlatformException catch (e) { if (e.code == 'ACCESSIBILITY_DENIED' && mounted) { _enterPermissionGate(); @@ -942,11 +983,11 @@ class _CopyPasteAppState extends State if (_appWindow.isGateMode) return; if (!_config.hideOnDeactivate) return; if (Platform.isLinux) { - // On Linux/GTK, window-move and other WM operations briefly steal focus. - // Delay the hide so we can cancel it if focus returns quickly (e.g. drag). _blurHideTimer?.cancel(); - _blurHideTimer = Timer(const Duration(milliseconds: 300), () { + _blurHideTimer = Timer(const Duration(milliseconds: 500), () async { _blurHideTimer = null; + final focus = await LinuxShell.getInputFocus(); + if (focus != null && focus.ownsFocus) return; unawaited(_appWindow.hideIfNotPinned()); }); } else { @@ -1015,7 +1056,7 @@ class _CopyPasteAppState extends State Future _onOnboardingDismissed(AppConfig fromOnboarding) async { _config = fromOnboarding.copyWith( - hasSeenWindowsOnboarding: true, + hasSeenOnboarding: true, hasCompletedOnboarding: true, lastRunVersion: AppConfig.appVersion, ); @@ -1023,7 +1064,7 @@ class _CopyPasteAppState extends State unawaited( _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), ); - setState(() => _showWindowsOnboarding = false); + setState(() => _showOnboarding = false); await _appWindow.exitGateMode(); unawaited(_showStartupBalloon()); } @@ -1033,7 +1074,7 @@ class _CopyPasteAppState extends State AppConfig fromOnboarding, ) async { _config = fromOnboarding.copyWith( - hasSeenWindowsOnboarding: true, + hasSeenOnboarding: true, hasCompletedOnboarding: true, lastRunVersion: AppConfig.appVersion, ); @@ -1041,7 +1082,7 @@ class _CopyPasteAppState extends State unawaited( _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), ); - setState(() => _showWindowsOnboarding = false); + setState(() => _showOnboarding = false); await _appWindow.exitGateMode(); await Future.delayed(const Duration(milliseconds: 150)); if (ctx.mounted) await _openSettings(ctx); @@ -1142,7 +1183,7 @@ class _CopyPasteAppState extends State ); } - if (_showWindowsOnboarding) { + if (_showOnboarding) { final binding = HotkeyBinding( virtualKey: _config.hotkeyVirtualKey, keyName: _config.hotkeyKeyName, @@ -1151,7 +1192,7 @@ class _CopyPasteAppState extends State useAlt: _config.hotkeyUseAlt, useShift: _config.hotkeyUseShift, ); - return WindowsOnboardingScreen( + return DesktopOnboardingScreen( hotkey: binding.label(), initialConfig: _config, onDismiss: (updated) => @@ -1213,6 +1254,13 @@ class _CopyPasteAppState extends State current: AppConfig.appVersion, state: _manifestState, ), + appConfig: Platform.isLinux ? _config : null, + linuxCapabilities: Platform.isLinux + ? LinuxCapabilitiesService.current + : null, + onLinuxConfigUpdate: Platform.isLinux + ? _updateLinuxConfig + : null, ); }, ), diff --git a/app/lib/screens/windows_onboarding_screen.dart b/app/lib/screens/desktop_onboarding_screen.dart similarity index 93% rename from app/lib/screens/windows_onboarding_screen.dart rename to app/lib/screens/desktop_onboarding_screen.dart index d88ca07c..0722a9aa 100644 --- a/app/lib/screens/windows_onboarding_screen.dart +++ b/app/lib/screens/desktop_onboarding_screen.dart @@ -1,202 +1,202 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; - -class WindowsOnboardingScreen extends StatefulWidget { - const WindowsOnboardingScreen({ - required this.hotkey, - required this.initialConfig, - required this.onDismiss, - required this.onSettings, - super.key, - }); - - final String hotkey; - final AppConfig initialConfig; - final void Function(AppConfig updated) onDismiss; - final void Function(AppConfig updated) onSettings; - - @override - State createState() => - _WindowsOnboardingScreenState(); -} - -class _WindowsOnboardingScreenState extends State { - AppConfig _buildConfig() => widget.initialConfig; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final cs = Theme.of(context).colorScheme; - final tt = Theme.of(context).textTheme; - - return Scaffold( - backgroundColor: cs.surface, - body: Center( - child: SizedBox( - width: 360, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Image.asset( - 'assets/icons/icon_app_256.png', - width: 64, - height: 64, - ), - ), - const SizedBox(height: 14), - Text( - l.onboardingTitle, - style: tt.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - l.onboardingSubtitle, - style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: 14), - _PrivacyBadge(label: l.onboardingPrivacyBadge, colorScheme: cs), - const SizedBox(height: 20), - Divider(color: cs.outlineVariant, height: 1), - const SizedBox(height: 16), - Text( - l.onboardingDescription(widget.hotkey), - style: tt.bodyMedium?.copyWith( - color: cs.onSurfaceVariant, - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - _HotkeyChip(hotkey: widget.hotkey, colorScheme: cs), - const SizedBox(height: 8), - Text( - l.onboardingTrayHint, - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 8, - children: [ - OutlinedButton( - onPressed: () => widget.onSettings(_buildConfig()), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - child: Text(l.onboardingSettingsButton), - ), - FilledButton( - onPressed: () => widget.onDismiss(_buildConfig()), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - child: Text(l.onboardingDismissButton), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _PrivacyBadge extends StatelessWidget { - const _PrivacyBadge({required this.label, required this.colorScheme}); - - final String label; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lock_outline_rounded, - size: 13, - color: colorScheme.primary, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); - } -} - -class _HotkeyChip extends StatelessWidget { - const _HotkeyChip({required this.hotkey, required this.colorScheme}); - - final String hotkey; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.keyboard_rounded, - size: 15, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 7), - Text( - hotkey, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - letterSpacing: 0.2, - ), - ), - ], - ), - ); - } -} +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; + +class DesktopOnboardingScreen extends StatefulWidget { + const DesktopOnboardingScreen({ + required this.hotkey, + required this.initialConfig, + required this.onDismiss, + required this.onSettings, + super.key, + }); + + final String hotkey; + final AppConfig initialConfig; + final void Function(AppConfig updated) onDismiss; + final void Function(AppConfig updated) onSettings; + + @override + State createState() => + _DesktopOnboardingScreenState(); +} + +class _DesktopOnboardingScreenState extends State { + AppConfig _buildConfig() => widget.initialConfig; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return Scaffold( + backgroundColor: cs.surface, + body: Center( + child: SizedBox( + width: 360, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + 'assets/icons/icon_app_256.png', + width: 64, + height: 64, + ), + ), + const SizedBox(height: 14), + Text( + l.onboardingTitle, + style: tt.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + l.onboardingSubtitle, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + _PrivacyBadge(label: l.onboardingPrivacyBadge, colorScheme: cs), + const SizedBox(height: 20), + Divider(color: cs.outlineVariant, height: 1), + const SizedBox(height: 16), + Text( + l.onboardingDescription(widget.hotkey), + style: tt.bodyMedium?.copyWith( + color: cs.onSurfaceVariant, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + _HotkeyChip(hotkey: widget.hotkey, colorScheme: cs), + const SizedBox(height: 8), + Text( + l.onboardingTrayHint, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: () => widget.onSettings(_buildConfig()), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + child: Text(l.onboardingSettingsButton), + ), + FilledButton( + onPressed: () => widget.onDismiss(_buildConfig()), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + child: Text(l.onboardingDismissButton), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _PrivacyBadge extends StatelessWidget { + const _PrivacyBadge({required this.label, required this.colorScheme}); + + final String label; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline_rounded, + size: 13, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _HotkeyChip extends StatelessWidget { + const _HotkeyChip({required this.hotkey, required this.colorScheme}); + + final String hotkey; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.keyboard_rounded, + size: 15, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 7), + Text( + hotkey, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + letterSpacing: 0.2, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/screens/linux_capabilities_banner.dart b/app/lib/screens/linux_capabilities_banner.dart new file mode 100644 index 00000000..d1a577b0 --- /dev/null +++ b/app/lib/screens/linux_capabilities_banner.dart @@ -0,0 +1,110 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../services/linux_capabilities.dart'; +import '../theme/theme_provider.dart'; + +typedef LinuxBannerDismissCallback = + Future Function(AppConfig Function(AppConfig) update); + +class LinuxCapabilitiesBanner extends StatelessWidget { + const LinuxCapabilitiesBanner({ + super.key, + required this.config, + required this.capabilities, + required this.onDismiss, + }); + + final AppConfig config; + final LinuxCapabilities capabilities; + final LinuxBannerDismissCallback onDismiss; + + _BannerKind? _resolveActiveBanner() { + if (!capabilities.isUsable) return null; + if (!capabilities.hasAppIndicator && + !config.linuxAppindicatorWarningDismissed) { + return _BannerKind.appIndicator; + } + if (!capabilities.hasXTest && !config.linuxXtestWarningDismissed) { + return _BannerKind.xtest; + } + return null; + } + + @override + Widget build(BuildContext context) { + final kind = _resolveActiveBanner(); + if (kind == null) return const SizedBox.shrink(); + + final l = AppLocalizations.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final (title, body) = switch (kind) { + _BannerKind.appIndicator => ( + l.linuxAppindicatorBannerTitle, + l.linuxAppindicatorBannerBody, + ), + _BannerKind.xtest => (l.linuxXtestBannerTitle, l.linuxXtestBannerBody), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + color: colors.primary.withValues(alpha: 0.10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: colors.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + body, + style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), + ), + ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _dismiss(kind), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Tooltip( + message: l.linuxBannerDismiss, + child: Icon( + Icons.close_rounded, + size: 16, + color: colors.onSurfaceMuted, + ), + ), + ), + ), + ], + ), + ); + } + + Future _dismiss(_BannerKind kind) { + return onDismiss((c) { + switch (kind) { + case _BannerKind.appIndicator: + return c.copyWith(linuxAppindicatorWarningDismissed: true); + case _BannerKind.xtest: + return c.copyWith(linuxXtestWarningDismissed: true); + } + }); + } +} + +enum _BannerKind { appIndicator, xtest } diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index 6c46ee96..feb8dbb8 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -1,843 +1,883 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../helpers/url_helper.dart'; -import '../l10n/app_localizations.dart'; -import '../services/auto_update_service.dart'; -import '../services/release_manifest_service.dart'; -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; -import '../widgets/clipboard_card.dart'; -import '../widgets/empty_state.dart'; -import '../widgets/filter_bar.dart'; -import '../widgets/filter_tab_bar.dart'; -import '../widgets/label_color_dialog.dart'; -import '../widgets/title_bar.dart'; - -enum ClipboardTab { recent, pinned } - -class MainScreen extends StatefulWidget { - const MainScreen({ - required this.clipboardService, - required this.onPaste, - required this.onPastePlain, - required this.onExit, - required this.onSettings, - this.resetScrollOnShow = true, - this.resetSearchOnShow = true, - this.resetFiltersOnShow = true, - this.cardMinLines = 2, - this.cardMaxLines = 5, - this.colorLabels = const {}, - this.showHint = false, - this.onDismissHint, - this.updateVersion, - this.updateSeverity, - super.key, - }); - - final ClipboardService clipboardService; - final void Function(ClipboardItem item) onPaste; - final void Function(ClipboardItem item) onPastePlain; - final VoidCallback onExit; - final VoidCallback onSettings; - final bool resetScrollOnShow; - final bool resetSearchOnShow; - final bool resetFiltersOnShow; - final int cardMinLines; - final int cardMaxLines; - final Map colorLabels; - final bool showHint; - final VoidCallback? onDismissHint; - final String? updateVersion; - final ManifestSeverity? updateSeverity; - - @override - State createState() => MainScreenState(); -} - -class MainScreenState extends State { - final _scrollController = ScrollController(); - final _searchController = TextEditingController(); - final _focusNode = FocusNode(); - final _searchFocusNode = FocusNode(); - final _filterBarKey = GlobalKey(); - final _cardKeys = {}; - - ClipboardTab _currentTab = ClipboardTab.recent; - List _items = []; - bool _loading = false; - bool _pendingReload = false; - int _selectedIndex = -1; - int _expandedIndex = -1; - Timer? _reloadDebounce; - - String _searchQuery = ''; - List _typeFilters = []; - List _colorFilters = []; - - StreamSubscription? _addedSub; - StreamSubscription? _reactivatedSub; - - static const int _pageSize = 30; - int _currentPage = 0; - bool _hasMore = true; - - bool _isFirstRender = true; - - @override - void initState() { - super.initState(); - _addedSub = widget.clipboardService.onItemAdded.listen((_) => _reload()); - _reactivatedSub = widget.clipboardService.onItemReactivated.listen( - (_) => _reload(), - ); - _searchFocusNode.onKeyEvent = _onSearchKeyEvent; - _scrollController.addListener(_onScroll); - _loadItems(); - } - - @override - void dispose() { - _addedSub?.cancel(); - _reactivatedSub?.cancel(); - _reloadDebounce?.cancel(); - _scrollController.dispose(); - _searchController.dispose(); - _focusNode.dispose(); - _searchFocusNode.dispose(); - super.dispose(); - } - - void onWindowShow() { - if (widget.resetFiltersOnShow) { - _typeFilters = []; - _colorFilters = []; - _currentTab = ClipboardTab.recent; - } - _reload(); - if (widget.resetScrollOnShow && _scrollController.hasClients) { - _scrollController.jumpTo(0); - } - if (widget.resetSearchOnShow) { - _searchController.clear(); - _searchQuery = ''; - } - _searchFocusNode.requestFocus(); - } - - void onWindowHide() { - _selectedIndex = -1; - _expandedIndex = -1; - if (_items.length > _pageSize) { - _items = _items.sublist(0, _pageSize); - _currentPage = 0; - _hasMore = true; - } - setState(() {}); - } - - Future _loadItems() async { - if (_loading) return; - _pendingReload = false; - setState(() => _loading = true); - - try { - final items = await widget.clipboardService.getHistoryAdvanced( - query: _searchQuery.isEmpty ? null : _searchQuery, - types: _typeFilters.isEmpty ? null : _typeFilters, - colors: _colorFilters.isEmpty ? null : _colorFilters, - isPinned: _currentTab == ClipboardTab.pinned ? true : null, - limit: _pageSize, - skip: _currentPage * _pageSize, - ); - - setState(() { - if (_currentPage == 0) { - _items = items; - final activeIds = items.map((e) => e.id).toSet(); - _cardKeys.removeWhere((id, _) => !activeIds.contains(id)); - } else { - _items.addAll(items); - } - _hasMore = items.length >= _pageSize; - _loading = false; - }); - } catch (e) { - AppLogger.error('Failed to load items: $e'); - setState(() => _loading = false); - } - - if (_pendingReload) { - _currentPage = 0; - _hasMore = true; - _pendingReload = false; - setState(() {}); - await _loadItems(); - } - } - - void _reload() { - _reloadDebounce?.cancel(); - _reloadDebounce = Timer(const Duration(milliseconds: 80), () { - if (_loading) { - _pendingReload = true; - return; - } - _currentPage = 0; - _hasMore = true; - _loadItems(); - }); - } - - void _onScroll() { - if (!_hasMore || _loading) return; - final max = _scrollController.position.maxScrollExtent; - if (_scrollController.offset >= max - 100) { - _currentPage++; - _loadItems(); - } - } - - void _onSearchChanged(String query) { - _searchQuery = query; - _selectedIndex = -1; - _reload(); - } - - void _onTabChanged(ClipboardTab tab) { - if (_currentTab == tab) return; - setState(() { - _currentTab = tab; - _selectedIndex = -1; - }); - _reload(); - } - - void _onTypeFilterChanged(List types) { - _typeFilters = types; - _selectedIndex = -1; - _reload(); - } - - void _onColorFilterChanged(List colors) { - _colorFilters = colors; - _selectedIndex = -1; - _reload(); - } - - void _clearFilters() { - _typeFilters = []; - _colorFilters = []; - _searchController.clear(); - _searchQuery = ''; - _selectedIndex = -1; - _reload(); - } - - Future _onItemTap(ClipboardItem item) async { - widget.onPaste(item); - } - - Future _onItemPin(ClipboardItem item) async { - await widget.clipboardService.updatePin(item.id, !item.isPinned); - _reload(); - } - - Future _onItemDelete(ClipboardItem item) async { - await widget.clipboardService.removeItem(item.id); - _reload(); - } - - Future _onItemOpen(ClipboardItem item) async { - bool opened = false; - try { - switch (item.type) { - case ClipboardContentType.image: - opened = await _openImageInTemp(item); - case ClipboardContentType.file: - case ClipboardContentType.folder: - case ClipboardContentType.audio: - case ClipboardContentType.video: - await UrlHelper.open(item.content.split('\n').first.trim()); - opened = true; - case ClipboardContentType.link: - await UrlHelper.open(item.content.trim()); - opened = true; - case ClipboardContentType.email: - await UrlHelper.open('mailto:${item.content.trim()}'); - opened = true; - case ClipboardContentType.phone: - await UrlHelper.open('tel:${item.content.trim()}'); - opened = true; - default: - break; - } - } catch (_) {} - if (opened) { - await widget.clipboardService.recordPaste(item.id); - _reload(); - } - } - - Future _openImageInTemp(ClipboardItem item) async { - final src = File(item.content); - if (!src.existsSync()) return false; - final name = item.content.split(Platform.pathSeparator).last; - final tmp = await Directory.systemTemp.createTemp('copypaste_'); - final dest = File('${tmp.path}${Platform.pathSeparator}$name'); - await src.copy(dest.path); - await UrlHelper.open(dest.path); - return true; - } - - Future _onItemLabelColor( - ClipboardItem item, - String? label, - CardColor color, - ) async { - await widget.clipboardService.updateLabelAndColor(item.id, label, color); - _reload(); - } - - KeyEventResult _onSearchKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - if (event.logicalKey == LogicalKeyboardKey.arrowDown && _items.isNotEmpty) { - setState(() => _selectedIndex = 0); - _focusNode.requestFocus(); - _ensureVisible(0); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - - final key = event.logicalKey; - final ctrl = - HardwareKeyboard.instance.isControlPressed || - (Platform.isMacOS && HardwareKeyboard.instance.isMetaPressed); - final alt = HardwareKeyboard.instance.isAltPressed; - - if (key == LogicalKeyboardKey.escape) { - if (_searchQuery.isNotEmpty || - _typeFilters.isNotEmpty || - _colorFilters.isNotEmpty) { - _searchController.clear(); - _onSearchChanged(''); - _clearFilters(); - return KeyEventResult.handled; - } - widget.onExit(); - return KeyEventResult.handled; - } - - if (alt && key == LogicalKeyboardKey.keyC) { - _searchFocusNode.requestFocus(); - setState(() => _selectedIndex = -1); - return KeyEventResult.handled; - } - - if (alt && - (key == LogicalKeyboardKey.keyG || key == LogicalKeyboardKey.keyT)) { - _filterBarKey.currentState?.openMenu(); - return KeyEventResult.handled; - } - - if (ctrl && key == LogicalKeyboardKey.digit1) { - _onTabChanged(ClipboardTab.recent); - return KeyEventResult.handled; - } - - if (ctrl && key == LogicalKeyboardKey.digit2) { - _onTabChanged(ClipboardTab.pinned); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.tab && - HardwareKeyboard.instance.isShiftPressed) { - _searchFocusNode.requestFocus(); - setState(() => _selectedIndex = -1); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowDown) { - if (_selectedIndex < _items.length - 1) { - setState(() => _selectedIndex++); - _ensureVisible(_selectedIndex); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowUp) { - if (_selectedIndex > 0) { - setState(() => _selectedIndex--); - _ensureVisible(_selectedIndex); - } else if (_selectedIndex == 0) { - setState(() => _selectedIndex = -1); - _searchFocusNode.requestFocus(); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.enter && _selectedIndex >= 0) { - _onItemTap(_items[_selectedIndex]); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.delete && _selectedIndex >= 0) { - _onItemDelete(_items[_selectedIndex]); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.keyP && _selectedIndex >= 0) { - _onItemPin(_items[_selectedIndex]); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.keyE && _selectedIndex >= 0) { - _editSelectedItem(); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowRight && _selectedIndex >= 0) { - setState(() { - _expandedIndex = _expandedIndex == _selectedIndex ? -1 : _selectedIndex; - }); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - - void _editSelectedItem() { - if (_selectedIndex < 0 || _selectedIndex >= _items.length) return; - final item = _items[_selectedIndex]; - _showEditDialog(item); - } - - Future _showEditDialog(ClipboardItem item) async { - if (!mounted) return; - final result = await LabelColorDialog.show( - context, - currentLabel: item.label, - currentColor: item.cardColor, - ); - if (result != null) { - await _onItemLabelColor(item, result.label, result.color); - } - } - - void _ensureVisible(int index) { - if (index < 0 || index >= _items.length) return; - final item = _items[index]; - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _cardKeys[item.id]?.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - ); - } - }); - } - - bool get _isEmpty => _items.isEmpty && !_loading; - - @override - Widget build(BuildContext context) { - final colors = CopyPasteTheme.colorsOf(context); - final theme = CopyPasteTheme.of(context); - final hasColorFilters = _colorFilters.isNotEmpty; - - return Focus( - focusNode: _focusNode, - onKeyEvent: _onKeyEvent, - descendantsAreTraversable: false, - child: Column( - children: [ - TitleBar( - searchController: _searchController, - searchFocusNode: _searchFocusNode, - onSearchChanged: _onSearchChanged, - trailing: FilterBar( - key: _filterBarKey, - selectedTypes: _typeFilters, - selectedColors: _colorFilters, - colorLabels: widget.colorLabels, - onTypesChanged: _onTypeFilterChanged, - onColorsChanged: _onColorFilterChanged, - onClear: hasColorFilters ? _clearFilters : null, - ), - ), - FilterTabBar( - selectedTypes: _typeFilters, - onTypesChanged: _onTypeFilterChanged, - isPinnedMode: _currentTab == ClipboardTab.pinned, - onPinnedModeChanged: (pinned) { - _onTabChanged(pinned ? ClipboardTab.pinned : ClipboardTab.recent); - }, - ), - if (widget.showHint) _buildHintBanner(colors), - Expanded( - child: _isEmpty - ? const EmptyState() - : _buildRealList(theme, _items), - ), - Divider(height: 1, thickness: 0.5, color: colors.divider), - _buildBottomBar(theme, colors), - ], - ), - ); - } - - Widget _buildHintBanner(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - color: colors.primary.withValues(alpha: 0.06), - child: Row( - children: [ - Icon( - Icons.lightbulb_outline_rounded, - size: 14, - color: colors.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: l.hintBannerText, - style: TextStyle(fontSize: 11, color: colors.onSurface), - ), - const TextSpan(text: ' '), - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - onTap: () { - widget.onDismissHint?.call(); - widget.onSettings(); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Text( - l.hintBannerAction, - style: TextStyle( - fontSize: 11, - color: colors.primary, - decoration: TextDecoration.underline, - decorationColor: colors.primary.withValues( - alpha: 0.5, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - GestureDetector( - onTap: widget.onDismissHint, - child: Icon( - Icons.close_rounded, - size: 14, - color: colors.onSurfaceMuted, - ), - ), - ], - ), - ); - } - - Widget _buildRealList(AppThemeData theme, List items) { - final animate = _isFirstRender; - if (_isFirstRender) { - _isFirstRender = false; - } - return ListView.builder( - controller: _scrollController, - padding: theme.spacing.listPadding.copyWith(top: 6, bottom: 8), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final cardKey = _cardKeys.putIfAbsent(item.id, GlobalKey.new); - final card = Padding( - key: cardKey, - padding: EdgeInsets.only(bottom: theme.spacing.cardGap), - child: ClipboardCard( - item: item, - isSelected: index == _selectedIndex, - isExpanded: index == _expandedIndex, - cardMinLines: widget.cardMinLines, - cardMaxLines: widget.cardMaxLines, - onTap: () => _onItemTap(item), - onPin: () => _onItemPin(item), - onDelete: () => _onItemDelete(item), - onLabelColor: (label, color) => - _onItemLabelColor(item, label, color), - onPastePlain: () => widget.onPastePlain(item), - onOpen: () => _onItemOpen(item), - onRequestThumbnailRefresh: - widget.clipboardService.requestThumbnailIfStale, - onSelect: () { - setState(() => _selectedIndex = index); - _focusNode.requestFocus(); - }, - onExpandToggle: () { - setState(() { - _expandedIndex = _expandedIndex == index ? -1 : index; - }); - }, - ), - ); - - if (animate && index < _pageSize) { - return _StaggeredFadeIn(index: index, child: card); - } - return card; - }, - ); - } - - Widget _buildBottomBar(AppThemeData theme, AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - final updateVersion = widget.updateVersion; - final severity = widget.updateSeverity; - final isImportant = severity != null && severity != ManifestSeverity.patch; - final badgeColor = isImportant ? colors.accentRed : colors.primary; - final badgeText = isImportant - ? l.updateBadgeImportant(updateVersion ?? '') - : l.updateBadge(updateVersion ?? ''); - - return Container( - height: theme.spacing.bottomBarHeight, - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - if (updateVersion != null) - Tooltip( - message: AutoUpdateService.isStoreBuild - ? l.updateTooltipStore(updateVersion) - : l.updateTooltipGeneric(updateVersion), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showUpdateDialog(context, updateVersion), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.system_update_outlined, - size: 13, - color: badgeColor, - ), - const SizedBox(width: 5), - Text( - badgeText, - style: theme.typography.branding.copyWith( - color: badgeColor, - letterSpacing: 0.3, - ), - ), - ], - ), - ), - ) - else - Opacity( - opacity: 0.35, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - 'assets/icons/icon_notification.png', - width: 12, - height: 12, - color: colors.onSurface, - colorBlendMode: BlendMode.srcIn, - ), - const SizedBox(width: 5), - Text( - 'CopyPaste', - style: theme.typography.branding.copyWith( - color: colors.onSurface, - letterSpacing: 0.3, - ), - ), - ], - ), - ), - const Spacer(), - _BottomBarAction( - icon: Icons.bug_report_outlined, - iconSize: 14, - opacity: 0.4, - onTap: () => - UrlHelper.open('https://github.com/rgdevment/CopyPaste/issues'), - ), - const SizedBox(width: 2), - _BottomBarAction( - icon: theme.icons.settings, - iconSize: 14, - opacity: 0.4, - onTap: widget.onSettings, - ), - ], - ), - ); - } - - void _showUpdateDialog(BuildContext context, String version) { - final l = AppLocalizations.of(context); - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: Text(l.updateDialogTitle), - content: SizedBox( - width: double.maxFinite, - child: Text( - AutoUpdateService.isStoreBuild - ? l.updateAvailableStore(version) - : Platform.isMacOS - ? l.updateAvailableMac(version) - : Platform.isLinux - ? l.updateAvailableLinux(version) - : l.updateAvailableWindows(version), - ), - ), - actionsOverflowButtonSpacing: 8, - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(), - child: Text(l.updateDismiss), - ), - if (!AutoUpdateService.isStoreBuild) - FilledButton( - onPressed: () { - Navigator.of(dialogCtx).pop(); - UrlHelper.open( - 'https://github.com/rgdevment/CopyPaste/releases/latest', - ); - }, - child: Text(l.updateViewRelease), - ), - ], - ), - ); - } -} - -class _StaggeredFadeIn extends StatefulWidget { - const _StaggeredFadeIn({required this.index, required this.child}); - - final int index; - final Widget child; - - @override - State<_StaggeredFadeIn> createState() => _StaggeredFadeInState(); -} - -class _StaggeredFadeInState extends State<_StaggeredFadeIn> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _opacity; - late final Animation _offset; - Timer? _delayTimer; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 80), - ); - _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut); - _offset = Tween( - begin: const Offset(0, -4), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - - _delayTimer = Timer(Duration(milliseconds: 20 * widget.index), () { - if (mounted) _controller.forward(); - }); - } - - @override - void dispose() { - _delayTimer?.cancel(); - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return Transform.translate( - offset: _offset.value, - child: Opacity(opacity: _opacity.value, child: child), - ); - }, - child: widget.child, - ); - } -} - -class _BottomBarAction extends StatefulWidget { - const _BottomBarAction({ - required this.icon, - required this.iconSize, - required this.opacity, - required this.onTap, - }); - - final IconData icon; - final double iconSize; - final double opacity; - final VoidCallback onTap; - - @override - State<_BottomBarAction> createState() => _BottomBarActionState(); -} - -class _BottomBarActionState extends State<_BottomBarAction> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - final colors = CopyPasteTheme.colorsOf(context); - - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - onTap: widget.onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Icon( - widget.icon, - size: widget.iconSize, - color: colors.onSurface.withValues( - alpha: _hovering ? widget.opacity + 0.25 : widget.opacity, - ), - ), - ), - ), - ); - } -} +import 'dart:async'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../helpers/url_helper.dart'; +import '../l10n/app_localizations.dart'; +import '../services/auto_update_service.dart'; +import '../services/linux_capabilities.dart'; +import '../services/release_manifest_service.dart'; +import '../theme/app_theme_data.dart'; +import '../theme/theme_provider.dart'; +import '../widgets/clipboard_card.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/filter_bar.dart'; +import '../widgets/filter_tab_bar.dart'; +import '../widgets/label_color_dialog.dart'; +import '../widgets/title_bar.dart'; +import 'linux_capabilities_banner.dart'; + +enum ClipboardTab { recent, pinned } + +class MainScreen extends StatefulWidget { + const MainScreen({ + required this.clipboardService, + required this.onPaste, + required this.onPastePlain, + required this.onExit, + required this.onSettings, + this.resetScrollOnShow = true, + this.resetSearchOnShow = true, + this.resetFiltersOnShow = true, + this.cardMinLines = 2, + this.cardMaxLines = 5, + this.colorLabels = const {}, + this.showHint = false, + this.onDismissHint, + this.updateVersion, + this.updateSeverity, + this.appConfig, + this.linuxCapabilities, + this.onLinuxConfigUpdate, + super.key, + }); + + final ClipboardService clipboardService; + final void Function(ClipboardItem item) onPaste; + final void Function(ClipboardItem item) onPastePlain; + final VoidCallback onExit; + final VoidCallback onSettings; + final bool resetScrollOnShow; + final bool resetSearchOnShow; + final bool resetFiltersOnShow; + final int cardMinLines; + final int cardMaxLines; + final Map colorLabels; + final bool showHint; + final VoidCallback? onDismissHint; + final String? updateVersion; + final ManifestSeverity? updateSeverity; + final AppConfig? appConfig; + final LinuxCapabilities? linuxCapabilities; + final Future Function(AppConfig Function(AppConfig))? + onLinuxConfigUpdate; + + @override + State createState() => MainScreenState(); +} + +class MainScreenState extends State { + final _scrollController = ScrollController(); + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + final _searchFocusNode = FocusNode(); + final _filterBarKey = GlobalKey(); + final _cardKeys = {}; + + ClipboardTab _currentTab = ClipboardTab.recent; + List _items = []; + bool _loading = false; + bool _pendingReload = false; + int _selectedIndex = -1; + int _expandedIndex = -1; + Timer? _reloadDebounce; + + String _searchQuery = ''; + List _typeFilters = []; + List _colorFilters = []; + + StreamSubscription? _addedSub; + StreamSubscription? _reactivatedSub; + + static const int _pageSize = 30; + int _currentPage = 0; + bool _hasMore = true; + + bool _isFirstRender = true; + + @override + void initState() { + super.initState(); + _addedSub = widget.clipboardService.onItemAdded.listen((_) => _reload()); + _reactivatedSub = widget.clipboardService.onItemReactivated.listen( + (_) => _reload(), + ); + _searchFocusNode.onKeyEvent = _onSearchKeyEvent; + _scrollController.addListener(_onScroll); + _loadItems(); + } + + @override + void dispose() { + _addedSub?.cancel(); + _reactivatedSub?.cancel(); + _reloadDebounce?.cancel(); + _scrollController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void onWindowShow() { + if (widget.resetFiltersOnShow) { + _typeFilters = []; + _colorFilters = []; + _currentTab = ClipboardTab.recent; + } + _reload(); + if (widget.resetScrollOnShow && _scrollController.hasClients) { + _scrollController.jumpTo(0); + } + if (widget.resetSearchOnShow) { + _searchController.clear(); + _searchQuery = ''; + } + _searchFocusNode.requestFocus(); + } + + void onWindowHide() { + _selectedIndex = -1; + _expandedIndex = -1; + if (_items.length > _pageSize) { + _items = _items.sublist(0, _pageSize); + _currentPage = 0; + _hasMore = true; + } + setState(() {}); + } + + Future _loadItems() async { + if (_loading) return; + _pendingReload = false; + setState(() => _loading = true); + + try { + final items = await widget.clipboardService.getHistoryAdvanced( + query: _searchQuery.isEmpty ? null : _searchQuery, + types: _typeFilters.isEmpty ? null : _typeFilters, + colors: _colorFilters.isEmpty ? null : _colorFilters, + isPinned: _currentTab == ClipboardTab.pinned ? true : null, + limit: _pageSize, + skip: _currentPage * _pageSize, + ); + + setState(() { + if (_currentPage == 0) { + _items = items; + final activeIds = items.map((e) => e.id).toSet(); + _cardKeys.removeWhere((id, _) => !activeIds.contains(id)); + } else { + _items.addAll(items); + } + _hasMore = items.length >= _pageSize; + _loading = false; + }); + } catch (e) { + AppLogger.error('Failed to load items: $e'); + setState(() => _loading = false); + } + + if (_pendingReload) { + _currentPage = 0; + _hasMore = true; + _pendingReload = false; + setState(() {}); + await _loadItems(); + } + } + + void _reload() { + _reloadDebounce?.cancel(); + _reloadDebounce = Timer(const Duration(milliseconds: 80), () { + if (_loading) { + _pendingReload = true; + return; + } + _currentPage = 0; + _hasMore = true; + _loadItems(); + }); + } + + void _onScroll() { + if (!_hasMore || _loading) return; + final max = _scrollController.position.maxScrollExtent; + if (_scrollController.offset >= max - 100) { + _currentPage++; + _loadItems(); + } + } + + void _onSearchChanged(String query) { + _searchQuery = query; + _selectedIndex = -1; + _reload(); + } + + void _onTabChanged(ClipboardTab tab) { + if (_currentTab == tab) return; + setState(() { + _currentTab = tab; + _selectedIndex = -1; + }); + _reload(); + } + + void _onTypeFilterChanged(List types) { + _typeFilters = types; + _selectedIndex = -1; + _reload(); + } + + void _onColorFilterChanged(List colors) { + _colorFilters = colors; + _selectedIndex = -1; + _reload(); + } + + void _clearFilters() { + _typeFilters = []; + _colorFilters = []; + _searchController.clear(); + _searchQuery = ''; + _selectedIndex = -1; + _reload(); + } + + Future _onItemTap(ClipboardItem item) async { + widget.onPaste(item); + } + + Future _onItemPin(ClipboardItem item) async { + await widget.clipboardService.updatePin(item.id, !item.isPinned); + _reload(); + } + + Future _onItemDelete(ClipboardItem item) async { + await widget.clipboardService.removeItem(item.id); + _reload(); + } + + Future _onItemOpen(ClipboardItem item) async { + bool opened = false; + try { + switch (item.type) { + case ClipboardContentType.image: + opened = await _openImageInTemp(item); + case ClipboardContentType.file: + case ClipboardContentType.folder: + case ClipboardContentType.audio: + case ClipboardContentType.video: + final path = item.content.split('\n').first.trim(); + if (path.isEmpty || + (!File(path).existsSync() && !Directory(path).existsSync())) { + _showFileNotFoundFeedback(); + return; + } + await UrlHelper.open(path); + opened = true; + case ClipboardContentType.link: + await UrlHelper.open(item.content.trim()); + opened = true; + case ClipboardContentType.email: + await UrlHelper.open('mailto:${item.content.trim()}'); + opened = true; + case ClipboardContentType.phone: + await UrlHelper.open('tel:${item.content.trim()}'); + opened = true; + default: + break; + } + } catch (_) {} + if (opened) { + await widget.clipboardService.recordPaste(item.id); + _reload(); + } + } + + Future _openImageInTemp(ClipboardItem item) async { + final src = File(item.content); + if (!src.existsSync()) { + _showFileNotFoundFeedback(); + return false; + } + final name = item.content.split(Platform.pathSeparator).last; + final tmp = await Directory.systemTemp.createTemp('copypaste_'); + final dest = File('${tmp.path}${Platform.pathSeparator}$name'); + await src.copy(dest.path); + await UrlHelper.open(dest.path); + return true; + } + + void _showFileNotFoundFeedback() { + final ctx = context; + if (!ctx.mounted) return; + final messenger = ScaffoldMessenger.maybeOf(ctx); + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(ctx).fileNotFound), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _onItemLabelColor( + ClipboardItem item, + String? label, + CardColor color, + ) async { + await widget.clipboardService.updateLabelAndColor(item.id, label, color); + _reload(); + } + + KeyEventResult _onSearchKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown && _items.isNotEmpty) { + setState(() => _selectedIndex = 0); + _focusNode.requestFocus(); + _ensureVisible(0); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + final ctrl = + HardwareKeyboard.instance.isControlPressed || + (Platform.isMacOS && HardwareKeyboard.instance.isMetaPressed); + final alt = HardwareKeyboard.instance.isAltPressed; + + if (key == LogicalKeyboardKey.escape) { + if (_searchQuery.isNotEmpty || + _typeFilters.isNotEmpty || + _colorFilters.isNotEmpty) { + _searchController.clear(); + _onSearchChanged(''); + _clearFilters(); + return KeyEventResult.handled; + } + widget.onExit(); + return KeyEventResult.handled; + } + + if (alt && key == LogicalKeyboardKey.keyC) { + _searchFocusNode.requestFocus(); + setState(() => _selectedIndex = -1); + return KeyEventResult.handled; + } + + if (alt && + (key == LogicalKeyboardKey.keyG || key == LogicalKeyboardKey.keyT)) { + _filterBarKey.currentState?.openMenu(); + return KeyEventResult.handled; + } + + if (ctrl && key == LogicalKeyboardKey.digit1) { + _onTabChanged(ClipboardTab.recent); + return KeyEventResult.handled; + } + + if (ctrl && key == LogicalKeyboardKey.digit2) { + _onTabChanged(ClipboardTab.pinned); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.tab && + HardwareKeyboard.instance.isShiftPressed) { + _searchFocusNode.requestFocus(); + setState(() => _selectedIndex = -1); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowDown) { + if (_selectedIndex < _items.length - 1) { + setState(() => _selectedIndex++); + _ensureVisible(_selectedIndex); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowUp) { + if (_selectedIndex > 0) { + setState(() => _selectedIndex--); + _ensureVisible(_selectedIndex); + } else if (_selectedIndex == 0) { + setState(() => _selectedIndex = -1); + _searchFocusNode.requestFocus(); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.enter && _selectedIndex >= 0) { + _onItemTap(_items[_selectedIndex]); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.delete && _selectedIndex >= 0) { + _onItemDelete(_items[_selectedIndex]); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.keyP && _selectedIndex >= 0) { + _onItemPin(_items[_selectedIndex]); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.keyE && _selectedIndex >= 0) { + _editSelectedItem(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowRight && _selectedIndex >= 0) { + setState(() { + _expandedIndex = _expandedIndex == _selectedIndex ? -1 : _selectedIndex; + }); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _editSelectedItem() { + if (_selectedIndex < 0 || _selectedIndex >= _items.length) return; + final item = _items[_selectedIndex]; + _showEditDialog(item); + } + + Future _showEditDialog(ClipboardItem item) async { + if (!mounted) return; + final result = await LabelColorDialog.show( + context, + currentLabel: item.label, + currentColor: item.cardColor, + ); + if (result != null) { + await _onItemLabelColor(item, result.label, result.color); + } + } + + void _ensureVisible(int index) { + if (index < 0 || index >= _items.length) return; + final item = _items[index]; + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _cardKeys[item.id]?.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ); + } + }); + } + + bool get _isEmpty => _items.isEmpty && !_loading; + + @override + Widget build(BuildContext context) { + final colors = CopyPasteTheme.colorsOf(context); + final theme = CopyPasteTheme.of(context); + final hasColorFilters = _colorFilters.isNotEmpty; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _onKeyEvent, + descendantsAreTraversable: false, + child: Column( + children: [ + TitleBar( + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onSearchChanged: _onSearchChanged, + trailing: FilterBar( + key: _filterBarKey, + selectedTypes: _typeFilters, + selectedColors: _colorFilters, + colorLabels: widget.colorLabels, + onTypesChanged: _onTypeFilterChanged, + onColorsChanged: _onColorFilterChanged, + onClear: hasColorFilters ? _clearFilters : null, + ), + ), + FilterTabBar( + selectedTypes: _typeFilters, + onTypesChanged: _onTypeFilterChanged, + isPinnedMode: _currentTab == ClipboardTab.pinned, + onPinnedModeChanged: (pinned) { + _onTabChanged(pinned ? ClipboardTab.pinned : ClipboardTab.recent); + }, + ), + if (widget.showHint) _buildHintBanner(colors), + if (widget.appConfig != null && + widget.linuxCapabilities != null && + widget.onLinuxConfigUpdate != null) + LinuxCapabilitiesBanner( + config: widget.appConfig!, + capabilities: widget.linuxCapabilities!, + onDismiss: widget.onLinuxConfigUpdate!, + ), + Expanded( + child: _isEmpty + ? const EmptyState() + : _buildRealList(theme, _items), + ), + Divider(height: 1, thickness: 0.5, color: colors.divider), + _buildBottomBar(theme, colors), + ], + ), + ); + } + + Widget _buildHintBanner(AppThemeColorScheme colors) { + final l = AppLocalizations.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + color: colors.primary.withValues(alpha: 0.06), + child: Row( + children: [ + Icon( + Icons.lightbulb_outline_rounded, + size: 14, + color: colors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: l.hintBannerText, + style: TextStyle(fontSize: 11, color: colors.onSurface), + ), + const TextSpan(text: ' '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + onTap: () { + widget.onDismissHint?.call(); + widget.onSettings(); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Text( + l.hintBannerAction, + style: TextStyle( + fontSize: 11, + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary.withValues( + alpha: 0.5, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + GestureDetector( + onTap: widget.onDismissHint, + child: Icon( + Icons.close_rounded, + size: 14, + color: colors.onSurfaceMuted, + ), + ), + ], + ), + ); + } + + Widget _buildRealList(AppThemeData theme, List items) { + final animate = _isFirstRender; + if (_isFirstRender) { + _isFirstRender = false; + } + return ListView.builder( + controller: _scrollController, + padding: theme.spacing.listPadding.copyWith(top: 6, bottom: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final cardKey = _cardKeys.putIfAbsent(item.id, GlobalKey.new); + final card = Padding( + key: cardKey, + padding: EdgeInsets.only(bottom: theme.spacing.cardGap), + child: ClipboardCard( + item: item, + isSelected: index == _selectedIndex, + isExpanded: index == _expandedIndex, + cardMinLines: widget.cardMinLines, + cardMaxLines: widget.cardMaxLines, + onTap: () => _onItemTap(item), + onPin: () => _onItemPin(item), + onDelete: () => _onItemDelete(item), + onLabelColor: (label, color) => + _onItemLabelColor(item, label, color), + onPastePlain: () => widget.onPastePlain(item), + onOpen: () => _onItemOpen(item), + onRequestThumbnailRefresh: + widget.clipboardService.requestThumbnailIfStale, + onSelect: () { + setState(() => _selectedIndex = index); + _focusNode.requestFocus(); + }, + onExpandToggle: () { + setState(() { + _expandedIndex = _expandedIndex == index ? -1 : index; + }); + }, + ), + ); + + if (animate && index < _pageSize) { + return _StaggeredFadeIn(index: index, child: card); + } + return card; + }, + ); + } + + Widget _buildBottomBar(AppThemeData theme, AppThemeColorScheme colors) { + final l = AppLocalizations.of(context); + final updateVersion = widget.updateVersion; + final severity = widget.updateSeverity; + final isImportant = severity != null && severity != ManifestSeverity.patch; + final badgeColor = isImportant ? colors.accentRed : colors.primary; + final badgeText = isImportant + ? l.updateBadgeImportant(updateVersion ?? '') + : l.updateBadge(updateVersion ?? ''); + + return Container( + height: theme.spacing.bottomBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + if (updateVersion != null) + Tooltip( + message: AutoUpdateService.isStoreBuild + ? l.updateTooltipStore(updateVersion) + : l.updateTooltipGeneric(updateVersion), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showUpdateDialog(context, updateVersion), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.system_update_outlined, + size: 13, + color: badgeColor, + ), + const SizedBox(width: 5), + Text( + badgeText, + style: theme.typography.branding.copyWith( + color: badgeColor, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ) + else + Opacity( + opacity: 0.35, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/icons/icon_notification.png', + width: 12, + height: 12, + color: colors.onSurface, + colorBlendMode: BlendMode.srcIn, + ), + const SizedBox(width: 5), + Text( + 'CopyPaste', + style: theme.typography.branding.copyWith( + color: colors.onSurface, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + const Spacer(), + _BottomBarAction( + icon: Icons.bug_report_outlined, + iconSize: 14, + opacity: 0.4, + onTap: () => + UrlHelper.open('https://github.com/rgdevment/CopyPaste/issues'), + ), + const SizedBox(width: 2), + _BottomBarAction( + icon: theme.icons.settings, + iconSize: 14, + opacity: 0.4, + onTap: widget.onSettings, + ), + ], + ), + ); + } + + void _showUpdateDialog(BuildContext context, String version) { + final l = AppLocalizations.of(context); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(l.updateDialogTitle), + content: SizedBox( + width: double.maxFinite, + child: Text( + AutoUpdateService.isStoreBuild + ? l.updateAvailableStore(version) + : Platform.isMacOS + ? l.updateAvailableMac(version) + : Platform.isLinux + ? l.updateAvailableLinux(version) + : l.updateAvailableWindows(version), + ), + ), + actionsOverflowButtonSpacing: 8, + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: Text(l.updateDismiss), + ), + if (!AutoUpdateService.isStoreBuild) + FilledButton( + onPressed: () { + Navigator.of(dialogCtx).pop(); + UrlHelper.open( + 'https://github.com/rgdevment/CopyPaste/releases/latest', + ); + }, + child: Text(l.updateViewRelease), + ), + ], + ), + ); + } +} + +class _StaggeredFadeIn extends StatefulWidget { + const _StaggeredFadeIn({required this.index, required this.child}); + + final int index; + final Widget child; + + @override + State<_StaggeredFadeIn> createState() => _StaggeredFadeInState(); +} + +class _StaggeredFadeInState extends State<_StaggeredFadeIn> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + late final Animation _offset; + Timer? _delayTimer; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 80), + ); + _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut); + _offset = Tween( + begin: const Offset(0, -4), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _delayTimer = Timer(Duration(milliseconds: 20 * widget.index), () { + if (mounted) _controller.forward(); + }); + } + + @override + void dispose() { + _delayTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: _offset.value, + child: Opacity(opacity: _opacity.value, child: child), + ); + }, + child: widget.child, + ); + } +} + +class _BottomBarAction extends StatefulWidget { + const _BottomBarAction({ + required this.icon, + required this.iconSize, + required this.opacity, + required this.onTap, + }); + + final IconData icon; + final double iconSize; + final double opacity; + final VoidCallback onTap; + + @override + State<_BottomBarAction> createState() => _BottomBarActionState(); +} + +class _BottomBarActionState extends State<_BottomBarAction> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final colors = CopyPasteTheme.colorsOf(context); + + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Icon( + widget.icon, + size: widget.iconSize, + color: colors.onSurface.withValues( + alpha: _hovering ? widget.opacity + 0.25 : widget.opacity, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index c8f7173b..a081452a 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -1060,7 +1060,11 @@ class _SettingsScreenState extends State { }, ), _ModifierChip( - label: Platform.isMacOS ? 'Cmd' : 'Win', + label: Platform.isMacOS + ? 'Cmd' + : Platform.isLinux + ? 'Super' + : 'Win', selected: _hotkeyWin, colors: colors, onTap: () { diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart new file mode 100644 index 00000000..f095e7d2 --- /dev/null +++ b/app/lib/services/linux_capabilities.dart @@ -0,0 +1,207 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../shell/linux_session.dart'; + +@immutable +class LinuxCapabilities { + const LinuxCapabilities({ + required this.session, + required this.isX11, + required this.hasXTest, + required this.hasAppIndicator, + required this.hasEwmh, + required this.detectedDesktopEnv, + required this.detectedWmName, + required this.detectionTimedOut, + }); + + final LinuxSessionInfo session; + final bool isX11; + final bool hasXTest; + final bool hasAppIndicator; + final bool hasEwmh; + final String detectedDesktopEnv; + final String detectedWmName; + final bool detectionTimedOut; + + bool get isLinux => Platform.isLinux; + bool get isWayland => session.isWayland; + bool get isUsable => isLinux && isX11; + + static const LinuxCapabilities unsupported = LinuxCapabilities( + session: LinuxSessionInfo.unsupported, + isX11: false, + hasXTest: false, + hasAppIndicator: false, + hasEwmh: false, + detectedDesktopEnv: '', + detectedWmName: '', + detectionTimedOut: false, + ); + + LinuxCapabilities copyWith({ + bool? isX11, + bool? hasXTest, + bool? hasAppIndicator, + bool? hasEwmh, + String? detectedDesktopEnv, + String? detectedWmName, + bool? detectionTimedOut, + }) { + return LinuxCapabilities( + session: session, + isX11: isX11 ?? this.isX11, + hasXTest: hasXTest ?? this.hasXTest, + hasAppIndicator: hasAppIndicator ?? this.hasAppIndicator, + hasEwmh: hasEwmh ?? this.hasEwmh, + detectedDesktopEnv: detectedDesktopEnv ?? this.detectedDesktopEnv, + detectedWmName: detectedWmName ?? this.detectedWmName, + detectionTimedOut: detectionTimedOut ?? this.detectionTimedOut, + ); + } + + @override + String toString() => + 'LinuxCapabilities(isX11=$isX11, hasXTest=$hasXTest, ' + 'hasAppIndicator=$hasAppIndicator, ' + 'hasEwmh=$hasEwmh, desktopEnv=$detectedDesktopEnv, wm=$detectedWmName, ' + 'timedOut=$detectionTimedOut, session=$session)'; +} + +abstract class LinuxCapabilitiesChannel { + Future?> invokeShell(String method); + Future?> invokeListener(String method); +} + +class _DefaultLinuxCapabilitiesChannel implements LinuxCapabilitiesChannel { + const _DefaultLinuxCapabilitiesChannel(); + + static const MethodChannel _shell = MethodChannel('copypaste/linux_shell'); + static const MethodChannel _listener = MethodChannel( + 'copypaste/clipboard_writer', + ); + + @override + Future?> invokeShell(String method) async { + final result = await _shell.invokeMethod(method); + return result is Map ? Map.from(result) : null; + } + + @override + Future?> invokeListener(String method) async { + final result = await _listener.invokeMethod(method); + return result is Map ? Map.from(result) : null; + } +} + +class LinuxCapabilitiesService { + LinuxCapabilitiesService._(); // coverage:ignore-line + + static LinuxCapabilities _cache = LinuxCapabilities.unsupported; + static bool _initialized = false; + + static LinuxCapabilities get current => _cache; + static bool get isInitialized => _initialized; + + @visibleForTesting + static void resetForTesting([LinuxCapabilities? value]) { + _cache = value ?? LinuxCapabilities.unsupported; + _initialized = value != null; + } + + static Future detect({ + LinuxCapabilitiesChannel channel = const _DefaultLinuxCapabilitiesChannel(), + Duration timeout = const Duration(milliseconds: 800), + @visibleForTesting LinuxSessionInfo? sessionOverride, + }) async { + if (!Platform.isLinux) { + _cache = LinuxCapabilities.unsupported; + _initialized = true; + return _cache; + } + + final session = sessionOverride ?? detectLinuxSession(); + final base = LinuxCapabilities.unsupported.copyWith().copyWithSession( + session, + ); + + if (!session.isX11) { + _cache = base; + _initialized = true; + return _cache; + } + + bool timedOut = false; + Map? shellCaps; + Map? listenerCaps; + + try { + final results = + await Future.wait([ + channel.invokeShell('getCapabilities').catchError((_) => null), + channel.invokeListener('getCapabilities').catchError((_) => null), + ]).timeout( + timeout, + onTimeout: () { + timedOut = true; + return [null, null]; + }, + ); + shellCaps = results[0]; + listenerCaps = results[1]; + } catch (e) { + AppLogger.warn('LinuxCapabilities.detect failed: $e'); + } + + final result = LinuxCapabilities( + session: session, + isX11: _readBool(shellCaps, 'isX11', fallback: true), + hasXTest: _readBool(listenerCaps, 'hasXTest'), + hasAppIndicator: _readBool(shellCaps, 'hasAppIndicator'), + hasEwmh: _readBool(shellCaps, 'hasEwmh'), + detectedDesktopEnv: _readString(shellCaps, 'desktopEnv'), + detectedWmName: _readString(shellCaps, 'wmName'), + detectionTimedOut: timedOut, + ); + + _cache = result; + _initialized = true; + return result; + } + + static bool _readBool( + Map? map, + String key, { + bool fallback = false, + }) { + if (map == null) return fallback; + final value = map[key]; + return value is bool ? value : fallback; + } + + static String _readString(Map? map, String key) { + if (map == null) return ''; + final value = map[key]; + return value is String ? value : ''; + } +} + +extension _LinuxCapabilitiesSession on LinuxCapabilities { + LinuxCapabilities copyWithSession(LinuxSessionInfo session) { + return LinuxCapabilities( + session: session, + isX11: isX11, + hasXTest: hasXTest, + hasAppIndicator: hasAppIndicator, + hasEwmh: hasEwmh, + detectedDesktopEnv: detectedDesktopEnv, + detectedWmName: detectedWmName, + detectionTimedOut: detectionTimedOut, + ); + } +} diff --git a/app/lib/services/linux_guard.dart b/app/lib/services/linux_guard.dart new file mode 100644 index 00000000..939e3c8f --- /dev/null +++ b/app/lib/services/linux_guard.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'linux_capabilities.dart'; + +class LinuxGuard { + const LinuxGuard._(); // coverage:ignore-line + + static LinuxCapabilities get _caps => LinuxCapabilitiesService.current; + + static bool get isLinux => Platform.isLinux; + static bool get isUsable => isLinux && _caps.isX11; + static bool get isWayland => isLinux && _caps.isWayland; + + static bool get canRegisterHotkey => isUsable && _caps.hasEwmh; + static bool get canPasteBack => isUsable && _caps.hasXTest; + static bool get canShowTray => isUsable && _caps.hasAppIndicator; + static bool get canAutostart => isUsable; + static bool get usesNativeWindowEffects => false; +} diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index af6851c0..47c3e7d8 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -1,445 +1,474 @@ -// coverage:ignore-file -import 'dart:ffi' hide Size; -import 'dart:io'; -import 'dart:ui' show Color, Offset, Size; - -import 'package:core/core.dart'; -import 'package:ffi/ffi.dart'; -import 'package:flutter_acrylic/flutter_acrylic.dart'; -import 'package:listener/listener.dart'; -import 'package:window_manager/window_manager.dart'; - -import 'linux_shell.dart'; - -typedef _SystemParametersInfoWNative = - Int32 Function( - Uint32 uiAction, - Uint32 uiParam, - Pointer lpvParam, - Uint32 fWinIni, - ); -typedef _SystemParametersInfoWDart = - int Function(int uiAction, int uiParam, Pointer lpvParam, int fWinIni); - -typedef _GetCursorPosNative = Int32 Function(Pointer lpPoint); -typedef _GetCursorPosDart = int Function(Pointer lpPoint); - -typedef _MonitorFromPointNative = - IntPtr Function(Int32 x, Int32 y, Uint32 dwFlags); -typedef _MonitorFromPointDart = int Function(int x, int y, int dwFlags); - -typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); -typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); - -class _Win32Pos { - _Win32Pos._(); - static _Win32Pos? _instance; - static _Win32Pos get instance => _instance ??= _Win32Pos._(); - - late final _u32 = DynamicLibrary.open('user32.dll'); - late final spiFunc = _u32 - .lookupFunction<_SystemParametersInfoWNative, _SystemParametersInfoWDart>( - 'SystemParametersInfoW', - ); - late final getCursorPosFunc = _u32 - .lookupFunction<_GetCursorPosNative, _GetCursorPosDart>('GetCursorPos'); - late final monitorFromPointFunc = _u32 - .lookupFunction<_MonitorFromPointNative, _MonitorFromPointDart>( - 'MonitorFromPoint', - ); - late final getMonitorInfoFunc = _u32 - .lookupFunction<_GetMonitorInfoWNative, _GetMonitorInfoWDart>( - 'GetMonitorInfoW', - ); -} - -class AppWindow { - AppWindow({ - this.onVisibilityChanged, - this.showInTaskbar = true, - double popupWidth = 360, - double popupHeight = 500, - }) : _popupWidth = popupWidth, - _popupHeight = popupHeight; - - bool showInTaskbar; - - static const double _settingsWidth = 820; - static const double _settingsHeight = 680; - - final void Function(bool visible)? onVisibilityChanged; - double _popupWidth; - double _popupHeight; - bool _visible = false; - bool _ready = false; - bool _settingsMode = false; - - bool get isVisible => _visible; - bool get isReady => _ready; - bool get isSettingsMode => _settingsMode; - - void updatePopupSize(double width, double height) { - _popupWidth = width; - _popupHeight = height; - } - - Future init({bool startVisible = false}) async { - AppLogger.info( - 'AppWindow.init: startVisible=$startVisible, ' - 'showInTaskbar=$showInTaskbar, ' - 'size=${_popupWidth}x$_popupHeight', - ); - try { - await windowManager - .waitUntilReadyToShow(null, () async { - await _configureWindow(startVisible); - }) - .timeout(const Duration(seconds: 5)); - AppLogger.info('AppWindow.init: waitUntilReadyToShow completed'); - } catch (e) { - AppLogger.warn( - 'AppWindow.init: waitUntilReadyToShow failed ($e), ' - 'attempting direct configuration', - ); - try { - await _configureWindow(startVisible); - AppLogger.info('AppWindow.init: direct configuration succeeded'); - } catch (e2) { - AppLogger.error('Window configuration failed: $e2'); - } - } - _visible = startVisible; - _ready = true; - AppLogger.info('AppWindow.init: done, ready=$_ready, visible=$_visible'); - } - - Future _configureWindow(bool startVisible) async { - await windowManager.setTitle('CopyPaste'); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setTitleBarStyle( - TitleBarStyle.hidden, - windowButtonVisibility: !Platform.isMacOS, - ); - await windowManager.setAlwaysOnTop(true); - await windowManager.setResizable(false); - await windowManager.setMaximizable(false); - await windowManager.setPreventClose(true); - final inTaskbar = showInTaskbar && Platform.isWindows; - await windowManager.setSkipTaskbar(!inTaskbar); - if (Platform.isWindows || Platform.isMacOS) { - await windowManager.setBackgroundColor(const Color(0x00000000)); - AppLogger.info('_configureWindow: applying initial effect'); - await applyEffect(); - } - if (startVisible) { - AppLogger.info('_configureWindow: centering and focusing'); - await windowManager.center(); - await windowManager.focus(); - } else if (inTaskbar) { - AppLogger.info('_configureWindow: minimizing to taskbar'); - await windowManager.minimize(); - } else { - AppLogger.info('_configureWindow: hiding window'); - await windowManager.hide(); - } - } - - bool _isDark = false; - - Future applyEffect({bool? dark}) async { - if (dark != null) _isDark = dark; - try { - if (Platform.isWindows) { - await Window.setEffect( - effect: WindowEffect.mica, - color: const Color(0x00000000), - dark: _isDark, - ).timeout(const Duration(seconds: 2)); - } else if (Platform.isMacOS) { - await Window.setEffect( - effect: WindowEffect.sidebar, - color: const Color(0x00000000), - dark: _isDark, - ).timeout(const Duration(seconds: 2)); - } - } catch (e) { - // Effect failure is non-fatal — app runs without the acrylic effect. - AppLogger.warn('applyEffect: window effect unavailable (non-fatal): $e'); - } - } - - Future _positionNearCursor() async { - if (Platform.isWindows) { - await _positionNearCursorWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { - await _positionNearCursorNative(); - } else { - await windowManager.center(); - } - } - - Future _positionNearCursorWindows() async { - try { - final cursor = _getCursorPosWin32(); - if (cursor == null) { - await windowManager.center(); - return; - } - final workArea = _getWorkAreaForPointWin32(cursor.$1, cursor.$2); - if (workArea == null) { - await windowManager.center(); - return; - } - await _applyPosition(cursor.$1, cursor.$2, workArea); - } catch (e) { - AppLogger.warn('_positionNearCursorWindows: fallback to center: $e'); - await windowManager.center(); - } - } - - Future _positionNearCursorNative() async { - try { - final info = await ClipboardWriter.getCursorAndScreenInfo(); - if (info == null) { - await windowManager.center(); - return; - } - final cursorX = info['cursorX'] ?? 0; - final cursorY = info['cursorY'] ?? 0; - final workArea = ( - info['waLeft'] ?? 0, - info['waTop'] ?? 0, - info['waRight'] ?? 1440, - info['waBottom'] ?? 900, - ); - await _applyPosition(cursorX, cursorY, workArea); - } catch (e) { - AppLogger.warn('_positionNearCursorNative: fallback to center: $e'); - await windowManager.center(); - } - } - - Future _applyPosition( - double cursorX, - double cursorY, - (double, double, double, double) workArea, - ) async { - final waLeft = workArea.$1; - final waTop = workArea.$2; - final waRight = workArea.$3; - final waBottom = workArea.$4; - - double x; - double y; - - if (cursorX + _popupWidth + 12 <= waRight) { - x = cursorX + 12; - } else if (cursorX - _popupWidth - 12 >= waLeft) { - x = cursorX - _popupWidth - 12; - } else { - x = waRight - _popupWidth - 12; - } - - y = cursorY - _popupHeight / 2; - if (y < waTop + 8) y = waTop + 8; - if (y + _popupHeight > waBottom - 8) y = waBottom - _popupHeight - 8; - - x = x.clamp(waLeft, waRight - _popupWidth); - y = y.clamp(waTop, waBottom - _popupHeight); - - await windowManager.setPosition(Offset(x, y)); - } - - static (double, double)? _getCursorPosWin32() { - final w = _Win32Pos.instance; - final pt = calloc(2); - try { - final result = w.getCursorPosFunc(pt); - if (result == 0) return null; - return (pt[0].toDouble(), pt[1].toDouble()); - } finally { - calloc.free(pt); - } - } - - static (double, double, double, double)? _getWorkAreaForPointWin32( - double x, - double y, - ) { - const monitorDefaultToNearest = 0x00000002; - final w = _Win32Pos.instance; - final hMonitor = w.monitorFromPointFunc( - x.toInt(), - y.toInt(), - monitorDefaultToNearest, - ); - if (hMonitor == 0) return _getWorkAreaWin32(); - - final mi = calloc(10); - try { - mi[0] = 40; - final result = w.getMonitorInfoFunc(hMonitor, mi); - if (result == 0) return _getWorkAreaWin32(); - return ( - mi[5].toDouble(), - mi[6].toDouble(), - mi[7].toDouble(), - mi[8].toDouble(), - ); - } finally { - calloc.free(mi); - } - } - - static (double, double, double, double)? _getWorkAreaWin32() { - const spiGetWorkArea = 0x0030; - final w = _Win32Pos.instance; - final rect = calloc(4); - try { - final result = w.spiFunc(spiGetWorkArea, 0, rect, 0); - if (result == 0) return null; - return ( - rect[0].toDouble(), - rect[1].toDouble(), - rect[2].toDouble(), - rect[3].toDouble(), - ); - } finally { - calloc.free(rect); - } - } - - Future show() async { - AppLogger.info('AppWindow.show: starting'); - if (Platform.isLinux) { - // On X11/GTK, show the window first (so it gets realized/mapped by the WM), - // then set the position (avoids WM initial-placement overriding our offset), - // then focus via gtk_window_present_with_time so GNOME doesn't block focus - // and show a spurious "está preparado" notification. - await windowManager.setSkipTaskbar(false); - await windowManager.show(); - await _positionNearCursor(); - await LinuxShell.focusWindow(); - } else { - await _positionNearCursor(); - if (Platform.isWindows) { - await windowManager.setSkipTaskbar(false); - } - await windowManager.show(); - await windowManager.focus(); - AppLogger.info('AppWindow.show: window shown and focused'); - if (Platform.isWindows) { - await applyEffect(); - } - } - _visible = true; - onVisibilityChanged?.call(true); - } - - Future hide() async { - if (!_visible) return; - _visible = false; - if (showInTaskbar && Platform.isWindows) { - await windowManager.minimize(); - } else { - await windowManager.hide(); - if (!Platform.isMacOS) { - await windowManager.setSkipTaskbar(true); - } - } - onVisibilityChanged?.call(false); - } - - Future toggle() async { - if (_visible) { - await hide(); - } else { - await show(); - } - } - - Future hideIfNotPinned() async { - if (_visible && !_settingsMode) { - await hide(); - } - } - - Future enterSettingsMode() async { - _settingsMode = true; - await windowManager.setResizable(true); - // GTK processes geometry hints asynchronously — wait one frame before - // applying new constraints so the WM doesn't reject the resize. - if (Platform.isLinux) { - await Future.delayed(const Duration(milliseconds: 50)); - } - await windowManager.setMinimumSize( - const Size(_settingsWidth, _settingsHeight), - ); - await windowManager.setMaximumSize(const Size(1200, 900)); - await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); - await windowManager.center(); - // Settings mode implies the window must be visible and focused. Without - // this, transitioning from gate/onboarding (which hides the window on - // exit) leaves Settings invisible behind other windows. - if (!await windowManager.isVisible()) { - await windowManager.show(); - } - await windowManager.focus(); - _visible = true; - } - - Future exitSettingsMode() async { - _settingsMode = false; - // On Linux the window may still be in resizable=true state from settings - // mode. Reset it explicitly and wait for GTK to process before resizing. - if (Platform.isLinux) { - await windowManager.setResizable(true); - await Future.delayed(const Duration(milliseconds: 50)); - } - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - // Wait for GTK to process the resize before locking with setResizable(false). - // Without this delay the WM may freeze the window at the old (large) size. - if (Platform.isLinux) { - await Future.delayed(const Duration(milliseconds: 100)); - } - await windowManager.setResizable(false); - await _positionNearCursor(); - } - - static const double _gateWidth = 480; - static const double _gateHeight = 540; - - bool _gateMode = false; - bool get isGateMode => _gateMode; - - Future enterGateMode() async { - AppLogger.info('AppWindow.enterGateMode: starting'); - _gateMode = true; - await windowManager.setResizable(false); - await windowManager.setMinimumSize(const Size(_gateWidth, _gateHeight)); - await windowManager.setMaximumSize(const Size(_gateWidth, _gateHeight)); - await windowManager.setSize(const Size(_gateWidth, _gateHeight)); - await windowManager.setAlwaysOnTop(false); - await windowManager.setSkipTaskbar(false); - await windowManager.center(); - await windowManager.show(); - await windowManager.focus(); - _visible = true; - AppLogger.info('AppWindow.enterGateMode: done'); - } - - Future exitGateMode() async { - _gateMode = false; - await windowManager.setAlwaysOnTop(true); - await windowManager.setSkipTaskbar(!(showInTaskbar && Platform.isWindows)); - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - await windowManager.hide(); - _visible = false; - } -} +// coverage:ignore-file +import 'dart:ffi' hide Size; +import 'dart:io'; +import 'dart:ui' show Color, Offset, Size; + +import 'package:core/core.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_acrylic/flutter_acrylic.dart'; +import 'package:listener/listener.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'linux_shell.dart'; + +typedef _SystemParametersInfoWNative = + Int32 Function( + Uint32 uiAction, + Uint32 uiParam, + Pointer lpvParam, + Uint32 fWinIni, + ); +typedef _SystemParametersInfoWDart = + int Function(int uiAction, int uiParam, Pointer lpvParam, int fWinIni); + +typedef _GetCursorPosNative = Int32 Function(Pointer lpPoint); +typedef _GetCursorPosDart = int Function(Pointer lpPoint); + +typedef _MonitorFromPointNative = + IntPtr Function(Int32 x, Int32 y, Uint32 dwFlags); +typedef _MonitorFromPointDart = int Function(int x, int y, int dwFlags); + +typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); +typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); + +class _Win32Pos { + _Win32Pos._(); + static _Win32Pos? _instance; + static _Win32Pos get instance => _instance ??= _Win32Pos._(); + + late final _u32 = DynamicLibrary.open('user32.dll'); + late final spiFunc = _u32 + .lookupFunction<_SystemParametersInfoWNative, _SystemParametersInfoWDart>( + 'SystemParametersInfoW', + ); + late final getCursorPosFunc = _u32 + .lookupFunction<_GetCursorPosNative, _GetCursorPosDart>('GetCursorPos'); + late final monitorFromPointFunc = _u32 + .lookupFunction<_MonitorFromPointNative, _MonitorFromPointDart>( + 'MonitorFromPoint', + ); + late final getMonitorInfoFunc = _u32 + .lookupFunction<_GetMonitorInfoWNative, _GetMonitorInfoWDart>( + 'GetMonitorInfoW', + ); +} + +class AppWindow { + AppWindow({ + this.onVisibilityChanged, + this.showInTaskbar = true, + double popupWidth = 360, + double popupHeight = 500, + }) : _popupWidth = popupWidth, + _popupHeight = popupHeight; + + bool showInTaskbar; + + static const double _settingsWidth = 820; + static const double _settingsHeight = 680; + + final void Function(bool visible)? onVisibilityChanged; + double _popupWidth; + double _popupHeight; + bool _visible = false; + bool _ready = false; + bool _settingsMode = false; + + bool get isVisible => _visible; + bool get isReady => _ready; + bool get isSettingsMode => _settingsMode; + + void updatePopupSize(double width, double height) { + _popupWidth = width; + _popupHeight = height; + } + + Future init({bool startVisible = false}) async { + AppLogger.info( + 'AppWindow.init: startVisible=$startVisible, ' + 'showInTaskbar=$showInTaskbar, ' + 'size=${_popupWidth}x$_popupHeight', + ); + try { + await windowManager + .waitUntilReadyToShow(null, () async { + await _configureWindow(startVisible); + }) + .timeout(const Duration(seconds: 5)); + AppLogger.info('AppWindow.init: waitUntilReadyToShow completed'); + } catch (e) { + AppLogger.warn( + 'AppWindow.init: waitUntilReadyToShow failed ($e), ' + 'attempting direct configuration', + ); + try { + await _configureWindow(startVisible); + AppLogger.info('AppWindow.init: direct configuration succeeded'); + } catch (e2) { + AppLogger.error('Window configuration failed: $e2'); + } + } + _visible = startVisible; + _ready = true; + AppLogger.info('AppWindow.init: done, ready=$_ready, visible=$_visible'); + } + + Future _configureWindow(bool startVisible) async { + await windowManager.setTitle('CopyPaste'); + await windowManager.setSize(Size(_popupWidth, _popupHeight)); + await windowManager.setMinimumSize(Size(_popupWidth, 400)); + await windowManager.setMaximumSize(Size(_popupWidth, 900)); + await windowManager.setTitleBarStyle( + TitleBarStyle.hidden, + windowButtonVisibility: !Platform.isMacOS, + ); + await windowManager.setAlwaysOnTop(true); + await windowManager.setResizable(false); + await windowManager.setMaximizable(false); + await windowManager.setPreventClose(true); + final inTaskbar = showInTaskbar && Platform.isWindows; + await windowManager.setSkipTaskbar(!inTaskbar); + if (Platform.isWindows || Platform.isMacOS) { + await windowManager.setBackgroundColor(const Color(0x00000000)); + AppLogger.info('_configureWindow: applying initial effect'); + await applyEffect(); + } + if (startVisible) { + AppLogger.info('_configureWindow: centering and focusing'); + await windowManager.center(); + await windowManager.focus(); + } else if (inTaskbar) { + AppLogger.info('_configureWindow: minimizing to taskbar'); + await windowManager.minimize(); + } else { + AppLogger.info('_configureWindow: hiding window'); + await windowManager.hide(); + } + } + + bool _isDark = false; + + Future applyEffect({bool? dark}) async { + if (dark != null) _isDark = dark; + try { + if (Platform.isWindows) { + await Window.setEffect( + effect: WindowEffect.mica, + color: const Color(0x00000000), + dark: _isDark, + ).timeout(const Duration(seconds: 2)); + } else if (Platform.isMacOS) { + await Window.setEffect( + effect: WindowEffect.sidebar, + color: const Color(0x00000000), + dark: _isDark, + ).timeout(const Duration(seconds: 2)); + } + } catch (e) { + AppLogger.warn('applyEffect: window effect unavailable (non-fatal): $e'); + } + } + + Future _positionNearCursor() async { + if (Platform.isWindows) { + await _positionNearCursorWindows(); + } else if (Platform.isLinux) { + await _positionNearCursorLinux(); + } else if (Platform.isMacOS) { + await _positionNearCursorNative(); + } else { + await windowManager.center(); + } + } + + Future _positionNearCursorLinux() async { + try { + final info = await LinuxShell.getCursorMonitor(); + if (info == null) { + await _positionNearCursorNative(); + return; + } + final workArea = ( + info.x, + info.y, + info.x + info.width, + info.y + info.height, + ); + await _applyPosition(info.cursorX, info.cursorY, workArea); + } catch (e) { + AppLogger.warn('_positionNearCursorLinux: fallback to native: $e'); + await _positionNearCursorNative(); + } + } + + Future _positionNearCursorWindows() async { + try { + final cursor = _getCursorPosWin32(); + if (cursor == null) { + await windowManager.center(); + return; + } + final workArea = _getWorkAreaForPointWin32(cursor.$1, cursor.$2); + if (workArea == null) { + await windowManager.center(); + return; + } + await _applyPosition(cursor.$1, cursor.$2, workArea); + } catch (e) { + AppLogger.warn('_positionNearCursorWindows: fallback to center: $e'); + await windowManager.center(); + } + } + + Future _positionNearCursorNative() async { + try { + final info = await ClipboardWriter.getCursorAndScreenInfo(); + if (info == null) { + await windowManager.center(); + return; + } + final cursorX = info['cursorX'] ?? 0; + final cursorY = info['cursorY'] ?? 0; + final workArea = ( + info['waLeft'] ?? 0, + info['waTop'] ?? 0, + info['waRight'] ?? 1440, + info['waBottom'] ?? 900, + ); + await _applyPosition(cursorX, cursorY, workArea); + } catch (e) { + AppLogger.warn('_positionNearCursorNative: fallback to center: $e'); + await windowManager.center(); + } + } + + Future _applyPosition( + double cursorX, + double cursorY, + (double, double, double, double) workArea, + ) async { + final waLeft = workArea.$1; + final waTop = workArea.$2; + final waRight = workArea.$3; + final waBottom = workArea.$4; + + double x; + double y; + + if (cursorX + _popupWidth + 12 <= waRight) { + x = cursorX + 12; + } else if (cursorX - _popupWidth - 12 >= waLeft) { + x = cursorX - _popupWidth - 12; + } else { + x = waRight - _popupWidth - 12; + } + + y = cursorY - _popupHeight / 2; + if (y < waTop + 8) y = waTop + 8; + if (y + _popupHeight > waBottom - 8) y = waBottom - _popupHeight - 8; + + x = x.clamp(waLeft, waRight - _popupWidth); + y = y.clamp(waTop, waBottom - _popupHeight); + + await windowManager.setPosition(Offset(x, y)); + } + + static (double, double)? _getCursorPosWin32() { + final w = _Win32Pos.instance; + final pt = calloc(2); + try { + final result = w.getCursorPosFunc(pt); + if (result == 0) return null; + return (pt[0].toDouble(), pt[1].toDouble()); + } finally { + calloc.free(pt); + } + } + + static (double, double, double, double)? _getWorkAreaForPointWin32( + double x, + double y, + ) { + const monitorDefaultToNearest = 0x00000002; + final w = _Win32Pos.instance; + final hMonitor = w.monitorFromPointFunc( + x.toInt(), + y.toInt(), + monitorDefaultToNearest, + ); + if (hMonitor == 0) return _getWorkAreaWin32(); + + final mi = calloc(10); + try { + mi[0] = 40; + final result = w.getMonitorInfoFunc(hMonitor, mi); + if (result == 0) return _getWorkAreaWin32(); + return ( + mi[5].toDouble(), + mi[6].toDouble(), + mi[7].toDouble(), + mi[8].toDouble(), + ); + } finally { + calloc.free(mi); + } + } + + static (double, double, double, double)? _getWorkAreaWin32() { + const spiGetWorkArea = 0x0030; + final w = _Win32Pos.instance; + final rect = calloc(4); + try { + final result = w.spiFunc(spiGetWorkArea, 0, rect, 0); + if (result == 0) return null; + return ( + rect[0].toDouble(), + rect[1].toDouble(), + rect[2].toDouble(), + rect[3].toDouble(), + ); + } finally { + calloc.free(rect); + } + } + + Future show() async { + AppLogger.info('AppWindow.show: starting'); + if (Platform.isLinux) { + await windowManager.setSkipTaskbar(false); + await windowManager.show(); + await _positionNearCursor(); + await LinuxShell.focusWindow(); + } else { + await _positionNearCursor(); + if (Platform.isWindows) { + await windowManager.setSkipTaskbar(false); + } + await windowManager.show(); + await windowManager.focus(); + AppLogger.info('AppWindow.show: window shown and focused'); + if (Platform.isWindows) { + await applyEffect(); + } + } + _visible = true; + onVisibilityChanged?.call(true); + } + + Future hide() async { + if (!_visible) return; + _visible = false; + if (showInTaskbar && Platform.isWindows) { + await windowManager.minimize(); + } else { + Future? unmappedFuture; + if (Platform.isLinux) { + unmappedFuture = LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(milliseconds: 300), + ); + } + await windowManager.hide(); + if (!Platform.isMacOS) { + await windowManager.setSkipTaskbar(true); + } + if (unmappedFuture != null) { + await unmappedFuture; + } + } + onVisibilityChanged?.call(false); + } + + Future toggle() async { + if (_visible) { + await hide(); + } else { + await show(); + } + } + + Future hideIfNotPinned() async { + if (_visible && !_settingsMode) { + await hide(); + } + } + + Future enterSettingsMode() async { + _settingsMode = true; + await windowManager.setResizable(true); + Future? configureFuture; + if (Platform.isLinux) { + configureFuture = LinuxShell.awaitEvent( + 'configureNotify', + timeout: const Duration(milliseconds: 250), + ); + } + await windowManager.setMinimumSize( + const Size(_settingsWidth, _settingsHeight), + ); + await windowManager.setMaximumSize(const Size(1200, 900)); + await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); + if (configureFuture != null) { + await configureFuture; + } + await windowManager.center(); + if (!await windowManager.isVisible()) { + await windowManager.show(); + } + await windowManager.focus(); + _visible = true; + } + + Future exitSettingsMode() async { + _settingsMode = false; + Future? configureFuture; + if (Platform.isLinux) { + await windowManager.setResizable(true); + configureFuture = LinuxShell.awaitEvent( + 'configureNotify', + timeout: const Duration(milliseconds: 250), + ); + } + await windowManager.setMinimumSize(Size(_popupWidth, 400)); + await windowManager.setMaximumSize(Size(_popupWidth, 900)); + await windowManager.setSize(Size(_popupWidth, _popupHeight)); + if (configureFuture != null) { + await configureFuture; + } + await windowManager.setResizable(false); + await _positionNearCursor(); + } + + static const double _gateWidth = 480; + static const double _gateHeight = 540; + + bool _gateMode = false; + bool get isGateMode => _gateMode; + + Future enterGateMode() async { + AppLogger.info('AppWindow.enterGateMode: starting'); + _gateMode = true; + await windowManager.setResizable(false); + await windowManager.setMinimumSize(const Size(_gateWidth, _gateHeight)); + await windowManager.setMaximumSize(const Size(_gateWidth, _gateHeight)); + await windowManager.setSize(const Size(_gateWidth, _gateHeight)); + await windowManager.setAlwaysOnTop(false); + await windowManager.setSkipTaskbar(false); + await windowManager.center(); + await windowManager.show(); + await windowManager.focus(); + _visible = true; + AppLogger.info('AppWindow.enterGateMode: done'); + } + + Future exitGateMode() async { + _gateMode = false; + await windowManager.setAlwaysOnTop(true); + await windowManager.setSkipTaskbar(!(showInTaskbar && Platform.isWindows)); + await windowManager.setMinimumSize(Size(_popupWidth, 400)); + await windowManager.setMaximumSize(Size(_popupWidth, 900)); + await windowManager.setSize(Size(_popupWidth, _popupHeight)); + await windowManager.hide(); + _visible = false; + } +} diff --git a/app/lib/shell/desktop_notifier.dart b/app/lib/shell/desktop_notifier.dart new file mode 100644 index 00000000..9d328b3b --- /dev/null +++ b/app/lib/shell/desktop_notifier.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'windows_balloon.dart'; + +/// Cross-platform desktop notification helper for tray balloons. +/// +/// Routes to the most idiomatic native channel per OS: +/// - Windows → `WindowsBalloon` (Shell_NotifyIconW via FFI). +/// - Linux → `notify-send` (libnotify CLI shipped on every desktop; +/// talks D-Bus to `org.freedesktop.Notifications`, which all +/// modern DEs implement: GNOME Shell, KDE Plasma, Xfce…). +/// - macOS → no-op (Mac uses dock badges + window UI; balloons would +/// collide with the system Notification Center conventions). +/// +/// Always returns a Future that completes — never throws. +class DesktopNotifier { + DesktopNotifier._(); + + /// Injectable process runner. Override in tests to avoid spawning real + /// system processes. + @visibleForTesting + static Future Function(String, List)? + processRunnerOverride; + + /// Shows a transient notification with [title] and [body]. + /// Returns true when the platform layer accepted the request. + static Future show({ + required String title, + required String body, + }) async { + if (Platform.isWindows) { + return WindowsBalloon.show(title: title, body: body); + } + if (Platform.isLinux) { + return _showLinux(title: title, body: body); + } + return false; + } + + /// Spawns `notify-send` to push a notification through D-Bus + /// (`org.freedesktop.Notifications`). Silent on systems without it. + /// + /// Flags: + /// --app-name=CopyPaste → grouping / branding in the shell. + /// --icon=copypaste → DE looks up the icon by name in the theme; + /// falls back gracefully if not installed. + /// --expire-time=7000 → matches Windows balloon dismiss window. + static Future _showLinux({ + required String title, + required String body, + }) async { + final runner = processRunnerOverride ?? Process.run; + try { + final result = await runner('notify-send', [ + '--app-name=CopyPaste', + '--icon=copypaste', + '--expire-time=7000', + title, + body, + ]); + return result.exitCode == 0; + } on ProcessException { + // notify-send not installed (rare; ships with libnotify-bin). + return false; + } catch (_) { + return false; + } + } +} diff --git a/app/lib/shell/focus_manager.dart b/app/lib/shell/focus_manager.dart index 63ead254..40ad1021 100644 --- a/app/lib/shell/focus_manager.dart +++ b/app/lib/shell/focus_manager.dart @@ -1,225 +1,229 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; -import 'package:listener/listener.dart'; - -typedef _GetForegroundWindowNative = IntPtr Function(); -typedef _GetForegroundWindowDart = int Function(); - -typedef _IsWindowNative = Int32 Function(IntPtr hWnd); -typedef _IsWindowDart = int Function(int hWnd); - -typedef _IsWindowVisibleNative = Int32 Function(IntPtr hWnd); -typedef _IsWindowVisibleDart = int Function(int hWnd); - -typedef _SetForegroundWindowNative = Int32 Function(IntPtr hWnd); -typedef _SetForegroundWindowDart = int Function(int hWnd); - -typedef _BringWindowToTopNative = Int32 Function(IntPtr hWnd); -typedef _BringWindowToTopDart = int Function(int hWnd); - -typedef _ShowWindowNative = Int32 Function(IntPtr hWnd, Int32 nCmdShow); -typedef _ShowWindowDart = int Function(int hWnd, int nCmdShow); - -typedef _GetWindowLongPtrNative = IntPtr Function(IntPtr hWnd, Int32 nIndex); -typedef _GetWindowLongPtrDart = int Function(int hWnd, int nIndex); - -typedef _GetWindowThreadProcessIdNative = - Uint32 Function(IntPtr hWnd, Pointer lpdwProcessId); -typedef _GetWindowThreadProcessIdDart = - int Function(int hWnd, Pointer lpdwProcessId); - -typedef _GetCurrentThreadIdNative = Uint32 Function(); -typedef _GetCurrentThreadIdDart = int Function(); - -typedef _AttachThreadInputNative = - Int32 Function(Uint32 idAttach, Uint32 idAttachTo, Int32 fAttach); -typedef _AttachThreadInputDart = - int Function(int idAttach, int idAttachTo, int fAttach); - -typedef _KeybdEventNative = - Void Function(Uint8 bVk, Uint8 bScan, Uint32 dwFlags, IntPtr dwExtraInfo); -typedef _KeybdEventDart = - void Function(int bVk, int bScan, int dwFlags, int dwExtraInfo); - -class _Win32 { - _Win32._() { - assert(Platform.isWindows, '_Win32 requires Windows'); - } - static _Win32? _instance; - static _Win32 get instance => _instance ??= _Win32._(); - - static const int swRestore = 9; - static const int gwlStyle = -16; - static const int wsMinimize = 0x20000000; - static const int keyeventfKeyup = 0x0002; - static const int vkControl = 0x11; - static const int vkV = 0x56; - - late final _user32 = DynamicLibrary.open('user32.dll'); - late final _kernel32 = DynamicLibrary.open('kernel32.dll'); - - late final getForegroundWindow = _user32 - .lookupFunction<_GetForegroundWindowNative, _GetForegroundWindowDart>( - 'GetForegroundWindow', - ); - late final isWindow = _user32.lookupFunction<_IsWindowNative, _IsWindowDart>( - 'IsWindow', - ); - late final isWindowVisible = _user32 - .lookupFunction<_IsWindowVisibleNative, _IsWindowVisibleDart>( - 'IsWindowVisible', - ); - late final setForegroundWindow = _user32 - .lookupFunction<_SetForegroundWindowNative, _SetForegroundWindowDart>( - 'SetForegroundWindow', - ); - late final bringWindowToTop = _user32 - .lookupFunction<_BringWindowToTopNative, _BringWindowToTopDart>( - 'BringWindowToTop', - ); - late final showWindow = _user32 - .lookupFunction<_ShowWindowNative, _ShowWindowDart>('ShowWindow'); - late final getWindowLongPtr = _user32 - .lookupFunction<_GetWindowLongPtrNative, _GetWindowLongPtrDart>( - 'GetWindowLongPtrW', - ); - late final getWindowThreadProcessId = _user32 - .lookupFunction< - _GetWindowThreadProcessIdNative, - _GetWindowThreadProcessIdDart - >('GetWindowThreadProcessId'); - late final getCurrentThreadId = _kernel32 - .lookupFunction<_GetCurrentThreadIdNative, _GetCurrentThreadIdDart>( - 'GetCurrentThreadId', - ); - late final attachThreadInput = _user32 - .lookupFunction<_AttachThreadInputNative, _AttachThreadInputDart>( - 'AttachThreadInput', - ); - late final keybdEvent = _user32 - .lookupFunction<_KeybdEventNative, _KeybdEventDart>('keybd_event'); -} - -class WindowFocusManager { - int _previousWindow = 0; - int _previousThreadId = 0; - String? _previousBundleId; - - Future capturePreviousWindow() async { - if (Platform.isWindows) { - _capturePreviousWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { - _previousBundleId = await ClipboardWriter.captureFrontmostApp(); - } - } - - Future restoreAndPaste({ - required int delayBeforeFocusMs, - required int maxFocusVerifyAttempts, - required int delayBeforePasteMs, - }) async { - if (Platform.isWindows && _previousWindow == 0) return; - if ((Platform.isMacOS || Platform.isLinux) && _previousBundleId == null) { - return; - } - - try { - await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); - - if (Platform.isMacOS || Platform.isLinux) { - await ClipboardWriter.activateAndPaste( - bundleId: _previousBundleId!, - delayMs: delayBeforePasteMs, - ); - return; - } - - if (!_restorePreviousWindows()) return; - - final focused = await _waitForFocusWindows(maxFocusVerifyAttempts); - if (!focused) { - await Future.delayed(Duration(milliseconds: delayBeforePasteMs)); - } else { - await Future.delayed(const Duration(milliseconds: 30)); - } - - _simulatePasteWindows(); - } finally { - clear(); - } - } - - void clear() { - _previousWindow = 0; - _previousThreadId = 0; - _previousBundleId = null; - } - - void _capturePreviousWindows() { - final w = _Win32.instance; - final hwnd = w.getForegroundWindow(); - if (hwnd != 0 && w.isWindow(hwnd) != 0 && w.isWindowVisible(hwnd) != 0) { - _previousWindow = hwnd; - final pidPtr = calloc(); - try { - _previousThreadId = w.getWindowThreadProcessId(hwnd, pidPtr); - } finally { - calloc.free(pidPtr); - } - } else { - _previousWindow = 0; - _previousThreadId = 0; - } - } - - bool _restorePreviousWindows() { - if (_previousWindow == 0) return false; - final w = _Win32.instance; - if (w.isWindow(_previousWindow) == 0) { - _previousWindow = 0; - return false; - } - - final currentThreadId = w.getCurrentThreadId(); - var attached = false; - - if (currentThreadId != _previousThreadId && _previousThreadId != 0) { - attached = - w.attachThreadInput(currentThreadId, _previousThreadId, 1) != 0; - } - - try { - final style = w.getWindowLongPtr(_previousWindow, _Win32.gwlStyle); - if (style & _Win32.wsMinimize != 0) { - w.showWindow(_previousWindow, _Win32.swRestore); - } - - w.bringWindowToTop(_previousWindow); - return w.setForegroundWindow(_previousWindow) != 0; - } finally { - if (attached) { - w.attachThreadInput(currentThreadId, _previousThreadId, 0); - } - } - } - - Future _waitForFocusWindows(int maxAttempts) async { - final w = _Win32.instance; - for (var i = 0; i < maxAttempts; i++) { - if (w.getForegroundWindow() == _previousWindow) return true; - await Future.delayed(const Duration(milliseconds: 10)); - } - return false; - } - - void _simulatePasteWindows() { - final w = _Win32.instance; - w.keybdEvent(_Win32.vkControl, 0, 0, 0); - w.keybdEvent(_Win32.vkV, 0, 0, 0); - w.keybdEvent(_Win32.vkV, 0, _Win32.keyeventfKeyup, 0); - w.keybdEvent(_Win32.vkControl, 0, _Win32.keyeventfKeyup, 0); - } -} +// coverage:ignore-file +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:listener/listener.dart'; + +typedef _GetForegroundWindowNative = IntPtr Function(); +typedef _GetForegroundWindowDart = int Function(); + +typedef _IsWindowNative = Int32 Function(IntPtr hWnd); +typedef _IsWindowDart = int Function(int hWnd); + +typedef _IsWindowVisibleNative = Int32 Function(IntPtr hWnd); +typedef _IsWindowVisibleDart = int Function(int hWnd); + +typedef _SetForegroundWindowNative = Int32 Function(IntPtr hWnd); +typedef _SetForegroundWindowDart = int Function(int hWnd); + +typedef _BringWindowToTopNative = Int32 Function(IntPtr hWnd); +typedef _BringWindowToTopDart = int Function(int hWnd); + +typedef _ShowWindowNative = Int32 Function(IntPtr hWnd, Int32 nCmdShow); +typedef _ShowWindowDart = int Function(int hWnd, int nCmdShow); + +typedef _GetWindowLongPtrNative = IntPtr Function(IntPtr hWnd, Int32 nIndex); +typedef _GetWindowLongPtrDart = int Function(int hWnd, int nIndex); + +typedef _GetWindowThreadProcessIdNative = + Uint32 Function(IntPtr hWnd, Pointer lpdwProcessId); +typedef _GetWindowThreadProcessIdDart = + int Function(int hWnd, Pointer lpdwProcessId); + +typedef _GetCurrentThreadIdNative = Uint32 Function(); +typedef _GetCurrentThreadIdDart = int Function(); + +typedef _AttachThreadInputNative = + Int32 Function(Uint32 idAttach, Uint32 idAttachTo, Int32 fAttach); +typedef _AttachThreadInputDart = + int Function(int idAttach, int idAttachTo, int fAttach); + +typedef _KeybdEventNative = + Void Function(Uint8 bVk, Uint8 bScan, Uint32 dwFlags, IntPtr dwExtraInfo); +typedef _KeybdEventDart = + void Function(int bVk, int bScan, int dwFlags, int dwExtraInfo); + +class _Win32 { + _Win32._() { + assert(Platform.isWindows, '_Win32 requires Windows'); + } + static _Win32? _instance; + static _Win32 get instance => _instance ??= _Win32._(); + + static const int swRestore = 9; + static const int gwlStyle = -16; + static const int wsMinimize = 0x20000000; + static const int keyeventfKeyup = 0x0002; + static const int vkControl = 0x11; + static const int vkV = 0x56; + + late final _user32 = DynamicLibrary.open('user32.dll'); + late final _kernel32 = DynamicLibrary.open('kernel32.dll'); + + late final getForegroundWindow = _user32 + .lookupFunction<_GetForegroundWindowNative, _GetForegroundWindowDart>( + 'GetForegroundWindow', + ); + late final isWindow = _user32.lookupFunction<_IsWindowNative, _IsWindowDart>( + 'IsWindow', + ); + late final isWindowVisible = _user32 + .lookupFunction<_IsWindowVisibleNative, _IsWindowVisibleDart>( + 'IsWindowVisible', + ); + late final setForegroundWindow = _user32 + .lookupFunction<_SetForegroundWindowNative, _SetForegroundWindowDart>( + 'SetForegroundWindow', + ); + late final bringWindowToTop = _user32 + .lookupFunction<_BringWindowToTopNative, _BringWindowToTopDart>( + 'BringWindowToTop', + ); + late final showWindow = _user32 + .lookupFunction<_ShowWindowNative, _ShowWindowDart>('ShowWindow'); + late final getWindowLongPtr = _user32 + .lookupFunction<_GetWindowLongPtrNative, _GetWindowLongPtrDart>( + 'GetWindowLongPtrW', + ); + late final getWindowThreadProcessId = _user32 + .lookupFunction< + _GetWindowThreadProcessIdNative, + _GetWindowThreadProcessIdDart + >('GetWindowThreadProcessId'); + late final getCurrentThreadId = _kernel32 + .lookupFunction<_GetCurrentThreadIdNative, _GetCurrentThreadIdDart>( + 'GetCurrentThreadId', + ); + late final attachThreadInput = _user32 + .lookupFunction<_AttachThreadInputNative, _AttachThreadInputDart>( + 'AttachThreadInput', + ); + late final keybdEvent = _user32 + .lookupFunction<_KeybdEventNative, _KeybdEventDart>('keybd_event'); +} + +class WindowFocusManager { + int _previousWindow = 0; + int _previousThreadId = 0; + String? _previousBundleId; + + Future capturePreviousWindow() async { + if (Platform.isWindows) { + _capturePreviousWindows(); + } else if (Platform.isMacOS || Platform.isLinux) { + _previousBundleId = await ClipboardWriter.captureFrontmostApp(); + } + } + + Future restoreAndPaste({ + required int delayBeforeFocusMs, + required int maxFocusVerifyAttempts, + required int delayBeforePasteMs, + }) async { + if (Platform.isWindows && _previousWindow == 0) { + return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); + } + if ((Platform.isMacOS || Platform.isLinux) && _previousBundleId == null) { + return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); + } + + try { + await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); + + if (Platform.isMacOS || Platform.isLinux) { + return await ClipboardWriter.activateAndPaste( + bundleId: _previousBundleId!, + delayMs: delayBeforePasteMs, + ); + } + + if (!_restorePreviousWindows()) { + return const PasteResponse(success: false, errorCode: 'restoreFailed'); + } + + final focused = await _waitForFocusWindows(maxFocusVerifyAttempts); + if (!focused) { + await Future.delayed(Duration(milliseconds: delayBeforePasteMs)); + } else { + await Future.delayed(const Duration(milliseconds: 30)); + } + + _simulatePasteWindows(); + return const PasteResponse(success: true); + } finally { + clear(); + } + } + + void clear() { + _previousWindow = 0; + _previousThreadId = 0; + _previousBundleId = null; + } + + void _capturePreviousWindows() { + final w = _Win32.instance; + final hwnd = w.getForegroundWindow(); + if (hwnd != 0 && w.isWindow(hwnd) != 0 && w.isWindowVisible(hwnd) != 0) { + _previousWindow = hwnd; + final pidPtr = calloc(); + try { + _previousThreadId = w.getWindowThreadProcessId(hwnd, pidPtr); + } finally { + calloc.free(pidPtr); + } + } else { + _previousWindow = 0; + _previousThreadId = 0; + } + } + + bool _restorePreviousWindows() { + if (_previousWindow == 0) return false; + final w = _Win32.instance; + if (w.isWindow(_previousWindow) == 0) { + _previousWindow = 0; + return false; + } + + final currentThreadId = w.getCurrentThreadId(); + var attached = false; + + if (currentThreadId != _previousThreadId && _previousThreadId != 0) { + attached = + w.attachThreadInput(currentThreadId, _previousThreadId, 1) != 0; + } + + try { + final style = w.getWindowLongPtr(_previousWindow, _Win32.gwlStyle); + if (style & _Win32.wsMinimize != 0) { + w.showWindow(_previousWindow, _Win32.swRestore); + } + + w.bringWindowToTop(_previousWindow); + return w.setForegroundWindow(_previousWindow) != 0; + } finally { + if (attached) { + w.attachThreadInput(currentThreadId, _previousThreadId, 0); + } + } + } + + Future _waitForFocusWindows(int maxAttempts) async { + final w = _Win32.instance; + for (var i = 0; i < maxAttempts; i++) { + if (w.getForegroundWindow() == _previousWindow) return true; + await Future.delayed(const Duration(milliseconds: 10)); + } + return false; + } + + void _simulatePasteWindows() { + final w = _Win32.instance; + w.keybdEvent(_Win32.vkControl, 0, 0, 0); + w.keybdEvent(_Win32.vkV, 0, 0, 0); + w.keybdEvent(_Win32.vkV, 0, _Win32.keyeventfKeyup, 0); + w.keybdEvent(_Win32.vkControl, 0, _Win32.keyeventfKeyup, 0); + } +} diff --git a/app/lib/shell/linux_hotkey_registration.dart b/app/lib/shell/linux_hotkey_registration.dart index 2a165323..0e49cb8e 100644 --- a/app/lib/shell/linux_hotkey_registration.dart +++ b/app/lib/shell/linux_hotkey_registration.dart @@ -1,129 +1,215 @@ -import 'package:flutter/foundation.dart'; - -import 'linux_shell.dart'; - -enum HotkeyRegistrationStatus { registered, fallbackRegistered, failed } - -@immutable -class HotkeyBinding { - const HotkeyBinding({ - required this.virtualKey, - required this.keyName, - required this.useCtrl, - required this.useWin, - required this.useAlt, - required this.useShift, - }); - - final int virtualKey; - final String keyName; - final bool useCtrl; - final bool useWin; - final bool useAlt; - final bool useShift; - - String label({bool isMac = false}) { - final parts = []; - if (useCtrl) parts.add('Ctrl'); - if (useWin) parts.add(isMac ? 'Cmd' : 'Win'); - if (useAlt) parts.add(isMac ? 'Option' : 'Alt'); - if (useShift) parts.add('Shift'); - parts.add(keyName); - return parts.join('+'); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is HotkeyBinding && - other.virtualKey == virtualKey && - other.keyName == keyName && - other.useCtrl == useCtrl && - other.useWin == useWin && - other.useAlt == useAlt && - other.useShift == useShift; - } - - @override - int get hashCode => - Object.hash(virtualKey, keyName, useCtrl, useWin, useAlt, useShift); -} - -const HotkeyBinding kLinuxTemporaryFallbackHotkey = HotkeyBinding( - virtualKey: 0x56, - keyName: 'V', - useCtrl: true, - useWin: false, - useAlt: true, - useShift: true, -); - -@immutable -class HotkeyRegistrationResult { - const HotkeyRegistrationResult({ - required this.status, - required this.requestedBinding, - this.effectiveBinding, - }); - - final HotkeyRegistrationStatus status; - final HotkeyBinding requestedBinding; - final HotkeyBinding? effectiveBinding; - - bool get isRegistered => - status == HotkeyRegistrationStatus.registered || - status == HotkeyRegistrationStatus.fallbackRegistered; -} - -abstract class LinuxHotkeyBindingApi { - Future registerHotkey(HotkeyBinding binding); -} - -class LinuxShellHotkeyBindingApi implements LinuxHotkeyBindingApi { - const LinuxShellHotkeyBindingApi(); - - @override - Future registerHotkey(HotkeyBinding binding) { - return LinuxShell.registerHotkey( - virtualKey: binding.virtualKey, - useCtrl: binding.useCtrl, - useWin: binding.useWin, - useAlt: binding.useAlt, - useShift: binding.useShift, - ); - } -} - -Future registerLinuxHotkeyWithFallback({ - required LinuxHotkeyBindingApi api, - required HotkeyBinding requestedBinding, - HotkeyBinding fallbackBinding = kLinuxTemporaryFallbackHotkey, -}) async { - if (await api.registerHotkey(requestedBinding)) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.registered, - requestedBinding: requestedBinding, - effectiveBinding: requestedBinding, - ); - } - - if (requestedBinding == fallbackBinding) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - ); - } - - if (await api.registerHotkey(fallbackBinding)) { - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.fallbackRegistered, - requestedBinding: requestedBinding, - effectiveBinding: fallbackBinding, - ); - } - - return HotkeyRegistrationResult( - status: HotkeyRegistrationStatus.failed, - requestedBinding: requestedBinding, - ); -} +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; + +import 'linux_shell.dart'; + +enum HotkeyRegistrationStatus { registered, fallbackRegistered, failed } + +enum HotkeyFailureReason { + unsupportedKey, + noModifier, + grabFailed, + noX11, + channelError, + unknown, +} + +HotkeyFailureReason _reasonFromCode(String? code) { + switch (code) { + case 'unsupportedKey': + return HotkeyFailureReason.unsupportedKey; + case 'noModifier': + return HotkeyFailureReason.noModifier; + case 'grabFailed': + return HotkeyFailureReason.grabFailed; + case 'noX11': + return HotkeyFailureReason.noX11; + case 'channelError': + return HotkeyFailureReason.channelError; + default: + return HotkeyFailureReason.unknown; + } +} + +final Set _supportedLinuxVirtualKeys = { + for (var k = 0x41; k <= 0x5A; k++) k, + for (var k = 0x30; k <= 0x39; k++) k, + for (var k = 0x70; k <= 0x87; k++) k, + 0x08, + 0x09, + 0x0D, + 0x1B, + 0x20, + 0x21, + 0x22, + 0x23, + 0x24, + 0x25, + 0x26, + 0x27, + 0x28, + 0x2D, + 0x2E, + 0xBA, + 0xBB, + 0xBC, + 0xBD, + 0xBE, + 0xBF, + 0xC0, + 0xDB, + 0xDC, + 0xDD, + 0xDE, +}; + +bool isLinuxSupportedVirtualKey(int virtualKey) => + _supportedLinuxVirtualKeys.contains(virtualKey); + +@immutable +class HotkeyBinding { + const HotkeyBinding({ + required this.virtualKey, + required this.keyName, + required this.useCtrl, + required this.useWin, + required this.useAlt, + required this.useShift, + }); + + final int virtualKey; + final String keyName; + final bool useCtrl; + final bool useWin; + final bool useAlt; + final bool useShift; + + String label({bool isMac = false}) { + final parts = []; + if (useCtrl) parts.add('Ctrl'); + if (useWin) { + if (isMac || Platform.isMacOS) { + parts.add('Cmd'); + } else if (Platform.isLinux) { + parts.add('Super'); + } else { + parts.add('Win'); + } + } + if (useAlt) parts.add(isMac || Platform.isMacOS ? 'Option' : 'Alt'); + if (useShift) parts.add('Shift'); + parts.add(keyName); + return parts.join('+'); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is HotkeyBinding && + other.virtualKey == virtualKey && + other.keyName == keyName && + other.useCtrl == useCtrl && + other.useWin == useWin && + other.useAlt == useAlt && + other.useShift == useShift; + } + + @override + int get hashCode => + Object.hash(virtualKey, keyName, useCtrl, useWin, useAlt, useShift); +} + +const HotkeyBinding kLinuxTemporaryFallbackHotkey = HotkeyBinding( + virtualKey: 0x56, + keyName: 'V', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: true, +); + +@immutable +class HotkeyRegistrationResult { + const HotkeyRegistrationResult({ + required this.status, + required this.requestedBinding, + this.effectiveBinding, + this.failureReason, + }); + + final HotkeyRegistrationStatus status; + final HotkeyBinding requestedBinding; + final HotkeyBinding? effectiveBinding; + final HotkeyFailureReason? failureReason; + + bool get isRegistered => + status == HotkeyRegistrationStatus.registered || + status == HotkeyRegistrationStatus.fallbackRegistered; +} + +abstract class LinuxHotkeyBindingApi { + Future registerHotkey(HotkeyBinding binding); +} + +class LinuxShellHotkeyBindingApi implements LinuxHotkeyBindingApi { + const LinuxShellHotkeyBindingApi(); + + @override + Future registerHotkey(HotkeyBinding binding) { + return LinuxShell.registerHotkey( + virtualKey: binding.virtualKey, + useCtrl: binding.useCtrl, + useWin: binding.useWin, + useAlt: binding.useAlt, + useShift: binding.useShift, + ); + } +} + +Future registerLinuxHotkeyWithFallback({ + required LinuxHotkeyBindingApi api, + required HotkeyBinding requestedBinding, + HotkeyBinding fallbackBinding = kLinuxTemporaryFallbackHotkey, +}) async { + if (!isLinuxSupportedVirtualKey(requestedBinding.virtualKey)) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + failureReason: HotkeyFailureReason.unsupportedKey, + ); + } + + final primary = await api.registerHotkey(requestedBinding); + if (primary.success) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.registered, + requestedBinding: requestedBinding, + effectiveBinding: requestedBinding, + ); + } + + if (requestedBinding == fallbackBinding) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + failureReason: _reasonFromCode(primary.errorCode), + ); + } + + final fallback = await api.registerHotkey(fallbackBinding); + if (fallback.success) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.fallbackRegistered, + requestedBinding: requestedBinding, + effectiveBinding: fallbackBinding, + failureReason: _reasonFromCode(primary.errorCode), + ); + } + + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + failureReason: _reasonFromCode(fallback.errorCode ?? primary.errorCode), + ); +} diff --git a/app/lib/shell/linux_session.dart b/app/lib/shell/linux_session.dart index 76895ac8..e95aece5 100644 --- a/app/lib/shell/linux_session.dart +++ b/app/lib/shell/linux_session.dart @@ -1,47 +1,144 @@ -import 'dart:io'; - -bool isWaylandSession() { - if (!Platform.isLinux) return false; - - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - if (sessionType == 'wayland') return true; - if (sessionType == 'x11' || sessionType == 'mir') return false; - - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - if (waylandDisplay.isNotEmpty) return true; - - final display = Platform.environment['DISPLAY'] ?? ''; - if (display.isNotEmpty) return false; - - return _hasWaylandSocket(); -} - -bool _hasWaylandSocket() { - final runtimeDir = Platform.environment['XDG_RUNTIME_DIR'] ?? ''; - if (runtimeDir.isEmpty) return false; - try { - return Directory(runtimeDir) - .listSync(followLinks: false) - .any((e) => e.uri.pathSegments.last.startsWith('wayland')); - } catch (_) { - return false; - } -} - -Future linuxPrefersDarkMode() async { - if (!Platform.isLinux) return false; - - try { - final result = await Process.run('gsettings', [ - 'get', - 'org.gnome.desktop.interface', - 'color-scheme', - ]); - if (result.exitCode == 0) { - return (result.stdout as String).contains('dark'); - } - } catch (_) {} - - final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); - return gtkTheme.contains('dark'); -} +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class LinuxSessionInfo { + const LinuxSessionInfo({ + required this.sessionType, + required this.hasDisplay, + required this.hasWaylandDisplay, + required this.hasWaylandSocket, + required this.desktopEnv, + required this.wmName, + }); + + final String sessionType; + final bool hasDisplay; + final bool hasWaylandDisplay; + final bool hasWaylandSocket; + final String desktopEnv; + final String wmName; + + bool get isWayland { + if (sessionType == 'wayland') return true; + if (sessionType == 'x11' || sessionType == 'mir' || sessionType == 'tty') { + return false; + } + if (hasWaylandDisplay) return true; + if (hasWaylandSocket && !hasDisplay) return true; + if (hasDisplay) return false; + return hasWaylandSocket; + } + + bool get isX11 { + if (sessionType == 'x11') return true; + if (sessionType == 'wayland' || + sessionType == 'mir' || + sessionType == 'tty') { + return false; + } + if (hasDisplay && !hasWaylandDisplay && !hasWaylandSocket) return true; + return false; + } + + bool get isXWayland => + hasDisplay && + (hasWaylandDisplay || hasWaylandSocket) && + sessionType == 'wayland'; + + bool get isUsable => isX11 || isWayland; + + static const LinuxSessionInfo unsupported = LinuxSessionInfo( + sessionType: '', + hasDisplay: false, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is LinuxSessionInfo && + other.sessionType == sessionType && + other.hasDisplay == hasDisplay && + other.hasWaylandDisplay == hasWaylandDisplay && + other.hasWaylandSocket == hasWaylandSocket && + other.desktopEnv == desktopEnv && + other.wmName == wmName; + } + + @override + int get hashCode => Object.hash( + sessionType, + hasDisplay, + hasWaylandDisplay, + hasWaylandSocket, + desktopEnv, + wmName, + ); + + @override + String toString() => + 'LinuxSessionInfo(sessionType=$sessionType, hasDisplay=$hasDisplay, ' + 'hasWaylandDisplay=$hasWaylandDisplay, hasWaylandSocket=$hasWaylandSocket, ' + 'desktopEnv=$desktopEnv, wmName=$wmName)'; +} + +LinuxSessionInfo detectLinuxSession() { + if (!Platform.isLinux) return LinuxSessionInfo.unsupported; + + final env = Platform.environment; + final sessionType = (env['XDG_SESSION_TYPE'] ?? '').trim().toLowerCase(); + final display = (env['DISPLAY'] ?? '').trim(); + final waylandDisplay = (env['WAYLAND_DISPLAY'] ?? '').trim(); + final desktopEnv = + (env['XDG_CURRENT_DESKTOP'] ?? env['DESKTOP_SESSION'] ?? '').trim(); + final wmName = (env['XDG_SESSION_DESKTOP'] ?? '').trim(); + + return LinuxSessionInfo( + sessionType: sessionType, + hasDisplay: display.isNotEmpty, + hasWaylandDisplay: waylandDisplay.isNotEmpty, + hasWaylandSocket: _hasWaylandSocket(env['XDG_RUNTIME_DIR']), + desktopEnv: desktopEnv, + wmName: wmName, + ); +} + +bool isWaylandSession() => detectLinuxSession().isWayland; + +bool _hasWaylandSocket(String? runtimeDir) { + if (runtimeDir == null || runtimeDir.isEmpty) return false; + try { + return Directory(runtimeDir) + .listSync(followLinks: false) + .any((e) => e.uri.pathSegments.last.startsWith('wayland')); + } catch (e) { + AppLogger.warn('linux_session: hasWaylandSocket failed: $e'); + return false; + } +} + +Future linuxPrefersDarkMode() async { + if (!Platform.isLinux) return false; + + try { + final result = await Process.run('gsettings', [ + 'get', + 'org.gnome.desktop.interface', + 'color-scheme', + ]); + if (result.exitCode == 0) { + return (result.stdout as String).contains('dark'); + } + } catch (e) { + AppLogger.warn('linux_session: gsettings color-scheme failed: $e'); + } + + final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); + return gtkTheme.contains('dark'); +} diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index 4b3b252f..5553f788 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -1,120 +1,257 @@ -// coverage:ignore-file -import 'dart:async'; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -class LinuxShell { - LinuxShell._(); - - static const MethodChannel _methodChannel = MethodChannel( - 'copypaste/linux_shell', - ); - static const EventChannel _eventChannel = EventChannel( - 'copypaste/linux_shell/events', - ); - - static StreamController? _eventsController; - static StreamSubscription? _eventChannelSubscription; - - static Stream get events { - if (_eventsController == null) { - _eventsController = StreamController.broadcast(); - _eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen( - (dynamic event) { - if (event is! Map) return; - final map = Map.from(event); - final type = map['type'] as String? ?? ''; - if (type.isNotEmpty) _eventsController?.add(type); - }, - onError: (Object error) => _eventsController?.addError(error), - ); - } - return _eventsController!.stream; - } - - static Future dispose() async { - await _eventChannelSubscription?.cancel(); - _eventChannelSubscription = null; - await _eventsController?.close(); - _eventsController = null; - } - - static Future initTray({ - required String iconPath, - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - final result = await _methodChannel.invokeMethod('initTray', { - 'iconPath': iconPath, - 'showHideLabel': showHideLabel, - 'exitLabel': exitLabel, - 'tooltip': tooltip, - }); - return result ?? false; - } - - static Future updateTray({ - required String iconPath, - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - final result = await _methodChannel.invokeMethod('updateTray', { - 'iconPath': iconPath, - 'showHideLabel': showHideLabel, - 'exitLabel': exitLabel, - 'tooltip': tooltip, - }); - return result ?? false; - } - - static Future destroyTray() async { - try { - await _methodChannel.invokeMethod('destroyTray'); - } catch (e) { - AppLogger.error('LinuxShell.destroyTray failed: $e'); - } - } - - static Future registerHotkey({ - required int virtualKey, - required bool useCtrl, - required bool useWin, - required bool useAlt, - required bool useShift, - }) async { - try { - final result = await _methodChannel.invokeMethod('registerHotkey', { - 'virtualKey': virtualKey, - 'useCtrl': useCtrl, - 'useWin': useWin, - 'useAlt': useAlt, - 'useShift': useShift, - }); - return result ?? false; - } catch (e) { - AppLogger.error('LinuxShell.registerHotkey failed: $e'); - return false; - } - } - - static Future unregisterHotkey() async { - try { - await _methodChannel.invokeMethod('unregisterHotkey'); - } catch (e) { - AppLogger.error('LinuxShell.unregisterHotkey failed: $e'); - } - } - - /// Raises and focuses the GTK window using the X11 hotkey event timestamp, - /// bypassing GNOME's focus-stealing prevention. - static Future focusWindow() async { - try { - await _methodChannel.invokeMethod('focusWindow'); - } catch (e) { - AppLogger.error('LinuxShell.focusWindow failed: $e'); - } - } -} +// coverage:ignore-file +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +class LinuxShell { + LinuxShell._(); + + static const MethodChannel _methodChannel = MethodChannel( + 'copypaste/linux_shell', + ); + static const EventChannel _eventChannel = EventChannel( + 'copypaste/linux_shell/events', + ); + + static StreamController? _eventsController; + static StreamSubscription? _eventChannelSubscription; + + static Stream get events { + if (_eventsController == null) { + _eventsController = StreamController.broadcast(); + _eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is! Map) return; + final map = Map.from(event); + final type = map['type'] as String? ?? ''; + if (type.isNotEmpty) _eventsController?.add(type); + }, + onError: (Object error) => _eventsController?.addError(error), + ); + } + return _eventsController!.stream; + } + + static Future dispose() async { + await _eventChannelSubscription?.cancel(); + _eventChannelSubscription = null; + await _eventsController?.close(); + _eventsController = null; + } + + static Future awaitEvent( + String type, { + Duration timeout = const Duration(milliseconds: 300), + }) async { + final completer = Completer(); + final sub = events.listen((event) { + if (event == type && !completer.isCompleted) completer.complete(true); + }); + final timer = Timer(timeout, () { + if (!completer.isCompleted) completer.complete(false); + }); + try { + return await completer.future; + } finally { + timer.cancel(); + await sub.cancel(); + } + } + + static Future initTray({ + required String iconPath, + required String showHideLabel, + required String exitLabel, + required String tooltip, + }) async { + return _invokeTrayMethod('initTray', { + 'iconPath': iconPath, + 'showHideLabel': showHideLabel, + 'exitLabel': exitLabel, + 'tooltip': tooltip, + }); + } + + static Future updateTray({ + required String iconPath, + required String showHideLabel, + required String exitLabel, + required String tooltip, + }) async { + return _invokeTrayMethod('updateTray', { + 'iconPath': iconPath, + 'showHideLabel': showHideLabel, + 'exitLabel': exitLabel, + 'tooltip': tooltip, + }); + } + + static Future _invokeTrayMethod( + String method, + Map args, + ) async { + try { + final result = await _methodChannel.invokeMethod(method, args); + if (result is Map) { + final map = Map.from(result); + final code = map['errorCode']; + return TrayResponse( + success: map['success'] == true, + errorCode: code is String ? code : null, + ); + } + if (result is bool) { + return TrayResponse(success: result); + } + return const TrayResponse(success: false, errorCode: 'unknown'); + } catch (e) { + AppLogger.error('LinuxShell.$method failed: $e'); + return const TrayResponse(success: false, errorCode: 'channelError'); + } + } + + static Future destroyTray() async { + try { + await _methodChannel.invokeMethod('destroyTray'); + } catch (e) { + AppLogger.error('LinuxShell.destroyTray failed: $e'); + } + } + + static Future registerHotkey({ + required int virtualKey, + required bool useCtrl, + required bool useWin, + required bool useAlt, + required bool useShift, + }) async { + try { + final result = await _methodChannel + .invokeMethod('registerHotkey', { + 'virtualKey': virtualKey, + 'useCtrl': useCtrl, + 'useWin': useWin, + 'useAlt': useAlt, + 'useShift': useShift, + }); + if (result is Map) { + final map = Map.from(result); + final success = map['success'] == true; + final code = map['errorCode']; + return HotkeyRegisterResponse( + success: success, + errorCode: code is String ? code : null, + ); + } + if (result is bool) { + return HotkeyRegisterResponse(success: result); + } + return const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); + } catch (e) { + AppLogger.error('LinuxShell.registerHotkey failed: $e'); + return const HotkeyRegisterResponse( + success: false, + errorCode: 'channelError', + ); + } + } + + static Future unregisterHotkey() async { + try { + await _methodChannel.invokeMethod('unregisterHotkey'); + } catch (e) { + AppLogger.error('LinuxShell.unregisterHotkey failed: $e'); + } + } + + static Future focusWindow() async { + try { + await _methodChannel.invokeMethod('focusWindow'); + } catch (e) { + AppLogger.error('LinuxShell.focusWindow failed: $e'); + } + } + + static Future getCursorMonitor() async { + try { + final result = await _methodChannel.invokeMethod( + 'getCursorMonitor', + ); + if (result is! Map) return null; + return CursorMonitorInfo( + cursorX: (result['cursorX'] as num?)?.toDouble() ?? 0, + cursorY: (result['cursorY'] as num?)?.toDouble() ?? 0, + x: (result['x'] as num?)?.toDouble() ?? 0, + y: (result['y'] as num?)?.toDouble() ?? 0, + width: (result['width'] as num?)?.toDouble() ?? 0, + height: (result['height'] as num?)?.toDouble() ?? 0, + scaleFactor: (result['scaleFactor'] as num?)?.toDouble() ?? 1.0, + ); + } catch (e) { + AppLogger.error('LinuxShell.getCursorMonitor failed: $e'); + return null; + } + } + + static Future getInputFocus() async { + try { + final result = await _methodChannel.invokeMethod('getInputFocus'); + if (result is! Map) return null; + return InputFocusInfo( + ownsFocus: result['ownsFocus'] as bool? ?? false, + focusWindow: (result['focusWindow'] as num?)?.toInt() ?? 0, + ownWindow: (result['ownWindow'] as num?)?.toInt() ?? 0, + ); + } catch (e) { + AppLogger.error('LinuxShell.getInputFocus failed: $e'); + return null; + } + } +} + +class CursorMonitorInfo { + const CursorMonitorInfo({ + required this.cursorX, + required this.cursorY, + required this.x, + required this.y, + required this.width, + required this.height, + required this.scaleFactor, + }); + + final double cursorX; + final double cursorY; + final double x; + final double y; + final double width; + final double height; + final double scaleFactor; +} + +class InputFocusInfo { + const InputFocusInfo({ + required this.ownsFocus, + required this.focusWindow, + required this.ownWindow, + }); + + final bool ownsFocus; + final int focusWindow; + final int ownWindow; +} + +class HotkeyRegisterResponse { + const HotkeyRegisterResponse({required this.success, this.errorCode}); + + final bool success; + final String? errorCode; +} + +class TrayResponse { + const TrayResponse({required this.success, this.errorCode}); + + final bool success; + final String? errorCode; +} diff --git a/app/lib/shell/startup_helper.dart b/app/lib/shell/startup_helper.dart index d82d0d79..3deace13 100644 --- a/app/lib/shell/startup_helper.dart +++ b/app/lib/shell/startup_helper.dart @@ -1,346 +1,347 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:ffi/ffi.dart'; -import 'package:flutter/foundation.dart'; - -import 'linux_session.dart'; -import 'msix_startup_task.dart'; -import 'win_package_context.dart'; - -typedef _RegOpenKeyExNative = - Int32 Function( - IntPtr hKey, - Pointer lpSubKey, - Uint32 ulOptions, - Int32 samDesired, - Pointer phkResult, - ); -typedef _RegOpenKeyExDart = - int Function( - int hKey, - Pointer lpSubKey, - int ulOptions, - int samDesired, - Pointer phkResult, - ); - -typedef _RegSetValueExNative = - Int32 Function( - IntPtr hKey, - Pointer lpValueName, - Uint32 reserved, - Uint32 dwType, - Pointer lpData, - Uint32 cbData, - ); -typedef _RegSetValueExDart = - int Function( - int hKey, - Pointer lpValueName, - int reserved, - int dwType, - Pointer lpData, - int cbData, - ); - -typedef _RegDeleteValueNative = - Int32 Function(IntPtr hKey, Pointer lpValueName); -typedef _RegDeleteValueDart = - int Function(int hKey, Pointer lpValueName); - -typedef _RegCloseKeyNative = Int32 Function(IntPtr hKey); -typedef _RegCloseKeyDart = int Function(int hKey); - -class _Win32Registry { - _Win32Registry._() { - assert(Platform.isWindows, '_Win32Registry requires Windows'); - } - static _Win32Registry? _instance; - static _Win32Registry get instance => _instance ??= _Win32Registry._(); - - late final _advapi32 = DynamicLibrary.open('advapi32.dll'); - - late final regOpenKeyEx = _advapi32 - .lookupFunction<_RegOpenKeyExNative, _RegOpenKeyExDart>('RegOpenKeyExW'); - late final regSetValueEx = _advapi32 - .lookupFunction<_RegSetValueExNative, _RegSetValueExDart>( - 'RegSetValueExW', - ); - late final regDeleteValue = _advapi32 - .lookupFunction<_RegDeleteValueNative, _RegDeleteValueDart>( - 'RegDeleteValueW', - ); - late final regCloseKey = _advapi32 - .lookupFunction<_RegCloseKeyNative, _RegCloseKeyDart>('RegCloseKey'); -} - -class StartupHelper { - static const int _hkeyCurrentUser = 0x80000001; - static const int _keySetValue = 0x0002; - static const int _regSz = 1; - static const String _registryPath = - r'Software\Microsoft\Windows\CurrentVersion\Run'; - static const String _appName = 'CopyPaste'; - static const String _msixStartupTaskId = 'CopyPasteStartup'; - static const String _macOsPlistLabel = 'com.rgdevment.copypaste'; - - static Future apply( - bool runOnStartup, { - bool fromUserAction = false, - }) async { - if (Platform.isWindows) { - if (WinPackageContext.isMsix) { - // MSIX uses the StartupTask declared in AppxManifest. Make sure no - // stale HKCU\...\Run entry from a previous standalone install lingers, - // otherwise Windows shows it with a generic icon and the raw registry - // path in the Startup settings page. - _removeRegistryValue(); - await _applyMsixStartupTask( - runOnStartup, - fromUserAction: fromUserAction, - ); - } else { - if (runOnStartup) { - _setRegistryValue(Platform.resolvedExecutable); - } else { - _removeRegistryValue(); - } - } - } else if (Platform.isMacOS) { - if (runOnStartup) { - _installLaunchAgent(); - } else { - _removeLaunchAgent(); - } - } else if (Platform.isLinux) { - // Never install autostart on Wayland — the app would launch and immediately - // show the unsupported screen, which is a poor experience. - if (isWaylandSession()) { - _removeDesktopAutostart(); - AppLogger.info('Wayland session: autostart entry removed/skipped.'); - return; - } - if (runOnStartup) { - _installDesktopAutostart(); - } else { - _removeDesktopAutostart(); - } - } - } - - static Future openWindowsStartupSettings() async { - try { - await Process.start('explorer.exe', ['ms-settings:startupapps']); - } catch (e) { - AppLogger.error('openWindowsStartupSettings failed: $e'); - } - } - - static Future _applyMsixStartupTask( - bool runOnStartup, { - required bool fromUserAction, - }) async { - if (runOnStartup) { - final state = await MsixStartupTask.enable(_msixStartupTaskId); - AppLogger.info('MSIX StartupTask enable -> $state'); - // When the user has explicitly disabled the task from Settings, only - // the user can re-enable it. Surface the system page so they can act. - if (fromUserAction && state == MsixStartupTaskState.disabledByUser) { - await openWindowsStartupSettings(); - } - } else { - final state = await MsixStartupTask.disable(_msixStartupTaskId); - AppLogger.info('MSIX StartupTask disable -> $state'); - } - } - - // Detects executables running from a Flutter build folder (dev runs). - // Writing those paths to HKCU\...\Run produces stale entries that Windows - // renders with a generic icon and only the registry path text once the - // build folder is cleaned. - @visibleForTesting - static bool isDevBuildPath(String exePath) { - final normalized = exePath.replaceAll('/', r'\').toLowerCase(); - return normalized.contains(r'\build\windows\'); - } - - static void _setRegistryValue(String exePath) { - if (!exePath.toLowerCase().endsWith('.exe') || - !File(exePath).existsSync()) { - AppLogger.error( - 'Skipping startup registry write: executable not found at "$exePath".', - ); - _removeRegistryValue(); - return; - } - - if (isDevBuildPath(exePath)) { - AppLogger.info( - 'Skipping startup registry write: running from a Flutter build folder ("$exePath").', - ); - _removeRegistryValue(); - return; - } - - final r = _Win32Registry.instance; - final subKey = _registryPath.toNativeUtf16(allocator: malloc); - final hKeyPtr = calloc(); - - try { - final result = r.regOpenKeyEx( - _hkeyCurrentUser, - subKey, - 0, - _keySetValue, - hKeyPtr, - ); - if (result != 0) { - AppLogger.error('Failed to open registry key for set: $result'); - return; - } - - final hKey = hKeyPtr.value; - final valueName = _appName.toNativeUtf16(allocator: malloc); - final valueData = '"$exePath"'.toNativeUtf16(allocator: malloc); - final dataSize = ('"$exePath"'.length + 1) * 2; - - try { - final setResult = r.regSetValueEx( - hKey, - valueName, - 0, - _regSz, - valueData, - dataSize, - ); - if (setResult != 0) { - AppLogger.error('Failed to set registry value: $setResult'); - } - } finally { - malloc.free(valueName); - malloc.free(valueData); - r.regCloseKey(hKey); - } - } finally { - malloc.free(subKey); - calloc.free(hKeyPtr); - } - } - - static void _removeRegistryValue() { - final r = _Win32Registry.instance; - final subKey = _registryPath.toNativeUtf16(allocator: malloc); - final hKeyPtr = calloc(); - - try { - final result = r.regOpenKeyEx( - _hkeyCurrentUser, - subKey, - 0, - _keySetValue, - hKeyPtr, - ); - if (result != 0) { - AppLogger.error('Failed to open registry key for delete: $result'); - return; - } - - final hKey = hKeyPtr.value; - final valueName = _appName.toNativeUtf16(allocator: malloc); - - try { - r.regDeleteValue(hKey, valueName); - } finally { - malloc.free(valueName); - r.regCloseKey(hKey); - } - } finally { - malloc.free(subKey); - calloc.free(hKeyPtr); - } - } - - static String get _launchAgentPath { - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/Library/LaunchAgents/$_macOsPlistLabel.plist'; - } - - static void _installLaunchAgent() { - try { - final exePath = Platform.resolvedExecutable; - final plist = - ''' - - - - Label - $_macOsPlistLabel - ProgramArguments - - $exePath - - RunAtLoad - - KeepAlive - - - -'''; - final agentDir = Directory( - '${Platform.environment['HOME']}/Library/LaunchAgents', - ); - if (!agentDir.existsSync()) agentDir.createSync(recursive: true); - File(_launchAgentPath).writeAsStringSync(plist); - } catch (e) { - AppLogger.error('Failed to install LaunchAgent: $e'); - } - } - - static void _removeLaunchAgent() { - try { - final file = File(_launchAgentPath); - if (file.existsSync()) file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to remove LaunchAgent: $e'); - } - } - - static String get _desktopAutostartPath { - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/.config/autostart/$_appName.desktop'; - } - - static void _installDesktopAutostart() { - try { - final exePath = Platform.resolvedExecutable; - final desktop = - '[Desktop Entry]\n' - 'Type=Application\n' - 'Name=$_appName\n' - 'Exec=$exePath\n' - 'X-GNOME-Autostart-enabled=true\n' - 'StartupNotify=false\n' - 'Terminal=false\n'; - final autostartDir = Directory( - '${Platform.environment['HOME']}/.config/autostart', - ); - if (!autostartDir.existsSync()) autostartDir.createSync(recursive: true); - File(_desktopAutostartPath).writeAsStringSync(desktop); - } catch (e) { - AppLogger.error('Failed to install autostart desktop entry: $e'); - } - } - - static void _removeDesktopAutostart() { - try { - final file = File(_desktopAutostartPath); - if (file.existsSync()) file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to remove autostart desktop entry: $e'); - } - } -} +// coverage:ignore-file +import 'dart:ffi'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; + +import 'linux_session.dart'; +import 'msix_startup_task.dart'; +import 'win_package_context.dart'; + +typedef _RegOpenKeyExNative = + Int32 Function( + IntPtr hKey, + Pointer lpSubKey, + Uint32 ulOptions, + Int32 samDesired, + Pointer phkResult, + ); +typedef _RegOpenKeyExDart = + int Function( + int hKey, + Pointer lpSubKey, + int ulOptions, + int samDesired, + Pointer phkResult, + ); + +typedef _RegSetValueExNative = + Int32 Function( + IntPtr hKey, + Pointer lpValueName, + Uint32 reserved, + Uint32 dwType, + Pointer lpData, + Uint32 cbData, + ); +typedef _RegSetValueExDart = + int Function( + int hKey, + Pointer lpValueName, + int reserved, + int dwType, + Pointer lpData, + int cbData, + ); + +typedef _RegDeleteValueNative = + Int32 Function(IntPtr hKey, Pointer lpValueName); +typedef _RegDeleteValueDart = + int Function(int hKey, Pointer lpValueName); + +typedef _RegCloseKeyNative = Int32 Function(IntPtr hKey); +typedef _RegCloseKeyDart = int Function(int hKey); + +class _Win32Registry { + _Win32Registry._() { + assert(Platform.isWindows, '_Win32Registry requires Windows'); + } + static _Win32Registry? _instance; + static _Win32Registry get instance => _instance ??= _Win32Registry._(); + + late final _advapi32 = DynamicLibrary.open('advapi32.dll'); + + late final regOpenKeyEx = _advapi32 + .lookupFunction<_RegOpenKeyExNative, _RegOpenKeyExDart>('RegOpenKeyExW'); + late final regSetValueEx = _advapi32 + .lookupFunction<_RegSetValueExNative, _RegSetValueExDart>( + 'RegSetValueExW', + ); + late final regDeleteValue = _advapi32 + .lookupFunction<_RegDeleteValueNative, _RegDeleteValueDart>( + 'RegDeleteValueW', + ); + late final regCloseKey = _advapi32 + .lookupFunction<_RegCloseKeyNative, _RegCloseKeyDart>('RegCloseKey'); +} + +class StartupHelper { + static const int _hkeyCurrentUser = 0x80000001; + static const int _keySetValue = 0x0002; + static const int _regSz = 1; + static const String _registryPath = + r'Software\Microsoft\Windows\CurrentVersion\Run'; + static const String _appName = 'CopyPaste'; + static const String _msixStartupTaskId = 'CopyPasteStartup'; + static const String _macOsPlistLabel = 'com.rgdevment.copypaste'; + + static Future apply( + bool runOnStartup, { + bool fromUserAction = false, + }) async { + if (Platform.isWindows) { + if (WinPackageContext.isMsix) { + // MSIX uses the StartupTask declared in AppxManifest. Make sure no + // stale HKCU\...\Run entry from a previous standalone install lingers, + // otherwise Windows shows it with a generic icon and the raw registry + // path in the Startup settings page. + _removeRegistryValue(); + await _applyMsixStartupTask( + runOnStartup, + fromUserAction: fromUserAction, + ); + } else { + if (runOnStartup) { + _setRegistryValue(Platform.resolvedExecutable); + } else { + _removeRegistryValue(); + } + } + } else if (Platform.isMacOS) { + if (runOnStartup) { + _installLaunchAgent(); + } else { + _removeLaunchAgent(); + } + } else if (Platform.isLinux) { + // Never install autostart on Wayland — the app would launch and immediately + // show the unsupported screen, which is a poor experience. + if (isWaylandSession()) { + _removeDesktopAutostart(); + AppLogger.info('Wayland session: autostart entry removed/skipped.'); + return; + } + if (runOnStartup) { + _installDesktopAutostart(); + } else { + _removeDesktopAutostart(); + } + } + } + + static Future openWindowsStartupSettings() async { + try { + await Process.start('explorer.exe', ['ms-settings:startupapps']); + } catch (e) { + AppLogger.error('openWindowsStartupSettings failed: $e'); + } + } + + static Future _applyMsixStartupTask( + bool runOnStartup, { + required bool fromUserAction, + }) async { + if (runOnStartup) { + final state = await MsixStartupTask.enable(_msixStartupTaskId); + AppLogger.info('MSIX StartupTask enable -> $state'); + // When the user has explicitly disabled the task from Settings, only + // the user can re-enable it. Surface the system page so they can act. + if (fromUserAction && state == MsixStartupTaskState.disabledByUser) { + await openWindowsStartupSettings(); + } + } else { + final state = await MsixStartupTask.disable(_msixStartupTaskId); + AppLogger.info('MSIX StartupTask disable -> $state'); + } + } + + // Detects executables running from a Flutter build folder (dev runs). + // Writing those paths to HKCU\...\Run produces stale entries that Windows + // renders with a generic icon and only the registry path text once the + // build folder is cleaned. + @visibleForTesting + static bool isDevBuildPath(String exePath) { + final normalized = exePath.replaceAll('/', r'\').toLowerCase(); + return normalized.contains(r'\build\windows\'); + } + + static void _setRegistryValue(String exePath) { + if (!exePath.toLowerCase().endsWith('.exe') || + !File(exePath).existsSync()) { + AppLogger.error( + 'Skipping startup registry write: executable not found at "$exePath".', + ); + _removeRegistryValue(); + return; + } + + if (isDevBuildPath(exePath)) { + AppLogger.info( + 'Skipping startup registry write: running from a Flutter build folder ("$exePath").', + ); + _removeRegistryValue(); + return; + } + + final r = _Win32Registry.instance; + final subKey = _registryPath.toNativeUtf16(allocator: malloc); + final hKeyPtr = calloc(); + + try { + final result = r.regOpenKeyEx( + _hkeyCurrentUser, + subKey, + 0, + _keySetValue, + hKeyPtr, + ); + if (result != 0) { + AppLogger.error('Failed to open registry key for set: $result'); + return; + } + + final hKey = hKeyPtr.value; + final valueName = _appName.toNativeUtf16(allocator: malloc); + final valueData = '"$exePath"'.toNativeUtf16(allocator: malloc); + final dataSize = ('"$exePath"'.length + 1) * 2; + + try { + final setResult = r.regSetValueEx( + hKey, + valueName, + 0, + _regSz, + valueData, + dataSize, + ); + if (setResult != 0) { + AppLogger.error('Failed to set registry value: $setResult'); + } + } finally { + malloc.free(valueName); + malloc.free(valueData); + r.regCloseKey(hKey); + } + } finally { + malloc.free(subKey); + calloc.free(hKeyPtr); + } + } + + static void _removeRegistryValue() { + final r = _Win32Registry.instance; + final subKey = _registryPath.toNativeUtf16(allocator: malloc); + final hKeyPtr = calloc(); + + try { + final result = r.regOpenKeyEx( + _hkeyCurrentUser, + subKey, + 0, + _keySetValue, + hKeyPtr, + ); + if (result != 0) { + AppLogger.error('Failed to open registry key for delete: $result'); + return; + } + + final hKey = hKeyPtr.value; + final valueName = _appName.toNativeUtf16(allocator: malloc); + + try { + r.regDeleteValue(hKey, valueName); + } finally { + malloc.free(valueName); + r.regCloseKey(hKey); + } + } finally { + malloc.free(subKey); + calloc.free(hKeyPtr); + } + } + + static String get _launchAgentPath { + final home = Platform.environment['HOME'] ?? '/tmp'; + return '$home/Library/LaunchAgents/$_macOsPlistLabel.plist'; + } + + static void _installLaunchAgent() { + try { + final exePath = Platform.resolvedExecutable; + final plist = + ''' + + + + Label + $_macOsPlistLabel + ProgramArguments + + $exePath + + RunAtLoad + + KeepAlive + + + +'''; + final agentDir = Directory( + '${Platform.environment['HOME']}/Library/LaunchAgents', + ); + if (!agentDir.existsSync()) agentDir.createSync(recursive: true); + File(_launchAgentPath).writeAsStringSync(plist); + } catch (e) { + AppLogger.error('Failed to install LaunchAgent: $e'); + } + } + + static void _removeLaunchAgent() { + try { + final file = File(_launchAgentPath); + if (file.existsSync()) file.deleteSync(); + } catch (e) { + AppLogger.error('Failed to remove LaunchAgent: $e'); + } + } + + static String get _desktopAutostartPath { + final home = Platform.environment['HOME'] ?? '/tmp'; + return '$home/.config/autostart/$_appName.desktop'; + } + + static void _installDesktopAutostart() { + try { + final exePath = Platform.resolvedExecutable; + final desktop = + '[Desktop Entry]\n' + 'Type=Application\n' + 'Name=$_appName\n' + 'Exec=$exePath\n' + 'X-GNOME-Autostart-enabled=true\n' + 'StartupNotify=false\n' + 'Terminal=false\n' + 'OnlyShowIn=GNOME;KDE;XFCE;Cinnamon;MATE;LXDE;LXQt;Pantheon;Unity;Budgie;Deepin;\n'; + final autostartDir = Directory( + '${Platform.environment['HOME']}/.config/autostart', + ); + if (!autostartDir.existsSync()) autostartDir.createSync(recursive: true); + File(_desktopAutostartPath).writeAsStringSync(desktop); + } catch (e) { + AppLogger.error('Failed to install autostart desktop entry: $e'); + } + } + + static void _removeDesktopAutostart() { + try { + final file = File(_desktopAutostartPath); + if (file.existsSync()) file.deleteSync(); + } catch (e) { + AppLogger.error('Failed to remove autostart desktop entry: $e'); + } + } +} diff --git a/app/lib/shell/tray_icon.dart b/app/lib/shell/tray_icon.dart index 2490a188..c96491bf 100644 --- a/app/lib/shell/tray_icon.dart +++ b/app/lib/shell/tray_icon.dart @@ -1,111 +1,114 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:io'; - -import 'package:tray_manager/tray_manager.dart'; - -import 'linux_shell.dart'; - -class TrayIcon with TrayListener { - TrayIcon({required this.onToggle, required this.onExit}); - - final void Function() onToggle; - final Future Function() onExit; - - StreamSubscription? _linuxEventsSubscription; - - static String get _iconPath { - if (Platform.isMacOS) return 'assets/icons/icon_mac_tray.png'; - if (Platform.isLinux) return 'assets/icons/icon_tray_64.png'; - return 'assets/icons/icon_tray.ico'; - } - - Future init() async { - if (Platform.isLinux) { - _linuxEventsSubscription ??= LinuxShell.events.listen((event) { - switch (event) { - case 'toggle': - onToggle(); - case 'exit': - onExit(); - } - }); - await LinuxShell.initTray( - iconPath: _iconPath, - showHideLabel: 'Show/Hide', - exitLabel: 'Exit', - tooltip: 'CopyPaste', - ); - return; - } - - trayManager.addListener(this); - await trayManager.setIcon(_iconPath); - await trayManager.setContextMenu( - Menu( - items: [ - MenuItem(key: 'toggle', label: 'Show/Hide'), - MenuItem.separator(), - MenuItem(key: 'exit', label: 'Exit'), - ], - ), - ); - } - - Future rebuild({ - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - if (Platform.isLinux) { - await LinuxShell.updateTray( - iconPath: _iconPath, - showHideLabel: showHideLabel, - exitLabel: exitLabel, - tooltip: tooltip, - ); - return; - } - - await trayManager.setToolTip(tooltip); - await trayManager.setContextMenu( - Menu( - items: [ - MenuItem(key: 'toggle', label: showHideLabel), - MenuItem.separator(), - MenuItem(key: 'exit', label: exitLabel), - ], - ), - ); - } - - @override - void onTrayIconMouseDown() => onToggle(); - - @override - void onTrayIconRightMouseDown() { - trayManager.popUpContextMenu(); - } - - @override - void onTrayMenuItemClick(MenuItem menuItem) { - switch (menuItem.key) { - case 'toggle': - onToggle(); - case 'exit': - onExit(); - } - } - - Future dispose() async { - if (Platform.isLinux) { - await _linuxEventsSubscription?.cancel(); - _linuxEventsSubscription = null; - await LinuxShell.destroyTray(); - return; - } - - trayManager.removeListener(this); - await trayManager.destroy(); - } -} +// coverage:ignore-file +import 'dart:async'; +import 'dart:io'; + +import 'package:tray_manager/tray_manager.dart'; + +import '../services/linux_guard.dart'; +import 'linux_shell.dart'; + +class TrayIcon with TrayListener { + TrayIcon({required this.onToggle, required this.onExit}); + + final void Function() onToggle; + final Future Function() onExit; + + StreamSubscription? _linuxEventsSubscription; + + static String get _iconPath { + if (Platform.isMacOS) return 'assets/icons/icon_mac_tray.png'; + if (Platform.isLinux) return 'assets/icons/icon_tray_64.png'; + return 'assets/icons/icon_tray.ico'; + } + + Future init() async { + if (Platform.isLinux) { + if (!LinuxGuard.canShowTray) return; + _linuxEventsSubscription ??= LinuxShell.events.listen((event) { + switch (event) { + case 'toggle': + onToggle(); + case 'exit': + onExit(); + } + }); + await LinuxShell.initTray( + iconPath: _iconPath, + showHideLabel: 'Show/Hide', + exitLabel: 'Exit', + tooltip: 'CopyPaste', + ); + return; + } + + trayManager.addListener(this); + await trayManager.setIcon(_iconPath); + await trayManager.setContextMenu( + Menu( + items: [ + MenuItem(key: 'toggle', label: 'Show/Hide'), + MenuItem.separator(), + MenuItem(key: 'exit', label: 'Exit'), + ], + ), + ); + } + + Future rebuild({ + required String showHideLabel, + required String exitLabel, + required String tooltip, + }) async { + if (Platform.isLinux) { + if (!LinuxGuard.canShowTray) return; + await LinuxShell.updateTray( + iconPath: _iconPath, + showHideLabel: showHideLabel, + exitLabel: exitLabel, + tooltip: tooltip, + ); + return; + } + + await trayManager.setToolTip(tooltip); + await trayManager.setContextMenu( + Menu( + items: [ + MenuItem(key: 'toggle', label: showHideLabel), + MenuItem.separator(), + MenuItem(key: 'exit', label: exitLabel), + ], + ), + ); + } + + @override + void onTrayIconMouseDown() => onToggle(); + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + switch (menuItem.key) { + case 'toggle': + onToggle(); + case 'exit': + onExit(); + } + } + + Future dispose() async { + if (Platform.isLinux) { + await _linuxEventsSubscription?.cancel(); + _linuxEventsSubscription = null; + await LinuxShell.destroyTray(); + return; + } + + trayManager.removeListener(this); + await trayManager.destroy(); + } +} diff --git a/app/lib/widgets/clipboard_card.dart b/app/lib/widgets/clipboard_card.dart index 313b02c4..7ecc4dee 100644 --- a/app/lib/widgets/clipboard_card.dart +++ b/app/lib/widgets/clipboard_card.dart @@ -1,1656 +1,1632 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; -import 'label_color_dialog.dart'; - -class ClipboardCard extends StatefulWidget { - const ClipboardCard({ - required this.item, - required this.onTap, - required this.onPin, - required this.onDelete, - required this.onLabelColor, - this.onPastePlain, - this.onExpandToggle, - this.onOpen, - this.onSelect, - this.onRequestThumbnailRefresh, - this.isSelected = false, - this.isExpanded = false, - this.cardMinLines, - this.cardMaxLines, - super.key, - }); - - final ClipboardItem item; - final VoidCallback onTap; - final VoidCallback onPin; - final VoidCallback onDelete; - final void Function(String? label, CardColor color) onLabelColor; - final VoidCallback? onPastePlain; - final VoidCallback? onExpandToggle; - final VoidCallback? onOpen; - final VoidCallback? onSelect; - - /// Invoked once per resolved image item to let the host trigger - /// background regeneration of `_thumb.png` when the source file's - /// `mtime` no longer matches `item.sourceModifiedAt`. - final void Function(ClipboardItem item)? onRequestThumbnailRefresh; - final bool isSelected; - final bool isExpanded; - final int? cardMinLines; - final int? cardMaxLines; - - @override - State createState() => _ClipboardCardState(); -} - -class _ClipboardCardState extends State { - bool _hovering = false; - String? _resolvedImagePath; - bool _resolvedIsThumb = false; - bool _imagePathResolved = false; - bool? _fileAvailable; - DateTime? _lastPrimaryDown; - bool _isTextOverflowing = false; - - static const _doubleTapTimeout = Duration(milliseconds: 300); - - void _handlePointerDown(PointerDownEvent event) { - if (event.buttons != kPrimaryButton) return; - widget.onSelect?.call(); - final now = DateTime.now(); - if (_lastPrimaryDown != null && - now.difference(_lastPrimaryDown!) < _doubleTapTimeout) { - _lastPrimaryDown = null; - widget.onTap(); - } else { - _lastPrimaryDown = now; - } - } - - @override - void initState() { - super.initState(); - _resolveImagePath(); - _resolveFileAvailability(); - } - - @override - void didUpdateWidget(ClipboardCard oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.item.id != widget.item.id || - oldWidget.item.content != widget.item.content || - oldWidget.item.thumbPath != widget.item.thumbPath || - oldWidget.item.metadata != widget.item.metadata) { - _imagePathResolved = false; - _resolvedIsThumb = false; - _fileAvailable = null; - _resolveImagePath(); - _resolveFileAvailability(); - } - } - - bool _needsExpandToggle(ClipboardItem item) { - if (widget.isExpanded) return true; - final type = item.type; - if (type == ClipboardContentType.text || - type == ClipboardContentType.unknown || - type == ClipboardContentType.json) { - return _isTextOverflowing; - } - return false; - } - - bool _needsOpenAction(ClipboardItem item) { - return switch (item.type) { - ClipboardContentType.image => - _imagePathResolved && - _resolvedImagePath != null && - (_fileAvailable ?? true), - ClipboardContentType.file || - ClipboardContentType.folder || - ClipboardContentType.audio || - ClipboardContentType.video => _fileAvailable ?? false, - ClipboardContentType.link || - ClipboardContentType.email || - ClipboardContentType.phone => true, - _ => false, - }; - } - - void _resolveImagePath() { - final item = widget.item; - final isImage = item.type == ClipboardContentType.image; - final isMedia = - item.type == ClipboardContentType.video || - item.type == ClipboardContentType.audio; - if (!isImage && !isMedia) { - return; - } - if (isImage) { - // Always ask the host to refresh the thumb if the source mtime is - // stale. The host is responsible for deciding (and rate-limiting). - widget.onRequestThumbnailRefresh?.call(item); - } - _checkImagePathsAsync(item, allowContentFallback: isImage); - } - - /// Resolves the best path to display for an image item: prefers - /// `item.thumbPath` (when present and the file exists), falls back to - /// `item.content`, finally null. - /// - /// When [allowContentFallback] is false (video / audio items) the - /// content path is never used as a fallback because it points to the - /// external media file, not a renderable image. - Future _checkImagePathsAsync( - ClipboardItem item, { - bool allowContentFallback = true, - }) async { - final thumb = item.thumbPath; - if (thumb != null && thumb.isNotEmpty) { - if (await File(thumb).exists()) { - if (!mounted) return; - setState(() { - _resolvedImagePath = thumb; - _resolvedIsThumb = true; - _imagePathResolved = true; - }); - return; - } - } - - if (!allowContentFallback) { - if (!mounted) return; - setState(() { - _resolvedImagePath = null; - _resolvedIsThumb = false; - _imagePathResolved = true; - }); - return; - } - - final content = item.content; - if (content.isEmpty) { - if (!mounted) return; - setState(() { - _resolvedImagePath = null; - _resolvedIsThumb = false; - _imagePathResolved = true; - }); - return; - } - final exists = await File(content).exists(); - if (!mounted) return; - setState(() { - _resolvedImagePath = exists ? content : null; - _resolvedIsThumb = false; - _imagePathResolved = true; - }); - } - - void _resolveFileAvailability() { - final item = widget.item; - if (item.type != ClipboardContentType.file && - item.type != ClipboardContentType.folder && - item.type != ClipboardContentType.audio && - item.type != ClipboardContentType.video && - item.type != ClipboardContentType.image) { - return; - } - final path = item.content.split('\n').first.trim(); - if (path.isEmpty) { - if (mounted) setState(() => _fileAvailable = false); - return; - } - _checkFileAvailableAsync(path); - } - - Future _checkFileAvailableAsync(String path) async { - final exists = await File(path).exists() || await Directory(path).exists(); - if (mounted) setState(() => _fileAvailable = exists); - } - - Future _editLabelColor(BuildContext context) async { - final result = await LabelColorDialog.show( - context, - currentLabel: widget.item.label, - currentColor: widget.item.cardColor, - ); - if (result != null && mounted) { - widget.onLabelColor(result.label, result.color); - } - } - - bool get _isPlainPasteable => - widget.item.type == ClipboardContentType.text || - widget.item.type == ClipboardContentType.link; - - Future _showContextMenu(BuildContext ctx, Offset position) async { - final size = MediaQuery.of(ctx).size; - final item = widget.item; - final colors = CopyPasteTheme.colorsOf(ctx); - final isDark = Theme.of(ctx).brightness == Brightness.dark; - final l = AppLocalizations.of(ctx); - final action = await showMenu<_ContextAction>( - context: ctx, - position: RelativeRect.fromLTRB( - position.dx, - position.dy, - size.width - position.dx, - size.height - position.dy, - ), - elevation: 8, - color: isDark ? colors.surfaceVariant : colors.cardBackground, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: [ - PopupMenuItem( - value: _ContextAction.paste, - height: 32, - child: _ContextMenuItem( - icon: Icons.content_paste_rounded, - label: l.menuPaste, - colors: colors, - ), - ), - if (_isPlainPasteable && widget.onPastePlain != null) - PopupMenuItem( - value: _ContextAction.pastePlain, - height: 32, - child: _ContextMenuItem( - icon: Icons.format_clear_rounded, - label: l.menuPastePlain, - colors: colors, - ), - ), - const PopupMenuDivider(height: 1), - PopupMenuItem( - value: _ContextAction.pin, - height: 32, - child: _ContextMenuItem( - icon: item.isPinned - ? Icons.push_pin_rounded - : Icons.push_pin_outlined, - label: item.isPinned ? l.menuUnpin : l.menuPin, - colors: colors, - ), - ), - PopupMenuItem( - value: _ContextAction.edit, - height: 32, - child: _ContextMenuItem( - icon: Icons.edit_rounded, - label: l.menuEdit, - colors: colors, - ), - ), - const PopupMenuDivider(height: 1), - PopupMenuItem( - value: _ContextAction.delete, - height: 32, - child: _ContextMenuItem( - icon: Icons.delete_rounded, - label: l.menuDelete, - colors: colors, - danger: true, - ), - ), - ], - ); - if (!mounted) return; - switch (action) { - case _ContextAction.paste: - widget.onTap(); - case _ContextAction.pastePlain: - widget.onPastePlain?.call(); - case _ContextAction.pin: - widget.onPin(); - case _ContextAction.edit: - await _editLabelColor(context); - case _ContextAction.delete: - widget.onDelete(); - case null: - break; - } - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final isDark = Theme.of(context).brightness == Brightness.dark; - final item = widget.item; - final accentColor = colors.accentForIndex(item.cardColor.value); - final hasColor = item.cardColor != CardColor.none; - - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: Listener( - onPointerDown: _handlePointerDown, - child: GestureDetector( - onSecondaryTapUp: (d) => _showContextMenu(context, d.globalPosition), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - constraints: BoxConstraints(minHeight: theme.sizing.cardMinHeight), - transform: _hovering ? Matrix4.translationValues(0, -1, 0) : null, - decoration: BoxDecoration( - color: _hovering && isDark - ? colors.surfaceVariant - : colors.cardBackground, - borderRadius: BorderRadius.circular(theme.radii.card), - border: Border.all( - color: widget.isSelected - ? colors.primary.withValues(alpha: 0.5) - : _hovering - ? colors.onSurface.withValues(alpha: isDark ? 0.1 : 0.18) - : colors.cardBorder, - width: theme.cardStyle.borderWidth, - ), - boxShadow: [ - if (widget.isSelected) - BoxShadow( - color: colors.primary.withValues(alpha: 0.2), - blurRadius: 8, - spreadRadius: 1, - ), - if (isDark) - BoxShadow( - color: Colors.black.withValues( - alpha: _hovering ? 0.3 : 0.2, - ), - blurRadius: _hovering ? 12 : 6, - offset: Offset(0, _hovering ? 3 : 1), - ) - else - BoxShadow( - color: Colors.black.withValues( - alpha: _hovering ? 0.1 : 0.07, - ), - blurRadius: _hovering ? 10 : 4, - offset: Offset(0, _hovering ? 3 : 1), - ), - ], - ), - child: Stack( - children: [ - if (hasColor) - Positioned( - left: 0, - top: 0, - bottom: 0, - child: Container( - width: theme.sizing.colorIndicatorWidth, - decoration: BoxDecoration( - color: accentColor, - borderRadius: - theme.cardStyle.colorIndicatorBorderRadius, - ), - ), - ), - Padding( - padding: theme.spacing.cardPadding.copyWith( - left: hasColor - ? theme.spacing.cardPadding.left + - theme.sizing.colorIndicatorWidth + - 2 - : theme.spacing.cardPadding.left, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(theme, colors, item), - const SizedBox(height: 4), - _buildContent(theme, colors, item), - if (_hasFooter(item)) ...[ - const SizedBox(height: 6), - _buildFooter(theme, colors, item), - ], - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildHeader( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final l = AppLocalizations.of(context); - final typeColor = _typeColor(item.type, colors); - final iconSize = theme.sizing.cardTypeIconContainerSize; - - final isDark = Theme.of(context).brightness == Brightness.dark; - final iconBgAlpha = isDark ? 0.2 : 0.13; - - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: iconSize, - height: iconSize, - decoration: BoxDecoration( - color: typeColor.withValues(alpha: iconBgAlpha), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Icon( - theme.icons.forContentType(item.type.value), - size: 16, - color: typeColor, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.label ?? _contentTypeName(item.type, l), - style: theme.typography.cardLabel.copyWith( - color: item.label != null - ? typeColor.withValues(alpha: 0.85) - : colors.onSurface.withValues( - alpha: theme.cardStyle.headerOpacity, - ), - letterSpacing: 0.06, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.appSource != null && - item.type != ClipboardContentType.color) ...[ - const SizedBox(height: 1), - Text( - '· ${item.appSource!}', - style: theme.typography.cardFooter.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.appSourceOpacity, - ), - fontSize: 10, - letterSpacing: 0.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ), - ], - ), - Positioned( - right: 0, - top: 0, - bottom: 0, - child: Align( - alignment: Alignment.centerRight, - child: Stack( - alignment: Alignment.centerRight, - children: [ - AnimatedOpacity( - opacity: _hovering ? 0.0 : 1.0, - duration: const Duration(milliseconds: 120), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (item.isPinned) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - theme.icons.pinFilled, - size: theme.sizing.iconSizeXs, - color: colors.primary.withValues(alpha: 0.5), - ), - ), - Text( - _formatTimestamp(item.modifiedAt, l), - style: theme.typography.cardTimestamp.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.timestampOpacity, - ), - ), - ), - ], - ), - ), - IgnorePointer( - ignoring: !_hovering, - child: AnimatedOpacity( - opacity: _hovering ? 1.0 : 0.0, - duration: const Duration(milliseconds: 120), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _CardActionButton( - icon: theme.icons.paste, - tooltip: l.menuPaste, - onTap: widget.onTap, - ), - const SizedBox(width: 3), - if (_isPlainPasteable && - widget.onPastePlain != null) ...[ - _CardActionButton( - icon: Icons.notes_rounded, - tooltip: l.menuPastePlain, - onTap: widget.onPastePlain!, - ), - const SizedBox(width: 3), - ], - _CardActionButton( - icon: theme.icons.edit, - tooltip: l.menuEdit, - onTap: () => _editLabelColor(context), - ), - const SizedBox(width: 3), - _CardActionButton( - icon: item.isPinned - ? theme.icons.pinFilled - : theme.icons.pin, - tooltip: item.isPinned ? l.menuUnpin : l.menuPin, - onTap: widget.onPin, - ), - const SizedBox(width: 3), - _CardActionButton( - icon: theme.icons.delete, - tooltip: l.menuDelete, - onTap: widget.onDelete, - isDanger: true, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - switch (item.type) { - case ClipboardContentType.image: - return _buildImageContent(theme, colors, item); - case ClipboardContentType.audio: - return _buildMediaContent(theme, colors, item); - case ClipboardContentType.video: - return _buildMediaContent(theme, colors, item); - case ClipboardContentType.file: - case ClipboardContentType.folder: - return _buildFileContent(theme, colors, item); - case ClipboardContentType.link: - return _buildLinkContent(theme, colors, item); - case ClipboardContentType.text: - case ClipboardContentType.unknown: - case ClipboardContentType.email: - case ClipboardContentType.phone: - case ClipboardContentType.ip: - case ClipboardContentType.uuid: - case ClipboardContentType.json: - return _buildTextContent(theme, colors, item); - case ClipboardContentType.color: - return _buildColorContent(theme, colors, item); - } - } - - Widget _buildTextContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final minLines = widget.cardMinLines ?? theme.sizing.cardMinLines; - final displayMaxLines = widget.isExpanded - ? (widget.cardMaxLines ?? theme.sizing.cardMaxLines) - : minLines; - final textStyle = theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues(alpha: theme.cardStyle.contentOpacity), - ); - - return LayoutBuilder( - builder: (context, constraints) { - final tp = TextPainter( - text: TextSpan(text: item.content, style: textStyle), - maxLines: minLines, - textDirection: Directionality.of(context), - )..layout(maxWidth: constraints.maxWidth); - final overflows = tp.didExceedMaxLines; - tp.dispose(); - if (overflows != _isTextOverflowing) { - _isTextOverflowing = overflows; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() {}); - }); - } - - return Text( - item.content, - style: textStyle, - maxLines: displayMaxLines, - overflow: TextOverflow.ellipsis, - ); - }, - ); - } - - Widget _buildColorContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - return Text( - item.content, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - ), - ); - } - - Widget _buildImageContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - if (!_imagePathResolved) { - return Container( - height: theme.sizing.cardImageHeight, - decoration: BoxDecoration( - color: colors.surfaceVariant, - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - ), - ); - } - - final l10n = AppLocalizations.of(context); - final contentPath = item.content.trim(); - final filename = contentPath.isEmpty - ? '' - : contentPath.split(Platform.pathSeparator).last; - - // File is known to be missing: show explicit warning instead of - // letting Image.file fail silently via errorBuilder. - if (_resolvedImagePath == null) { - return Semantics( - label: filename.isEmpty - ? l10n.imageFile - : '${l10n.imageFile}: $filename, ${l10n.fileNotFound}', - child: Container( - height: theme.sizing.cardImageHeight, - decoration: BoxDecoration( - color: colors.surfaceVariant, - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - ), - child: contentPath.isEmpty - ? Center( - child: Icon( - theme.icons.image, - size: theme.sizing.iconSizeLg, - color: colors.onSurfaceMuted, - ), - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - theme.icons.warning, - size: theme.sizing.iconSizeLg, - color: colors.warning, - ), - const SizedBox(height: 4), - _ExtBadge( - label: l10n.fileNotFound, - color: colors.warning, - ), - ], - ), - ), - ), - ); - } - - return Semantics( - label: filename.isEmpty - ? l10n.imageFile - : (_fileAvailable == false - ? '${l10n.imageFile}: $filename, ${l10n.fileNotFound}' - : '${l10n.imageFile}: $filename'), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - child: Container( - height: theme.sizing.cardImageHeight, - width: double.infinity, - color: colors.surfaceVariant, - child: Image.file( - File(_resolvedImagePath!), - fit: BoxFit.cover, - cacheWidth: _resolvedIsThumb ? 256 : 700, - errorBuilder: (_, e, s) => Center( - child: Icon( - theme.icons.warning, - color: colors.warning, - size: theme.sizing.iconSizeLg, - ), - ), - ), - ), - ), - if (_fileAvailable == false) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ExtBadge(label: l10n.fileNotFound, color: colors.warning), - ], - ), - ], - ], - ), - ); - } - - Widget _buildFileContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final files = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - final available = item.isFileAvailable(); - final firstName = files.isEmpty - ? '' - : files.first.split(Platform.pathSeparator).last; - - final semanticsLabel = [ - if (firstName.isNotEmpty) firstName else item.content, - if (!available) AppLocalizations.of(context).fileNotFound, - ].join(', '); - - return Semantics( - label: semanticsLabel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - firstName.isEmpty ? item.content : firstName, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (files.length > 1 || !available) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (files.length > 1) ...[ - _ExtBadge( - label: '+${files.length - 1}', - color: colors.onSurfaceMuted, - ), - ], - if (!available) ...[ - if (files.length > 1) const SizedBox(width: 4), - _ExtBadge( - label: AppLocalizations.of(context).fileNotFound, - color: colors.warning, - ), - ], - ], - ), - ], - ], - ), - ); - } - - Widget _buildMediaContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final path = item.content.trim(); - final filename = path.isEmpty - ? '' - : path.split(Platform.pathSeparator).last; - final isAudio = item.type == ClipboardContentType.audio; - final typeColor = _typeColor(item.type, colors); - final l10n = AppLocalizations.of(context); - final typeName = isAudio ? l10n.audioFile : l10n.videoFile; - final missing = _fileAvailable == false; - - final semanticsLabel = [ - filename.isEmpty ? typeName : filename, - if (missing) l10n.fileNotFound, - ].join(', '); - - final hasThumb = _imagePathResolved && _resolvedImagePath != null; - - if (!isAudio && hasThumb) { - return Semantics( - label: semanticsLabel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - child: Container( - height: theme.sizing.cardImageHeight, - width: double.infinity, - color: colors.surfaceVariant, - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(_resolvedImagePath!), - fit: BoxFit.contain, - cacheWidth: _resolvedIsThumb ? 256 : 700, - errorBuilder: (_, e, st) => _MediaIcon( - isAudio: false, - typeColor: typeColor, - radius: theme.radii.thumbnail, - ), - ), - Center( - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.play_arrow_rounded, - size: 16, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 4), - Text( - filename.isEmpty ? l10n.videoFile : filename, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - fontSize: 11, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (missing) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ExtBadge(label: l10n.fileNotFound, color: colors.warning), - ], - ), - ], - ], - ), - ); - } - - return Semantics( - label: semanticsLabel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - filename.isEmpty ? typeName : filename, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (missing) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ExtBadge(label: l10n.fileNotFound, color: colors.warning), - ], - ), - ], - ], - ), - ); - } - - Widget _buildLinkContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final uri = Uri.tryParse(item.content.trim()); - final domain = uri?.host ?? ''; - final typeColor = _typeColor(item.type, colors); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.content.trim(), - style: theme.typography.cardContent.copyWith( - color: colors.primary.withValues(alpha: 0.85), - decoration: TextDecoration.underline, - decorationColor: colors.primary.withValues(alpha: 0.3), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (domain.isNotEmpty) ...[ - const SizedBox(height: 3), - Row( - mainAxisSize: MainAxisSize.min, - children: [_ExtBadge(label: domain, color: typeColor)], - ), - ], - ], - ), - ), - ], - ); - } - - Map? _parseMetadata(ClipboardItem item) { - if (item.metadata == null || item.metadata!.isEmpty) return null; - try { - return json.decode(item.metadata!) as Map; - } catch (_) { - return null; - } - } - - String _getExtForItem(ClipboardItem item) { - if (item.type != ClipboardContentType.file && - item.type != ClipboardContentType.folder && - item.type != ClipboardContentType.audio && - item.type != ClipboardContentType.video && - item.type != ClipboardContentType.image) { - return ''; - } - final lines = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - if (lines.isEmpty) return ''; - final firstName = lines.first.split(Platform.pathSeparator).last; - return firstName.contains('.') - ? firstName.split('.').last.toUpperCase() - : ''; - } - - bool _hasFooter(ClipboardItem item) { - if (_needsExpandToggle(item)) return true; - if (_needsOpenAction(item)) return true; - if (item.pasteCount > 0) return true; - if (_getExtForItem(item).isNotEmpty) return true; - final meta = _parseMetadata(item); - if (meta == null) return false; - return meta.containsKey('file_size') || - meta.containsKey('size') || - meta.containsKey('width') || - meta.containsKey('video_width') || - meta.containsKey('duration'); - } - - Widget _buildFooter( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final meta = _parseMetadata(item); - final footerAlpha = theme.cardStyle.footerOpacity; - final footerColor = colors.onSurface.withValues(alpha: footerAlpha); - final footerStyle = theme.typography.cardFooter.copyWith( - color: footerColor, - ); - final iconColor = colors.onSurface.withValues(alpha: footerAlpha - 0.1); - - final ext = _getExtForItem(item); - final typeColor = _typeColor(item.type, colors); - final widgets = []; - - final w = meta?['width'] ?? meta?['video_width']; - final h = meta?['height'] ?? meta?['video_height']; - if (w != null && h != null) { - widgets.add( - _FooterChip( - icon: Icons.aspect_ratio_rounded, - label: '$w×$h', - style: footerStyle, - iconColor: iconColor, - iconSize: theme.sizing.iconSizeXs, - ), - ); - } - - final fileSize = meta?['file_size'] ?? meta?['size']; - if (fileSize != null && fileSize is num && fileSize > 0) { - widgets.add( - _FooterChip( - icon: Icons.storage_rounded, - label: _formatFileSize(fileSize.toInt()), - style: footerStyle, - iconColor: iconColor, - iconSize: theme.sizing.iconSizeXs, - ), - ); - } - - final duration = meta?['duration']; - if (duration != null && duration is num && duration > 0) { - widgets.add( - _FooterChip( - icon: Icons.timer_outlined, - label: _formatDuration(duration.toInt()), - style: footerStyle, - iconColor: iconColor, - iconSize: theme.sizing.iconSizeXs, - ), - ); - } - - if (item.pasteCount > 0) { - widgets.add( - Text( - '×${item.pasteCount}', - style: footerStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - } - - final showExpand = _needsExpandToggle(item); - final showOpen = !showExpand && _needsOpenAction(item); - - return Row( - children: [ - if (item.type == ClipboardContentType.color) - _ColorBadge(value: item.content.trim()) - else if (item.type == ClipboardContentType.phone) ...[ - if (_resolvePhoneCountry(item.content) case final c?) - _ExtBadge(label: c, color: typeColor), - ] else if (item.type == ClipboardContentType.email) ...[ - if (_resolveEmailProvider(item.content) case final p?) - _ExtBadge(label: p, color: typeColor), - ] else if (ext.isNotEmpty) - _ExtBadge(label: ext, color: typeColor), - if (showExpand) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.onExpandToggle?.call(), - canRequestFocus: false, - borderRadius: BorderRadius.circular(8), - hoverColor: colors.onSurface.withValues(alpha: 0.06), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - child: Icon( - widget.isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 14, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - ), - ), - ), - if (showOpen) - Padding( - padding: EdgeInsets.only(left: ext.isNotEmpty ? 6 : 0), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.onOpen?.call(), - canRequestFocus: false, - borderRadius: BorderRadius.circular(8), - hoverColor: colors.onSurface.withValues(alpha: 0.06), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - child: Icon( - Icons.open_in_new_rounded, - size: 14, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - ), - ), - ), - if (widgets.isNotEmpty) - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (int i = 0; i < widgets.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - Flexible(fit: FlexFit.loose, child: widgets[i]), - ], - ], - ), - ), - ], - ); - } - - static String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } - - static String _formatDuration(int seconds) { - final h = seconds ~/ 3600; - final m = (seconds % 3600) ~/ 60; - final s = seconds % 60; - if (h > 0) { - return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; - } - return '$m:${s.toString().padLeft(2, '0')}'; - } - - Color _typeColor(ClipboardContentType type, AppThemeColorScheme colors) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return switch (type) { - ClipboardContentType.text => colors.accentBlue, - ClipboardContentType.image => colors.accentOrange, - ClipboardContentType.file => colors.accentYellow, - ClipboardContentType.folder => colors.accentYellow, - ClipboardContentType.link => colors.accentGreen, - ClipboardContentType.audio => - isDark ? const Color(0xFF7DD3FC) : const Color(0xFF075985), - ClipboardContentType.video => colors.accentRed, - ClipboardContentType.email => colors.accentBlue, - ClipboardContentType.phone => colors.accentGreen, - ClipboardContentType.color => colors.accentOrange, - ClipboardContentType.ip => - isDark ? const Color(0xFFD4A5F5) : const Color(0xFF6B21A8), - ClipboardContentType.uuid => - isDark ? const Color(0xFF94A3B8) : const Color(0xFF475569), - ClipboardContentType.json => colors.accentYellow, - ClipboardContentType.unknown => colors.onSurfaceMuted, - }; - } - - String _contentTypeName(ClipboardContentType type, AppLocalizations l) => - switch (type) { - ClipboardContentType.text => l.typeText, - ClipboardContentType.image => l.typeImage, - ClipboardContentType.file => l.typeFile, - ClipboardContentType.folder => l.typeFolder, - ClipboardContentType.link => l.typeLink, - ClipboardContentType.audio => l.typeAudio, - ClipboardContentType.video => l.typeVideo, - ClipboardContentType.email => l.typeEmail, - ClipboardContentType.phone => l.typePhone, - ClipboardContentType.color => l.typeColor, - ClipboardContentType.ip => l.typeIp, - ClipboardContentType.uuid => l.typeUuid, - ClipboardContentType.json => l.typeJson, - ClipboardContentType.unknown => 'Unknown', - }; - - static const _phoneCountries = { - '1': 'US/CA', - '7': 'Russia', - '20': 'Egypt', - '27': 'S.Africa', - '30': 'Greece', - '31': 'Netherlands', - '32': 'Belgium', - '33': 'France', - '34': 'Spain', - '36': 'Hungary', - '39': 'Italy', - '40': 'Romania', - '41': 'Switzerland', - '43': 'Austria', - '44': 'UK', - '45': 'Denmark', - '46': 'Sweden', - '47': 'Norway', - '48': 'Poland', - '49': 'Germany', - '51': 'Peru', - '52': 'Mexico', - '53': 'Cuba', - '54': 'Argentina', - '55': 'Brazil', - '56': 'Chile', - '57': 'Colombia', - '58': 'Venezuela', - '60': 'Malaysia', - '61': 'Australia', - '62': 'Indonesia', - '63': 'Philippines', - '64': 'NZ', - '65': 'Singapore', - '66': 'Thailand', - '81': 'Japan', - '82': 'Korea', - '84': 'Vietnam', - '86': 'China', - '90': 'Turkey', - '91': 'India', - '92': 'Pakistan', - '94': 'Sri Lanka', - '98': 'Iran', - '212': 'Morocco', - '213': 'Algeria', - '216': 'Tunisia', - '234': 'Nigeria', - '254': 'Kenya', - '351': 'Portugal', - '352': 'Luxembourg', - '353': 'Ireland', - '354': 'Iceland', - '358': 'Finland', - '380': 'Ukraine', - '381': 'Serbia', - '385': 'Croatia', - '420': 'Czech', - '421': 'Slovakia', - '502': 'Guatemala', - '503': 'El Salvador', - '504': 'Honduras', - '505': 'Nicaragua', - '506': 'Costa Rica', - '507': 'Panama', - '591': 'Bolivia', - '593': 'Ecuador', - '595': 'Paraguay', - '598': 'Uruguay', - '855': 'Cambodia', - '880': 'Bangladesh', - '886': 'Taiwan', - '961': 'Lebanon', - '962': 'Jordan', - '964': 'Iraq', - '965': 'Kuwait', - '966': 'Saudi Arabia', - '971': 'UAE', - '972': 'Israel', - '974': 'Qatar', - '977': 'Nepal', - '994': 'Azerbaijan', - '995': 'Georgia', - '998': 'Uzbekistan', - }; - - // Keyed by first domain label — covers all regional variants automatically. - // e.g. outlook.com / outlook.com.ar / outlook.cl all resolve to 'Outlook' - static const _emailPrefixes = { - 'gmail': 'Gmail', - 'googlemail': 'Gmail', - 'outlook': 'Outlook', - 'hotmail': 'Hotmail', - 'live': 'Outlook', - 'msn': 'MSN', - 'yahoo': 'Yahoo', - 'icloud': 'iCloud', - 'me': 'iCloud', - 'mac': 'iCloud', - 'proton': 'Proton', - 'protonmail': 'Proton', - 'tutanota': 'Tutanota', - 'tuta': 'Tuta', - 'zoho': 'Zoho', - 'aol': 'AOL', - 'yandex': 'Yandex', - 'gmx': 'GMX', - 'fastmail': 'FastMail', - 'hey': 'HEY', - }; - - static String? _resolvePhoneCountry(String phone) { - if (!phone.trimLeft().startsWith('+')) return null; - final digits = phone.replaceAll(RegExp(r'\D'), ''); - for (final len in [3, 2, 1]) { - if (digits.length >= len) { - final country = _phoneCountries[digits.substring(0, len)]; - if (country != null) return country; - } - } - return null; - } - - static String? _resolveEmailProvider(String email) { - final at = email.indexOf('@'); - if (at == -1 || at >= email.length - 1) return null; - final domain = email.substring(at + 1).toLowerCase(); - final prefix = domain.split('.').first; - return _emailPrefixes[prefix] ?? domain; - } - - String _formatTimestamp(DateTime dt, AppLocalizations l) { - final now = DateTime.now(); - final diff = now.difference(dt); - - if (diff.inMinutes < 1) return l.timeNow; - if (diff.inMinutes < 60) return '${diff.inMinutes}m'; - if (diff.inHours < 24) return '${diff.inHours}h'; - if (diff.inDays < 7) return '${diff.inDays}d'; - return '${dt.month}/${dt.day}'; - } -} - -class _CardActionButton extends StatelessWidget { - const _CardActionButton({ - required this.icon, - required this.onTap, - this.tooltip, - this.isDanger = false, - }); - - final IconData icon; - final VoidCallback onTap; - final String? tooltip; - final bool isDanger; - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final isDark = Theme.of(context).brightness == Brightness.dark; - - final bg = isDark - ? colors.surfaceVariant - : Colors.white.withValues(alpha: 0.95); - - final button = SizedBox( - width: 30, - height: 30, - child: Material( - color: bg, - borderRadius: BorderRadius.circular(theme.radii.button), - child: InkWell( - onTap: onTap, - canRequestFocus: false, - borderRadius: BorderRadius.circular(theme.radii.button), - hoverColor: isDanger - ? colors.danger.withValues(alpha: 0.08) - : colors.onSurface.withValues(alpha: 0.06), - splashColor: isDanger - ? colors.danger.withValues(alpha: 0.15) - : colors.onSurface.withValues(alpha: 0.1), - child: Center( - child: Icon( - icon, - size: 13, - color: isDanger - ? colors.danger.withValues(alpha: 0.7) - : colors.onSurface.withValues(alpha: 0.5), - ), - ), - ), - ), - ); - - if (tooltip != null) { - return Tooltip( - message: tooltip!, - textStyle: const TextStyle(fontSize: 10, color: Colors.white), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.75), - borderRadius: BorderRadius.circular(4), - ), - preferBelow: false, - verticalOffset: 16, - waitDuration: const Duration(milliseconds: 400), - child: button, - ); - } - return button; - } -} - -class _MediaIcon extends StatelessWidget { - const _MediaIcon({ - required this.isAudio, - required this.typeColor, - required this.radius, - }); - - final bool isAudio; - final Color typeColor; - final double radius; - - @override - Widget build(BuildContext context) { - return Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: typeColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(radius), - ), - child: Center( - child: Icon( - isAudio - ? Icons.music_note_rounded - : Icons.play_circle_outline_rounded, - size: 22, - color: typeColor, - ), - ), - ); - } -} - -class _ColorBadge extends StatelessWidget { - const _ColorBadge({required this.value}); - - final String value; - - static Color? _parse(String value) { - final hex = value.startsWith('#') ? value.substring(1) : null; - if (hex == null) return null; - final normalized = switch (hex.length) { - 3 => 'FF${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}', - 6 => 'FF$hex', - 8 => hex, - _ => null, - }; - if (normalized == null) return null; - final int? v = int.tryParse(normalized, radix: 16); - return v != null ? Color(v) : null; - } - - static String _format(String value) { - final v = value.trimLeft().toLowerCase(); - if (v.startsWith('#')) return 'HEX'; - if (v.startsWith('rgba')) return 'RGBA'; - if (v.startsWith('rgb')) return 'RGB'; - if (v.startsWith('hsla')) return 'HSLA'; - if (v.startsWith('hsl')) return 'HSL'; - return 'COLOR'; - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final color = _parse(value); - final label = _format(value); - - if (color == null) { - return _ExtBadge(label: label, color: colors.accentOrange); - } - - final onColor = color.computeLuminance() > 0.4 - ? Colors.black.withValues(alpha: 0.75) - : Colors.white.withValues(alpha: 0.9); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - style: theme.typography.cardFooter.copyWith( - fontSize: 9, - fontWeight: FontWeight.w600, - color: onColor, - letterSpacing: 0.3, - ), - ), - ); - } -} - -class _ExtBadge extends StatelessWidget { - const _ExtBadge({required this.label, required this.color}); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w600, - color: color.withValues(alpha: 0.85), - letterSpacing: 0.3, - ), - ), - ), - ); - } -} - -class _FooterChip extends StatelessWidget { - const _FooterChip({ - required this.icon, - required this.label, - required this.style, - required this.iconColor, - required this.iconSize, - }); - - final IconData icon; - final String label; - final TextStyle style; - final Color iconColor; - final double iconSize; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: iconSize, color: iconColor), - const SizedBox(width: 3), - Flexible( - child: Text( - label, - style: style, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } -} - -enum _ContextAction { paste, pastePlain, pin, edit, delete } - -class _ContextMenuItem extends StatelessWidget { - const _ContextMenuItem({ - required this.icon, - required this.label, - required this.colors, - this.danger = false, - }); - - final IconData icon; - final String label; - final AppThemeColorScheme colors; - final bool danger; - - @override - Widget build(BuildContext context) { - final color = danger ? colors.danger : colors.onSurface; - return Row( - children: [ - Icon(icon, size: 13, color: color.withValues(alpha: 0.7)), - const SizedBox(width: 8), - Expanded( - child: Text(label, style: TextStyle(fontSize: 12, color: color)), - ), - ], - ); - } -} +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/app_theme_data.dart'; +import '../theme/theme_provider.dart'; +import 'label_color_dialog.dart'; + +class ClipboardCard extends StatefulWidget { + const ClipboardCard({ + required this.item, + required this.onTap, + required this.onPin, + required this.onDelete, + required this.onLabelColor, + this.onPastePlain, + this.onExpandToggle, + this.onOpen, + this.onSelect, + this.onRequestThumbnailRefresh, + this.isSelected = false, + this.isExpanded = false, + this.cardMinLines, + this.cardMaxLines, + super.key, + }); + + final ClipboardItem item; + final VoidCallback onTap; + final VoidCallback onPin; + final VoidCallback onDelete; + final void Function(String? label, CardColor color) onLabelColor; + final VoidCallback? onPastePlain; + final VoidCallback? onExpandToggle; + final VoidCallback? onOpen; + final VoidCallback? onSelect; + final void Function(ClipboardItem item)? onRequestThumbnailRefresh; + final bool isSelected; + final bool isExpanded; + final int? cardMinLines; + final int? cardMaxLines; + + @override + State createState() => _ClipboardCardState(); +} + +class _ClipboardCardState extends State { + bool _hovering = false; + String? _resolvedImagePath; + bool _resolvedIsThumb = false; + bool _imagePathResolved = false; + DateTime? _lastPrimaryDown; + bool _isTextOverflowing = false; + + static const _doubleTapTimeout = Duration(milliseconds: 300); + + void _handlePointerDown(PointerDownEvent event) { + if (event.buttons != kPrimaryButton) return; + widget.onSelect?.call(); + final now = DateTime.now(); + if (_lastPrimaryDown != null && + now.difference(_lastPrimaryDown!) < _doubleTapTimeout) { + _lastPrimaryDown = null; + widget.onTap(); + } else { + _lastPrimaryDown = now; + } + } + + @override + void initState() { + super.initState(); + _resolveImagePath(); + } + + @override + void didUpdateWidget(ClipboardCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.item.id != widget.item.id || + oldWidget.item.content != widget.item.content || + oldWidget.item.thumbPath != widget.item.thumbPath || + oldWidget.item.metadata != widget.item.metadata) { + _imagePathResolved = false; + _resolvedIsThumb = false; + _resolveImagePath(); + } + } + + bool _needsExpandToggle(ClipboardItem item) { + if (widget.isExpanded) return true; + final type = item.type; + if (type == ClipboardContentType.text || + type == ClipboardContentType.unknown || + type == ClipboardContentType.json) { + return _isTextOverflowing; + } + return false; + } + + bool _needsOpenAction(ClipboardItem item) { + return switch (item.type) { + ClipboardContentType.image => + _imagePathResolved && + _resolvedImagePath != null && + _imageSourceExists(item), + ClipboardContentType.file || + ClipboardContentType.folder || + ClipboardContentType.audio || + ClipboardContentType.video => item.isFileAvailable(), + ClipboardContentType.link || + ClipboardContentType.email || + ClipboardContentType.phone => true, + _ => false, + }; + } + + bool _imageSourceExists(ClipboardItem item) { + final path = item.content.trim(); + if (path.isEmpty) return false; + return File(path).existsSync(); + } + + void _resolveImagePath() { + final item = widget.item; + final isImage = item.type == ClipboardContentType.image; + final isMedia = + item.type == ClipboardContentType.video || + item.type == ClipboardContentType.audio; + if (!isImage && !isMedia) { + return; + } + if (isImage) { + // Always ask the host to refresh the thumb if the source mtime is + // stale. The host is responsible for deciding (and rate-limiting). + widget.onRequestThumbnailRefresh?.call(item); + } + _checkImagePathsAsync(item, allowContentFallback: isImage); + } + + /// Resolves the best path to display for an image item: prefers + /// `item.thumbPath` (when present and the file exists), falls back to + /// `item.content`, finally null. + /// + /// When [allowContentFallback] is false (video / audio items) the + /// content path is never used as a fallback because it points to the + /// external media file, not a renderable image. + Future _checkImagePathsAsync( + ClipboardItem item, { + bool allowContentFallback = true, + }) async { + final thumb = item.thumbPath; + if (thumb != null && thumb.isNotEmpty) { + if (await File(thumb).exists()) { + if (!mounted) return; + setState(() { + _resolvedImagePath = thumb; + _resolvedIsThumb = true; + _imagePathResolved = true; + }); + return; + } + } + + if (!allowContentFallback) { + if (!mounted) return; + setState(() { + _resolvedImagePath = null; + _resolvedIsThumb = false; + _imagePathResolved = true; + }); + return; + } + + final content = item.content; + if (content.isEmpty) { + if (!mounted) return; + setState(() { + _resolvedImagePath = null; + _resolvedIsThumb = false; + _imagePathResolved = true; + }); + return; + } + final exists = await File(content).exists(); + if (!mounted) return; + setState(() { + _resolvedImagePath = exists ? content : null; + _resolvedIsThumb = false; + _imagePathResolved = true; + }); + } + + Future _editLabelColor(BuildContext context) async { + final result = await LabelColorDialog.show( + context, + currentLabel: widget.item.label, + currentColor: widget.item.cardColor, + ); + if (result != null && mounted) { + widget.onLabelColor(result.label, result.color); + } + } + + bool get _isPlainPasteable => + widget.item.type == ClipboardContentType.text || + widget.item.type == ClipboardContentType.link; + + Future _showContextMenu(BuildContext ctx, Offset position) async { + final size = MediaQuery.of(ctx).size; + final item = widget.item; + final colors = CopyPasteTheme.colorsOf(ctx); + final isDark = Theme.of(ctx).brightness == Brightness.dark; + final l = AppLocalizations.of(ctx); + final action = await showMenu<_ContextAction>( + context: ctx, + position: RelativeRect.fromLTRB( + position.dx, + position.dy, + size.width - position.dx, + size.height - position.dy, + ), + elevation: 8, + color: isDark ? colors.surfaceVariant : colors.cardBackground, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: [ + PopupMenuItem( + value: _ContextAction.paste, + height: 32, + child: _ContextMenuItem( + icon: Icons.content_paste_rounded, + label: l.menuPaste, + colors: colors, + ), + ), + if (_isPlainPasteable && widget.onPastePlain != null) + PopupMenuItem( + value: _ContextAction.pastePlain, + height: 32, + child: _ContextMenuItem( + icon: Icons.format_clear_rounded, + label: l.menuPastePlain, + colors: colors, + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: _ContextAction.pin, + height: 32, + child: _ContextMenuItem( + icon: item.isPinned + ? Icons.push_pin_rounded + : Icons.push_pin_outlined, + label: item.isPinned ? l.menuUnpin : l.menuPin, + colors: colors, + ), + ), + PopupMenuItem( + value: _ContextAction.edit, + height: 32, + child: _ContextMenuItem( + icon: Icons.edit_rounded, + label: l.menuEdit, + colors: colors, + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: _ContextAction.delete, + height: 32, + child: _ContextMenuItem( + icon: Icons.delete_rounded, + label: l.menuDelete, + colors: colors, + danger: true, + ), + ), + ], + ); + if (!mounted) return; + switch (action) { + case _ContextAction.paste: + widget.onTap(); + case _ContextAction.pastePlain: + widget.onPastePlain?.call(); + case _ContextAction.pin: + widget.onPin(); + case _ContextAction.edit: + await _editLabelColor(context); + case _ContextAction.delete: + widget.onDelete(); + case null: + break; + } + } + + @override + Widget build(BuildContext context) { + final theme = CopyPasteTheme.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + final item = widget.item; + final accentColor = colors.accentForIndex(item.cardColor.value); + final hasColor = item.cardColor != CardColor.none; + + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: Listener( + onPointerDown: _handlePointerDown, + child: GestureDetector( + onSecondaryTapUp: (d) => _showContextMenu(context, d.globalPosition), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + constraints: BoxConstraints(minHeight: theme.sizing.cardMinHeight), + transform: _hovering ? Matrix4.translationValues(0, -1, 0) : null, + decoration: BoxDecoration( + color: _hovering && isDark + ? colors.surfaceVariant + : colors.cardBackground, + borderRadius: BorderRadius.circular(theme.radii.card), + border: Border.all( + color: widget.isSelected + ? colors.primary.withValues(alpha: 0.5) + : _hovering + ? colors.onSurface.withValues(alpha: isDark ? 0.1 : 0.18) + : colors.cardBorder, + width: theme.cardStyle.borderWidth, + ), + boxShadow: [ + if (widget.isSelected) + BoxShadow( + color: colors.primary.withValues(alpha: 0.2), + blurRadius: 8, + spreadRadius: 1, + ), + if (isDark) + BoxShadow( + color: Colors.black.withValues( + alpha: _hovering ? 0.3 : 0.2, + ), + blurRadius: _hovering ? 12 : 6, + offset: Offset(0, _hovering ? 3 : 1), + ) + else + BoxShadow( + color: Colors.black.withValues( + alpha: _hovering ? 0.1 : 0.07, + ), + blurRadius: _hovering ? 10 : 4, + offset: Offset(0, _hovering ? 3 : 1), + ), + ], + ), + child: Stack( + children: [ + if (hasColor) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + width: theme.sizing.colorIndicatorWidth, + decoration: BoxDecoration( + color: accentColor, + borderRadius: + theme.cardStyle.colorIndicatorBorderRadius, + ), + ), + ), + Padding( + padding: theme.spacing.cardPadding.copyWith( + left: hasColor + ? theme.spacing.cardPadding.left + + theme.sizing.colorIndicatorWidth + + 2 + : theme.spacing.cardPadding.left, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(theme, colors, item), + const SizedBox(height: 4), + _buildContent(theme, colors, item), + if (_hasFooter(item)) ...[ + const SizedBox(height: 6), + _buildFooter(theme, colors, item), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final l = AppLocalizations.of(context); + final typeColor = _typeColor(item.type, colors); + final iconSize = theme.sizing.cardTypeIconContainerSize; + + final isDark = Theme.of(context).brightness == Brightness.dark; + final iconBgAlpha = isDark ? 0.2 : 0.13; + + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: typeColor.withValues(alpha: iconBgAlpha), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + theme.icons.forContentType(item.type.value), + size: 16, + color: typeColor, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.label ?? _contentTypeName(item.type, l), + style: theme.typography.cardLabel.copyWith( + color: item.label != null + ? typeColor.withValues(alpha: 0.85) + : colors.onSurface.withValues( + alpha: theme.cardStyle.headerOpacity, + ), + letterSpacing: 0.06, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.appSource != null && + item.type != ClipboardContentType.color) ...[ + const SizedBox(height: 1), + Text( + '· ${item.appSource!}', + style: theme.typography.cardFooter.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.appSourceOpacity, + ), + fontSize: 10, + letterSpacing: 0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerRight, + child: Stack( + alignment: Alignment.centerRight, + children: [ + AnimatedOpacity( + opacity: _hovering ? 0.0 : 1.0, + duration: const Duration(milliseconds: 120), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.isPinned) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + theme.icons.pinFilled, + size: theme.sizing.iconSizeXs, + color: colors.primary.withValues(alpha: 0.5), + ), + ), + Text( + _formatTimestamp(item.modifiedAt, l), + style: theme.typography.cardTimestamp.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.timestampOpacity, + ), + ), + ), + ], + ), + ), + IgnorePointer( + ignoring: !_hovering, + child: AnimatedOpacity( + opacity: _hovering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 120), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _CardActionButton( + icon: theme.icons.paste, + tooltip: l.menuPaste, + onTap: widget.onTap, + ), + const SizedBox(width: 3), + if (_isPlainPasteable && + widget.onPastePlain != null) ...[ + _CardActionButton( + icon: Icons.notes_rounded, + tooltip: l.menuPastePlain, + onTap: widget.onPastePlain!, + ), + const SizedBox(width: 3), + ], + _CardActionButton( + icon: theme.icons.edit, + tooltip: l.menuEdit, + onTap: () => _editLabelColor(context), + ), + const SizedBox(width: 3), + _CardActionButton( + icon: item.isPinned + ? theme.icons.pinFilled + : theme.icons.pin, + tooltip: item.isPinned ? l.menuUnpin : l.menuPin, + onTap: widget.onPin, + ), + const SizedBox(width: 3), + _CardActionButton( + icon: theme.icons.delete, + tooltip: l.menuDelete, + onTap: widget.onDelete, + isDanger: true, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + switch (item.type) { + case ClipboardContentType.image: + return _buildImageContent(theme, colors, item); + case ClipboardContentType.audio: + return _buildMediaContent(theme, colors, item); + case ClipboardContentType.video: + return _buildMediaContent(theme, colors, item); + case ClipboardContentType.file: + case ClipboardContentType.folder: + return _buildFileContent(theme, colors, item); + case ClipboardContentType.link: + return _buildLinkContent(theme, colors, item); + case ClipboardContentType.text: + case ClipboardContentType.unknown: + case ClipboardContentType.email: + case ClipboardContentType.phone: + case ClipboardContentType.ip: + case ClipboardContentType.uuid: + case ClipboardContentType.json: + return _buildTextContent(theme, colors, item); + case ClipboardContentType.color: + return _buildColorContent(theme, colors, item); + } + } + + Widget _buildTextContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final minLines = widget.cardMinLines ?? theme.sizing.cardMinLines; + final displayMaxLines = widget.isExpanded + ? (widget.cardMaxLines ?? theme.sizing.cardMaxLines) + : minLines; + final textStyle = theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues(alpha: theme.cardStyle.contentOpacity), + ); + + return LayoutBuilder( + builder: (context, constraints) { + final tp = TextPainter( + text: TextSpan(text: item.content, style: textStyle), + maxLines: minLines, + textDirection: Directionality.of(context), + )..layout(maxWidth: constraints.maxWidth); + final overflows = tp.didExceedMaxLines; + tp.dispose(); + if (overflows != _isTextOverflowing) { + _isTextOverflowing = overflows; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + + return Text( + item.content, + style: textStyle, + maxLines: displayMaxLines, + overflow: TextOverflow.ellipsis, + ); + }, + ); + } + + Widget _buildColorContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + return Text( + item.content, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + ), + ); + } + + Widget _buildImageContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + if (!_imagePathResolved) { + return Container( + height: theme.sizing.cardImageHeight, + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + ), + ); + } + + final l10n = AppLocalizations.of(context); + final contentPath = item.content.trim(); + final filename = contentPath.isEmpty + ? '' + : contentPath.split(Platform.pathSeparator).last; + + // File is known to be missing: show explicit warning instead of + // letting Image.file fail silently via errorBuilder. + if (_resolvedImagePath == null) { + return Semantics( + label: filename.isEmpty + ? l10n.imageFile + : '${l10n.imageFile}: $filename, ${l10n.fileNotFound}', + child: Container( + height: theme.sizing.cardImageHeight, + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + ), + child: contentPath.isEmpty + ? Center( + child: Icon( + theme.icons.image, + size: theme.sizing.iconSizeLg, + color: colors.onSurfaceMuted, + ), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + theme.icons.warning, + size: theme.sizing.iconSizeLg, + color: colors.warning, + ), + const SizedBox(height: 4), + _ExtBadge( + label: l10n.fileNotFound, + color: colors.warning, + ), + ], + ), + ), + ), + ); + } + + return Semantics( + label: filename.isEmpty + ? l10n.imageFile + : (!_imageSourceExists(item) + ? '${l10n.imageFile}: $filename, ${l10n.fileNotFound}' + : '${l10n.imageFile}: $filename'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + child: Container( + height: theme.sizing.cardImageHeight, + width: double.infinity, + color: colors.surfaceVariant, + child: Image.file( + File(_resolvedImagePath!), + fit: BoxFit.cover, + cacheWidth: _resolvedIsThumb ? 256 : 700, + errorBuilder: (_, e, s) => Center( + child: Icon( + theme.icons.warning, + color: colors.warning, + size: theme.sizing.iconSizeLg, + ), + ), + ), + ), + ), + if (!_imageSourceExists(item)) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ExtBadge(label: l10n.fileNotFound, color: colors.warning), + ], + ), + ], + ], + ), + ); + } + + Widget _buildFileContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final files = item.content.split('\n').where((s) => s.isNotEmpty).toList(); + final available = item.isFileAvailable(); + final firstName = files.isEmpty + ? '' + : files.first.split(Platform.pathSeparator).last; + + final semanticsLabel = [ + if (firstName.isNotEmpty) firstName else item.content, + if (!available) AppLocalizations.of(context).fileNotFound, + ].join(', '); + + return Semantics( + label: semanticsLabel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firstName.isEmpty ? item.content : firstName, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (files.length > 1 || !available) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (files.length > 1) ...[ + _ExtBadge( + label: '+${files.length - 1}', + color: colors.onSurfaceMuted, + ), + ], + if (!available) ...[ + if (files.length > 1) const SizedBox(width: 4), + _ExtBadge( + label: AppLocalizations.of(context).fileNotFound, + color: colors.warning, + ), + ], + ], + ), + ], + ], + ), + ); + } + + Widget _buildMediaContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final path = item.content.trim(); + final filename = path.isEmpty + ? '' + : path.split(Platform.pathSeparator).last; + final isAudio = item.type == ClipboardContentType.audio; + final typeColor = _typeColor(item.type, colors); + final l10n = AppLocalizations.of(context); + final typeName = isAudio ? l10n.audioFile : l10n.videoFile; + final missing = !item.isFileAvailable(); + + final semanticsLabel = [ + filename.isEmpty ? typeName : filename, + if (missing) l10n.fileNotFound, + ].join(', '); + + final hasThumb = _imagePathResolved && _resolvedImagePath != null; + + if (!isAudio && hasThumb) { + return Semantics( + label: semanticsLabel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + child: Container( + height: theme.sizing.cardImageHeight, + width: double.infinity, + color: colors.surfaceVariant, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(_resolvedImagePath!), + fit: BoxFit.contain, + cacheWidth: _resolvedIsThumb ? 256 : 700, + errorBuilder: (_, e, st) => _MediaIcon( + isAudio: false, + typeColor: typeColor, + radius: theme.radii.thumbnail, + ), + ), + Center( + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + Icons.play_arrow_rounded, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 4), + Text( + filename.isEmpty ? l10n.videoFile : filename, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (missing) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ExtBadge(label: l10n.fileNotFound, color: colors.warning), + ], + ), + ], + ], + ), + ); + } + + return Semantics( + label: semanticsLabel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + filename.isEmpty ? typeName : filename, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (missing) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ExtBadge(label: l10n.fileNotFound, color: colors.warning), + ], + ), + ], + ], + ), + ); + } + + Widget _buildLinkContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final uri = Uri.tryParse(item.content.trim()); + final domain = uri?.host ?? ''; + final typeColor = _typeColor(item.type, colors); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.content.trim(), + style: theme.typography.cardContent.copyWith( + color: colors.primary.withValues(alpha: 0.85), + decoration: TextDecoration.underline, + decorationColor: colors.primary.withValues(alpha: 0.3), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (domain.isNotEmpty) ...[ + const SizedBox(height: 3), + Row( + mainAxisSize: MainAxisSize.min, + children: [_ExtBadge(label: domain, color: typeColor)], + ), + ], + ], + ), + ), + ], + ); + } + + Map? _parseMetadata(ClipboardItem item) { + if (item.metadata == null || item.metadata!.isEmpty) return null; + try { + return json.decode(item.metadata!) as Map; + } catch (_) { + return null; + } + } + + String _getExtForItem(ClipboardItem item) { + if (item.type != ClipboardContentType.file && + item.type != ClipboardContentType.folder && + item.type != ClipboardContentType.audio && + item.type != ClipboardContentType.video && + item.type != ClipboardContentType.image) { + return ''; + } + final lines = item.content.split('\n').where((s) => s.isNotEmpty).toList(); + if (lines.isEmpty) return ''; + final firstName = lines.first.split(Platform.pathSeparator).last; + return firstName.contains('.') + ? firstName.split('.').last.toUpperCase() + : ''; + } + + bool _hasFooter(ClipboardItem item) { + if (_needsExpandToggle(item)) return true; + if (_needsOpenAction(item)) return true; + if (item.pasteCount > 0) return true; + if (_getExtForItem(item).isNotEmpty) return true; + final meta = _parseMetadata(item); + if (meta == null) return false; + return meta.containsKey('file_size') || + meta.containsKey('size') || + meta.containsKey('width') || + meta.containsKey('video_width') || + meta.containsKey('duration'); + } + + Widget _buildFooter( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final meta = _parseMetadata(item); + final footerAlpha = theme.cardStyle.footerOpacity; + final footerColor = colors.onSurface.withValues(alpha: footerAlpha); + final footerStyle = theme.typography.cardFooter.copyWith( + color: footerColor, + ); + final iconColor = colors.onSurface.withValues(alpha: footerAlpha - 0.1); + + final ext = _getExtForItem(item); + final typeColor = _typeColor(item.type, colors); + final widgets = []; + + final w = meta?['width'] ?? meta?['video_width']; + final h = meta?['height'] ?? meta?['video_height']; + if (w != null && h != null) { + widgets.add( + _FooterChip( + icon: Icons.aspect_ratio_rounded, + label: '$w×$h', + style: footerStyle, + iconColor: iconColor, + iconSize: theme.sizing.iconSizeXs, + ), + ); + } + + final fileSize = meta?['file_size'] ?? meta?['size']; + if (fileSize != null && fileSize is num && fileSize > 0) { + widgets.add( + _FooterChip( + icon: Icons.storage_rounded, + label: _formatFileSize(fileSize.toInt()), + style: footerStyle, + iconColor: iconColor, + iconSize: theme.sizing.iconSizeXs, + ), + ); + } + + final duration = meta?['duration']; + if (duration != null && duration is num && duration > 0) { + widgets.add( + _FooterChip( + icon: Icons.timer_outlined, + label: _formatDuration(duration.toInt()), + style: footerStyle, + iconColor: iconColor, + iconSize: theme.sizing.iconSizeXs, + ), + ); + } + + if (item.pasteCount > 0) { + widgets.add( + Text( + '×${item.pasteCount}', + style: footerStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } + + final showExpand = _needsExpandToggle(item); + final showOpen = !showExpand && _needsOpenAction(item); + + return Row( + children: [ + if (item.type == ClipboardContentType.color) + _ColorBadge(value: item.content.trim()) + else if (item.type == ClipboardContentType.phone) ...[ + if (_resolvePhoneCountry(item.content) case final c?) + _ExtBadge(label: c, color: typeColor), + ] else if (item.type == ClipboardContentType.email) ...[ + if (_resolveEmailProvider(item.content) case final p?) + _ExtBadge(label: p, color: typeColor), + ] else if (ext.isNotEmpty) + _ExtBadge(label: ext, color: typeColor), + if (showExpand) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onExpandToggle?.call(), + canRequestFocus: false, + borderRadius: BorderRadius.circular(8), + hoverColor: colors.onSurface.withValues(alpha: 0.06), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + child: Icon( + widget.isExpanded + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 14, + color: colors.onSurface.withValues(alpha: 0.35), + ), + ), + ), + ), + ), + if (showOpen) + Padding( + padding: EdgeInsets.only(left: ext.isNotEmpty ? 6 : 0), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onOpen?.call(), + canRequestFocus: false, + borderRadius: BorderRadius.circular(8), + hoverColor: colors.onSurface.withValues(alpha: 0.06), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + child: Icon( + Icons.open_in_new_rounded, + size: 14, + color: colors.onSurface.withValues(alpha: 0.35), + ), + ), + ), + ), + ), + if (widgets.isNotEmpty) + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (int i = 0; i < widgets.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Flexible(fit: FlexFit.loose, child: widgets[i]), + ], + ], + ), + ), + ], + ); + } + + static String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + static String _formatDuration(int seconds) { + final h = seconds ~/ 3600; + final m = (seconds % 3600) ~/ 60; + final s = seconds % 60; + if (h > 0) { + return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + return '$m:${s.toString().padLeft(2, '0')}'; + } + + Color _typeColor(ClipboardContentType type, AppThemeColorScheme colors) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return switch (type) { + ClipboardContentType.text => colors.accentBlue, + ClipboardContentType.image => colors.accentOrange, + ClipboardContentType.file => colors.accentYellow, + ClipboardContentType.folder => colors.accentYellow, + ClipboardContentType.link => colors.accentGreen, + ClipboardContentType.audio => + isDark ? const Color(0xFF7DD3FC) : const Color(0xFF075985), + ClipboardContentType.video => colors.accentRed, + ClipboardContentType.email => colors.accentBlue, + ClipboardContentType.phone => colors.accentGreen, + ClipboardContentType.color => colors.accentOrange, + ClipboardContentType.ip => + isDark ? const Color(0xFFD4A5F5) : const Color(0xFF6B21A8), + ClipboardContentType.uuid => + isDark ? const Color(0xFF94A3B8) : const Color(0xFF475569), + ClipboardContentType.json => colors.accentYellow, + ClipboardContentType.unknown => colors.onSurfaceMuted, + }; + } + + String _contentTypeName(ClipboardContentType type, AppLocalizations l) => + switch (type) { + ClipboardContentType.text => l.typeText, + ClipboardContentType.image => l.typeImage, + ClipboardContentType.file => l.typeFile, + ClipboardContentType.folder => l.typeFolder, + ClipboardContentType.link => l.typeLink, + ClipboardContentType.audio => l.typeAudio, + ClipboardContentType.video => l.typeVideo, + ClipboardContentType.email => l.typeEmail, + ClipboardContentType.phone => l.typePhone, + ClipboardContentType.color => l.typeColor, + ClipboardContentType.ip => l.typeIp, + ClipboardContentType.uuid => l.typeUuid, + ClipboardContentType.json => l.typeJson, + ClipboardContentType.unknown => 'Unknown', + }; + + static const _phoneCountries = { + '1': 'US/CA', + '7': 'Russia', + '20': 'Egypt', + '27': 'S.Africa', + '30': 'Greece', + '31': 'Netherlands', + '32': 'Belgium', + '33': 'France', + '34': 'Spain', + '36': 'Hungary', + '39': 'Italy', + '40': 'Romania', + '41': 'Switzerland', + '43': 'Austria', + '44': 'UK', + '45': 'Denmark', + '46': 'Sweden', + '47': 'Norway', + '48': 'Poland', + '49': 'Germany', + '51': 'Peru', + '52': 'Mexico', + '53': 'Cuba', + '54': 'Argentina', + '55': 'Brazil', + '56': 'Chile', + '57': 'Colombia', + '58': 'Venezuela', + '60': 'Malaysia', + '61': 'Australia', + '62': 'Indonesia', + '63': 'Philippines', + '64': 'NZ', + '65': 'Singapore', + '66': 'Thailand', + '81': 'Japan', + '82': 'Korea', + '84': 'Vietnam', + '86': 'China', + '90': 'Turkey', + '91': 'India', + '92': 'Pakistan', + '94': 'Sri Lanka', + '98': 'Iran', + '212': 'Morocco', + '213': 'Algeria', + '216': 'Tunisia', + '234': 'Nigeria', + '254': 'Kenya', + '351': 'Portugal', + '352': 'Luxembourg', + '353': 'Ireland', + '354': 'Iceland', + '358': 'Finland', + '380': 'Ukraine', + '381': 'Serbia', + '385': 'Croatia', + '420': 'Czech', + '421': 'Slovakia', + '502': 'Guatemala', + '503': 'El Salvador', + '504': 'Honduras', + '505': 'Nicaragua', + '506': 'Costa Rica', + '507': 'Panama', + '591': 'Bolivia', + '593': 'Ecuador', + '595': 'Paraguay', + '598': 'Uruguay', + '855': 'Cambodia', + '880': 'Bangladesh', + '886': 'Taiwan', + '961': 'Lebanon', + '962': 'Jordan', + '964': 'Iraq', + '965': 'Kuwait', + '966': 'Saudi Arabia', + '971': 'UAE', + '972': 'Israel', + '974': 'Qatar', + '977': 'Nepal', + '994': 'Azerbaijan', + '995': 'Georgia', + '998': 'Uzbekistan', + }; + + // Keyed by first domain label — covers all regional variants automatically. + // e.g. outlook.com / outlook.com.ar / outlook.cl all resolve to 'Outlook' + static const _emailPrefixes = { + 'gmail': 'Gmail', + 'googlemail': 'Gmail', + 'outlook': 'Outlook', + 'hotmail': 'Hotmail', + 'live': 'Outlook', + 'msn': 'MSN', + 'yahoo': 'Yahoo', + 'icloud': 'iCloud', + 'me': 'iCloud', + 'mac': 'iCloud', + 'proton': 'Proton', + 'protonmail': 'Proton', + 'tutanota': 'Tutanota', + 'tuta': 'Tuta', + 'zoho': 'Zoho', + 'aol': 'AOL', + 'yandex': 'Yandex', + 'gmx': 'GMX', + 'fastmail': 'FastMail', + 'hey': 'HEY', + }; + + static String? _resolvePhoneCountry(String phone) { + if (!phone.trimLeft().startsWith('+')) return null; + final digits = phone.replaceAll(RegExp(r'\D'), ''); + for (final len in [3, 2, 1]) { + if (digits.length >= len) { + final country = _phoneCountries[digits.substring(0, len)]; + if (country != null) return country; + } + } + return null; + } + + static String? _resolveEmailProvider(String email) { + final at = email.indexOf('@'); + if (at == -1 || at >= email.length - 1) return null; + final domain = email.substring(at + 1).toLowerCase(); + final prefix = domain.split('.').first; + return _emailPrefixes[prefix] ?? domain; + } + + String _formatTimestamp(DateTime dt, AppLocalizations l) { + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inMinutes < 1) return l.timeNow; + if (diff.inMinutes < 60) return '${diff.inMinutes}m'; + if (diff.inHours < 24) return '${diff.inHours}h'; + if (diff.inDays < 7) return '${diff.inDays}d'; + return '${dt.month}/${dt.day}'; + } +} + +class _CardActionButton extends StatelessWidget { + const _CardActionButton({ + required this.icon, + required this.onTap, + this.tooltip, + this.isDanger = false, + }); + + final IconData icon; + final VoidCallback onTap; + final String? tooltip; + final bool isDanger; + + @override + Widget build(BuildContext context) { + final theme = CopyPasteTheme.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + + final bg = isDark + ? colors.surfaceVariant + : Colors.white.withValues(alpha: 0.95); + + final button = SizedBox( + width: 30, + height: 30, + child: Material( + color: bg, + borderRadius: BorderRadius.circular(theme.radii.button), + child: InkWell( + onTap: onTap, + canRequestFocus: false, + borderRadius: BorderRadius.circular(theme.radii.button), + hoverColor: isDanger + ? colors.danger.withValues(alpha: 0.08) + : colors.onSurface.withValues(alpha: 0.06), + splashColor: isDanger + ? colors.danger.withValues(alpha: 0.15) + : colors.onSurface.withValues(alpha: 0.1), + child: Center( + child: Icon( + icon, + size: 13, + color: isDanger + ? colors.danger.withValues(alpha: 0.7) + : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ), + ); + + if (tooltip != null) { + return Tooltip( + message: tooltip!, + textStyle: const TextStyle(fontSize: 10, color: Colors.white), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(4), + ), + preferBelow: false, + verticalOffset: 16, + waitDuration: const Duration(milliseconds: 400), + child: button, + ); + } + return button; + } +} + +class _MediaIcon extends StatelessWidget { + const _MediaIcon({ + required this.isAudio, + required this.typeColor, + required this.radius, + }); + + final bool isAudio; + final Color typeColor; + final double radius; + + @override + Widget build(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: typeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(radius), + ), + child: Center( + child: Icon( + isAudio + ? Icons.music_note_rounded + : Icons.play_circle_outline_rounded, + size: 22, + color: typeColor, + ), + ), + ); + } +} + +class _ColorBadge extends StatelessWidget { + const _ColorBadge({required this.value}); + + final String value; + + static Color? _parse(String value) { + final hex = value.startsWith('#') ? value.substring(1) : null; + if (hex == null) return null; + final normalized = switch (hex.length) { + 3 => 'FF${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}', + 6 => 'FF$hex', + 8 => hex, + _ => null, + }; + if (normalized == null) return null; + final int? v = int.tryParse(normalized, radix: 16); + return v != null ? Color(v) : null; + } + + static String _format(String value) { + final v = value.trimLeft().toLowerCase(); + if (v.startsWith('#')) return 'HEX'; + if (v.startsWith('rgba')) return 'RGBA'; + if (v.startsWith('rgb')) return 'RGB'; + if (v.startsWith('hsla')) return 'HSLA'; + if (v.startsWith('hsl')) return 'HSL'; + return 'COLOR'; + } + + @override + Widget build(BuildContext context) { + final theme = CopyPasteTheme.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final color = _parse(value); + final label = _format(value); + + if (color == null) { + return _ExtBadge(label: label, color: colors.accentOrange); + } + + final onColor = color.computeLuminance() > 0.4 + ? Colors.black.withValues(alpha: 0.75) + : Colors.white.withValues(alpha: 0.9); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: theme.typography.cardFooter.copyWith( + fontSize: 9, + fontWeight: FontWeight.w600, + color: onColor, + letterSpacing: 0.3, + ), + ), + ); + } +} + +class _ExtBadge extends StatelessWidget { + const _ExtBadge({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: color.withValues(alpha: 0.85), + letterSpacing: 0.3, + ), + ), + ), + ); + } +} + +class _FooterChip extends StatelessWidget { + const _FooterChip({ + required this.icon, + required this.label, + required this.style, + required this.iconColor, + required this.iconSize, + }); + + final IconData icon; + final String label; + final TextStyle style; + final Color iconColor; + final double iconSize; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: iconSize, color: iconColor), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +enum _ContextAction { paste, pastePlain, pin, edit, delete } + +class _ContextMenuItem extends StatelessWidget { + const _ContextMenuItem({ + required this.icon, + required this.label, + required this.colors, + this.danger = false, + }); + + final IconData icon; + final String label; + final AppThemeColorScheme colors; + final bool danger; + + @override + Widget build(BuildContext context) { + final color = danger ? colors.danger : colors.onSurface; + return Row( + children: [ + Icon(icon, size: 13, color: color.withValues(alpha: 0.7)), + const SizedBox(width: 8), + Expanded( + child: Text(label, style: TextStyle(fontSize: 12, color: color)), + ), + ], + ); + } +} diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 61849f4c..4b91a817 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -1,578 +1,850 @@ -#include "copypaste_linux_shell.h" - -#include -#include -#include -#ifdef HAVE_APPINDICATOR -#include -#endif -#include -#include -#include -#include - -#ifdef GDK_WINDOWING_X11 -#include -#include -#include -#include -#include -#endif - -struct _CopyPasteLinuxShell { - FlMethodChannel* method_channel; - FlEventChannel* event_channel; - gboolean events_listening; - -#ifdef HAVE_APPINDICATOR - AppIndicator* app_indicator; -#else - GtkStatusIcon* tray_icon; -#endif - GtkWidget* tray_menu; - GtkWidget* toggle_item; - GtkWidget* exit_item; - gchar* resolved_icon_path; - - GtkWindow* gtk_window; - - gboolean hotkey_registered; -#ifdef GDK_WINDOWING_X11 - Display* xdisplay; - Window root_window; - guint hotkey_keycode; - guint hotkey_modifiers; - guint32 last_hotkey_time; -#endif -}; - -static const gchar* kShellChannelName = "copypaste/linux_shell"; -static const gchar* kShellEventChannelName = "copypaste/linux_shell/events"; - -static gboolean shell_is_x11(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* display = gdk_display_get_default(); - return display != NULL && GDK_IS_X11_DISPLAY(display); -#else - return FALSE; -#endif -} - -static FlValue* shell_event(const gchar* type) { - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_string(type)); - return fl_value_ref(event); -} - -static void send_shell_event(CopyPasteLinuxShell* shell, const gchar* type) { - if (!shell->events_listening || shell->event_channel == NULL) { - return; - } - - g_autoptr(FlValue) event = shell_event(type); - g_autoptr(GError) error = NULL; - if (!fl_event_channel_send(shell->event_channel, event, NULL, &error) && - error != NULL) { - g_warning("Failed to send linux shell event: %s", error->message); - } -} - -static gchar* resolve_asset_path(const gchar* asset_path) { - if (asset_path == NULL || *asset_path == '\0') { - return NULL; - } - - if (g_path_is_absolute(asset_path) && g_file_test(asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(asset_path); - } - - gchar exe_path[PATH_MAX + 1]; - ssize_t length = readlink("/proc/self/exe", exe_path, PATH_MAX); - if (length <= 0) { - return g_file_test(asset_path, G_FILE_TEST_EXISTS) ? g_strdup(asset_path) : NULL; - } - - exe_path[length] = '\0'; - g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); - g_autofree gchar* flutter_asset_path = - g_build_filename(exe_dir, "data", "flutter_assets", asset_path, NULL); - if (g_file_test(flutter_asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(flutter_asset_path); - } - - g_autofree gchar* sibling_asset_path = g_build_filename(exe_dir, asset_path, NULL); - if (g_file_test(sibling_asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(sibling_asset_path); - } - - return NULL; -} - -static void destroy_tray_menu(CopyPasteLinuxShell* shell) { - if (shell->tray_menu != NULL) { - gtk_widget_destroy(shell->tray_menu); - shell->tray_menu = NULL; - shell->toggle_item = NULL; - shell->exit_item = NULL; - } -} - -static void tray_toggle_cb(GtkMenuItem* item, gpointer user_data) { - (void)item; - send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); -} - -static void tray_exit_cb(GtkMenuItem* item, gpointer user_data) { - (void)item; - send_shell_event((CopyPasteLinuxShell*)user_data, "exit"); -} - -#ifndef HAVE_APPINDICATOR -static void tray_activate_cb(GtkStatusIcon* status_icon, gpointer user_data) { - (void)status_icon; - send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); -} - -static void tray_popup_menu_cb(GtkStatusIcon* status_icon, - guint button, - guint activate_time, - gpointer user_data) { - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - if (shell->tray_menu == NULL) { - return; - } - - gtk_menu_popup(GTK_MENU(shell->tray_menu), NULL, NULL, - gtk_status_icon_position_menu, status_icon, button, - activate_time); -} -#endif - -static void rebuild_tray_menu(CopyPasteLinuxShell* shell, - const gchar* show_hide_label, - const gchar* exit_label) { - destroy_tray_menu(shell); - - shell->tray_menu = gtk_menu_new(); - shell->toggle_item = gtk_menu_item_new_with_label(show_hide_label); - shell->exit_item = gtk_menu_item_new_with_label(exit_label); - GtkWidget* separator = gtk_separator_menu_item_new(); - - g_signal_connect(shell->toggle_item, "activate", G_CALLBACK(tray_toggle_cb), - shell); - g_signal_connect(shell->exit_item, "activate", G_CALLBACK(tray_exit_cb), shell); - - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->toggle_item); - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), separator); - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->exit_item); - gtk_widget_show_all(shell->tray_menu); -} - -static void parse_tray_args(FlValue* args, - const gchar** out_icon_path, - const gchar** out_tooltip, - const gchar** out_show_hide, - const gchar** out_exit_label) { - FlValue* icon_value = args != NULL ? fl_value_lookup_string(args, "iconPath") : NULL; - FlValue* tooltip_value = args != NULL ? fl_value_lookup_string(args, "tooltip") : NULL; - FlValue* toggle_value = args != NULL ? fl_value_lookup_string(args, "showHideLabel") : NULL; - FlValue* exit_value = args != NULL ? fl_value_lookup_string(args, "exitLabel") : NULL; - - *out_icon_path = - icon_value != NULL && fl_value_get_type(icon_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(icon_value) - : NULL; - *out_tooltip = - tooltip_value != NULL && fl_value_get_type(tooltip_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(tooltip_value) - : "CopyPaste"; - *out_show_hide = - toggle_value != NULL && fl_value_get_type(toggle_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(toggle_value) - : "Show/Hide"; - *out_exit_label = - exit_value != NULL && fl_value_get_type(exit_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(exit_value) - : "Exit"; -} - -static gboolean init_tray(CopyPasteLinuxShell* shell, FlValue* args) { - const gchar* icon_path; - const gchar* tooltip; - const gchar* show_hide; - const gchar* exit_label; - parse_tray_args(args, &icon_path, &tooltip, &show_hide, &exit_label); - - g_free(shell->resolved_icon_path); - shell->resolved_icon_path = resolve_asset_path(icon_path); - -#ifdef HAVE_APPINDICATOR - if (shell->app_indicator == NULL) { - shell->app_indicator = app_indicator_new( - "com.rgdevment.copypaste", "copypaste", - APP_INDICATOR_CATEGORY_APPLICATION_STATUS); - } - - if (shell->resolved_icon_path != NULL) { - g_autofree gchar* icon_dir = - g_path_get_dirname(shell->resolved_icon_path); - g_autofree gchar* icon_base = - g_path_get_basename(shell->resolved_icon_path); - gchar* dot = strrchr(icon_base, '.'); - if (dot != NULL) *dot = '\0'; - app_indicator_set_icon_theme_path(shell->app_indicator, icon_dir); - app_indicator_set_icon_full(shell->app_indicator, icon_base, tooltip); - } - - app_indicator_set_title(shell->app_indicator, tooltip); - rebuild_tray_menu(shell, show_hide, exit_label); - app_indicator_set_menu(shell->app_indicator, GTK_MENU(shell->tray_menu)); - app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_ACTIVE); -#else - if (shell->tray_icon == NULL) { - shell->tray_icon = gtk_status_icon_new(); - g_signal_connect(shell->tray_icon, "activate", - G_CALLBACK(tray_activate_cb), shell); - g_signal_connect(shell->tray_icon, "popup-menu", - G_CALLBACK(tray_popup_menu_cb), shell); - } - - if (shell->resolved_icon_path != NULL) { - gtk_status_icon_set_from_file(shell->tray_icon, shell->resolved_icon_path); - } - - gtk_status_icon_set_tooltip_text(shell->tray_icon, tooltip); - gtk_status_icon_set_visible(shell->tray_icon, TRUE); - rebuild_tray_menu(shell, show_hide, exit_label); -#endif - - return TRUE; -} - -static gboolean destroy_tray(CopyPasteLinuxShell* shell) { - destroy_tray_menu(shell); - -#ifdef HAVE_APPINDICATOR - if (shell->app_indicator != NULL) { - app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_PASSIVE); - g_clear_object(&shell->app_indicator); - } -#else - if (shell->tray_icon != NULL) { - gtk_status_icon_set_visible(shell->tray_icon, FALSE); - g_clear_object(&shell->tray_icon); - } -#endif - - g_clear_pointer(&shell->resolved_icon_path, g_free); - return TRUE; -} - -#ifdef GDK_WINDOWING_X11 -static guint modifier_combinations[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask}; -static int (*previous_x11_error_handler)(Display*, XErrorEvent*) = NULL; -static Display* trapped_x11_display = NULL; -static int trapped_x11_error_code = Success; - -static int hotkey_x11_error_handler(Display* display, XErrorEvent* event) { - if (display == trapped_x11_display) { - trapped_x11_error_code = event->error_code; - return 0; - } - - if (previous_x11_error_handler != NULL) { - return previous_x11_error_handler(display, event); - } - - return 0; -} - -static gboolean trap_x11_grab(Display* display, - Window root_window, - KeyCode keycode, - guint modifiers) { - previous_x11_error_handler = XSetErrorHandler(hotkey_x11_error_handler); - trapped_x11_display = display; - trapped_x11_error_code = Success; - - XGrabKey(display, (int)keycode, (int)modifiers, root_window, True, - GrabModeAsync, GrabModeAsync); - XSync(display, False); - - trapped_x11_display = NULL; - XSetErrorHandler(previous_x11_error_handler); - previous_x11_error_handler = NULL; - - return trapped_x11_error_code == Success; -} - -static void ungrab_hotkey_variants(Display* display, - Window root_window, - KeyCode keycode, - guint modifiers) { - for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - XUngrabKey(display, (int)keycode, - (int)(modifiers | modifier_combinations[i]), root_window); - } - XSync(display, False); -} - -static KeySym virtual_key_to_keysym(gint64 virtual_key) { - if (virtual_key >= 0x41 && virtual_key <= 0x5A) { - return (KeySym)(XK_A + (virtual_key - 0x41)); - } - return NoSymbol; -} - -static guint compute_modifier_mask(FlValue* args) { - guint modifiers = 0; - - FlValue* ctrl = fl_value_lookup_string(args, "useCtrl"); - FlValue* meta = fl_value_lookup_string(args, "useWin"); - FlValue* alt = fl_value_lookup_string(args, "useAlt"); - FlValue* shift = fl_value_lookup_string(args, "useShift"); - - if (ctrl != NULL && fl_value_get_type(ctrl) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(ctrl)) { - modifiers |= ControlMask; - } - if (meta != NULL && fl_value_get_type(meta) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(meta)) { - modifiers |= Mod4Mask; - } - if (alt != NULL && fl_value_get_type(alt) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(alt)) { - modifiers |= Mod1Mask; - } - if (shift != NULL && fl_value_get_type(shift) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(shift)) { - modifiers |= ShiftMask; - } - - return modifiers; -} - -static void unregister_hotkey(CopyPasteLinuxShell* shell) { - if (!shell->hotkey_registered || shell->xdisplay == NULL || shell->hotkey_keycode == 0) { - shell->hotkey_registered = FALSE; - return; - } - - ungrab_hotkey_variants(shell->xdisplay, shell->root_window, - (KeyCode)shell->hotkey_keycode, - shell->hotkey_modifiers); - shell->hotkey_registered = FALSE; - shell->hotkey_keycode = 0; - shell->hotkey_modifiers = 0; -} - -static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { - if (!shell_is_x11() || shell->xdisplay == NULL) { - return FALSE; - } - - unregister_hotkey(shell); - - FlValue* key_value = args != NULL ? fl_value_lookup_string(args, "virtualKey") : NULL; - gint64 virtual_key = (key_value != NULL && fl_value_get_type(key_value) == FL_VALUE_TYPE_INT) - ? fl_value_get_int(key_value) : 0; - KeySym keysym = virtual_key_to_keysym(virtual_key); - if (keysym == NoSymbol) { - g_warning("registerHotkey: unsupported virtual key 0x%llx", (unsigned long long)virtual_key); - return FALSE; - } - - guint modifiers = compute_modifier_mask(args); - if (modifiers == 0) { - g_warning("registerHotkey: no modifier keys specified"); - return FALSE; - } - - KeyCode keycode = XKeysymToKeycode(shell->xdisplay, keysym); - if (keycode == 0) { - g_warning("registerHotkey: no keycode for keysym %lu", (unsigned long)keysym); - return FALSE; - } - - for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - if (!trap_x11_grab(shell->xdisplay, shell->root_window, keycode, - modifiers | modifier_combinations[i])) { - g_warning("registerHotkey: XGrabKey failed (modifier variant 0x%x) — key may be in use", - modifiers | modifier_combinations[i]); - ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, - modifiers); - return FALSE; - } - } - - // Flush pending requests before reading window attributes to avoid stale state. - XSync(shell->xdisplay, False); - XWindowAttributes attrs; - if (XGetWindowAttributes(shell->xdisplay, shell->root_window, &attrs) != 0) { - XSelectInput(shell->xdisplay, shell->root_window, - attrs.your_event_mask | KeyPressMask); - } else { - XSelectInput(shell->xdisplay, shell->root_window, KeyPressMask); - } - XSync(shell->xdisplay, False); - - shell->hotkey_registered = TRUE; - shell->hotkey_keycode = keycode; - shell->hotkey_modifiers = modifiers; - return TRUE; -} - -static GdkFilterReturn x11_event_filter(GdkXEvent* xevent, - GdkEvent* event, - gpointer user_data) { - (void)event; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - if (!shell->hotkey_registered) { - return GDK_FILTER_CONTINUE; - } - - XEvent* x_event = (XEvent*)xevent; - if (x_event->type != KeyPress) { - return GDK_FILTER_CONTINUE; - } - - guint relevant_mask = ControlMask | ShiftMask | Mod1Mask | Mod4Mask; - guint state = (guint)x_event->xkey.state & relevant_mask; - if ((guint)x_event->xkey.keycode == shell->hotkey_keycode && - state == shell->hotkey_modifiers) { - shell->last_hotkey_time = x_event->xkey.time; - send_shell_event(shell, "hotkey"); - return GDK_FILTER_REMOVE; - } - - return GDK_FILTER_CONTINUE; -} -#endif - -static FlMethodErrorResponse* shell_listen_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - shell->events_listening = TRUE; - return NULL; -} - -static FlMethodErrorResponse* shell_cancel_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - shell->events_listening = FALSE; - return NULL; -} - -static void respond_method_success(FlMethodCall* method_call, FlValue* result) { - g_autoptr(GError) error = NULL; - g_autoptr(FlValue) owned = result; - if (!fl_method_call_respond_success(method_call, owned, &error) && error != NULL) { - g_warning("Failed to respond to linux shell method call: %s", error->message); - } -} - -static void shell_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - (void)channel; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "initTray") == 0 || strcmp(method, "updateTray") == 0) { - respond_method_success(method_call, fl_value_new_bool(init_tray(shell, args))); - return; - } - - if (strcmp(method, "destroyTray") == 0) { - respond_method_success(method_call, fl_value_new_bool(destroy_tray(shell))); - return; - } - - if (strcmp(method, "registerHotkey") == 0) { -#ifdef GDK_WINDOWING_X11 - respond_method_success(method_call, fl_value_new_bool(register_hotkey(shell, args))); -#else - respond_method_success(method_call, fl_value_new_bool(FALSE)); -#endif - return; - } - - if (strcmp(method, "unregisterHotkey") == 0) { -#ifdef GDK_WINDOWING_X11 - unregister_hotkey(shell); -#endif - respond_method_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - if (strcmp(method, "focusWindow") == 0) { - if (shell->gtk_window != NULL) { -#ifdef GDK_WINDOWING_X11 - guint32 t = shell->last_hotkey_time != 0 ? shell->last_hotkey_time - : GDK_CURRENT_TIME; - gtk_window_present_with_time(shell->gtk_window, t); -#else - gtk_window_present(shell->gtk_window); -#endif - } - respond_method_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - g_autoptr(FlMethodResponse) response = - FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); - fl_method_call_respond(method_call, response, NULL); -} - -CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, - GtkWindow* window) { - CopyPasteLinuxShell* shell = g_new0(CopyPasteLinuxShell, 1); - shell->gtk_window = window; - - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - shell->method_channel = - fl_method_channel_new(messenger, kShellChannelName, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler(shell->method_channel, - shell_method_call_cb, shell, NULL); - - shell->event_channel = - fl_event_channel_new(messenger, kShellEventChannelName, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers(shell->event_channel, shell_listen_cb, - shell_cancel_cb, shell, NULL); - -#ifdef GDK_WINDOWING_X11 - if (shell_is_x11()) { - GdkDisplay* display = gdk_display_get_default(); - shell->xdisplay = gdk_x11_display_get_xdisplay(display); - shell->root_window = DefaultRootWindow(shell->xdisplay); - gdk_window_add_filter(NULL, x11_event_filter, shell); - } -#endif - - return shell; -} - -void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { - if (shell == NULL) { - return; - } - -#ifdef GDK_WINDOWING_X11 - if (shell_is_x11()) { - unregister_hotkey(shell); - gdk_window_remove_filter(NULL, x11_event_filter, shell); - } -#endif - - destroy_tray(shell); - g_clear_object(&shell->method_channel); - g_clear_object(&shell->event_channel); - g_free(shell); -} +#include "copypaste_linux_shell.h" + +#include +#include +#include +#ifdef HAVE_APPINDICATOR +#include +#endif +#include +#include +#include +#include + +#ifdef GDK_WINDOWING_X11 +#include +#include +#include +#include +#include +#include +#endif + +struct _CopyPasteLinuxShell { + FlMethodChannel* method_channel; + FlEventChannel* event_channel; + gboolean events_listening; + +#ifdef HAVE_APPINDICATOR + AppIndicator* app_indicator; +#endif + GtkWidget* tray_menu; + GtkWidget* toggle_item; + GtkWidget* exit_item; + gchar* resolved_icon_path; + + GtkWindow* gtk_window; + + gboolean hotkey_registered; +#ifdef GDK_WINDOWING_X11 + Display* xdisplay; + Window root_window; + guint hotkey_keycode; + guint hotkey_modifiers; + guint32 last_hotkey_time; +#endif +}; + +static const gchar* kShellChannelName = "copypaste/linux_shell"; +static const gchar* kShellEventChannelName = "copypaste/linux_shell/events"; + +static gboolean shell_is_x11(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* display = gdk_display_get_default(); + return display != NULL && GDK_IS_X11_DISPLAY(display); +#else + return FALSE; +#endif +} + +static FlValue* shell_event(const gchar* type) { + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_string(type)); + return fl_value_ref(event); +} + +static void send_shell_event(CopyPasteLinuxShell* shell, const gchar* type) { + if (!shell->events_listening || shell->event_channel == NULL) { + return; + } + + g_autoptr(FlValue) event = shell_event(type); + g_autoptr(GError) error = NULL; + if (!fl_event_channel_send(shell->event_channel, event, NULL, &error) && + error != NULL) { + g_warning("Failed to send linux shell event: %s", error->message); + } +} + +static gboolean window_unmap_event_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + (void)widget; + (void)event; + send_shell_event((CopyPasteLinuxShell*)user_data, "unmapped"); + return FALSE; +} + +static gboolean window_map_event_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + (void)widget; + (void)event; + send_shell_event((CopyPasteLinuxShell*)user_data, "mapped"); + return FALSE; +} + +static gboolean window_configure_event_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + (void)widget; + (void)event; + send_shell_event((CopyPasteLinuxShell*)user_data, "configureNotify"); + return FALSE; +} + +static gchar* resolve_asset_path(const gchar* asset_path) { + if (asset_path == NULL || *asset_path == '\0') { + return NULL; + } + + if (g_path_is_absolute(asset_path) && g_file_test(asset_path, G_FILE_TEST_EXISTS)) { + return g_strdup(asset_path); + } + + gchar exe_path[PATH_MAX + 1]; + ssize_t length = readlink("/proc/self/exe", exe_path, PATH_MAX); + if (length <= 0) { + return g_file_test(asset_path, G_FILE_TEST_EXISTS) ? g_strdup(asset_path) : NULL; + } + + exe_path[length] = '\0'; + g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); + g_autofree gchar* flutter_asset_path = + g_build_filename(exe_dir, "data", "flutter_assets", asset_path, NULL); + if (g_file_test(flutter_asset_path, G_FILE_TEST_EXISTS)) { + return g_strdup(flutter_asset_path); + } + + g_autofree gchar* sibling_asset_path = g_build_filename(exe_dir, asset_path, NULL); + if (g_file_test(sibling_asset_path, G_FILE_TEST_EXISTS)) { + return g_strdup(sibling_asset_path); + } + + return NULL; +} + +static void destroy_tray_menu(CopyPasteLinuxShell* shell) { + if (shell->tray_menu != NULL) { + gtk_widget_destroy(shell->tray_menu); + shell->tray_menu = NULL; + shell->toggle_item = NULL; + shell->exit_item = NULL; + } +} + +static void tray_toggle_cb(GtkMenuItem* item, gpointer user_data) { + (void)item; + send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); +} + +static void tray_exit_cb(GtkMenuItem* item, gpointer user_data) { + (void)item; + send_shell_event((CopyPasteLinuxShell*)user_data, "exit"); +} + +static void rebuild_tray_menu(CopyPasteLinuxShell* shell, + const gchar* show_hide_label, + const gchar* exit_label) { + destroy_tray_menu(shell); + + shell->tray_menu = gtk_menu_new(); + shell->toggle_item = gtk_menu_item_new_with_label(show_hide_label); + shell->exit_item = gtk_menu_item_new_with_label(exit_label); + GtkWidget* separator = gtk_separator_menu_item_new(); + + g_signal_connect(shell->toggle_item, "activate", G_CALLBACK(tray_toggle_cb), + shell); + g_signal_connect(shell->exit_item, "activate", G_CALLBACK(tray_exit_cb), shell); + + gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->toggle_item); + gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), separator); + gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->exit_item); + gtk_widget_show_all(shell->tray_menu); +} + +static void parse_tray_args(FlValue* args, + const gchar** out_icon_path, + const gchar** out_tooltip, + const gchar** out_show_hide, + const gchar** out_exit_label) { + FlValue* icon_value = args != NULL ? fl_value_lookup_string(args, "iconPath") : NULL; + FlValue* tooltip_value = args != NULL ? fl_value_lookup_string(args, "tooltip") : NULL; + FlValue* toggle_value = args != NULL ? fl_value_lookup_string(args, "showHideLabel") : NULL; + FlValue* exit_value = args != NULL ? fl_value_lookup_string(args, "exitLabel") : NULL; + + *out_icon_path = + icon_value != NULL && fl_value_get_type(icon_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(icon_value) + : NULL; + *out_tooltip = + tooltip_value != NULL && fl_value_get_type(tooltip_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(tooltip_value) + : "CopyPaste"; + *out_show_hide = + toggle_value != NULL && fl_value_get_type(toggle_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(toggle_value) + : "Show/Hide"; + *out_exit_label = + exit_value != NULL && fl_value_get_type(exit_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(exit_value) + : "Exit"; +} + +static FlValue* make_tray_result(gboolean success, const char* error_code) { + FlValue* map = fl_value_new_map(); + fl_value_set_string_take(map, "success", fl_value_new_bool(success)); + if (error_code != NULL) { + fl_value_set_string_take(map, "errorCode", fl_value_new_string(error_code)); + } + return map; +} + +static FlValue* init_tray(CopyPasteLinuxShell* shell, FlValue* args) { +#ifdef HAVE_APPINDICATOR + const gchar* icon_path; + const gchar* tooltip; + const gchar* show_hide; + const gchar* exit_label; + parse_tray_args(args, &icon_path, &tooltip, &show_hide, &exit_label); + + g_free(shell->resolved_icon_path); + shell->resolved_icon_path = resolve_asset_path(icon_path); + + if (shell->app_indicator == NULL) { + shell->app_indicator = app_indicator_new( + "com.rgdevment.copypaste", "copypaste", + APP_INDICATOR_CATEGORY_APPLICATION_STATUS); + } + + if (shell->app_indicator == NULL) { + return make_tray_result(FALSE, "noAppIndicator"); + } + + if (shell->resolved_icon_path != NULL) { + g_autofree gchar* icon_dir = + g_path_get_dirname(shell->resolved_icon_path); + g_autofree gchar* icon_base = + g_path_get_basename(shell->resolved_icon_path); + gchar* dot = strrchr(icon_base, '.'); + if (dot != NULL) *dot = '\0'; + app_indicator_set_icon_theme_path(shell->app_indicator, icon_dir); + app_indicator_set_icon_full(shell->app_indicator, icon_base, tooltip); + } + + app_indicator_set_title(shell->app_indicator, tooltip); + rebuild_tray_menu(shell, show_hide, exit_label); + app_indicator_set_menu(shell->app_indicator, GTK_MENU(shell->tray_menu)); + app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_ACTIVE); + return make_tray_result(TRUE, NULL); +#else + (void)shell; + (void)args; + return make_tray_result(FALSE, "noAppIndicator"); +#endif +} + +static FlValue* destroy_tray(CopyPasteLinuxShell* shell) { + destroy_tray_menu(shell); + +#ifdef HAVE_APPINDICATOR + if (shell->app_indicator != NULL) { + app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_PASSIVE); + g_clear_object(&shell->app_indicator); + } +#endif + + g_clear_pointer(&shell->resolved_icon_path, g_free); + return make_tray_result(TRUE, NULL); +} + +static FlValue* make_hotkey_result(gboolean success, const char* error_code) { + FlValue* map = fl_value_new_map(); + fl_value_set_string_take(map, "success", fl_value_new_bool(success)); + if (error_code != NULL) { + fl_value_set_string_take(map, "errorCode", fl_value_new_string(error_code)); + } + return map; +} + +#ifdef GDK_WINDOWING_X11 +static guint modifier_combinations[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask}; +static int (*previous_x11_error_handler)(Display*, XErrorEvent*) = NULL; +static Display* trapped_x11_display = NULL; +static int trapped_x11_error_code = Success; + +static int hotkey_x11_error_handler(Display* display, XErrorEvent* event) { + if (display == trapped_x11_display) { + trapped_x11_error_code = event->error_code; + return 0; + } + + if (previous_x11_error_handler != NULL) { + return previous_x11_error_handler(display, event); + } + + return 0; +} + +static gboolean trap_x11_grab(Display* display, + Window root_window, + KeyCode keycode, + guint modifiers) { + previous_x11_error_handler = XSetErrorHandler(hotkey_x11_error_handler); + trapped_x11_display = display; + trapped_x11_error_code = Success; + + XGrabKey(display, (int)keycode, (int)modifiers, root_window, True, + GrabModeAsync, GrabModeAsync); + XSync(display, False); + + trapped_x11_display = NULL; + XSetErrorHandler(previous_x11_error_handler); + previous_x11_error_handler = NULL; + + return trapped_x11_error_code == Success; +} + +static void ungrab_hotkey_variants(Display* display, + Window root_window, + KeyCode keycode, + guint modifiers) { + for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { + XUngrabKey(display, (int)keycode, + (int)(modifiers | modifier_combinations[i]), root_window); + } + XSync(display, False); +} + +static KeySym virtual_key_to_keysym(gint64 virtual_key) { + if (virtual_key >= 0x41 && virtual_key <= 0x5A) { + return (KeySym)(XK_A + (virtual_key - 0x41)); + } + if (virtual_key >= 0x30 && virtual_key <= 0x39) { + return (KeySym)(XK_0 + (virtual_key - 0x30)); + } + if (virtual_key >= 0x70 && virtual_key <= 0x87) { + return (KeySym)(XK_F1 + (virtual_key - 0x70)); + } + switch (virtual_key) { + case 0x08: return XK_BackSpace; + case 0x09: return XK_Tab; + case 0x0D: return XK_Return; + case 0x1B: return XK_Escape; + case 0x20: return XK_space; + case 0x21: return XK_Page_Up; + case 0x22: return XK_Page_Down; + case 0x23: return XK_End; + case 0x24: return XK_Home; + case 0x25: return XK_Left; + case 0x26: return XK_Up; + case 0x27: return XK_Right; + case 0x28: return XK_Down; + case 0x2D: return XK_Insert; + case 0x2E: return XK_Delete; + case 0xBA: return XK_semicolon; + case 0xBB: return XK_equal; + case 0xBC: return XK_comma; + case 0xBD: return XK_minus; + case 0xBE: return XK_period; + case 0xBF: return XK_slash; + case 0xC0: return XK_grave; + case 0xDB: return XK_bracketleft; + case 0xDC: return XK_backslash; + case 0xDD: return XK_bracketright; + case 0xDE: return XK_apostrophe; + default: return NoSymbol; + } +} + +static guint compute_modifier_mask(FlValue* args) { + guint modifiers = 0; + + FlValue* ctrl = fl_value_lookup_string(args, "useCtrl"); + FlValue* meta = fl_value_lookup_string(args, "useWin"); + FlValue* alt = fl_value_lookup_string(args, "useAlt"); + FlValue* shift = fl_value_lookup_string(args, "useShift"); + + if (ctrl != NULL && fl_value_get_type(ctrl) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(ctrl)) { + modifiers |= ControlMask; + } + if (meta != NULL && fl_value_get_type(meta) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(meta)) { + modifiers |= Mod4Mask; + } + if (alt != NULL && fl_value_get_type(alt) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(alt)) { + modifiers |= Mod1Mask; + } + if (shift != NULL && fl_value_get_type(shift) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(shift)) { + modifiers |= ShiftMask; + } + + return modifiers; +} + +static void unregister_hotkey(CopyPasteLinuxShell* shell) { + if (!shell->hotkey_registered || shell->xdisplay == NULL || shell->hotkey_keycode == 0) { + shell->hotkey_registered = FALSE; + return; + } + + ungrab_hotkey_variants(shell->xdisplay, shell->root_window, + (KeyCode)shell->hotkey_keycode, + shell->hotkey_modifiers); + shell->hotkey_registered = FALSE; + shell->hotkey_keycode = 0; + shell->hotkey_modifiers = 0; +} + +static FlValue* register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { + if (!shell_is_x11() || shell->xdisplay == NULL) { + return make_hotkey_result(FALSE, "noX11"); + } + + unregister_hotkey(shell); + + FlValue* key_value = args != NULL ? fl_value_lookup_string(args, "virtualKey") : NULL; + gint64 virtual_key = (key_value != NULL && fl_value_get_type(key_value) == FL_VALUE_TYPE_INT) + ? fl_value_get_int(key_value) : 0; + KeySym keysym = virtual_key_to_keysym(virtual_key); + if (keysym == NoSymbol) { + g_warning("registerHotkey: unsupported virtual key 0x%llx", (unsigned long long)virtual_key); + return make_hotkey_result(FALSE, "unsupportedKey"); + } + + guint modifiers = compute_modifier_mask(args); + if (modifiers == 0) { + g_warning("registerHotkey: no modifier keys specified"); + return make_hotkey_result(FALSE, "noModifier"); + } + + KeyCode keycode = XKeysymToKeycode(shell->xdisplay, keysym); + if (keycode == 0) { + g_warning("registerHotkey: no keycode for keysym %lu", (unsigned long)keysym); + return make_hotkey_result(FALSE, "unsupportedKey"); + } + + for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { + if (!trap_x11_grab(shell->xdisplay, shell->root_window, keycode, + modifiers | modifier_combinations[i])) { + g_warning("registerHotkey: XGrabKey failed (modifier variant 0x%x) — key may be in use", + modifiers | modifier_combinations[i]); + ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, + modifiers); + return make_hotkey_result(FALSE, "grabFailed"); + } + } + + XSync(shell->xdisplay, False); + XWindowAttributes attrs; + if (XGetWindowAttributes(shell->xdisplay, shell->root_window, &attrs) != 0) { + XSelectInput(shell->xdisplay, shell->root_window, + attrs.your_event_mask | KeyPressMask); + } else { + XSelectInput(shell->xdisplay, shell->root_window, KeyPressMask); + } + XSync(shell->xdisplay, False); + + shell->hotkey_registered = TRUE; + shell->hotkey_keycode = keycode; + shell->hotkey_modifiers = modifiers; + return make_hotkey_result(TRUE, NULL); +} + +static GdkFilterReturn x11_event_filter(GdkXEvent* xevent, + GdkEvent* event, + gpointer user_data) { + (void)event; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + if (!shell->hotkey_registered) { + return GDK_FILTER_CONTINUE; + } + + XEvent* x_event = (XEvent*)xevent; + if (x_event->type != KeyPress) { + return GDK_FILTER_CONTINUE; + } + + guint relevant_mask = ControlMask | ShiftMask | Mod1Mask | Mod4Mask; + guint state = (guint)x_event->xkey.state & relevant_mask; + if ((guint)x_event->xkey.keycode == shell->hotkey_keycode && + state == shell->hotkey_modifiers) { + shell->last_hotkey_time = x_event->xkey.time; + send_shell_event(shell, "hotkey"); + return GDK_FILTER_REMOVE; + } + + return GDK_FILTER_CONTINUE; +} +#endif + +static FlMethodErrorResponse* shell_listen_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + shell->events_listening = TRUE; + return NULL; +} + +static FlMethodErrorResponse* shell_cancel_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + shell->events_listening = FALSE; + return NULL; +} + +static void respond_method_success(FlMethodCall* method_call, FlValue* result) { + g_autoptr(GError) error = NULL; + g_autoptr(FlValue) owned = result; + if (!fl_method_call_respond_success(method_call, owned, &error) && error != NULL) { + g_warning("Failed to respond to linux shell method call: %s", error->message); + } +} + +static gboolean has_app_indicator_runtime(void) { +#ifdef HAVE_APPINDICATOR + return TRUE; +#else + return FALSE; +#endif +} + +static gboolean ewmh_supports_active_window(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* gdk_display = gdk_display_get_default(); + if (gdk_display == NULL || !GDK_IS_X11_DISPLAY(gdk_display)) { + return FALSE; + } + Display* xdisplay = GDK_DISPLAY_XDISPLAY(gdk_display); + if (xdisplay == NULL) return FALSE; + Atom net_supported = XInternAtom(xdisplay, "_NET_SUPPORTED", True); + Atom net_active_window = XInternAtom(xdisplay, "_NET_ACTIVE_WINDOW", True); + if (net_supported == None || net_active_window == None) return FALSE; + Window root = DefaultRootWindow(xdisplay); + Atom actual_type = None; + int actual_format = 0; + unsigned long nitems = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + int status = XGetWindowProperty(xdisplay, root, net_supported, 0, 1024, False, + XA_ATOM, &actual_type, &actual_format, + &nitems, &bytes_after, &data); + gboolean found = FALSE; + if (status == Success && actual_type == XA_ATOM && actual_format == 32 && data != NULL) { + Atom* atoms = (Atom*)data; + for (unsigned long i = 0; i < nitems; ++i) { + if (atoms[i] == net_active_window) { found = TRUE; break; } + } + } + if (data != NULL) XFree(data); + return found; +#else + return FALSE; +#endif +} + +static gchar* read_wm_name(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* gdk_display = gdk_display_get_default(); + if (gdk_display == NULL || !GDK_IS_X11_DISPLAY(gdk_display)) return NULL; + Display* xdisplay = GDK_DISPLAY_XDISPLAY(gdk_display); + Atom check = XInternAtom(xdisplay, "_NET_SUPPORTING_WM_CHECK", True); + Atom utf8 = XInternAtom(xdisplay, "UTF8_STRING", True); + Atom wm_name = XInternAtom(xdisplay, "_NET_WM_NAME", True); + if (check == None || wm_name == None) return NULL; + Window root = DefaultRootWindow(xdisplay); + Atom actual_type = None; + int actual_format = 0; + unsigned long nitems = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + if (XGetWindowProperty(xdisplay, root, check, 0, 1, False, XA_WINDOW, + &actual_type, &actual_format, &nitems, &bytes_after, + &data) != Success || data == NULL) { + return NULL; + } + Window wm_window = *(Window*)data; + XFree(data); + data = NULL; + if (wm_window == None) return NULL; + Atom string_type = utf8 != None ? utf8 : XA_STRING; + if (XGetWindowProperty(xdisplay, wm_window, wm_name, 0, 256, False, + string_type, &actual_type, &actual_format, &nitems, + &bytes_after, &data) != Success || data == NULL) { + return NULL; + } + gchar* name = g_strndup((const gchar*)data, nitems); + XFree(data); + return name; +#else + return NULL; +#endif +} + +static FlValue* build_capabilities(void) { + FlValue* caps = fl_value_new_map(); + fl_value_set_string_take(caps, "isX11", fl_value_new_bool(shell_is_x11())); + fl_value_set_string_take(caps, "hasAppIndicator", + fl_value_new_bool(has_app_indicator_runtime())); + fl_value_set_string_take(caps, "hasEwmh", + fl_value_new_bool(ewmh_supports_active_window())); + const gchar* desktop_env = g_getenv("XDG_CURRENT_DESKTOP"); + if (desktop_env == NULL) desktop_env = g_getenv("DESKTOP_SESSION"); + fl_value_set_string_take(caps, "desktopEnv", + fl_value_new_string(desktop_env != NULL ? desktop_env : "")); + g_autofree gchar* wm = read_wm_name(); + fl_value_set_string_take(caps, "wmName", + fl_value_new_string(wm != NULL ? wm : "")); + return caps; +} + +static FlValue* build_cursor_monitor(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL) { + return fl_value_new_null(); + } + GdkSeat* seat = gdk_display_get_default_seat(display); + if (seat == NULL) { + return fl_value_new_null(); + } + GdkDevice* pointer = gdk_seat_get_pointer(seat); + if (pointer == NULL) { + return fl_value_new_null(); + } + gint cursor_x = 0; + gint cursor_y = 0; + GdkScreen* screen = NULL; + gdk_device_get_position(pointer, &screen, &cursor_x, &cursor_y); + + GdkMonitor* monitor = + gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); + if (monitor == NULL) { + monitor = gdk_display_get_primary_monitor(display); + } + if (monitor == NULL) { + return fl_value_new_null(); + } + + GdkRectangle workarea = {0, 0, 0, 0}; + gdk_monitor_get_workarea(monitor, &workarea); + gint scale = gdk_monitor_get_scale_factor(monitor); + if (scale <= 0) { + scale = 1; + } + + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "cursorX", + fl_value_new_float((double)cursor_x)); + fl_value_set_string_take(result, "cursorY", + fl_value_new_float((double)cursor_y)); + fl_value_set_string_take(result, "x", + fl_value_new_float((double)workarea.x)); + fl_value_set_string_take(result, "y", + fl_value_new_float((double)workarea.y)); + fl_value_set_string_take(result, "width", + fl_value_new_float((double)workarea.width)); + fl_value_set_string_take(result, "height", + fl_value_new_float((double)workarea.height)); + fl_value_set_string_take(result, "scaleFactor", + fl_value_new_float((double)scale)); + return result; +} + +static FlValue* build_input_focus(CopyPasteLinuxShell* shell) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(FALSE)); + fl_value_set_string_take(result, "focusWindow", fl_value_new_int(0)); + fl_value_set_string_take(result, "ownWindow", fl_value_new_int(0)); +#ifdef GDK_WINDOWING_X11 + if (!shell_is_x11() || shell->xdisplay == NULL) { + return result; + } + Window focused = None; + int revert_to = 0; + XGetInputFocus(shell->xdisplay, &focused, &revert_to); + fl_value_set_string_take(result, "focusWindow", + fl_value_new_int((gint64)focused)); + + if (shell->gtk_window == NULL) { + return result; + } + GdkWindow* gdk_window = + gtk_widget_get_window(GTK_WIDGET(shell->gtk_window)); + if (gdk_window == NULL) { + return result; + } + Window own = gdk_x11_window_get_xid(gdk_window); + fl_value_set_string_take(result, "ownWindow", fl_value_new_int((gint64)own)); + + gboolean owns = (focused == own); + if (!owns && focused != None && focused != PointerRoot) { + Window root = None; + Window parent = None; + Window* children = NULL; + unsigned int nchildren = 0; + Window cursor = focused; + for (int depth = 0; depth < 8; depth++) { + if (XQueryTree(shell->xdisplay, cursor, &root, &parent, &children, + &nchildren) == 0) { + break; + } + if (children != NULL) { + XFree(children); + } + if (parent == None || parent == root) { + break; + } + if (parent == own) { + owns = TRUE; + break; + } + cursor = parent; + } + } + fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(owns)); +#else + (void)shell; +#endif + return result; +} + +static void shell_method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + (void)channel; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "getCapabilities") == 0) { + respond_method_success(method_call, build_capabilities()); + return; + } + + if (strcmp(method, "initTray") == 0 || strcmp(method, "updateTray") == 0) { + respond_method_success(method_call, init_tray(shell, args)); + return; + } + + if (strcmp(method, "destroyTray") == 0) { + respond_method_success(method_call, destroy_tray(shell)); + return; + } + + if (strcmp(method, "registerHotkey") == 0) { +#ifdef GDK_WINDOWING_X11 + respond_method_success(method_call, register_hotkey(shell, args)); +#else + respond_method_success(method_call, make_hotkey_result(FALSE, "noX11")); +#endif + return; + } + + if (strcmp(method, "unregisterHotkey") == 0) { +#ifdef GDK_WINDOWING_X11 + unregister_hotkey(shell); +#endif + respond_method_success(method_call, fl_value_new_bool(TRUE)); + return; + } + + if (strcmp(method, "focusWindow") == 0) { + if (shell->gtk_window != NULL) { +#ifdef GDK_WINDOWING_X11 + guint32 t = shell->last_hotkey_time != 0 ? shell->last_hotkey_time + : GDK_CURRENT_TIME; + gtk_window_present_with_time(shell->gtk_window, t); +#else + gtk_window_present(shell->gtk_window); +#endif + } + respond_method_success(method_call, fl_value_new_bool(TRUE)); + return; + } + + if (strcmp(method, "getCursorMonitor") == 0) { + respond_method_success(method_call, build_cursor_monitor()); + return; + } + + if (strcmp(method, "getInputFocus") == 0) { + respond_method_success(method_call, build_input_focus(shell)); + return; + } + + g_autoptr(FlMethodResponse) response = + FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + fl_method_call_respond(method_call, response, NULL); +} + +CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, + GtkWindow* window) { + CopyPasteLinuxShell* shell = g_new0(CopyPasteLinuxShell, 1); + shell->gtk_window = window; + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + shell->method_channel = + fl_method_channel_new(messenger, kShellChannelName, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(shell->method_channel, + shell_method_call_cb, shell, NULL); + + shell->event_channel = + fl_event_channel_new(messenger, kShellEventChannelName, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(shell->event_channel, shell_listen_cb, + shell_cancel_cb, shell, NULL); + + if (shell->gtk_window != NULL) { + g_signal_connect(shell->gtk_window, "unmap-event", + G_CALLBACK(window_unmap_event_cb), shell); + g_signal_connect(shell->gtk_window, "map-event", + G_CALLBACK(window_map_event_cb), shell); + g_signal_connect(shell->gtk_window, "configure-event", + G_CALLBACK(window_configure_event_cb), shell); + } + +#ifdef GDK_WINDOWING_X11 + if (shell_is_x11()) { + GdkDisplay* display = gdk_display_get_default(); + shell->xdisplay = gdk_x11_display_get_xdisplay(display); + shell->root_window = DefaultRootWindow(shell->xdisplay); + GdkWindow* gdk_root = gdk_get_default_root_window(); + gdk_window_add_filter(gdk_root, x11_event_filter, shell); + } +#endif + + return shell; +} + +void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { + if (shell == NULL) { + return; + } + +#ifdef GDK_WINDOWING_X11 + if (shell_is_x11()) { + unregister_hotkey(shell); + GdkWindow* gdk_root = gdk_get_default_root_window(); + gdk_window_remove_filter(gdk_root, x11_event_filter, shell); + } +#endif + + destroy_tray(shell); + g_clear_object(&shell->method_channel); + g_clear_object(&shell->event_channel); + g_free(shell); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc index 18560197..b1e16f54 100644 --- a/app/linux/runner/my_application.cc +++ b/app/linux/runner/my_application.cc @@ -1,133 +1,130 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "copypaste_linux_shell.h" -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; - CopyPasteLinuxShell* shell; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "CopyPaste"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "CopyPaste"); - } - - gtk_window_set_default_size(window, 368, 500); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - gdk_rgba_parse(&background_color, "#1a1a2e"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - gtk_widget_realize(GTK_WIDGET(window)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - FlBinaryMessenger* messenger = - fl_engine_get_binary_messenger(fl_view_get_engine(view)); - self->shell = copypaste_linux_shell_new(messenger, window); - - gtk_widget_grab_focus(GTK_WIDGET(view)); - - // Complete the XDG startup notification immediately so desktop environments - // (GNOME Shell, KDE Plasma, etc.) don't show "CopyPaste is ready" when the - // window is first made visible on hotkey press. - gdk_notify_startup_complete(); -} - -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -static void my_application_startup(GApplication* application) { - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -static void my_application_shutdown(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - if (self->shell != nullptr) { - copypaste_linux_shell_dispose(self->shell); - self->shell = nullptr; - } - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) { self->shell = nullptr; } - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "copypaste_linux_shell.h" +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; + CopyPasteLinuxShell* shell; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "CopyPaste"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "CopyPaste"); + } + + gtk_window_set_default_size(window, 368, 500); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + gdk_rgba_parse(&background_color, "#1a1a2e"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + gtk_widget_realize(GTK_WIDGET(window)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + FlBinaryMessenger* messenger = + fl_engine_get_binary_messenger(fl_view_get_engine(view)); + self->shell = copypaste_linux_shell_new(messenger, window); + + gtk_widget_grab_focus(GTK_WIDGET(view)); + + gdk_notify_startup_complete(); +} + +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +static void my_application_startup(GApplication* application) { + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +static void my_application_shutdown(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + if (self->shell != nullptr) { + copypaste_linux_shell_dispose(self->shell); + self->shell = nullptr; + } + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) { self->shell = nullptr; } + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/test/screens/windows_onboarding_screen_test.dart b/app/test/screens/desktop_onboarding_screen_test.dart similarity index 93% rename from app/test/screens/windows_onboarding_screen_test.dart rename to app/test/screens/desktop_onboarding_screen_test.dart index a530bd65..44cd0c11 100644 --- a/app/test/screens/windows_onboarding_screen_test.dart +++ b/app/test/screens/desktop_onboarding_screen_test.dart @@ -1,210 +1,210 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/screens/windows_onboarding_screen.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -Widget _wrap(Widget child, {Locale locale = const Locale('en')}) { - return MaterialApp( - locale: locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme(themeData: CompactTheme(), child: child), - ); -} - -Future _pump( - WidgetTester tester, - Widget child, { - Locale locale = const Locale('en'), -}) async { - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - await tester.pumpWidget(_wrap(child, locale: locale)); - await tester.pump(); -} - -void main() { - const hotkey = 'Ctrl+Shift+V'; - - Widget screen({VoidCallback? onDismiss, VoidCallback? onSettings}) => - WindowsOnboardingScreen( - hotkey: hotkey, - initialConfig: const AppConfig(), - onDismiss: (_) => (onDismiss ?? () {})(), - onSettings: (_) => (onSettings ?? () {})(), - ); - - group('WindowsOnboardingScreen', () { - testWidgets('renders title and subtitle', (tester) async { - await _pump(tester, screen()); - - expect(find.text('Welcome to CopyPaste'), findsOneWidget); - expect(find.text('Everything you copy, saved.'), findsOneWidget); - }); - - testWidgets('renders privacy badge with lock icon', (tester) async { - await _pump(tester, screen()); - - expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget); - expect(find.text('No cloud · No tracking · 100% local'), findsOneWidget); - }); - - testWidgets('renders hotkey chip with keyboard icon', (tester) async { - await _pump(tester, screen()); - - expect(find.byIcon(Icons.keyboard_rounded), findsOneWidget); - expect(find.text(hotkey), findsOneWidget); - }); - - testWidgets('renders tray hint text', (tester) async { - await _pump(tester, screen()); - - expect( - find.text('Look for the CP icon next to your clock.'), - findsOneWidget, - ); - }); - - testWidgets('renders description containing the hotkey', (tester) async { - await _pump(tester, screen()); - - expect(find.textContaining(hotkey), findsWidgets); - }); - - testWidgets('tapping dismiss button invokes onDismiss', (tester) async { - var dismissed = false; - await _pump(tester, screen(onDismiss: () => dismissed = true)); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(dismissed, isTrue); - }); - - testWidgets('tapping settings button invokes onSettings', (tester) async { - var opened = false; - await _pump(tester, screen(onSettings: () => opened = true)); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(opened, isTrue); - }); - - testWidgets('renders both action buttons', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(FilledButton), findsOneWidget); - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('renders in dark mode without errors', (tester) async { - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData(brightness: Brightness.dark), - home: CopyPasteTheme(themeData: CompactTheme(), child: screen()), - ), - ); - await tester.pump(); - - expect(find.text('Welcome to CopyPaste'), findsOneWidget); - }); - - testWidgets('renders in Spanish locale', (tester) async { - await _pump(tester, screen(), locale: const Locale('es')); - - expect(find.text('Bienvenido a CopyPaste'), findsOneWidget); - expect(find.text('Sin nube · Sin rastreo · 100% local'), findsOneWidget); - }); - - testWidgets('app icon is displayed', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(Image), findsOneWidget); - }); - - testWidgets('uses different hotkey string when provided', (tester) async { - const customHotkey = 'Ctrl+Alt+V'; - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - - await tester.pumpWidget( - _wrap( - WindowsOnboardingScreen( - hotkey: customHotkey, - initialConfig: const AppConfig(), - onDismiss: (_) {}, - onSettings: (_) {}, - ), - ), - ); - await tester.pump(); - - expect(find.text(customHotkey), findsOneWidget); - }); - - testWidgets('no Switch or Slider rendered (personalize section removed)', ( - tester, - ) async { - await _pump(tester, screen()); - - expect(find.byType(Switch), findsNothing); - expect(find.byType(Slider), findsNothing); - }); - - testWidgets('dismiss callback receives unmodified initialConfig', ( - tester, - ) async { - const config = AppConfig(); - AppConfig? received; - await _pump( - tester, - WindowsOnboardingScreen( - hotkey: hotkey, - initialConfig: config, - onDismiss: (c) => received = c, - onSettings: (_) {}, - ), - ); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(received, equals(config)); - }); - - testWidgets('settings callback receives unmodified initialConfig', ( - tester, - ) async { - const config = AppConfig(); - AppConfig? received; - await _pump( - tester, - WindowsOnboardingScreen( - hotkey: hotkey, - initialConfig: config, - onDismiss: (_) {}, - onSettings: (c) => received = c, - ), - ); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(received, equals(config)); - }); - }); -} +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/l10n/app_localizations.dart'; +import 'package:copypaste/screens/desktop_onboarding_screen.dart'; +import 'package:copypaste/theme/compact_theme.dart'; +import 'package:copypaste/theme/theme_provider.dart'; + +Widget _wrap(Widget child, {Locale locale = const Locale('en')}) { + return MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CopyPasteTheme(themeData: CompactTheme(), child: child), + ); +} + +Future _pump( + WidgetTester tester, + Widget child, { + Locale locale = const Locale('en'), +}) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(_wrap(child, locale: locale)); + await tester.pump(); +} + +void main() { + const hotkey = 'Ctrl+Shift+V'; + + Widget screen({VoidCallback? onDismiss, VoidCallback? onSettings}) => + DesktopOnboardingScreen( + hotkey: hotkey, + initialConfig: const AppConfig(), + onDismiss: (_) => (onDismiss ?? () {})(), + onSettings: (_) => (onSettings ?? () {})(), + ); + + group('DesktopOnboardingScreen', () { + testWidgets('renders title and subtitle', (tester) async { + await _pump(tester, screen()); + + expect(find.text('Welcome to CopyPaste'), findsOneWidget); + expect(find.text('Everything you copy, saved.'), findsOneWidget); + }); + + testWidgets('renders privacy badge with lock icon', (tester) async { + await _pump(tester, screen()); + + expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget); + expect(find.text('No cloud · No tracking · 100% local'), findsOneWidget); + }); + + testWidgets('renders hotkey chip with keyboard icon', (tester) async { + await _pump(tester, screen()); + + expect(find.byIcon(Icons.keyboard_rounded), findsOneWidget); + expect(find.text(hotkey), findsOneWidget); + }); + + testWidgets('renders tray hint text', (tester) async { + await _pump(tester, screen()); + + expect( + find.text('Look for the CP icon next to your clock.'), + findsOneWidget, + ); + }); + + testWidgets('renders description containing the hotkey', (tester) async { + await _pump(tester, screen()); + + expect(find.textContaining(hotkey), findsWidgets); + }); + + testWidgets('tapping dismiss button invokes onDismiss', (tester) async { + var dismissed = false; + await _pump(tester, screen(onDismiss: () => dismissed = true)); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(dismissed, isTrue); + }); + + testWidgets('tapping settings button invokes onSettings', (tester) async { + var opened = false; + await _pump(tester, screen(onSettings: () => opened = true)); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(opened, isTrue); + }); + + testWidgets('renders both action buttons', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(FilledButton), findsOneWidget); + expect(find.byType(OutlinedButton), findsOneWidget); + }); + + testWidgets('renders in dark mode without errors', (tester) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(brightness: Brightness.dark), + home: CopyPasteTheme(themeData: CompactTheme(), child: screen()), + ), + ); + await tester.pump(); + + expect(find.text('Welcome to CopyPaste'), findsOneWidget); + }); + + testWidgets('renders in Spanish locale', (tester) async { + await _pump(tester, screen(), locale: const Locale('es')); + + expect(find.text('Bienvenido a CopyPaste'), findsOneWidget); + expect(find.text('Sin nube · Sin rastreo · 100% local'), findsOneWidget); + }); + + testWidgets('app icon is displayed', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('uses different hotkey string when provided', (tester) async { + const customHotkey = 'Ctrl+Alt+V'; + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + _wrap( + DesktopOnboardingScreen( + hotkey: customHotkey, + initialConfig: const AppConfig(), + onDismiss: (_) {}, + onSettings: (_) {}, + ), + ), + ); + await tester.pump(); + + expect(find.text(customHotkey), findsOneWidget); + }); + + testWidgets('no Switch or Slider rendered (personalize section removed)', ( + tester, + ) async { + await _pump(tester, screen()); + + expect(find.byType(Switch), findsNothing); + expect(find.byType(Slider), findsNothing); + }); + + testWidgets('dismiss callback receives unmodified initialConfig', ( + tester, + ) async { + const config = AppConfig(); + AppConfig? received; + await _pump( + tester, + DesktopOnboardingScreen( + hotkey: hotkey, + initialConfig: config, + onDismiss: (c) => received = c, + onSettings: (_) {}, + ), + ); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(received, equals(config)); + }); + + testWidgets('settings callback receives unmodified initialConfig', ( + tester, + ) async { + const config = AppConfig(); + AppConfig? received; + await _pump( + tester, + DesktopOnboardingScreen( + hotkey: hotkey, + initialConfig: config, + onDismiss: (_) {}, + onSettings: (c) => received = c, + ), + ); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(received, equals(config)); + }); + }); +} diff --git a/app/test/screens/linux_capabilities_banner_test.dart b/app/test/screens/linux_capabilities_banner_test.dart new file mode 100644 index 00000000..fc9edca9 --- /dev/null +++ b/app/test/screens/linux_capabilities_banner_test.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/l10n/app_localizations.dart'; +import 'package:copypaste/screens/linux_capabilities_banner.dart'; +import 'package:copypaste/services/linux_capabilities.dart'; +import 'package:copypaste/shell/linux_session.dart'; +import 'package:copypaste/theme/compact_theme.dart'; +import 'package:copypaste/theme/theme_provider.dart'; +import 'package:core/core.dart'; + +LinuxCapabilities _caps({bool hasAppIndicator = true, bool hasXTest = true}) { + return LinuxCapabilities( + session: const LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'gnome', + wmName: 'mutter', + ), + isX11: true, + hasXTest: hasXTest, + hasAppIndicator: hasAppIndicator, + hasEwmh: true, + detectedDesktopEnv: 'gnome', + detectedWmName: 'mutter', + detectionTimedOut: false, + ); +} + +Widget _wrap(Widget child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CopyPasteTheme( + themeData: CompactTheme(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + group('LinuxCapabilitiesBanner', () { + testWidgets('renders nothing when not on Linux', (tester) async { + if (Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (_) async {}, + ), + ), + ); + expect(find.byType(Icon), findsNothing); + }); + + testWidgets('renders AppIndicator banner when missing and not dismissed', ( + tester, + ) async { + if (!Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (_) async {}, + ), + ), + ); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + }); + + testWidgets( + 'renders nothing when capability missing but already dismissed', + (tester) async { + if (!Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig( + linuxAppindicatorWarningDismissed: true, + linuxXtestWarningDismissed: true, + ), + capabilities: _caps(hasAppIndicator: false, hasXTest: false), + onDismiss: (_) async {}, + ), + ), + ); + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + }, + ); + + testWidgets('dismiss callback fires when close icon tapped', ( + tester, + ) async { + if (!Platform.isLinux) return; + AppConfig? captured; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (update) async { + captured = update(const AppConfig()); + }, + ), + ), + ); + await tester.tap(find.byIcon(Icons.close_rounded)); + await tester.pump(); + expect(captured?.linuxAppindicatorWarningDismissed, isTrue); + }); + }); +} diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index 9693e1e1..5bc1dbe6 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -1,10 +1,14 @@ import 'package:core/core.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:copypaste/helpers/url_helper.dart'; import 'package:copypaste/l10n/app_localizations.dart'; import 'package:copypaste/screens/main_screen.dart'; +import 'package:copypaste/services/linux_capabilities.dart'; +import 'package:copypaste/services/release_manifest_service.dart'; import 'package:copypaste/theme/compact_theme.dart'; import 'package:copypaste/theme/theme_provider.dart'; import 'package:copypaste/widgets/clipboard_card.dart'; @@ -22,6 +26,10 @@ Widget _buildApp({ bool showHint = false, VoidCallback? onDismissHint, String? updateVersion, + ManifestSeverity? updateSeverity, + AppConfig? appConfig, + LinuxCapabilities? linuxCapabilities, + Future Function(AppConfig Function(AppConfig))? onLinuxConfigUpdate, Key? key, }) { return MaterialApp( @@ -45,6 +53,10 @@ Widget _buildApp({ showHint: showHint, onDismissHint: onDismissHint, updateVersion: updateVersion, + updateSeverity: updateSeverity, + appConfig: appConfig, + linuxCapabilities: linuxCapabilities, + onLinuxConfigUpdate: onLinuxConfigUpdate, ), ), ), @@ -1260,5 +1272,316 @@ void main() { // Screen still renders correctly. expect(find.byType(MainScreen), findsOneWidget); }); + + testWidgets( + 'LinuxCapabilitiesBanner renders when all linux params provided', + (tester) async { + const capabilities = LinuxCapabilities.unsupported; + const config = AppConfig(); + bool callbackCalled = false; + + await tester.pumpWidget( + _buildApp( + service: service, + onPaste: (_) {}, + appConfig: config, + linuxCapabilities: capabilities, + onLinuxConfigUpdate: (fn) async { + callbackCalled = true; + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + expect(callbackCalled, isFalse); + }, + ); + + testWidgets('Alt+T shortcut opens filter bar', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, key: key), + ); + await tester.pumpAndSettle(); + + key.currentState!.onWindowShow(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyT); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('update dialog View Release button triggers dismiss', ( + tester, + ) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, updateVersion: '3.0.0'), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('v3.0.0 is available, please update')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + + final viewRelease = find.text('View Release'); + if (viewRelease.evaluate().isNotEmpty) { + await tester.tap(viewRelease.first); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + } + }); + + testWidgets( + 'update badge with critical severity uses important badge text', + (tester) async { + await tester.pumpWidget( + _buildApp( + service: service, + onPaste: (_) {}, + updateVersion: '4.0.0', + updateSeverity: ManifestSeverity.critical, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + final badge = find.textContaining('4.0.0'); + expect(badge, findsAtLeastNWidgets(1)); + }, + ); + + testWidgets('_onItemOpen link item calls UrlHelper', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: 'https://example.com', + type: ClipboardContentType.link, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('_onItemOpen email item calls mailto', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: 'test@example.com', + type: ClipboardContentType.email, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('_onItemOpen phone item calls tel scheme', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem(content: '+1234567890', type: ClipboardContentType.phone), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets( + '_onItemOpen image with missing file returns false gracefully', + (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: '/nonexistent/path/image.png', + type: ClipboardContentType.image, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_rounded); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }, + ); + + testWidgets('_onItemOpen file item opens path', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: '/tmp/some_file.txt', + type: ClipboardContentType.file, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_rounded); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('_onSearchKeyEvent ArrowDown moves selection to first item', ( + tester, + ) async { + await repo.save( + ClipboardItem(content: 'first', type: ClipboardContentType.text), + ); + await repo.save( + ClipboardItem(content: 'second', type: ClipboardContentType.text), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final searchField = find.byType(TextField).first; + await tester.tap(searchField); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('onWindowHide trims _items list when > pageSize', ( + tester, + ) async { + for (var i = 0; i < 35; i++) { + await repo.save( + ClipboardItem(content: 'Item $i', type: ClipboardContentType.text), + ); + } + + final key = GlobalKey(); + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, key: key), + ); + await tester.pumpAndSettle(); + + key.currentState!.onWindowShow(); + await tester.pump(); + + await tester.drag(find.byType(ListView).last, const Offset(0, -5000)); + await tester.pumpAndSettle(); + + key.currentState!.onWindowHide(); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets( + 'second page loaded on scroll accumulates items in _items.addAll path', + (tester) async { + for (var i = 0; i < 35; i++) { + await repo.save( + ClipboardItem( + content: 'Page item $i', + type: ClipboardContentType.text, + ), + ); + } + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(ListView).last, const Offset(0, -5000)); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }, + ); + + testWidgets('_BottomBarAction hover state changes icon opacity', ( + tester, + ) async { + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final bugIcon = find.byIcon(Icons.bug_report_outlined); + if (bugIcon.evaluate().isNotEmpty) { + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(bugIcon)); + await tester.pump(); + expect(find.byType(MainScreen), findsOneWidget); + await gesture.moveTo(Offset.zero); + await tester.pump(); + } + }); + + testWidgets('_loadItems with non-empty searchQuery uses query param', ( + tester, + ) async { + await repo.save( + ClipboardItem(content: 'SearchMe', type: ClipboardContentType.text), + ); + + final key = GlobalKey(); + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, key: key), + ); + await tester.pumpAndSettle(); + + key.currentState!.onWindowShow(); + await tester.pump(); + + final searchField = find.byType(TextField).first; + await tester.enterText(searchField, 'SearchMe'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); }); } diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart new file mode 100644 index 00000000..2785bd87 --- /dev/null +++ b/app/test/services/linux_capabilities_test.dart @@ -0,0 +1,266 @@ +import 'dart:io'; + +import 'package:copypaste/services/linux_capabilities.dart'; +import 'package:copypaste/services/linux_guard.dart'; +import 'package:copypaste/shell/linux_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeChannel implements LinuxCapabilitiesChannel { + _FakeChannel({ + this.shellResponse, + this.listenerResponse, + this.shellThrows, + this.listenerThrows, + this.shellDelay = Duration.zero, + }); + + final Map? shellResponse; + final Map? listenerResponse; + final Object? shellThrows; + final Object? listenerThrows; + final Duration shellDelay; + + int shellCalls = 0; + int listenerCalls = 0; + + @override + Future?> invokeShell(String method) async { + shellCalls++; + if (shellDelay > Duration.zero) await Future.delayed(shellDelay); + if (shellThrows != null) throw shellThrows!; + return shellResponse; + } + + @override + Future?> invokeListener(String method) async { + listenerCalls++; + if (listenerThrows != null) throw listenerThrows!; + return listenerResponse; + } +} + +LinuxSessionInfo _x11Session() => const LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'GNOME', + wmName: 'Mutter', +); + +void main() { + setUp(() { + LinuxCapabilitiesService.resetForTesting(); + }); + group('LinuxCapabilitiesService.detect', () { + test('returns unsupported on non-Linux platforms', () async { + if (Platform.isLinux) return; + final caps = await LinuxCapabilitiesService.detect(); + expect(caps, equals(LinuxCapabilities.unsupported)); + expect(LinuxCapabilitiesService.isInitialized, isTrue); + expect(LinuxCapabilitiesService.current, equals(caps)); + }); + + test('parses full capability map from both channels', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellResponse: const { + 'isX11': true, + 'hasAppIndicator': true, + 'hasEwmh': true, + 'desktopEnv': 'GNOME', + 'wmName': 'Mutter', + }, + listenerResponse: const {'isX11': true, 'hasXTest': true}, + ); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + sessionOverride: _x11Session(), + ); + expect(caps.hasXTest, isTrue); + expect(caps.hasAppIndicator, isTrue); + expect(caps.hasEwmh, isTrue); + expect(caps.detectedDesktopEnv, equals('GNOME')); + expect(caps.detectedWmName, equals('Mutter')); + expect(caps.detectionTimedOut, isFalse); + }); + + test('returns conservative defaults when channels throw', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellThrows: Exception('shell boom'), + listenerThrows: Exception('listener boom'), + ); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + sessionOverride: _x11Session(), + ); + expect(caps.hasXTest, isFalse); + expect(caps.hasAppIndicator, isFalse); + expect(caps.hasEwmh, isFalse); + expect(caps.detectionTimedOut, isFalse); + }); + + test('marks detectionTimedOut when timeout fires', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellResponse: const {'isX11': true, 'hasEwmh': true}, + shellDelay: const Duration(milliseconds: 200), + ); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + timeout: const Duration(milliseconds: 20), + sessionOverride: _x11Session(), + ); + expect(caps.detectionTimedOut, isTrue); + expect(caps.hasEwmh, isFalse); + }); + + test('does not query channels when session is not X11', () async { + if (Platform.isLinux) return; + final channel = _FakeChannel(shellResponse: const {'isX11': true}); + await LinuxCapabilitiesService.detect(channel: channel); + expect(channel.shellCalls, equals(0)); + expect(channel.listenerCalls, equals(0)); + }); + + test('caches the last detected value in current', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellResponse: const {'isX11': true, 'hasEwmh': true}, + listenerResponse: const {'hasXTest': true}, + ); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + sessionOverride: _x11Session(), + ); + expect(LinuxCapabilitiesService.current, equals(caps)); + }); + }); + + group('LinuxGuard', () { + test('isLinux delegates to Platform', () { + expect(LinuxGuard.isLinux, equals(Platform.isLinux)); + }); + + test('all guards are false when capabilities are unsupported', () { + LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); + expect(LinuxGuard.canRegisterHotkey, isFalse); + expect(LinuxGuard.canPasteBack, isFalse); + expect(LinuxGuard.canShowTray, isFalse); + expect(LinuxGuard.canAutostart, isFalse); + expect(LinuxGuard.usesNativeWindowEffects, isFalse); + }); + + test('canPasteBack requires X11 + XTest', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true, hasXTest: true), + ); + expect(LinuxGuard.canPasteBack, isTrue); + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true, hasXTest: false), + ); + expect(LinuxGuard.canPasteBack, isFalse); + }); + + test('canShowTray requires X11 + AppIndicator', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith( + isX11: true, + hasAppIndicator: true, + ), + ); + expect(LinuxGuard.canShowTray, isTrue); + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true), + ); + expect(LinuxGuard.canShowTray, isFalse); + }); + + test('canRegisterHotkey requires X11 + EWMH', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true, hasEwmh: true), + ); + expect(LinuxGuard.canRegisterHotkey, isTrue); + }); + + test('isWayland returns true when capabilities have isWayland', () { + if (!Platform.isLinux) return; + const waylandSession = LinuxSessionInfo( + sessionType: 'wayland', + hasDisplay: false, + hasWaylandDisplay: true, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + const waylandCaps = LinuxCapabilities( + session: waylandSession, + isX11: false, + hasXTest: false, + hasAppIndicator: false, + hasEwmh: false, + detectedDesktopEnv: '', + detectedWmName: '', + detectionTimedOut: false, + ); + LinuxCapabilitiesService.resetForTesting(waylandCaps); + expect(LinuxGuard.isWayland, isTrue); + LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); + expect(LinuxGuard.isWayland, isFalse); + }); + + test('isUsable requires isLinux and X11', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true), + ); + expect(LinuxGuard.isUsable, isTrue); + LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); + expect(LinuxGuard.isUsable, isFalse); + }); + }); + + group('LinuxCapabilities', () { + test('copyWith preserves unchanged fields', () { + if (!Platform.isLinux) return; + final original = LinuxCapabilities.unsupported.copyWith( + isX11: true, + hasXTest: true, + hasEwmh: true, + detectedDesktopEnv: 'GNOME', + detectedWmName: 'Mutter', + ); + final copy = original.copyWith(hasAppIndicator: true); + expect(copy.isX11, isTrue); + expect(copy.hasXTest, isTrue); + expect(copy.hasEwmh, isTrue); + expect(copy.hasAppIndicator, isTrue); + expect(copy.detectedDesktopEnv, 'GNOME'); + expect(copy.detectedWmName, 'Mutter'); + }); + + test('toString contains key field values', () { + if (!Platform.isLinux) return; + final caps = LinuxCapabilities.unsupported.copyWith(isX11: true); + final s = caps.toString(); + expect(s, contains('isX11=true')); + expect(s, contains('LinuxCapabilities(')); + }); + + test('isUsable is false when isX11 is false', () { + if (!Platform.isLinux) return; + const caps = LinuxCapabilities.unsupported; + expect(caps.isUsable, isFalse); + }); + + test('isUsable is true when isX11 is true and running on Linux', () { + if (!Platform.isLinux) return; + final caps = LinuxCapabilities.unsupported.copyWith(isX11: true); + expect(caps.isUsable, isTrue); + }); + }); +} diff --git a/app/test/shell/desktop_notifier_test.dart b/app/test/shell/desktop_notifier_test.dart new file mode 100644 index 00000000..e6337c23 --- /dev/null +++ b/app/test/shell/desktop_notifier_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/desktop_notifier.dart'; + +void main() { + tearDown(() { + DesktopNotifier.processRunnerOverride = null; + }); + + group('DesktopNotifier – macOS', () { + test('returns false on macOS (no-op)', () async { + if (!Platform.isMacOS) return; + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }); + }); + + group('DesktopNotifier – Linux routing', () { + test('spawns notify-send with correct arguments', () async { + if (!Platform.isLinux) return; + + String? capturedExe; + List? capturedArgs; + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + capturedExe = exe; + capturedArgs = List.from(args); + return ProcessResult(0, 0, '', ''); + }; + + final result = await DesktopNotifier.show( + title: 'CopyPaste', + body: 'Running in the background.', + ); + + expect(result, isTrue); + expect(capturedExe, equals('notify-send')); + expect(capturedArgs, isNotNull); + expect(capturedArgs, contains('--app-name=CopyPaste')); + expect(capturedArgs, contains('--icon=copypaste')); + expect(capturedArgs, contains('--expire-time=7000')); + expect(capturedArgs, contains('CopyPaste')); + expect(capturedArgs, contains('Running in the background.')); + }); + + test('title and body are forwarded verbatim', () async { + if (!Platform.isLinux) return; + + const title = 'My Title'; + const body = 'My Body Line'; + String? gotTitle; + String? gotBody; + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + gotTitle = args[args.length - 2]; + gotBody = args[args.length - 1]; + return ProcessResult(0, 0, '', ''); + }; + + await DesktopNotifier.show(title: title, body: body); + expect(gotTitle, equals(title)); + expect(gotBody, equals(body)); + }); + + test('returns false when notify-send exits with non-zero code', () async { + if (!Platform.isLinux) return; + + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + return ProcessResult(0, 1, '', 'error'); + }; + + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }); + + test( + 'returns false when notify-send is not installed (ProcessException)', + () async { + if (!Platform.isLinux) return; + + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + throw ProcessException(exe, args, 'No such file or directory', 2); + }; + + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }, + ); + + test('returns false on unexpected exception (never throws)', () async { + if (!Platform.isLinux) return; + + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + throw StateError('unexpected'); + }; + + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }); + }); + + group('DesktopNotifier – processRunnerOverride lifecycle', () { + test('override is invoked when set', () async { + if (!Platform.isLinux) return; + + var called = false; + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + called = true; + return ProcessResult(0, 0, '', ''); + }; + + await DesktopNotifier.show(title: 'T', body: 'B'); + expect(called, isTrue); + }); + + test('override resets to null after tearDown (isolation)', () { + // Confirms test isolation — override should be null at this point + // because tearDown clears it. + expect(DesktopNotifier.processRunnerOverride, isNull); + }); + }); +} diff --git a/app/test/shell/linux_hotkey_registration_test.dart b/app/test/shell/linux_hotkey_registration_test.dart index 27e002a2..fd1efa43 100644 --- a/app/test/shell/linux_hotkey_registration_test.dart +++ b/app/test/shell/linux_hotkey_registration_test.dart @@ -1,96 +1,184 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_hotkey_registration.dart'; - -class _FakeLinuxHotkeyBindingApi implements LinuxHotkeyBindingApi { - _FakeLinuxHotkeyBindingApi(this.responses); - - final List responses; - final List attempts = []; - - @override - Future registerHotkey(HotkeyBinding binding) async { - attempts.add(binding); - if (responses.isEmpty) return false; - return responses.removeAt(0); - } -} - -void main() { - group('registerLinuxHotkeyWithFallback', () { - const requested = HotkeyBinding( - virtualKey: 0x56, - keyName: 'V', - useCtrl: true, - useWin: false, - useAlt: true, - useShift: false, - ); - - test('registers requested binding when available', () async { - final api = _FakeLinuxHotkeyBindingApi([true]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.status, HotkeyRegistrationStatus.registered); - expect(result.effectiveBinding, requested); - expect(api.attempts, [requested]); - }); - - test( - 'falls back to temporary Linux shortcut when requested binding fails', - () async { - final api = _FakeLinuxHotkeyBindingApi([false, true]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.status, HotkeyRegistrationStatus.fallbackRegistered); - expect(result.effectiveBinding, kLinuxTemporaryFallbackHotkey); - expect(api.attempts, [ - requested, - kLinuxTemporaryFallbackHotkey, - ]); - }, - ); - - test( - 'fails cleanly when requested and temporary fallback both fail', - () async { - final api = _FakeLinuxHotkeyBindingApi([false, false]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: requested, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(result.effectiveBinding, isNull); - expect(api.attempts, [ - requested, - kLinuxTemporaryFallbackHotkey, - ]); - }, - ); - - test( - 'does not retry when requested binding already matches temporary fallback', - () async { - final api = _FakeLinuxHotkeyBindingApi([false]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: kLinuxTemporaryFallbackHotkey, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); - }, - ); - }); -} +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/linux_hotkey_registration.dart'; +import 'package:copypaste/shell/linux_shell.dart'; + +class _FakeLinuxHotkeyBindingApi implements LinuxHotkeyBindingApi { + _FakeLinuxHotkeyBindingApi(this.responses); + + final List responses; + final List attempts = []; + + @override + Future registerHotkey(HotkeyBinding binding) async { + attempts.add(binding); + if (responses.isEmpty) { + return const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); + } + return responses.removeAt(0); + } +} + +HotkeyRegisterResponse _ok() => const HotkeyRegisterResponse(success: true); +HotkeyRegisterResponse _fail(String code) => + HotkeyRegisterResponse(success: false, errorCode: code); + +void main() { + group('isLinuxSupportedVirtualKey', () { + test('accepts A-Z, 0-9, F-keys, navigation, symbols', () { + expect(isLinuxSupportedVirtualKey(0x41), isTrue); + expect(isLinuxSupportedVirtualKey(0x5A), isTrue); + expect(isLinuxSupportedVirtualKey(0x30), isTrue); + expect(isLinuxSupportedVirtualKey(0x70), isTrue); + expect(isLinuxSupportedVirtualKey(0x87), isTrue); + expect(isLinuxSupportedVirtualKey(0x20), isTrue); + expect(isLinuxSupportedVirtualKey(0x25), isTrue); + expect(isLinuxSupportedVirtualKey(0xC0), isTrue); + }); + + test('rejects unmapped virtual keys', () { + expect(isLinuxSupportedVirtualKey(0x00), isFalse); + expect(isLinuxSupportedVirtualKey(0x90), isFalse); + expect(isLinuxSupportedVirtualKey(0xFF), isFalse); + }); + }); + + group('registerLinuxHotkeyWithFallback', () { + const requested = HotkeyBinding( + virtualKey: 0x56, + keyName: 'V', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: false, + ); + + test( + 'short-circuits when requested key is unsupported (no remote call)', + () async { + const unsupported = HotkeyBinding( + virtualKey: 0x99, + keyName: '?', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: false, + ); + final api = _FakeLinuxHotkeyBindingApi([]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: unsupported, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.failureReason, HotkeyFailureReason.unsupportedKey); + expect(api.attempts, isEmpty); + }, + ); + + test('registers requested binding when available', () async { + final api = _FakeLinuxHotkeyBindingApi([_ok()]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.registered); + expect(result.effectiveBinding, requested); + expect(result.failureReason, isNull); + expect(api.attempts, [requested]); + }); + + test('falls back when requested binding fails with grabFailed', () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + _ok(), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.fallbackRegistered); + expect(result.effectiveBinding, kLinuxTemporaryFallbackHotkey); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + expect(api.attempts, [ + requested, + kLinuxTemporaryFallbackHotkey, + ]); + }); + + test('fails cleanly when requested and fallback both fail', () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + _fail('grabFailed'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.effectiveBinding, isNull); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + }); + + test( + 'does not retry when requested binding equals temporary fallback', + () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: kLinuxTemporaryFallbackHotkey, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); + }, + ); + + test('maps unknown error code to HotkeyFailureReason.unknown', () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('something_weird'), + _fail('something_weird'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.failureReason, HotkeyFailureReason.unknown); + }); + + test('maps noModifier and noX11 error codes', () async { + final api1 = _FakeLinuxHotkeyBindingApi([ + _fail('noModifier'), + _fail('noModifier'), + ]); + final r1 = await registerLinuxHotkeyWithFallback( + api: api1, + requestedBinding: requested, + ); + expect(r1.failureReason, HotkeyFailureReason.noModifier); + + final api2 = _FakeLinuxHotkeyBindingApi([ + _fail('noX11'), + _fail('noX11'), + ]); + final r2 = await registerLinuxHotkeyWithFallback( + api: api2, + requestedBinding: requested, + ); + expect(r2.failureReason, HotkeyFailureReason.noX11); + }); + }); +} diff --git a/app/test/shell/linux_session_test.dart b/app/test/shell/linux_session_test.dart index 508168e5..eabad8e2 100644 --- a/app/test/shell/linux_session_test.dart +++ b/app/test/shell/linux_session_test.dart @@ -1,59 +1,164 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_session.dart'; - -void main() { - group('isWaylandSession', () { - test('returns false on non-Linux platforms', () { - if (Platform.isLinux) return; - expect(isWaylandSession(), isFalse); - }); - - test('is consistent with current environment variables', () { - if (!Platform.isLinux) { - expect(isWaylandSession(), isFalse); - return; - } - - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - if (sessionType == 'wayland' || waylandDisplay.isNotEmpty) { - expect(isWaylandSession(), isTrue); - } - }); - - test('returns false on headless / X11 CI environment', () { - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - final hasEnvIndicator = - sessionType == 'wayland' || waylandDisplay.isNotEmpty; - - if (!hasEnvIndicator && Platform.isLinux) { - expect(isWaylandSession(), isA()); - } - }); - - test('return type is bool', () { - expect(isWaylandSession(), isA()); - }); - - test('is idempotent — same result on repeated calls', () { - expect(isWaylandSession(), equals(isWaylandSession())); - }); - }); - - group('linuxPrefersDarkMode', () { - test('returns a bool', () async { - expect(await linuxPrefersDarkMode(), isA()); - }); - - test('returns false on non-Linux platforms', () async { - if (Platform.isLinux) return; - expect(await linuxPrefersDarkMode(), isFalse); - }); - }); -} +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/linux_session.dart'; + +void main() { + group('isWaylandSession', () { + test('returns false on non-Linux platforms', () { + if (Platform.isLinux) return; + expect(isWaylandSession(), isFalse); + }); + + test('is consistent with current environment variables', () { + if (!Platform.isLinux) { + expect(isWaylandSession(), isFalse); + return; + } + + final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; + + if (sessionType == 'wayland' || waylandDisplay.isNotEmpty) { + expect(isWaylandSession(), isTrue); + } + }); + + test('returns false on headless / X11 CI environment', () { + final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; + + final hasEnvIndicator = + sessionType == 'wayland' || waylandDisplay.isNotEmpty; + + if (!hasEnvIndicator && Platform.isLinux) { + expect(isWaylandSession(), isA()); + } + }); + + test('return type is bool', () { + expect(isWaylandSession(), isA()); + }); + + test('is idempotent — same result on repeated calls', () { + expect(isWaylandSession(), equals(isWaylandSession())); + }); + }); + + group('linuxPrefersDarkMode', () { + test('returns a bool', () async { + expect(await linuxPrefersDarkMode(), isA()); + }); + + test('returns false on non-Linux platforms', () async { + if (Platform.isLinux) return; + expect(await linuxPrefersDarkMode(), isFalse); + }); + }); + + group('LinuxSessionInfo', () { + test('unsupported is the safe default for non-Linux', () { + if (Platform.isLinux) return; + final info = detectLinuxSession(); + expect(info, equals(LinuxSessionInfo.unsupported)); + expect(info.isWayland, isFalse); + expect(info.isX11, isFalse); + expect(info.isUsable, isFalse); + }); + + test('detectLinuxSession returns a value type', () { + expect(detectLinuxSession(), isA()); + }); + + test('isWayland prioritises XDG_SESSION_TYPE=wayland', () { + const info = LinuxSessionInfo( + sessionType: 'wayland', + hasDisplay: true, + hasWaylandDisplay: true, + hasWaylandSocket: true, + desktopEnv: 'GNOME', + wmName: '', + ); + expect(info.isWayland, isTrue); + expect(info.isX11, isFalse); + expect(info.isXWayland, isTrue); + }); + + test('isX11 honours XDG_SESSION_TYPE=x11 even with Wayland socket', () { + const info = LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: true, + desktopEnv: 'KDE', + wmName: '', + ); + expect(info.isX11, isTrue); + expect(info.isWayland, isFalse); + }); + + test('empty sessionType + WAYLAND_DISPLAY set => Wayland', () { + const info = LinuxSessionInfo( + sessionType: '', + hasDisplay: true, + hasWaylandDisplay: true, + hasWaylandSocket: true, + desktopEnv: '', + wmName: '', + ); + expect(info.isWayland, isTrue); + expect(info.isX11, isFalse); + }); + + test('empty sessionType + only DISPLAY => X11', () { + const info = LinuxSessionInfo( + sessionType: '', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + expect(info.isX11, isTrue); + expect(info.isWayland, isFalse); + }); + + test('TTY / headless => neither X11 nor Wayland', () { + const info = LinuxSessionInfo( + sessionType: 'tty', + hasDisplay: false, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + expect(info.isUsable, isFalse); + }); + + test('isWaylandSession is a derived alias of detectLinuxSession', () { + expect(isWaylandSession(), equals(detectLinuxSession().isWayland)); + }); + + test('equality and hashCode work for value type', () { + const a = LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'GNOME', + wmName: 'gnome', + ); + const b = LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'GNOME', + wmName: 'gnome', + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); +} diff --git a/app/test/shell/linux_shell_test.dart b/app/test/shell/linux_shell_test.dart new file mode 100644 index 00000000..fde3ffd7 --- /dev/null +++ b/app/test/shell/linux_shell_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:copypaste/shell/linux_shell.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const eventChannelName = 'copypaste/linux_shell/events'; + StreamController? controller; + + Future emit(Object event) async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final data = const StandardMethodCodec().encodeSuccessEnvelope(event); + await messenger.handlePlatformMessage(eventChannelName, data, (_) {}); + } + + setUp(() { + controller = StreamController.broadcast(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + const EventChannel(eventChannelName), + MockStreamHandler.inline( + onListen: (_, sink) { + controller!.stream.listen(sink.success); + }, + onCancel: (_) {}, + ), + ); + }); + + tearDown(() async { + await LinuxShell.dispose(); + await controller?.close(); + controller = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler(const EventChannel(eventChannelName), null); + }); + + group('LinuxShell.awaitEvent', () { + test('completes true when matching event arrives', () async { + final future = LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(seconds: 1), + ); + await Future.delayed(const Duration(milliseconds: 20)); + await emit({'type': 'unmapped'}); + expect(await future, isTrue); + }); + + test('completes false on timeout when event never arrives', () async { + final result = await LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(milliseconds: 50), + ); + expect(result, isFalse); + }); + + test('ignores non-matching events and times out', () async { + final future = LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(milliseconds: 80), + ); + await Future.delayed(const Duration(milliseconds: 10)); + await emit({'type': 'mapped'}); + await emit({'type': 'hotkey'}); + expect(await future, isFalse); + }); + }); + + group('LinuxShell.getCursorMonitor', () { + const channel = MethodChannel('copypaste/linux_shell'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('parses Map response into CursorMonitorInfo', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getCursorMonitor') return null; + return { + 'cursorX': 800.0, + 'cursorY': 450.0, + 'x': 0.0, + 'y': 0.0, + 'width': 1920.0, + 'height': 1080.0, + 'scaleFactor': 2.0, + }; + }); + final info = await LinuxShell.getCursorMonitor(); + expect(info, isNotNull); + expect(info!.cursorX, equals(800.0)); + expect(info.width, equals(1920.0)); + expect(info.scaleFactor, equals(2.0)); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + expect(await LinuxShell.getCursorMonitor(), isNull); + }); + }); + + group('LinuxShell.getInputFocus', () { + const channel = MethodChannel('copypaste/linux_shell'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('parses Map response into InputFocusInfo', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getInputFocus') return null; + return { + 'ownsFocus': true, + 'focusWindow': 0xabc, + 'ownWindow': 0xabc, + }; + }); + final info = await LinuxShell.getInputFocus(); + expect(info, isNotNull); + expect(info!.ownsFocus, isTrue); + expect(info.focusWindow, equals(0xabc)); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + expect(await LinuxShell.getInputFocus(), isNull); + }); + }); +} diff --git a/codecov.yml b/codecov.yml index 72f6c725..83fcf0d5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,15 +2,13 @@ coverage: precision: 2 round: down ignore: - # Generated localization files — coverage annotations not processed by CI runner + - "core/lib/repository/sqlite_repository.g.dart" - "app/lib/l10n/app_localizations_en.dart" - "app/lib/l10n/app_localizations_es.dart" - # Platform-specific shell integrations (Win32 FFI, hotkeys, tray — untestable in headless CI) - "app/lib/shell" - "app/lib/services" - "app/lib/screens/settings_screen.dart" - "app/lib/main.dart" - # Platform-conditional helper — only one OS branch can be hit per CI run - "app/lib/helpers/url_helper.dart" comment: diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index 24942a61..d4a9ccb3 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -38,13 +38,15 @@ class AppConfig { this.themeMode = 'dark', this.accessibilityWasGranted = false, this.lastRunVersion = '', - this.hasSeenWindowsOnboarding = false, + this.hasSeenOnboarding = false, this.hasCompletedOnboarding = false, this.generateImageThumbnails = true, this.generateVideoThumbnails = true, this.generateAudioThumbnails = true, this.maxImageProcessingSizeMB = 25, this.imagesQuotaMB = 0, + this.linuxAppindicatorWarningDismissed = false, + this.linuxXtestWarningDismissed = false, }); factory AppConfig.fromJson(Map json) { @@ -107,12 +109,14 @@ class AppConfig { defaults.accessibilityWasGranted, lastRunVersion: json['lastRunVersion'] as String? ?? defaults.lastRunVersion, - hasSeenWindowsOnboarding: + hasSeenOnboarding: + json['hasSeenOnboarding'] as bool? ?? json['hasSeenWindowsOnboarding'] as bool? ?? - defaults.hasSeenWindowsOnboarding, + defaults.hasSeenOnboarding, hasCompletedOnboarding: json['hasCompletedOnboarding'] as bool? ?? - (json['hasSeenWindowsOnboarding'] as bool? ?? + (json['hasSeenOnboarding'] as bool? ?? + json['hasSeenWindowsOnboarding'] as bool? ?? defaults.hasCompletedOnboarding), generateImageThumbnails: json['generateImageThumbnails'] as bool? ?? @@ -127,6 +131,12 @@ class AppConfig { json['maxImageProcessingSizeMB'] as int? ?? defaults.maxImageProcessingSizeMB, imagesQuotaMB: json['imagesQuotaMB'] as int? ?? defaults.imagesQuotaMB, + linuxAppindicatorWarningDismissed: + json['linuxAppindicatorWarningDismissed'] as bool? ?? + defaults.linuxAppindicatorWarningDismissed, + linuxXtestWarningDismissed: + json['linuxXtestWarningDismissed'] as bool? ?? + defaults.linuxXtestWarningDismissed, ); } @@ -185,7 +195,7 @@ class AppConfig { final String themeMode; final bool accessibilityWasGranted; final String lastRunVersion; - final bool hasSeenWindowsOnboarding; + final bool hasSeenOnboarding; final bool hasCompletedOnboarding; // Multimedia & thumbnails @@ -199,6 +209,10 @@ class AppConfig { // owned bytes drop back below the limit. Pinned items are never purged. final int imagesQuotaMB; + // Linux capability warning banners (dismissible). + final bool linuxAppindicatorWarningDismissed; + final bool linuxXtestWarningDismissed; + AppConfig copyWith({ String? preferredLanguage, bool? runOnStartup, @@ -231,13 +245,15 @@ class AppConfig { String? themeMode, bool? accessibilityWasGranted, String? lastRunVersion, - bool? hasSeenWindowsOnboarding, + bool? hasSeenOnboarding, bool? hasCompletedOnboarding, bool? generateImageThumbnails, bool? generateVideoThumbnails, bool? generateAudioThumbnails, int? maxImageProcessingSizeMB, int? imagesQuotaMB, + bool? linuxAppindicatorWarningDismissed, + bool? linuxXtestWarningDismissed, }) => AppConfig( preferredLanguage: preferredLanguage ?? this.preferredLanguage, runOnStartup: runOnStartup ?? this.runOnStartup, @@ -275,8 +291,7 @@ class AppConfig { accessibilityWasGranted: accessibilityWasGranted ?? this.accessibilityWasGranted, lastRunVersion: lastRunVersion ?? this.lastRunVersion, - hasSeenWindowsOnboarding: - hasSeenWindowsOnboarding ?? this.hasSeenWindowsOnboarding, + hasSeenOnboarding: hasSeenOnboarding ?? this.hasSeenOnboarding, hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, generateImageThumbnails: @@ -288,6 +303,11 @@ class AppConfig { maxImageProcessingSizeMB: maxImageProcessingSizeMB ?? this.maxImageProcessingSizeMB, imagesQuotaMB: imagesQuotaMB ?? this.imagesQuotaMB, + linuxAppindicatorWarningDismissed: + linuxAppindicatorWarningDismissed ?? + this.linuxAppindicatorWarningDismissed, + linuxXtestWarningDismissed: + linuxXtestWarningDismissed ?? this.linuxXtestWarningDismissed, ); Map toJson() => { @@ -323,13 +343,15 @@ class AppConfig { 'themeMode': themeMode, 'accessibilityWasGranted': accessibilityWasGranted, 'lastRunVersion': lastRunVersion, - 'hasSeenWindowsOnboarding': hasSeenWindowsOnboarding, + 'hasSeenOnboarding': hasSeenOnboarding, 'hasCompletedOnboarding': hasCompletedOnboarding, 'generateImageThumbnails': generateImageThumbnails, 'generateVideoThumbnails': generateVideoThumbnails, 'generateAudioThumbnails': generateAudioThumbnails, 'maxImageProcessingSizeMB': maxImageProcessingSizeMB, 'imagesQuotaMB': imagesQuotaMB, + 'linuxAppindicatorWarningDismissed': linuxAppindicatorWarningDismissed, + 'linuxXtestWarningDismissed': linuxXtestWarningDismissed, }; static Future load(String configPath) async { diff --git a/core/lib/config/storage_config.dart b/core/lib/config/storage_config.dart index 15d445e6..1aebeb32 100644 --- a/core/lib/config/storage_config.dart +++ b/core/lib/config/storage_config.dart @@ -1,87 +1,95 @@ -import 'dart:io'; - -import '../services/app_logger.dart'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -class StorageConfig { - StorageConfig._({required this.baseDir}) - : databasePath = p.join(baseDir, 'clipboard.db'), - imagesPath = p.join(baseDir, 'images'), - configPath = p.join(baseDir, 'config'), - logsPath = p.join(baseDir, 'logs'); - - final String baseDir; - final String databasePath; - final String imagesPath; - final String configPath; - final String logsPath; - - String get _initFlagPath => p.join(baseDir, '.initialized'); - - static Future create({ - String? baseDir, - String? Function()? windowsLocalAppDataResolver, - }) async { - final String base; - if (baseDir != null) { - base = baseDir; - } else if (Platform.isWindows) { - final resolved = - windowsLocalAppDataResolver?.call() ?? - Platform.environment['LOCALAPPDATA']; - base = resolved != null - ? p.join(resolved, 'CopyPaste') - : p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); - } else { - base = p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); - } - return StorageConfig._(baseDir: base); - } - - Future ensureDirectories() async { - for (final dir in [baseDir, imagesPath, configPath, logsPath]) { - await Directory(dir).create(recursive: true); - } - } - - bool get isFirstRun => !File(_initFlagPath).existsSync(); - - void markAsInitialized() { - try { - File(_initFlagPath) - ..createSync(recursive: true) - ..writeAsStringSync(DateTime.now().toUtc().toIso8601String()); - } catch (e) { - AppLogger.error('Failed to mark as initialized: $e'); - } - } - - void clearInitialized() { - try { - final f = File(_initFlagPath); - if (f.existsSync()) f.deleteSync(); - } catch (e) { - AppLogger.error('Failed to clear initialized flag: $e'); - } - } - - void cleanOrphanImages(List validImagePaths) { - _cleanDirectory(imagesPath, validImagePaths.toSet()); - } - - void _cleanDirectory(String dirPath, Set validFiles) { - final dir = Directory(dirPath); - if (!dir.existsSync()) return; - for (final file in dir.listSync().whereType()) { - if (!validFiles.contains(file.path)) { - try { - file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to delete orphan file: $e'); - } - } - } - } -} +import 'dart:io'; + +import '../services/app_logger.dart'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class StorageConfig { + StorageConfig._({required this.baseDir}) + : databasePath = p.join(baseDir, 'clipboard.db'), + imagesPath = p.join(baseDir, 'images'), + configPath = p.join(baseDir, 'config'), + logsPath = p.join(baseDir, 'logs'); + + final String baseDir; + final String databasePath; + final String imagesPath; + final String configPath; + final String logsPath; + + String get _initFlagPath => p.join(baseDir, '.initialized'); + + static Future create({ + String? baseDir, + String? Function()? windowsLocalAppDataResolver, + }) async { + final String base; + if (baseDir != null) { + base = baseDir; + } else if (Platform.isWindows) { + // coverage:ignore-start + final resolved = + windowsLocalAppDataResolver?.call() ?? + Platform.environment['LOCALAPPDATA']; + base = resolved != null + ? p.join(resolved, 'CopyPaste') + : p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); + } else { + // coverage:ignore-end + base = p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); + } + return StorageConfig._(baseDir: base); + } + + Future ensureDirectories() async { + for (final dir in [baseDir, imagesPath, configPath, logsPath]) { + await Directory(dir).create(recursive: true); + } + } + + bool get isFirstRun => !File(_initFlagPath).existsSync(); + + void markAsInitialized() { + try { + File(_initFlagPath) + ..createSync(recursive: true) + ..writeAsStringSync(DateTime.now().toUtc().toIso8601String()); + } catch (e) { + AppLogger.error( + 'Failed to mark as initialized: $e', + ); // coverage:ignore-line + } + } + + void clearInitialized() { + try { + final f = File(_initFlagPath); + if (f.existsSync()) f.deleteSync(); + } catch (e) { + AppLogger.error( + 'Failed to clear initialized flag: $e', + ); // coverage:ignore-line + } + } + + void cleanOrphanImages(List validImagePaths) { + _cleanDirectory(imagesPath, validImagePaths.toSet()); + } + + void _cleanDirectory(String dirPath, Set validFiles) { + final dir = Directory(dirPath); + if (!dir.existsSync()) return; + for (final file in dir.listSync().whereType()) { + if (!validFiles.contains(file.path)) { + try { + file.deleteSync(); + } catch (e) { + AppLogger.error( + 'Failed to delete orphan file: $e', + ); // coverage:ignore-line + } + } + } + } +} diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 7393960d..1a919679 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -1,444 +1,488 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import '../models/card_color.dart'; -import '../models/clipboard_content_type.dart'; -import '../models/clipboard_item.dart'; -import '../repository/i_clipboard_repository.dart'; -import 'app_logger.dart'; -import 'image_processing_queue.dart'; -import 'native_thumbnail_provider.dart'; -import 'text_classifier.dart'; -import 'thumbnail_queue.dart'; -import 'thumbnail_service.dart'; - -class ClipboardService { - ClipboardService( - this._repository, { - String? imagesPath, - NativeThumbnailProvider? nativeThumbnailProvider, - bool Function(ClipboardContentType type)? isThumbnailTypeEnabled, - int Function()? getMaxImageBytes, - }) : _imagesPath = imagesPath, - _thumbnailService = (imagesPath != null && imagesPath.isNotEmpty) - ? ThumbnailService( - imagesPath: imagesPath, - nativeProvider: nativeThumbnailProvider, - isTypeEnabled: isThumbnailTypeEnabled, - ) - : null { - _imageQueue = ImageProcessingQueue( - repository: _repository, - onItemUpdated: _onImageItemUpdated, - getMaxImageBytes: getMaxImageBytes, - ); - final service = _thumbnailService; - _thumbQueue = service == null - ? null - : ThumbnailQueue( - repository: _repository, - service: service, - onItemUpdated: _onThumbItemUpdated, - ); - } - - final IClipboardRepository _repository; - final String? _imagesPath; - late final ImageProcessingQueue _imageQueue; - final ThumbnailService? _thumbnailService; - late final ThumbnailQueue? _thumbQueue; - final _itemAdded = StreamController.broadcast(); - final _itemReactivated = StreamController.broadcast(); - bool _disposed = false; - - void _onImageItemUpdated(ClipboardItem item) { - if (!_disposed) { - try { - _itemReactivated.add(item); - } on StateError catch (_) {} - } - } - - void _onThumbItemUpdated(ClipboardItem item) { - if (_disposed) return; - try { - _itemReactivated.add(item); - } on StateError catch (_) {} - } - - /// Requests background regeneration of [item]'s thumbnail if the source - /// file's `mtime` no longer matches the recorded `sourceModifiedAt`. - /// No-op when no `imagesPath` was configured. Safe to call from `build()` - /// — work is enqueued asynchronously. - void requestThumbnailIfStale(ClipboardItem item) { - _thumbQueue?.enqueueIfStale(item); - } - - /// Forces an enqueue regardless of staleness (e.g. the user explicitly - /// asked to refresh the thumb). - void requestThumbnailRefresh(ClipboardItem item) { - _thumbQueue?.enqueue(item, reason: ThumbnailJobReason.manualRefresh); - } - - void updateThumbnailTypeGate(bool Function(ClipboardContentType type)? gate) { - _thumbnailService?.isTypeEnabled = gate; - } - - void updateMaxImageBytesGate(int Function()? gate) { - _imageQueue.getMaxImageBytes = gate; - } - - Stream get onItemAdded => _itemAdded.stream; - Stream get onItemReactivated => _itemReactivated.stream; - - int pasteIgnoreWindowMs = 450; - - Stopwatch? _pasteStopwatch; - String? _lastPastedContent; - - Future notifyPasteInitiated(String itemId) async { - _pasteStopwatch = Stopwatch()..start(); - final item = await _repository.getById(itemId); - _lastPastedContent = item?.content; - } - - bool _shouldIgnore(String? content) { - final sw = _pasteStopwatch; - if (sw == null) return false; - final elapsed = sw.elapsedMilliseconds; - if (elapsed < pasteIgnoreWindowMs) return true; - if (content != null && - content == _lastPastedContent && - elapsed < pasteIgnoreWindowMs * 2) { - return true; - } - return false; - } - - Future processText( - String content, - ClipboardContentType type, { - String? source, - List? rtfBytes, - List? htmlBytes, - }) async { - if (_shouldIgnore(content)) return null; - - final resolvedType = type == ClipboardContentType.text - ? TextClassifier.classify(content) - : type; - - final existing = await _repository.findByContentAndType( - content, - resolvedType, - ); - if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - _itemReactivated.add(updated); - return updated; - } - - if (resolvedType != ClipboardContentType.text) { - final legacy = await _repository.findByContentAndType( - content, - ClipboardContentType.text, - ); - if (legacy != null) { - final updated = legacy.copyWith( - type: resolvedType, - modifiedAt: DateTime.now().toUtc(), - ); - await _repository.update(updated); - _itemReactivated.add(updated); - return updated; - } - } - - final meta = {}; - if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); - if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); - - final item = ClipboardItem( - content: content, - type: resolvedType, - appSource: source, - metadata: meta.isNotEmpty ? jsonEncode(meta) : null, - ); - await _repository.save(item); - _itemAdded.add(item); - return item; - } - - Future processImage( - String contentHash, { - String? source, - String? imagePath, - List? imageBytes, - }) async { - if (_shouldIgnore(null)) return null; - - final existing = await _repository.findByContentHash(contentHash); - if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - _itemReactivated.add(updated); - // Items captured before the native thumb provider was wired may - // have no thumbPath yet. enqueueIfStale is a no-op when thumb is - // already up-to-date. - _thumbQueue?.enqueueIfStale(updated); - return updated; - } - - final item = ClipboardItem( - content: imagePath ?? '', - type: ClipboardContentType.image, - appSource: source, - contentHash: contentHash, - ); - - var savedItem = item; - if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { - try { - final tempPath = p.join(_imagesPath, '${item.id}.bmp'); - await File(tempPath).writeAsBytes(imageBytes); - savedItem = item.copyWith(content: tempPath); - } catch (e) { - AppLogger.warn( - 'processImage: could not write temp BMP for ${item.id}: $e', - ); - } - } - - await _repository.save(savedItem); - _itemAdded.add(savedItem); - - if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { - _imageQueue.enqueue( - item: savedItem, - imageBytes: imageBytes, - imagesPath: _imagesPath, - ); - } else { - // External image referenced by path: schedule thumb generation. - // (When imageBytes is non-empty the result will land inside - // imagesPath and ThumbnailService skips it by design.) - _thumbQueue?.enqueue(savedItem); - } - - return savedItem; - } - - Future processFiles( - List files, - ClipboardContentType type, { - String? source, - }) async { - if (files.isEmpty) return null; - if (_shouldIgnore(null)) return null; - - final content = files.join('\n'); - final existing = await _repository.findByContentAndType(content, type); - if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - _itemReactivated.add(updated); - // Cover items captured before the native thumb provider was wired. - if (files.length == 1) { - _thumbQueue?.enqueueIfStale(updated); - } - return updated; - } - - final firstFile = files.first; - final meta = { - 'file_count': files.length, - 'file_name': p.basename(firstFile), - 'first_ext': p.extension(firstFile), - 'is_directory': type == ClipboardContentType.folder, - }; - - if (files.length == 1) { - try { - final fileSize = File(firstFile).lengthSync(); - meta['file_size'] = fileSize; - } catch (e) { - AppLogger.warn('processFiles: could not read size of $firstFile: $e'); - } - } - - final item = ClipboardItem( - content: content, - type: type, - appSource: source, - metadata: jsonEncode(meta), - ); - await _repository.save(item); - _itemAdded.add(item); - - // Native-backed thumbs cover video/audio (and image when the path is - // external). The queue ignores types it cannot handle, so this is a - // safe fire-and-forget call. - if (files.length == 1) { - _thumbQueue?.enqueue(item); - } - - return item; - } - - Future recordPaste(String itemId) async { - final now = DateTime.now().toUtc(); - final item = await _repository.getById(itemId); - if (item == null) return null; - final updated = item.copyWith( - pasteCount: item.pasteCount + 1, - modifiedAt: now, - ); - await _repository.update(updated); - return updated; - } - - Future removeItem(String id) async { - final item = await _repository.getById(id); - await _repository.delete(id); - if (item != null) { - _cleanupItemFiles(item); - } - } - - /// Deletes a file only if [path] is canonically contained inside the app's - /// own images directory. Any path outside is refused and logged. - /// - /// This is the single entry point for file deletion in this service. Never - /// call `File.delete*` directly on a path that comes from user input, item - /// content, or any source outside the app's own path builder. - bool _deleteAppFile(String path) { - final imagesPath = _imagesPath; - if (imagesPath == null || imagesPath.isEmpty) return false; - final String canonicalBase; - final String canonicalTarget; - try { - canonicalBase = p.canonicalize(imagesPath); - canonicalTarget = p.canonicalize(path); - } catch (e) { - AppLogger.warn('_deleteAppFile: canonicalize failed for "$path": $e'); - return false; - } - final baseWithSep = canonicalBase.endsWith(p.separator) - ? canonicalBase - : '$canonicalBase${p.separator}'; - if (!canonicalTarget.startsWith(baseWithSep)) { - AppLogger.error( - '_deleteAppFile: refused to delete out-of-scope path ' - '"$canonicalTarget" (base="$canonicalBase")', - ); - return false; - } - try { - final file = File(canonicalTarget); - if (file.existsSync()) file.deleteSync(); - return true; - } catch (e) { - AppLogger.warn('_deleteAppFile: delete failed for "$path": $e'); - return false; - } - } - - void _cleanupItemFiles(ClipboardItem item) { - if (item.type == ClipboardContentType.image && item.content.isNotEmpty) { - _deleteAppFile(item.content); - } - final thumb = item.thumbPath; - if (thumb != null && thumb.isNotEmpty) { - _deleteAppFile(thumb); - } - } - - Future> getHistoryAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - int limit = 50, - int skip = 0, - }) => _repository.searchAdvanced( - query: query, - types: types, - colors: colors, - isPinned: isPinned, - limit: limit, - skip: skip, - ); - - Future updatePin(String id, bool isPinned) async { - final item = await _repository.getById(id); - if (item == null) return; - await _repository.update( - item.copyWith(isPinned: isPinned, modifiedAt: DateTime.now().toUtc()), - ); - } - - Future updateLabelAndColor( - String id, - String? label, - CardColor color, - ) async { - final item = await _repository.getById(id); - if (item == null) return; - await _repository.update( - item.copyWith( - label: label, - cardColor: color, - modifiedAt: DateTime.now().toUtc(), - ), - ); - } - - Future clearUnpinnedHistory() => _repository.deleteAllUnpinned(); - - Future getItemCount() => _repository.count(); - - Future reclassifyLegacyTextItems() async { - const batchSize = 50; - var skip = 0; - while (true) { - if (_disposed) return; - final batch = await _repository.searchAdvanced( - types: [ClipboardContentType.text], - limit: batchSize, - skip: skip, - ); - if (batch.isEmpty) return; - for (final item in batch) { - if (_disposed) return; - final resolved = TextClassifier.classify(item.content); - if (resolved != ClipboardContentType.text) { - await _repository.update(item.copyWith(type: resolved)); - } - } - if (batch.length < batchSize) return; - skip += batchSize; - } - } - - Future walCheckpoint() => _repository.walCheckpoint(); - - Future updateMetadata(String id, String metadata) async { - final item = await _repository.getById(id); - if (item == null) return; - final updated = item.copyWith(metadata: metadata); - await _repository.update(updated); - if (!_disposed) _itemReactivated.add(updated); - } - - Future dispose() async { - _disposed = true; - await _imageQueue.dispose(); - await _thumbQueue?.dispose(); - await _itemAdded.close(); - await _itemReactivated.close(); - } -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../models/card_color.dart'; +import '../models/clipboard_content_type.dart'; +import '../models/clipboard_item.dart'; +import '../repository/i_clipboard_repository.dart'; +import 'app_logger.dart'; +import 'image_processing_queue.dart'; +import 'native_thumbnail_provider.dart'; +import 'text_classifier.dart'; +import 'thumbnail_queue.dart'; +import 'thumbnail_service.dart'; + +class ClipboardService { + ClipboardService( + this._repository, { + String? imagesPath, + NativeThumbnailProvider? nativeThumbnailProvider, + bool Function(ClipboardContentType type)? isThumbnailTypeEnabled, + int Function()? getMaxImageBytes, + }) : _imagesPath = imagesPath, + _thumbnailService = (imagesPath != null && imagesPath.isNotEmpty) + ? ThumbnailService( + imagesPath: imagesPath, + nativeProvider: nativeThumbnailProvider, + isTypeEnabled: isThumbnailTypeEnabled, + ) + : null { + _imageQueue = ImageProcessingQueue( + repository: _repository, + onItemUpdated: _onImageItemUpdated, + getMaxImageBytes: getMaxImageBytes, + ); + final service = _thumbnailService; + _thumbQueue = service == null + ? null + : ThumbnailQueue( + repository: _repository, + service: service, + onItemUpdated: _onThumbItemUpdated, + ); + } + + final IClipboardRepository _repository; + final String? _imagesPath; + late final ImageProcessingQueue _imageQueue; + final ThumbnailService? _thumbnailService; + late final ThumbnailQueue? _thumbQueue; + final _itemAdded = StreamController.broadcast(); + final _itemReactivated = StreamController.broadcast(); + bool _disposed = false; + + void _onImageItemUpdated(ClipboardItem item) { + if (!_disposed) { + try { + _itemReactivated.add(item); + } on StateError catch (_) {} + } + } + + void _onThumbItemUpdated(ClipboardItem item) { + if (_disposed) return; + try { + _itemReactivated.add(item); + } on StateError catch (_) {} + } + + /// Requests background regeneration of [item]'s thumbnail if the source + /// file's `mtime` no longer matches the recorded `sourceModifiedAt`. + /// No-op when no `imagesPath` was configured. Safe to call from `build()` + /// — work is enqueued asynchronously. + void requestThumbnailIfStale(ClipboardItem item) { + _thumbQueue?.enqueueIfStale(item); + } + + /// Forces an enqueue regardless of staleness (e.g. the user explicitly + /// asked to refresh the thumb). + void requestThumbnailRefresh(ClipboardItem item) { + _thumbQueue?.enqueue(item, reason: ThumbnailJobReason.manualRefresh); + } + + void updateThumbnailTypeGate(bool Function(ClipboardContentType type)? gate) { + _thumbnailService?.isTypeEnabled = gate; + } + + void updateMaxImageBytesGate(int Function()? gate) { + _imageQueue.getMaxImageBytes = gate; + } + + Stream get onItemAdded => _itemAdded.stream; + Stream get onItemReactivated => _itemReactivated.stream; + + int pasteIgnoreWindowMs = 450; + + Stopwatch? _pasteStopwatch; + String? _lastPastedContent; + + static const Duration _suppressionTtl = Duration(seconds: 5); + final Map _suppressedKeys = {}; + + String? _suppressionKeyForItem(ClipboardItem item) { + if (item.type == ClipboardContentType.image) { + final hash = item.contentHash; + if (hash == null || hash.isEmpty) return null; + return 'i:$hash'; + } + if (item.content.isEmpty) return null; + return 'c:${item.content}'; + } + + void _markSuppressed(ClipboardItem item) { + final key = _suppressionKeyForItem(item); + if (key == null) return; + _suppressedKeys[key] = DateTime.now().toUtc(); + } + + bool _consumeSuppression(String? key) { + if (key == null || key.isEmpty) return false; + const expiry = _suppressionTtl; + final now = DateTime.now().toUtc(); + _suppressedKeys.removeWhere((_, ts) => now.difference(ts) > expiry); + return _suppressedKeys.remove(key) != null; + } + + Future notifyPasteInitiated(String itemId) async { + _pasteStopwatch = Stopwatch()..start(); + final item = await _repository.getById(itemId); + _lastPastedContent = item?.content; + } + + bool _shouldIgnore(String? content) { + final sw = _pasteStopwatch; + if (sw == null) return false; + final elapsed = sw.elapsedMilliseconds; + if (elapsed < pasteIgnoreWindowMs) return true; + if (content != null && + content == _lastPastedContent && + elapsed < pasteIgnoreWindowMs * 2) { + return true; + } + return false; + } + + Future processText( + String content, + ClipboardContentType type, { + String? source, + List? rtfBytes, + List? htmlBytes, + }) async { + if (_shouldIgnore(content)) return null; + if (_consumeSuppression('c:$content')) return null; + + final resolvedType = type == ClipboardContentType.text + ? TextClassifier.classify(content) + : type; + + final existing = await _repository.findByContentAndType( + content, + resolvedType, + ); + if (existing != null) { + final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + await _repository.update(updated); + _itemReactivated.add(updated); + return updated; + } + + if (resolvedType != ClipboardContentType.text) { + final legacy = await _repository.findByContentAndType( + content, + ClipboardContentType.text, + ); + if (legacy != null) { + final updated = legacy.copyWith( + type: resolvedType, + modifiedAt: DateTime.now().toUtc(), + ); + await _repository.update(updated); + _itemReactivated.add(updated); + return updated; + } + } + + final meta = {}; + if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); + if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); + + final item = ClipboardItem( + content: content, + type: resolvedType, + appSource: source, + metadata: meta.isNotEmpty ? jsonEncode(meta) : null, + ); + await _repository.save(item); + _itemAdded.add(item); + return item; + } + + Future processImage( + String contentHash, { + String? source, + String? imagePath, + List? imageBytes, + }) async { + if (_shouldIgnore(null)) return null; + if (_consumeSuppression('i:$contentHash')) return null; + + final existing = await _repository.findByContentHash(contentHash); + if (existing != null) { + final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + await _repository.update(updated); + _itemReactivated.add(updated); + // Items captured before the native thumb provider was wired may + // have no thumbPath yet. enqueueIfStale is a no-op when thumb is + // already up-to-date. + _thumbQueue?.enqueueIfStale(updated); + return updated; + } + + final item = ClipboardItem( + content: imagePath ?? '', + type: ClipboardContentType.image, + appSource: source, + contentHash: contentHash, + ); + + var savedItem = item; + if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { + try { + final tempPath = p.join(_imagesPath, '${item.id}.bmp'); + await File(tempPath).writeAsBytes(imageBytes); + savedItem = item.copyWith(content: tempPath); + } catch (e) { + AppLogger.warn( + 'processImage: could not write temp BMP for ${item.id}: $e', + ); + } + } + + await _repository.save(savedItem); + _itemAdded.add(savedItem); + + if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { + _imageQueue.enqueue( + item: savedItem, + imageBytes: imageBytes, + imagesPath: _imagesPath, + ); + } else { + // External image referenced by path: schedule thumb generation. + // (When imageBytes is non-empty the result will land inside + // imagesPath and ThumbnailService skips it by design.) + _thumbQueue?.enqueue(savedItem); + } + + return savedItem; + } + + Future processFiles( + List files, + ClipboardContentType type, { + String? source, + }) async { + if (files.isEmpty) return null; + if (_shouldIgnore(null)) return null; + + final content = files.join('\n'); + if (_consumeSuppression('c:$content')) return null; + final existing = await _repository.findByContentAndType(content, type); + if (existing != null) { + final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + await _repository.update(updated); + _itemReactivated.add(updated); + // Cover items captured before the native thumb provider was wired. + if (files.length == 1) { + _thumbQueue?.enqueueIfStale(updated); + } + return updated; + } + + final firstFile = files.first; + final meta = { + 'file_count': files.length, + 'file_name': p.basename(firstFile), + 'first_ext': p.extension(firstFile), + 'is_directory': type == ClipboardContentType.folder, + }; + + if (files.length == 1) { + try { + final fileSize = File(firstFile).lengthSync(); + meta['file_size'] = fileSize; + } catch (e) { + AppLogger.warn('processFiles: could not read size of $firstFile: $e'); + } + } + + final item = ClipboardItem( + content: content, + type: type, + appSource: source, + metadata: jsonEncode(meta), + ); + await _repository.save(item); + _itemAdded.add(item); + + // Native-backed thumbs cover video/audio (and image when the path is + // external). The queue ignores types it cannot handle, so this is a + // safe fire-and-forget call. + if (files.length == 1) { + _thumbQueue?.enqueue(item); + } + + return item; + } + + Future recordPaste(String itemId) async { + final now = DateTime.now().toUtc(); + final item = await _repository.getById(itemId); + if (item == null) return null; + final updated = item.copyWith( + pasteCount: item.pasteCount + 1, + modifiedAt: now, + ); + await _repository.update(updated); + return updated; + } + + Future removeItem(String id) async { + final item = await _repository.getById(id); + if (item != null) { + _markSuppressed(item); + } + await _repository.delete(id); + if (item != null) { + _cleanupItemFiles(item); + } + } + + /// Deletes a file only if [path] is canonically contained inside the app's + /// own images directory. Any path outside is refused and logged. + /// + /// This is the single entry point for file deletion in this service. Never + /// call `File.delete*` directly on a path that comes from user input, item + /// content, or any source outside the app's own path builder. + bool _deleteAppFile(String path) { + final imagesPath = _imagesPath; + if (imagesPath == null || imagesPath.isEmpty) return false; + final String canonicalBase; + final String canonicalTarget; + try { + canonicalBase = p.canonicalize(imagesPath); + canonicalTarget = p.canonicalize(path); + } catch (e) { + AppLogger.warn('_deleteAppFile: canonicalize failed for "$path": $e'); + return false; + } + final baseWithSep = canonicalBase.endsWith(p.separator) + ? canonicalBase + : '$canonicalBase${p.separator}'; + if (!canonicalTarget.startsWith(baseWithSep)) { + AppLogger.error( + '_deleteAppFile: refused to delete out-of-scope path ' + '"$canonicalTarget" (base="$canonicalBase")', + ); + return false; + } + try { + final file = File(canonicalTarget); + if (file.existsSync()) file.deleteSync(); + return true; + } catch (e) { + AppLogger.warn('_deleteAppFile: delete failed for "$path": $e'); + return false; + } + } + + void _cleanupItemFiles(ClipboardItem item) { + if (item.type == ClipboardContentType.image && item.content.isNotEmpty) { + _deleteAppFile(item.content); + } + final thumb = item.thumbPath; + if (thumb != null && thumb.isNotEmpty) { + _deleteAppFile(thumb); + } + } + + Future> getHistoryAdvanced({ + String? query, + List? types, + List? colors, + bool? isPinned, + int limit = 50, + int skip = 0, + }) => _repository.searchAdvanced( + query: query, + types: types, + colors: colors, + isPinned: isPinned, + limit: limit, + skip: skip, + ); + + Future updatePin(String id, bool isPinned) async { + final item = await _repository.getById(id); + if (item == null) return; + await _repository.update( + item.copyWith(isPinned: isPinned, modifiedAt: DateTime.now().toUtc()), + ); + } + + Future updateLabelAndColor( + String id, + String? label, + CardColor color, + ) async { + final item = await _repository.getById(id); + if (item == null) return; + await _repository.update( + item.copyWith( + label: label, + cardColor: color, + modifiedAt: DateTime.now().toUtc(), + ), + ); + } + + Future clearUnpinnedHistory() async { + final unpinned = await _repository.searchAdvanced( + isPinned: false, + limit: 100000, + skip: 0, + ); + for (final item in unpinned) { + _markSuppressed(item); + } + return _repository.deleteAllUnpinned(); + } + + Future getItemCount() => _repository.count(); + + Future reclassifyLegacyTextItems() async { + const batchSize = 50; + var skip = 0; + while (true) { + if (_disposed) return; + final batch = await _repository.searchAdvanced( + types: [ClipboardContentType.text], + limit: batchSize, + skip: skip, + ); + if (batch.isEmpty) return; + for (final item in batch) { + if (_disposed) return; + final resolved = TextClassifier.classify(item.content); + if (resolved != ClipboardContentType.text) { + await _repository.update(item.copyWith(type: resolved)); + } + } + if (batch.length < batchSize) return; + skip += batchSize; + } + } + + Future walCheckpoint() => _repository.walCheckpoint(); + + Future updateMetadata(String id, String metadata) async { + final item = await _repository.getById(id); + if (item == null) return; + final updated = item.copyWith(metadata: metadata); + await _repository.update(updated); + if (!_disposed) _itemReactivated.add(updated); + } + + Future dispose() async { + _disposed = true; + _suppressedKeys.clear(); + await _imageQueue.dispose(); + await _thumbQueue?.dispose(); + await _itemAdded.close(); + await _itemReactivated.close(); + } +} diff --git a/core/lib/services/crash_logger.dart b/core/lib/services/crash_logger.dart index 97c13637..bbc1d1ba 100644 --- a/core/lib/services/crash_logger.dart +++ b/core/lib/services/crash_logger.dart @@ -1,120 +1,130 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -class CrashLogger { - CrashLogger._(); - - static const String fileName = 'crash.log'; - static const int _maxSizeBytes = 512 * 1024; - - static String? _filePath; - - static String? get filePath => _filePath; - - static void initialize(String baseDir) { - try { - Directory(baseDir).createSync(recursive: true); - _filePath = p.join(baseDir, fileName); - } catch (_) { - _filePath = null; - } - } - - static String? resolveBootstrapPath() { - try { - final base = _bootstrapBaseDir(); - if (base == null) return null; - Directory(base).createSync(recursive: true); - return p.join(base, fileName); - } catch (_) { - return null; - } - } - - static void report( - Object error, - StackTrace? stack, { - String context = '', - String? overridePath, - }) { - final target = overridePath ?? _filePath ?? resolveBootstrapPath(); - if (target == null) return; - try { - final file = File(target); - if (file.existsSync() && file.lengthSync() > _maxSizeBytes) { - file.writeAsStringSync('', flush: true); - } - final ts = DateTime.now().toUtc().toIso8601String(); - final sb = StringBuffer() - ..writeln('==== $ts ====') - ..writeln( - 'Platform: ${Platform.operatingSystem} ' - '${Platform.operatingSystemVersion}', - ) - ..writeln('Dart: ${Platform.version}'); - if (context.isNotEmpty) sb.writeln('Context: $context'); - sb.writeln('Error: ${redact(error.toString())}'); - if (stack != null) { - sb.writeln('Stack:'); - sb.writeln(redact(stack.toString())); - } - sb.writeln(); - file.writeAsStringSync(sb.toString(), mode: FileMode.append, flush: true); - } catch (_) {} - } - - static String redact(String input) { - var out = input; - final userProfile = Platform.environment['USERPROFILE']; - final home = Platform.environment['HOME']; - final username = - Platform.environment['USERNAME'] ?? Platform.environment['USER']; - for (final raw in [userProfile, home]) { - if (raw != null && raw.isNotEmpty) { - out = out.replaceAll(raw, ''); - } - } - if (username != null && username.isNotEmpty && username.length > 1) { - out = out.replaceAll( - RegExp(r'\\Users\\' + RegExp.escape(username), caseSensitive: false), - r'\Users\', - ); - out = out.replaceAll( - RegExp(r'/Users/' + RegExp.escape(username)), - '/Users/', - ); - out = out.replaceAll( - RegExp(r'/home/' + RegExp.escape(username)), - '/home/', - ); - } - out = out.replaceAll( - RegExp(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), - '', - ); - return out; - } - - static String? _bootstrapBaseDir() { - if (Platform.isWindows) { - final local = Platform.environment['LOCALAPPDATA']; - if (local != null && local.isNotEmpty) { - return p.join(local, 'CopyPaste'); - } - final profile = Platform.environment['USERPROFILE']; - if (profile != null && profile.isNotEmpty) { - return p.join(profile, 'AppData', 'Local', 'CopyPaste'); - } - return p.join(Directory.systemTemp.path, 'CopyPaste'); - } - final home = Platform.environment['HOME']; - if (home != null && home.isNotEmpty) { - if (Platform.isMacOS) { - return p.join(home, 'Library', 'Application Support', 'CopyPaste'); - } - return p.join(home, '.local', 'share', 'CopyPaste'); - } - return p.join(Directory.systemTemp.path, 'CopyPaste'); - } -} +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class CrashLogger { + CrashLogger._(); // coverage:ignore-line + + static const String fileName = 'crash.log'; + static const int _maxSizeBytes = 512 * 1024; + + static String? _filePath; + + static String? get filePath => _filePath; + + static void initialize(String baseDir) { + try { + Directory(baseDir).createSync(recursive: true); + _filePath = p.join(baseDir, fileName); + } catch (_) { + _filePath = null; + } + } + + static String? resolveBootstrapPath() { + try { + final base = _bootstrapBaseDir(); + if (base == null) return null; + Directory(base).createSync(recursive: true); + return p.join(base, fileName); + } catch (_) { + return null; + } + } + + static void report( + Object error, + StackTrace? stack, { + String context = '', + String? overridePath, + }) { + final target = overridePath ?? _filePath ?? resolveBootstrapPath(); + if (target == null) return; + try { + final file = File(target); + if (file.existsSync() && file.lengthSync() > _maxSizeBytes) { + file.writeAsStringSync('', flush: true); + } + final ts = DateTime.now().toUtc().toIso8601String(); + final sb = StringBuffer() + ..writeln('==== $ts ====') + ..writeln( + 'Platform: ${Platform.operatingSystem} ' + '${Platform.operatingSystemVersion}', + ) + ..writeln('Dart: ${Platform.version}'); + if (context.isNotEmpty) sb.writeln('Context: $context'); + sb.writeln('Error: ${redact(error.toString())}'); + if (stack != null) { + sb.writeln('Stack:'); + sb.writeln(redact(stack.toString())); + } + sb.writeln(); + file.writeAsStringSync(sb.toString(), mode: FileMode.append, flush: true); + } catch (_) {} + } + + static String redact(String input) { + var out = input; + final userProfile = Platform.environment['USERPROFILE']; + final home = Platform.environment['HOME']; + final username = + Platform.environment['USERNAME'] ?? Platform.environment['USER']; + for (final raw in [userProfile, home]) { + if (raw != null && raw.isNotEmpty) { + out = out.replaceAll(raw, ''); + } + } + if (username != null && username.isNotEmpty && username.length > 1) { + out = out.replaceAll( + RegExp(r'\\Users\\' + RegExp.escape(username), caseSensitive: false), + r'\Users\', + ); + out = out.replaceAll( + RegExp(r'/Users/' + RegExp.escape(username)), + '/Users/', + ); + out = out.replaceAll( + RegExp(r'/home/' + RegExp.escape(username)), + '/home/', + ); + } + out = out.replaceAll( + RegExp(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), + '', + ); + return out; + } + + static String? _bootstrapBaseDir() { + // coverage:ignore-start + if (Platform.isWindows) { + final local = Platform.environment['LOCALAPPDATA']; + if (local != null && local.isNotEmpty) { + return p.join(local, 'CopyPaste'); + } + final profile = Platform.environment['USERPROFILE']; + if (profile != null && profile.isNotEmpty) { + return p.join(profile, 'AppData', 'Local', 'CopyPaste'); + } + return p.join(Directory.systemTemp.path, 'CopyPaste'); + } + // coverage:ignore-end + final home = Platform.environment['HOME']; + if (home != null && home.isNotEmpty) { + if (Platform.isMacOS) { + return p.join( + home, + 'Library', + 'Application Support', + 'CopyPaste', + ); // coverage:ignore-line + } + return p.join(home, '.local', 'share', 'CopyPaste'); + } + return p.join( + Directory.systemTemp.path, + 'CopyPaste', + ); // coverage:ignore-line + } +} diff --git a/core/lib/services/support_service.dart b/core/lib/services/support_service.dart index 06ab8264..f18f00f4 100644 --- a/core/lib/services/support_service.dart +++ b/core/lib/services/support_service.dart @@ -1,156 +1,160 @@ -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:path/path.dart' as p; - -import '../config/storage_config.dart'; -import 'app_logger.dart'; -import 'crash_logger.dart'; - -class SupportService { - SupportService._(); - - /// Exports all log files into a zip archive saved at [savePath]. - /// - /// The zip includes: - /// - All `.log` files from [StorageConfig.logsPath]. - /// - A `device_info.txt` with basic platform and version details. - /// - /// Returns the number of log files included, or throws on failure. - static Future exportLogs( - StorageConfig storage, - String appVersion, - String savePath, - ) async { - AppLogger.info('exportLogs: starting — savePath=$savePath'); - final logsDir = Directory(storage.logsPath); - final archive = Archive(); - - final logFiles = logsDir.existsSync() - ? logsDir - .listSync() - .whereType() - .where((f) => f.path.endsWith('.log')) - .toList() - : []; - - if (logFiles.isEmpty) { - AppLogger.warn('exportLogs: no .log files found in ${storage.logsPath}'); - } - - for (final file in logFiles) { - try { - final raw = await file.readAsString(); - final redacted = CrashLogger.redact(raw); - final bytes = redacted.codeUnits; - archive.addFile( - ArchiveFile(p.basename(file.path), bytes.length, bytes), - ); - } catch (e) { - AppLogger.error('exportLogs: failed to read ${file.path}: $e'); - } - } - - final crashFile = File(p.join(storage.baseDir, CrashLogger.fileName)); - if (crashFile.existsSync()) { - try { - final raw = await crashFile.readAsString(); - final redacted = CrashLogger.redact(raw); - final bytes = redacted.codeUnits; - archive.addFile(ArchiveFile(CrashLogger.fileName, bytes.length, bytes)); - } catch (e) { - AppLogger.error('exportLogs: failed to read crash.log: $e'); - } - } - - // Add device info so the report is self-contained - final info = _buildDeviceInfo(appVersion); - final infoBytes = info.codeUnits; - archive.addFile( - ArchiveFile('device_info.txt', infoBytes.length, infoBytes), - ); - - final zipData = ZipEncoder().encode(archive); - if (zipData.isEmpty) { - AppLogger.error('exportLogs: ZipEncoder returned empty data'); - throw StateError('Zip encoding produced no output'); - } - - await File(savePath).writeAsBytes(zipData); - AppLogger.info( - 'exportLogs: done — ${logFiles.length} log file(s) → $savePath', - ); - return logFiles.length; - } - - /// Reveals [filePath] in the system file browser (Finder, Explorer, etc.). - static Future revealFile(String filePath) async { - AppLogger.info('revealFile: $filePath'); - try { - if (Platform.isWindows) { - await Process.run('explorer', ['/select,', filePath]); - } else if (Platform.isMacOS) { - await Process.run('open', ['-R', filePath]); - } else if (Platform.isLinux) { - await Process.run('xdg-open', [File(filePath).parent.path]); - } - } catch (e, s) { - AppLogger.exception(e, s, 'revealFile'); - } - } - - /// Opens the logs directory in the system file browser. - static Future openLogsFolder(StorageConfig storage) async { - final logsDir = Directory(storage.logsPath); - if (!logsDir.existsSync()) { - AppLogger.info('openLogsFolder: logs dir missing, creating it'); - await logsDir.create(recursive: true); - } - - AppLogger.info('openLogsFolder: opening ${logsDir.path}'); - try { - if (Platform.isWindows) { - // Process.run('explorer', path) silently fails in MSIX packages because - // Windows routes the open request via DDE to the existing shell process, - // and the AppContainer blocks cross-process DDE. Using cmd's start - // command calls ShellExecuteEx instead, which works correctly in MSIX. - await Process.run('cmd', ['/c', 'start', '', logsDir.path]); - } else if (Platform.isMacOS) { - await Process.run('open', [logsDir.path]); - } else if (Platform.isLinux) { - await Process.run('xdg-open', [logsDir.path]); - } - } catch (e, s) { - AppLogger.exception(e, s, 'openLogsFolder'); - rethrow; - } - } - - static String _buildDeviceInfo(String appVersion) { - final osVersion = Platform.isWindows - ? correctWindowsVersion(Platform.operatingSystemVersion) - : Platform.operatingSystemVersion; - final lines = [ - 'CopyPaste v$appVersion', - 'Generated: ${DateTime.now().toUtc().toIso8601String()}', - '', - 'Platform : ${Platform.operatingSystem}', - 'OS : $osVersion', - 'Locale : ${Platform.localeName}', - 'Dart : ${Platform.version}', - ]; - return lines.join('\n'); - } - - // Dart/Flutter always reports "Windows 10" even on Windows 11 due to Win32 - // backwards-compat shim. Windows 11 starts at build 22000. - @visibleForTesting - static String correctWindowsVersion(String raw) { - if (!raw.contains('Windows 10')) return raw; - final match = RegExp(r'Build (\d+)').firstMatch(raw); - if (match == null) return raw; - final build = int.tryParse(match.group(1) ?? '') ?? 0; - return build >= 22000 ? raw.replaceFirst('Windows 10', 'Windows 11') : raw; - } -} +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:path/path.dart' as p; + +import '../config/storage_config.dart'; +import 'app_logger.dart'; +import 'crash_logger.dart'; + +class SupportService { + SupportService._(); // coverage:ignore-line + + /// Exports all log files into a zip archive saved at [savePath]. + /// + /// The zip includes: + /// - All `.log` files from [StorageConfig.logsPath]. + /// - A `device_info.txt` with basic platform and version details. + /// + /// Returns the number of log files included, or throws on failure. + static Future exportLogs( + StorageConfig storage, + String appVersion, + String savePath, + ) async { + AppLogger.info('exportLogs: starting — savePath=$savePath'); + final logsDir = Directory(storage.logsPath); + final archive = Archive(); + + final logFiles = logsDir.existsSync() + ? logsDir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.log')) + .toList() + : []; + + if (logFiles.isEmpty) { + AppLogger.warn('exportLogs: no .log files found in ${storage.logsPath}'); + } + + for (final file in logFiles) { + try { + final raw = await file.readAsString(); + final redacted = CrashLogger.redact(raw); + final bytes = redacted.codeUnits; + archive.addFile( + ArchiveFile(p.basename(file.path), bytes.length, bytes), + ); + } catch (e) { + AppLogger.error('exportLogs: failed to read ${file.path}: $e'); + } + } + + final crashFile = File(p.join(storage.baseDir, CrashLogger.fileName)); + if (crashFile.existsSync()) { + try { + final raw = await crashFile.readAsString(); + final redacted = CrashLogger.redact(raw); + final bytes = redacted.codeUnits; + archive.addFile(ArchiveFile(CrashLogger.fileName, bytes.length, bytes)); + } catch (e) { + AppLogger.error('exportLogs: failed to read crash.log: $e'); + } + } + + // Add device info so the report is self-contained + final info = _buildDeviceInfo(appVersion); + final infoBytes = info.codeUnits; + archive.addFile( + ArchiveFile('device_info.txt', infoBytes.length, infoBytes), + ); + + final zipData = ZipEncoder().encode(archive); + if (zipData.isEmpty) { + AppLogger.error('exportLogs: ZipEncoder returned empty data'); + throw StateError('Zip encoding produced no output'); + } + + await File(savePath).writeAsBytes(zipData); + AppLogger.info( + 'exportLogs: done — ${logFiles.length} log file(s) → $savePath', + ); + return logFiles.length; + } + + /// Reveals [filePath] in the system file browser (Finder, Explorer, etc.). + static Future revealFile(String filePath) async { + AppLogger.info('revealFile: $filePath'); + try { + // coverage:ignore-start + if (Platform.isWindows) { + await Process.run('explorer', ['/select,', filePath]); + } else if (Platform.isMacOS) { + await Process.run('open', ['-R', filePath]); + } else // coverage:ignore-end + if (Platform.isLinux) { + await Process.run('xdg-open', [File(filePath).parent.path]); + } + } catch (e, s) { + AppLogger.exception(e, s, 'revealFile'); + } + } + + /// Opens the logs directory in the system file browser. + static Future openLogsFolder(StorageConfig storage) async { + final logsDir = Directory(storage.logsPath); + if (!logsDir.existsSync()) { + AppLogger.info('openLogsFolder: logs dir missing, creating it'); + await logsDir.create(recursive: true); + } + + AppLogger.info('openLogsFolder: opening ${logsDir.path}'); + try { + // coverage:ignore-start + if (Platform.isWindows) { + // Process.run('explorer', path) silently fails in MSIX packages because + // Windows routes the open request via DDE to the existing shell process, + // and the AppContainer blocks cross-process DDE. Using cmd's start + // command calls ShellExecuteEx instead, which works correctly in MSIX. + await Process.run('cmd', ['/c', 'start', '', logsDir.path]); + } else if (Platform.isMacOS) { + await Process.run('open', [logsDir.path]); + } else // coverage:ignore-end + if (Platform.isLinux) { + await Process.run('xdg-open', [logsDir.path]); + } + } catch (e, s) { + AppLogger.exception(e, s, 'openLogsFolder'); + rethrow; + } + } + + static String _buildDeviceInfo(String appVersion) { + final osVersion = Platform.isWindows + ? correctWindowsVersion(Platform.operatingSystemVersion) + : Platform.operatingSystemVersion; + final lines = [ + 'CopyPaste v$appVersion', + 'Generated: ${DateTime.now().toUtc().toIso8601String()}', + '', + 'Platform : ${Platform.operatingSystem}', + 'OS : $osVersion', + 'Locale : ${Platform.localeName}', + 'Dart : ${Platform.version}', + ]; + return lines.join('\n'); + } + + // Dart/Flutter always reports "Windows 10" even on Windows 11 due to Win32 + // backwards-compat shim. Windows 11 starts at build 22000. + @visibleForTesting + static String correctWindowsVersion(String raw) { + if (!raw.contains('Windows 10')) return raw; + final match = RegExp(r'Build (\d+)').firstMatch(raw); + if (match == null) return raw; + final build = int.tryParse(match.group(1) ?? '') ?? 0; + return build >= 22000 ? raw.replaceFirst('Windows 10', 'Windows 11') : raw; + } +} diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index ef9b4fa5..9b257302 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -196,29 +196,56 @@ void main() { expect(config.cardMaxLines, equals(5)); }); - test('hasSeenWindowsOnboarding defaults to false', () { + test('hasSeenOnboarding defaults to false', () { const config = AppConfig(); - expect(config.hasSeenWindowsOnboarding, isFalse); + expect(config.hasSeenOnboarding, isFalse); }); - test('hasSeenWindowsOnboarding round-trips via JSON', () { - const config = AppConfig(hasSeenWindowsOnboarding: true); + test('hasSeenOnboarding round-trips via JSON', () { + const config = AppConfig(hasSeenOnboarding: true); + expect(AppConfig.fromJson(config.toJson()).hasSeenOnboarding, isTrue); + }); + + test('hasSeenOnboarding absent in JSON defaults to false', () { + expect(AppConfig.fromJson({}).hasSeenOnboarding, isFalse); + }); + + test('copyWith hasSeenOnboarding updates value', () { + const config = AppConfig(); expect( - AppConfig.fromJson(config.toJson()).hasSeenWindowsOnboarding, + config.copyWith(hasSeenOnboarding: true).hasSeenOnboarding, isTrue, ); }); - test('hasSeenWindowsOnboarding absent in JSON defaults to false', () { - expect(AppConfig.fromJson({}).hasSeenWindowsOnboarding, isFalse); + test('linux capability dismiss flags default to false', () { + const config = AppConfig(); + expect(config.linuxAppindicatorWarningDismissed, isFalse); + expect(config.linuxXtestWarningDismissed, isFalse); + }); + + test('linux capability dismiss flags round-trip via JSON', () { + const config = AppConfig( + linuxAppindicatorWarningDismissed: true, + linuxXtestWarningDismissed: true, + ); + final restored = AppConfig.fromJson(config.toJson()); + expect(restored.linuxAppindicatorWarningDismissed, isTrue); + expect(restored.linuxXtestWarningDismissed, isTrue); }); - test('copyWith hasSeenWindowsOnboarding updates value', () { + test('copyWith updates linux capability dismiss flags individually', () { const config = AppConfig(); expect( config - .copyWith(hasSeenWindowsOnboarding: true) - .hasSeenWindowsOnboarding, + .copyWith(linuxAppindicatorWarningDismissed: true) + .linuxAppindicatorWarningDismissed, + isTrue, + ); + expect( + config + .copyWith(linuxXtestWarningDismissed: true) + .linuxXtestWarningDismissed, isTrue, ); }); @@ -346,7 +373,7 @@ void main() { themeMode: 'test', accessibilityWasGranted: true, lastRunVersion: 'v', - hasSeenWindowsOnboarding: true, + hasSeenOnboarding: true, ); final json = config.toJson(); expect(json['preferredLanguage'], 'fr'); @@ -378,7 +405,7 @@ void main() { expect(json['themeMode'], 'test'); expect(json['accessibilityWasGranted'], true); expect(json['lastRunVersion'], 'v'); - expect(json['hasSeenWindowsOnboarding'], true); + expect(json['hasSeenOnboarding'], true); }); }); @@ -649,6 +676,28 @@ void main() { }, ); + test('hasSeenOnboarding migrates from legacy hasSeenWindowsOnboarding', () { + final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); + expect(c.hasSeenOnboarding, isTrue); + }); + + test('hasSeenOnboarding new key takes precedence over legacy', () { + final c = AppConfig.fromJson({ + 'hasSeenWindowsOnboarding': false, + 'hasSeenOnboarding': true, + }); + expect(c.hasSeenOnboarding, isTrue); + }); + + test( + 'both hasSeenOnboarding and hasCompletedOnboarding populated from legacy', + () { + final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); + expect(c.hasSeenOnboarding, isTrue); + expect(c.hasCompletedOnboarding, isTrue); + }, + ); + test( 'hasCompletedOnboarding stays false when neither legacy nor new is set', () { diff --git a/core/test/support_service_test.dart b/core/test/support_service_test.dart index ae28cd22..9dea286e 100644 --- a/core/test/support_service_test.dart +++ b/core/test/support_service_test.dart @@ -1,230 +1,267 @@ -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late Directory tempDir; - late StorageConfig storage; - - setUp(() async { - tempDir = Directory.systemTemp.createTempSync('support_test_'); - storage = await StorageConfig.create(baseDir: tempDir.path); - await Directory(storage.logsPath).create(recursive: true); - }); - - tearDown(() => tempDir.deleteSync(recursive: true)); - - // --------------------------------------------------------------------------- - // correctWindowsVersion - // --------------------------------------------------------------------------- - group('SupportService.correctWindowsVersion', () { - test('replaces Windows 10 with Windows 11 for build >= 22000', () { - const raw = 'Windows 10.0.22621 Build 22621'; - expect( - SupportService.correctWindowsVersion(raw), - equals('Windows 11.0.22621 Build 22621'), - ); - }); - - test('does not replace for build < 22000', () { - const raw = 'Windows 10.0.19045 Build 19045'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - - test('returns raw string unchanged when no Windows 10 text', () { - const raw = 'Windows 11.0.22621'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - - test('returns raw string unchanged when no Build number present', () { - const raw = 'Windows 10 Pro'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - - test('handles build exactly at boundary (22000 → Windows 11)', () { - const raw = 'Windows 10.0.22000 Build 22000'; - expect(SupportService.correctWindowsVersion(raw), contains('Windows 11')); - }); - - test('handles build one below boundary (21999 → unchanged)', () { - const raw = 'Windows 10.0.21999 Build 21999'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - }); - - // --------------------------------------------------------------------------- - // exportLogs - // --------------------------------------------------------------------------- - group('SupportService.exportLogs', () { - test('returns 0 when logs directory is empty', () async { - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(0)); - expect(File(savePath).existsSync(), isTrue); - }); - - test('returns 0 when logs directory does not exist', () async { - await Directory(storage.logsPath).delete(recursive: true); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(0)); - }); - - test('returns correct count of .log files', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log1'); - File(p.join(storage.logsPath, 'app2.log')).writeAsStringSync('log2'); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(2)); - }); - - test('excludes non-.log files from count and archive', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); - File(p.join(storage.logsPath, 'readme.txt')).writeAsStringSync('text'); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(1)); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final names = archive.map((f) => f.name).toList(); - expect(names, contains('app.log')); - expect(names, isNot(contains('readme.txt'))); - }); - - test('ZIP always contains device_info.txt', () async { - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.5.1', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); - final content = String.fromCharCodes(infoFile.content as List); - expect(content, contains('CopyPaste v2.5.1')); - }); - - test('device_info.txt contains platform and Dart version', () async { - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); - final content = String.fromCharCodes(infoFile.content as List); - expect(content, contains('Platform')); - expect(content, contains('Dart')); - expect(content, contains('Generated:')); - }); - - test('ZIP contains log file content verbatim', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('hello log'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final logFile = archive.firstWhere((f) => f.name == 'app.log'); - final content = String.fromCharCodes(logFile.content as List); - expect(content, equals('hello log')); - }); - - test('saves zip file at specified path', () async { - final savePath = p.join(tempDir.path, 'subdir', 'export.zip'); - await Directory(p.join(tempDir.path, 'subdir')).create(); - await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(File(savePath).existsSync(), isTrue); - expect(File(savePath).lengthSync(), greaterThan(0)); - }); - - test('includes crash.log in archive when it exists', () async { - File( - p.join(storage.baseDir, 'crash.log'), - ).writeAsStringSync('==== crash entry ===='); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final names = archive.map((f) => f.name).toList(); - expect(names, contains('crash.log')); - }); - - test('does not include crash.log entry when file does not exist', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final names = archive.map((f) => f.name).toList(); - expect(names, isNot(contains('crash.log'))); - }); - - test('crash.log count is not added to returned log file count', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); - File( - p.join(storage.baseDir, 'crash.log'), - ).writeAsStringSync('crash entry'); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(1)); - }); - - test('log files are redacted in archive — email is replaced', () async { - File( - p.join(storage.logsPath, 'app.log'), - ).writeAsStringSync('error for admin@corp.example.com'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final logFile = archive.firstWhere((f) => f.name == 'app.log'); - final content = String.fromCharCodes(logFile.content as List); - expect(content, isNot(contains('admin@corp.example.com'))); - expect(content, contains('')); - }); - - test('crash.log is redacted in archive — email is replaced', () async { - File( - p.join(storage.baseDir, 'crash.log'), - ).writeAsStringSync('crash for user@example.com'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final crashFile = archive.firstWhere((f) => f.name == 'crash.log'); - final content = String.fromCharCodes(crashFile.content as List); - expect(content, isNot(contains('user@example.com'))); - expect(content, contains('')); - }); - - test('non-sensitive log content is preserved after redaction', () async { - File( - p.join(storage.logsPath, 'app.log'), - ).writeAsStringSync('[INFO] Bootstrap: CopyPaste 2.0 starting'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final logFile = archive.firstWhere((f) => f.name == 'app.log'); - final content = String.fromCharCodes(logFile.content as List); - expect(content, equals('[INFO] Bootstrap: CopyPaste 2.0 starting')); - }); - }); -} +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import 'package:core/core.dart'; + +void main() { + late Directory tempDir; + late StorageConfig storage; + + setUp(() async { + tempDir = Directory.systemTemp.createTempSync('support_test_'); + storage = await StorageConfig.create(baseDir: tempDir.path); + await Directory(storage.logsPath).create(recursive: true); + }); + + tearDown(() => tempDir.deleteSync(recursive: true)); + + // --------------------------------------------------------------------------- + // correctWindowsVersion + // --------------------------------------------------------------------------- + group('SupportService.correctWindowsVersion', () { + test('replaces Windows 10 with Windows 11 for build >= 22000', () { + const raw = 'Windows 10.0.22621 Build 22621'; + expect( + SupportService.correctWindowsVersion(raw), + equals('Windows 11.0.22621 Build 22621'), + ); + }); + + test('does not replace for build < 22000', () { + const raw = 'Windows 10.0.19045 Build 19045'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + + test('returns raw string unchanged when no Windows 10 text', () { + const raw = 'Windows 11.0.22621'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + + test('returns raw string unchanged when no Build number present', () { + const raw = 'Windows 10 Pro'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + + test('handles build exactly at boundary (22000 → Windows 11)', () { + const raw = 'Windows 10.0.22000 Build 22000'; + expect(SupportService.correctWindowsVersion(raw), contains('Windows 11')); + }); + + test('handles build one below boundary (21999 → unchanged)', () { + const raw = 'Windows 10.0.21999 Build 21999'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + }); + + // --------------------------------------------------------------------------- + // exportLogs + // --------------------------------------------------------------------------- + group('SupportService.exportLogs', () { + test('returns 0 when logs directory is empty', () async { + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(0)); + expect(File(savePath).existsSync(), isTrue); + }); + + test('returns 0 when logs directory does not exist', () async { + await Directory(storage.logsPath).delete(recursive: true); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(0)); + }); + + test('returns correct count of .log files', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log1'); + File(p.join(storage.logsPath, 'app2.log')).writeAsStringSync('log2'); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(2)); + }); + + test('excludes non-.log files from count and archive', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); + File(p.join(storage.logsPath, 'readme.txt')).writeAsStringSync('text'); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(1)); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final names = archive.map((f) => f.name).toList(); + expect(names, contains('app.log')); + expect(names, isNot(contains('readme.txt'))); + }); + + test('ZIP always contains device_info.txt', () async { + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.5.1', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); + final content = String.fromCharCodes(infoFile.content as List); + expect(content, contains('CopyPaste v2.5.1')); + }); + + test('device_info.txt contains platform and Dart version', () async { + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); + final content = String.fromCharCodes(infoFile.content as List); + expect(content, contains('Platform')); + expect(content, contains('Dart')); + expect(content, contains('Generated:')); + }); + + test('ZIP contains log file content verbatim', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('hello log'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final logFile = archive.firstWhere((f) => f.name == 'app.log'); + final content = String.fromCharCodes(logFile.content as List); + expect(content, equals('hello log')); + }); + + test('saves zip file at specified path', () async { + final savePath = p.join(tempDir.path, 'subdir', 'export.zip'); + await Directory(p.join(tempDir.path, 'subdir')).create(); + await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(File(savePath).existsSync(), isTrue); + expect(File(savePath).lengthSync(), greaterThan(0)); + }); + + test('includes crash.log in archive when it exists', () async { + File( + p.join(storage.baseDir, 'crash.log'), + ).writeAsStringSync('==== crash entry ===='); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final names = archive.map((f) => f.name).toList(); + expect(names, contains('crash.log')); + }); + + test('does not include crash.log entry when file does not exist', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final names = archive.map((f) => f.name).toList(); + expect(names, isNot(contains('crash.log'))); + }); + + test('crash.log count is not added to returned log file count', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); + File( + p.join(storage.baseDir, 'crash.log'), + ).writeAsStringSync('crash entry'); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(1)); + }); + + test('log files are redacted in archive — email is replaced', () async { + File( + p.join(storage.logsPath, 'app.log'), + ).writeAsStringSync('error for admin@corp.example.com'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final logFile = archive.firstWhere((f) => f.name == 'app.log'); + final content = String.fromCharCodes(logFile.content as List); + expect(content, isNot(contains('admin@corp.example.com'))); + expect(content, contains('')); + }); + + test('crash.log is redacted in archive — email is replaced', () async { + File( + p.join(storage.baseDir, 'crash.log'), + ).writeAsStringSync('crash for user@example.com'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final crashFile = archive.firstWhere((f) => f.name == 'crash.log'); + final content = String.fromCharCodes(crashFile.content as List); + expect(content, isNot(contains('user@example.com'))); + expect(content, contains('')); + }); + + test('non-sensitive log content is preserved after redaction', () async { + File( + p.join(storage.logsPath, 'app.log'), + ).writeAsStringSync('[INFO] Bootstrap: CopyPaste 2.0 starting'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final logFile = archive.firstWhere((f) => f.name == 'app.log'); + final content = String.fromCharCodes(logFile.content as List); + expect(content, equals('[INFO] Bootstrap: CopyPaste 2.0 starting')); + }); + }); + + group('SupportService.revealFile', () { + test('completes without throwing on Linux', () async { + if (!Platform.isLinux) return; + final file = File(p.join(tempDir.path, 'reveal_test.log')) + ..writeAsStringSync('data'); + // xdg-open is called internally; exceptions are caught, so always completes + await expectLater(SupportService.revealFile(file.path), completes); + }); + + test('completes without throwing when path is empty string', () async { + // Platform checks guard the Process.run call; no spawn attempted for empty + await expectLater(SupportService.revealFile(''), completes); + }); + }); + group('SupportService.openLogsFolder', () { + test('creates logs directory when it does not exist', () async { + await Directory(storage.logsPath).delete(recursive: true); + expect(Directory(storage.logsPath).existsSync(), isFalse); + try { + await SupportService.openLogsFolder(storage); + } catch (_) { + // xdg-open may not be available in headless CI; that's acceptable + } + expect(Directory(storage.logsPath).existsSync(), isTrue); + }); + + test('opens existing logs folder on Linux', () async { + if (!Platform.isLinux) return; + // xdg-open may fail in headless CI, but the function body is covered + try { + await SupportService.openLogsFolder(storage); + } catch (_) { + // ProcessException acceptable when no display server available + } + }); + }); +} diff --git a/listener/lib/clipboard_writer.dart b/listener/lib/clipboard_writer.dart index fe716172..afe84d02 100644 --- a/listener/lib/clipboard_writer.dart +++ b/listener/lib/clipboard_writer.dart @@ -1,168 +1,189 @@ -import 'dart:convert'; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -class ClipboardWriter { - static const MethodChannel _channel = MethodChannel( - 'copypaste/clipboard_writer', - ); - - static Future setText( - String content, { - String? metadata, - bool plainText = false, - }) async { - final args = { - 'type': 0, - 'content': content, - 'plainText': plainText, - }; - - if (!plainText && metadata != null && metadata.isNotEmpty) { - try { - final json = jsonDecode(metadata) as Map; - final rtfB64 = json['rtf'] as String?; - if (rtfB64 != null && rtfB64.isNotEmpty) { - args['rtf'] = base64Decode(rtfB64); - } - final htmlB64 = json['html'] as String?; - if (htmlB64 != null && htmlB64.isNotEmpty) { - args['html'] = base64Decode(htmlB64); - } - } catch (e) { - AppLogger.error('ClipboardWriter metadata parse error: $e'); - } - } - - final result = await _channel.invokeMethod( - 'setClipboardContent', - args, - ); - return result ?? false; - } - - static Future setImage(String imagePath) async { - final result = await _channel.invokeMethod( - 'setClipboardContent', - {'type': 1, 'content': imagePath}, - ); - return result ?? false; - } - - static Future setFiles(String content, int typeValue) async { - final result = await _channel.invokeMethod( - 'setClipboardContent', - {'type': typeValue, 'content': content}, - ); - return result ?? false; - } - - static Future setFromItem({ - required int typeValue, - required String content, - String? metadata, - bool plainText = false, - }) async { - switch (typeValue) { - case 0: - case 4: - return setText(content, metadata: metadata, plainText: plainText); - case 1: - return setImage(content); - case 2: - case 3: - case 5: - case 6: - return setFiles(content, typeValue); - default: - return setText(content, plainText: true); - } - } - - static Future?> getMediaInfo(String path) async { - try { - final result = await _channel.invokeMapMethod( - 'getMediaInfo', - {'path': path}, - ); - return result; - } catch (e) { - AppLogger.error('ClipboardWriter.getMediaInfo failed: $e'); - return null; - } - } - - static Future captureFrontmostApp() async { - try { - return await _channel.invokeMethod('captureFrontmostApp'); - } catch (e) { - AppLogger.error('ClipboardWriter.captureFrontmostApp failed: $e'); - return null; - } - } - - static Future activateAndPaste({ - required String bundleId, - required int delayMs, - }) async { - try { - final result = await _channel.invokeMethod( - 'activateAndPaste', - {'bundleId': bundleId, 'delayMs': delayMs}, - ); - return result ?? false; - } on PlatformException catch (e) { - if (e.code == 'ACCESSIBILITY_DENIED') rethrow; - AppLogger.error( - 'ClipboardWriter.activateAndPaste platform failure ' - '[${e.code}]: ${e.message}', - ); - return false; - } catch (e) { - AppLogger.error('ClipboardWriter.activateAndPaste failed: $e'); - return false; - } - } - - static Future?> getCursorAndScreenInfo() async { - try { - final result = await _channel.invokeMapMethod( - 'getCursorAndScreenInfo', - ); - if (result == null) return null; - return result.map((k, v) => MapEntry(k, (v as num).toDouble())); - } catch (e) { - AppLogger.error('ClipboardWriter.getCursorAndScreenInfo failed: $e'); - return null; - } - } - - static Future checkAccessibility() async { - try { - final result = await _channel.invokeMethod('checkAccessibility'); - return result ?? false; - } catch (e) { - AppLogger.error('ClipboardWriter.checkAccessibility failed: $e'); - return false; - } - } - - static Future requestAccessibility() async { - try { - final result = await _channel.invokeMethod('requestAccessibility'); - return result ?? false; - } catch (e) { - AppLogger.error('ClipboardWriter.requestAccessibility failed: $e'); - return false; - } - } - - static Future openAccessibilitySettings() async { - try { - await _channel.invokeMethod('openAccessibilitySettings'); - } catch (e) { - AppLogger.error('ClipboardWriter.openAccessibilitySettings failed: $e'); - } - } -} +import 'dart:convert'; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +class ClipboardWriter { + static const MethodChannel _channel = MethodChannel( + 'copypaste/clipboard_writer', + ); + + static Future setText( + String content, { + String? metadata, + bool plainText = false, + }) async { + final args = { + 'type': 0, + 'content': content, + 'plainText': plainText, + }; + + if (!plainText && metadata != null && metadata.isNotEmpty) { + try { + final json = jsonDecode(metadata) as Map; + final rtfB64 = json['rtf'] as String?; + if (rtfB64 != null && rtfB64.isNotEmpty) { + args['rtf'] = base64Decode(rtfB64); + } + final htmlB64 = json['html'] as String?; + if (htmlB64 != null && htmlB64.isNotEmpty) { + args['html'] = base64Decode(htmlB64); + } + } catch (e) { + AppLogger.error('ClipboardWriter metadata parse error: $e'); + } + } + + final result = await _channel.invokeMethod( + 'setClipboardContent', + args, + ); + return result ?? false; + } + + static Future setImage(String imagePath) async { + final result = await _channel.invokeMethod( + 'setClipboardContent', + {'type': 1, 'content': imagePath}, + ); + return result ?? false; + } + + static Future setFiles(String content, int typeValue) async { + final result = await _channel.invokeMethod( + 'setClipboardContent', + {'type': typeValue, 'content': content}, + ); + return result ?? false; + } + + static Future setFromItem({ + required int typeValue, + required String content, + String? metadata, + bool plainText = false, + }) async { + switch (typeValue) { + case 0: + case 4: + return setText(content, metadata: metadata, plainText: plainText); + case 1: + return setImage(content); + case 2: + case 3: + case 5: + case 6: + return setFiles(content, typeValue); + default: + return setText(content, plainText: true); + } + } + + static Future?> getMediaInfo(String path) async { + try { + final result = await _channel.invokeMapMethod( + 'getMediaInfo', + {'path': path}, + ); + return result; + } catch (e) { + AppLogger.error('ClipboardWriter.getMediaInfo failed: $e'); + return null; + } + } + + static Future captureFrontmostApp() async { + try { + return await _channel.invokeMethod('captureFrontmostApp'); + } catch (e) { + AppLogger.error('ClipboardWriter.captureFrontmostApp failed: $e'); + return null; + } + } + + static Future activateAndPaste({ + required String bundleId, + required int delayMs, + int focusTimeoutMs = 250, + }) async { + try { + final result = await _channel.invokeMethod( + 'activateAndPaste', + { + 'bundleId': bundleId, + 'delayMs': delayMs, + 'focusTimeoutMs': focusTimeoutMs, + }, + ); + if (result is Map) { + final map = Map.from(result); + return PasteResponse( + success: map['success'] == true, + errorCode: map['errorCode'] as String?, + ); + } + return PasteResponse(success: result == true); + } on PlatformException catch (e) { + if (e.code == 'ACCESSIBILITY_DENIED') rethrow; + AppLogger.error( + 'ClipboardWriter.activateAndPaste platform failure ' + '[${e.code}]: ${e.message}', + ); + return const PasteResponse(success: false, errorCode: 'platformError'); + } catch (e) { + AppLogger.error('ClipboardWriter.activateAndPaste failed: $e'); + return const PasteResponse(success: false, errorCode: 'unknown'); + } + } + + static Future?> getCursorAndScreenInfo() async { + try { + final result = await _channel.invokeMapMethod( + 'getCursorAndScreenInfo', + ); + if (result == null) return null; + return result.map((k, v) => MapEntry(k, (v as num).toDouble())); + } catch (e) { + AppLogger.error('ClipboardWriter.getCursorAndScreenInfo failed: $e'); + return null; + } + } + + static Future checkAccessibility() async { + try { + final result = await _channel.invokeMethod('checkAccessibility'); + return result ?? false; + } catch (e) { + AppLogger.error('ClipboardWriter.checkAccessibility failed: $e'); + return false; + } + } + + static Future requestAccessibility() async { + try { + final result = await _channel.invokeMethod('requestAccessibility'); + return result ?? false; + } catch (e) { + AppLogger.error('ClipboardWriter.requestAccessibility failed: $e'); + return false; + } + } + + static Future openAccessibilitySettings() async { + try { + await _channel.invokeMethod('openAccessibilitySettings'); + } catch (e) { + AppLogger.error('ClipboardWriter.openAccessibilitySettings failed: $e'); + } + } +} + +class PasteResponse { + const PasteResponse({required this.success, this.errorCode}); + + final bool success; + final String? errorCode; + + bool get isFocusTimeout => errorCode == 'focusTimeout'; +} diff --git a/listener/lib/linux_native_thumbnail_provider.dart b/listener/lib/linux_native_thumbnail_provider.dart new file mode 100644 index 00000000..c7515beb --- /dev/null +++ b/listener/lib/linux_native_thumbnail_provider.dart @@ -0,0 +1,79 @@ +// coverage:ignore-file +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' as ui show PlatformDispatcher; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +/// Linux-backed [NativeThumbnailProvider]. Bridges to the native handler +/// `getNativeThumbnail` exposed by the listener plugin, which uses +/// `gdk_pixbuf_new_from_file_at_size()` to decode the source image and +/// `gdk_pixbuf_save_to_buffer(... "png")` to encode PNG bytes. +/// +/// Backed by GdkPixbuf, which natively decodes PNG/JPEG/BMP/GIF/TIFF/ICO, +/// plus SVG (via librsvg-loader) and any other format with an installed +/// gdk-pixbuf-loader. Video/audio frames are not handled here (would +/// require libavformat); the Dart fallback covers those (returns null → +/// generic type icon). +/// +/// This provider is a no-op on non-Linux platforms. +/// +/// HiDPI: the requested [sizePx] is multiplied by the platform device +/// pixel ratio so the OS produces a bitmap large enough for the largest +/// connected display. The C side enforces a 64-px minimum heuristic to +/// reject icon-only fallbacks. +class LinuxNativeThumbnailProvider implements NativeThumbnailProvider { + LinuxNativeThumbnailProvider({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); + + final MethodChannel _channel; + + @override + Future request(String path, {int sizePx = 256}) async { + if (!Platform.isLinux) return null; + if (path.isEmpty || sizePx <= 0) return null; + + final scaled = (sizePx * _devicePixelRatio()).round().clamp(64, 1024); + + try { + final result = await _channel.invokeMethod( + 'getNativeThumbnail', + {'path': path, 'sizePx': scaled}, + ); + if (result is Uint8List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return result; + } + if (result is List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return Uint8List.fromList(result); + } + AppLogger.info('[NativeThumb] empty for $path (size=$scaled)'); + return null; + } on PlatformException catch (e, s) { + AppLogger.warn( + 'LinuxNativeThumbnailProvider: platform error: ${e.code} ${e.message}\n$s', + ); + return null; + } on MissingPluginException { + // Plugin not registered (e.g. running in a unit test host without the + // listener plugin loaded). Quiet fallback. + return null; + } + } + + double _devicePixelRatio() { + final views = ui.PlatformDispatcher.instance.views; + if (views.isEmpty) return 1.0; + var maxRatio = 1.0; + for (final view in views) { + if (view.devicePixelRatio > maxRatio) maxRatio = view.devicePixelRatio; + } + return maxRatio; + } +} diff --git a/listener/lib/listener.dart b/listener/lib/listener.dart index 96d5605b..b33f14bc 100644 --- a/listener/lib/listener.dart +++ b/listener/lib/listener.dart @@ -1,5 +1,6 @@ export 'clipboard_event.dart'; export 'clipboard_writer.dart'; +export 'linux_native_thumbnail_provider.dart'; export 'macos_native_thumbnail_provider.dart'; export 'windows_clipboard_listener.dart'; export 'windows_native_thumbnail_provider.dart'; diff --git a/listener/lib/macos_native_thumbnail_provider.dart b/listener/lib/macos_native_thumbnail_provider.dart index 189a24a5..8b2f2e57 100644 --- a/listener/lib/macos_native_thumbnail_provider.dart +++ b/listener/lib/macos_native_thumbnail_provider.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'dart:async'; import 'dart:io' show Platform; import 'dart:ui' as ui show PlatformDispatcher; diff --git a/listener/lib/windows_native_thumbnail_provider.dart b/listener/lib/windows_native_thumbnail_provider.dart index 790f980a..f53e025e 100644 --- a/listener/lib/windows_native_thumbnail_provider.dart +++ b/listener/lib/windows_native_thumbnail_provider.dart @@ -1,73 +1,74 @@ -import 'dart:async'; -import 'dart:io' show Platform; -import 'dart:ui' as ui show PlatformDispatcher; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -/// Windows-backed [NativeThumbnailProvider]. Bridges to the native handler -/// `getNativeThumbnail` exposed by the listener plugin, which uses -/// `IShellItemImageFactory::GetImage(SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY)` -/// and re-encodes the resulting bitmap as PNG before returning the bytes. -/// -/// This provider is a no-op on non-Windows platforms — the call returns -/// `null` immediately so the queue can fall back to the Dart pipeline. -/// -/// HiDPI: the requested [sizePx] is multiplied by the platform device -/// pixel ratio so the OS produces a bitmap large enough for the largest -/// connected display. The C++ side enforces a 64-px minimum heuristic to -/// reject generic file-type icons. -class WindowsNativeThumbnailProvider implements NativeThumbnailProvider { - WindowsNativeThumbnailProvider({MethodChannel? channel}) - : _channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); - - final MethodChannel _channel; - - @override - Future request(String path, {int sizePx = 256}) async { - if (!Platform.isWindows) return null; - if (path.isEmpty || sizePx <= 0) return null; - - final scaled = (sizePx * _devicePixelRatio()).round().clamp(64, 1024); - - try { - final result = await _channel.invokeMethod( - 'getNativeThumbnail', - {'path': path, 'sizePx': scaled}, - ); - if (result is Uint8List && result.isNotEmpty) { - AppLogger.info( - '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', - ); - return result; - } - if (result is List && result.isNotEmpty) { - AppLogger.info( - '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', - ); - return Uint8List.fromList(result); - } - AppLogger.info('[NativeThumb] empty for $path (size=$scaled)'); - return null; - } on PlatformException catch (e, s) { - AppLogger.warn( - 'WindowsNativeThumbnailProvider: platform error: ${e.code} ${e.message}\n$s', - ); - return null; - } on MissingPluginException { - // Plugin not registered (e.g. running in a unit test host without the - // listener plugin loaded). Quiet fallback. - return null; - } - } - - double _devicePixelRatio() { - final views = ui.PlatformDispatcher.instance.views; - if (views.isEmpty) return 1.0; - var maxRatio = 1.0; - for (final view in views) { - if (view.devicePixelRatio > maxRatio) maxRatio = view.devicePixelRatio; - } - return maxRatio; - } -} +// coverage:ignore-file +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' as ui show PlatformDispatcher; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +/// Windows-backed [NativeThumbnailProvider]. Bridges to the native handler +/// `getNativeThumbnail` exposed by the listener plugin, which uses +/// `IShellItemImageFactory::GetImage(SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY)` +/// and re-encodes the resulting bitmap as PNG before returning the bytes. +/// +/// This provider is a no-op on non-Windows platforms — the call returns +/// `null` immediately so the queue can fall back to the Dart pipeline. +/// +/// HiDPI: the requested [sizePx] is multiplied by the platform device +/// pixel ratio so the OS produces a bitmap large enough for the largest +/// connected display. The C++ side enforces a 64-px minimum heuristic to +/// reject generic file-type icons. +class WindowsNativeThumbnailProvider implements NativeThumbnailProvider { + WindowsNativeThumbnailProvider({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); + + final MethodChannel _channel; + + @override + Future request(String path, {int sizePx = 256}) async { + if (!Platform.isWindows) return null; + if (path.isEmpty || sizePx <= 0) return null; + + final scaled = (sizePx * _devicePixelRatio()).round().clamp(64, 1024); + + try { + final result = await _channel.invokeMethod( + 'getNativeThumbnail', + {'path': path, 'sizePx': scaled}, + ); + if (result is Uint8List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return result; + } + if (result is List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return Uint8List.fromList(result); + } + AppLogger.info('[NativeThumb] empty for $path (size=$scaled)'); + return null; + } on PlatformException catch (e, s) { + AppLogger.warn( + 'WindowsNativeThumbnailProvider: platform error: ${e.code} ${e.message}\n$s', + ); + return null; + } on MissingPluginException { + // Plugin not registered (e.g. running in a unit test host without the + // listener plugin loaded). Quiet fallback. + return null; + } + } + + double _devicePixelRatio() { + final views = ui.PlatformDispatcher.instance.views; + if (views.isEmpty) return 1.0; + var maxRatio = 1.0; + for (final view in views) { + if (view.devicePixelRatio > maxRatio) maxRatio = view.devicePixelRatio; + } + return maxRatio; + } +} diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 967fa18d..4a45eb69 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -1,1102 +1,1278 @@ -#include "include/listener/listener_plugin.h" - -#include -#include -#include -#include -#include - -#ifdef GDK_WINDOWING_X11 -#include -#include -#include -#include -#include -#include -#endif - -#include -#include -#include -#include -#include - -#include "listener_plugin_private.h" - -// Clipboard content type codes — must match Dart ClipboardDataType enum order. -#define CLIP_TYPE_TEXT 0 -#define CLIP_TYPE_IMAGE 1 -#define CLIP_TYPE_FILE 2 -#define CLIP_TYPE_FOLDER 3 -#define CLIP_TYPE_LINK 4 -#define CLIP_TYPE_AUDIO 5 -#define CLIP_TYPE_VIDEO 6 - -#define LISTENER_PLUGIN(obj) \ - (G_TYPE_CHECK_INSTANCE_CAST((obj), listener_plugin_get_type(), ListenerPlugin)) - -static const gchar* kClipboardChannelName = "copypaste/clipboard"; -static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; -static const guint64 kClipboardDebounceMs = 500; -static const guint kClipboardPollIntervalMs = 250; -static const guint64 kClipboardWriteIgnoreMs = 700; - -typedef struct { -#ifdef GDK_WINDOWING_X11 - Window window; -#else - unsigned long window; -#endif - gboolean valid; -} ActiveX11Window; - -struct _ListenerPlugin { - GObject parent_instance; - - FlEventChannel* event_channel; - FlMethodChannel* method_channel; - - gboolean is_listening; - guint poll_timer_id; - gchar* last_content_hash; - guint64 last_change_tick_ms; - guint64 last_write_tick_ms; -}; - -G_DEFINE_TYPE(ListenerPlugin, listener_plugin, g_object_get_type()) - -static guint64 now_ms(void) { - return (guint64)(g_get_monotonic_time() / 1000); -} - -static gchar* compute_fnv1a_hash(const gchar* text) { - uint64_t hash = 14695981039346656037ULL; - const guchar* bytes = (const guchar*)text; - for (gsize i = 0; bytes[i] != 0; i++) { - hash ^= bytes[i]; - hash *= 1099511628211ULL; - } - return g_strdup_printf("%" G_GINT64_MODIFIER "x", (guint64)hash); -} - -static gboolean is_url_text(const gchar* text) { - if (text == NULL || *text == '\0') { - return FALSE; - } - - const gchar* prefixes[] = { - "https://", "http://", "ftp://", "file:///", "mailto:", NULL, - }; - - gchar* lower = g_ascii_strdown(text, -1); - gboolean matches = FALSE; - for (guint i = 0; prefixes[i] != NULL; i++) { - if (g_str_has_prefix(lower, prefixes[i])) { - matches = TRUE; - break; - } - } - g_free(lower); - - return matches && strchr(text, ' ') == NULL && strchr(text, '\n') == NULL; -} - -static int detect_file_type(const gchar* path) { - if (path == NULL || *path == '\0') { - return CLIP_TYPE_FILE; - } - - if (g_file_test(path, G_FILE_TEST_IS_DIR)) { - return CLIP_TYPE_FOLDER; - } - - gchar* lower = g_ascii_strdown(path, -1); - const gchar* ext = strrchr(lower, '.'); - int type = CLIP_TYPE_FILE; - - if (ext != NULL) { - if (g_strcmp0(ext, ".png") == 0 || g_strcmp0(ext, ".jpg") == 0 || - g_strcmp0(ext, ".jpeg") == 0 || g_strcmp0(ext, ".gif") == 0 || - g_strcmp0(ext, ".bmp") == 0 || g_strcmp0(ext, ".webp") == 0 || - g_strcmp0(ext, ".svg") == 0 || g_strcmp0(ext, ".ico") == 0 || - g_strcmp0(ext, ".tiff") == 0 || g_strcmp0(ext, ".heic") == 0) { - type = CLIP_TYPE_IMAGE; - } else if (g_strcmp0(ext, ".mp3") == 0 || g_strcmp0(ext, ".wav") == 0 || - g_strcmp0(ext, ".flac") == 0 || g_strcmp0(ext, ".aac") == 0 || - g_strcmp0(ext, ".ogg") == 0 || g_strcmp0(ext, ".m4a") == 0) { - type = CLIP_TYPE_AUDIO; - } else if (g_strcmp0(ext, ".mp4") == 0 || g_strcmp0(ext, ".avi") == 0 || - g_strcmp0(ext, ".mkv") == 0 || g_strcmp0(ext, ".mov") == 0 || - g_strcmp0(ext, ".wmv") == 0 || g_strcmp0(ext, ".flv") == 0 || - g_strcmp0(ext, ".webm") == 0) { - type = CLIP_TYPE_VIDEO; - } - } - - g_free(lower); - return type; -} - -static gboolean plugin_is_x11(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* display = gdk_display_get_default(); - return display != NULL && GDK_IS_X11_DISPLAY(display); -#else - return FALSE; -#endif -} - -#ifdef GDK_WINDOWING_X11 -// Cached X11 atoms — interned once per process. -static Atom s_atom_net_active_window = None; -static Atom s_atom_net_wm_pid = None; - -static Atom atom_net_active_window(Display* display) { - if (s_atom_net_active_window == None) { - s_atom_net_active_window = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); - } - return s_atom_net_active_window; -} - -static Atom atom_net_wm_pid(Display* display) { - if (s_atom_net_wm_pid == None) { - s_atom_net_wm_pid = XInternAtom(display, "_NET_WM_PID", False); - } - return s_atom_net_wm_pid; -} - -// XTest extension availability — checked once per process. -static gboolean s_xtest_checked = FALSE; -static gboolean s_xtest_available = FALSE; - -static gboolean ensure_xtest(Display* display) { - if (s_xtest_checked) { - return s_xtest_available; - } - s_xtest_checked = TRUE; - int event_base, error_base, major, minor; - s_xtest_available = XTestQueryExtension(display, &event_base, &error_base, - &major, &minor) != 0; - if (!s_xtest_available) { - g_warning("XTest extension not available — paste simulation disabled"); - } - return s_xtest_available; -} - -static Display* get_xdisplay(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL || !GDK_IS_X11_DISPLAY(display)) { - return NULL; - } - - return gdk_x11_display_get_xdisplay(display); -} - -static ActiveX11Window get_active_x11_window(void) { - ActiveX11Window result = {0}; - Display* display = get_xdisplay(); - if (display == NULL) { - return result; - } - - Atom property = atom_net_active_window(display); - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - - if (XGetWindowProperty(display, DefaultRootWindow(display), property, 0, 1, - False, AnyPropertyType, &actual_type, &actual_format, - &item_count, &bytes_after, &data) == Success && - data != NULL && item_count == 1) { - result.window = *(Window*)data; - result.valid = result.window != 0; - } - - (void)actual_type; - (void)actual_format; - (void)bytes_after; - - if (data != NULL) { - XFree(data); - } - - return result; -} - -static gchar* read_proc_comm(unsigned long pid) { - gchar path[64]; - g_snprintf(path, sizeof(path), "/proc/%lu/comm", pid); - gchar* content = NULL; - gsize length = 0; - if (!g_file_get_contents(path, &content, &length, NULL) || content == NULL) { - return NULL; - } - - g_strchomp(content); - return content; -} - -static gchar* get_x11_window_source(Window window) { - Display* display = get_xdisplay(); - if (display == NULL || window == 0) { - return g_strdup(""); - } - - XClassHint class_hint; - if (XGetClassHint(display, window, &class_hint) != 0) { - gchar* value = g_strdup(class_hint.res_class != NULL ? class_hint.res_class - : class_hint.res_name); - if (class_hint.res_name != NULL) { - XFree(class_hint.res_name); - } - if (class_hint.res_class != NULL) { - XFree(class_hint.res_class); - } - if (value != NULL && *value != '\0') { - return value; - } - g_free(value); - } - - Atom pid_atom = atom_net_wm_pid(display); - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - - if (XGetWindowProperty(display, window, pid_atom, 0, 1, False, - XA_CARDINAL, &actual_type, &actual_format, - &item_count, &bytes_after, &data) == Success && - data != NULL && item_count == 1) { - unsigned long pid = *(unsigned long*)data; - XFree(data); - data = NULL; - gchar* comm = read_proc_comm(pid); - if (comm != NULL) { - return comm; - } - } - - (void)actual_type; - (void)actual_format; - (void)bytes_after; - - if (data != NULL) { - XFree(data); - } - - return g_strdup(""); -} - -static gchar* capture_frontmost_x11_identifier(void) { - ActiveX11Window active = get_active_x11_window(); - if (!active.valid) { - return NULL; - } - - return g_strdup_printf("x11:0x%lx", (unsigned long)active.window); -} - -static int activate_noop_error_handler(Display* display, XErrorEvent* event) { - (void)display; - (void)event; - return 0; -} - -static gboolean request_activate_x11_window(Window window) { - Display* display = get_xdisplay(); - if (display == NULL || window == 0) { - return FALSE; - } - - // 1. Send the EWMH _NET_ACTIVE_WINDOW message (honours ICCCM; most WMs). - // source=2 (pager) is more trusted than 1 (application) on WMs that - // apply focus-stealing prevention (KDE Plasma, some GNOME configs). - XEvent event; - memset(&event, 0, sizeof(event)); - event.xclient.type = ClientMessage; - event.xclient.window = window; - event.xclient.message_type = atom_net_active_window(display); - event.xclient.format = 32; - event.xclient.data.l[0] = 2; // pager source — more likely to bypass focus-steal guards - event.xclient.data.l[1] = CurrentTime; - event.xclient.data.l[2] = 0; - event.xclient.data.l[3] = 0; - event.xclient.data.l[4] = 0; - - Status status = XSendEvent(display, DefaultRootWindow(display), False, - SubstructureNotifyMask | SubstructureRedirectMask, - &event); - - // 2. Raise the window and attempt a direct input focus as a fallback for WMs - // that ignore _NET_ACTIVE_WINDOW (tiling WMs, minimal WMs). - // Trap X errors: XSetInputFocus produces BadMatch on unmapped/invisible windows. - XRaiseWindow(display, window); - XSync(display, False); - int (*prev_handler)(Display*, XErrorEvent*) = XSetErrorHandler(activate_noop_error_handler); - XSetInputFocus(display, window, RevertToParent, CurrentTime); - XSync(display, False); - XSetErrorHandler(prev_handler); - - XFlush(display); - return status != 0; -} - -static gboolean simulate_paste_x11(void) { - Display* display = get_xdisplay(); - if (display == NULL) { - return FALSE; - } - - if (!ensure_xtest(display)) { - return FALSE; - } - - KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); - KeyCode v = XKeysymToKeycode(display, XK_v); - if (ctrl == 0 || v == 0) { - return FALSE; - } - - XTestFakeKeyEvent(display, ctrl, True, CurrentTime); - XTestFakeKeyEvent(display, v, True, CurrentTime); - XTestFakeKeyEvent(display, v, False, CurrentTime); - XTestFakeKeyEvent(display, ctrl, False, CurrentTime); - XFlush(display); - return TRUE; -} -#endif - -static gchar* get_clipboard_source(void) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - ActiveX11Window active = get_active_x11_window(); - if (active.valid) { - return get_x11_window_source(active.window); - } - } -#endif - return g_strdup(""); -} - -static GtkSelectionData* get_target_contents(GtkClipboard* clipboard, - const gchar* target_name) { - GdkAtom atom = gdk_atom_intern(target_name, FALSE); - return gtk_clipboard_wait_for_contents(clipboard, atom); -} - -static FlValue* get_selection_data_value(GtkClipboard* clipboard, - const gchar* const* targets) { - for (guint i = 0; targets[i] != NULL; i++) { - GtkSelectionData* data = get_target_contents(clipboard, targets[i]); - if (data == NULL) { - continue; - } - - gint length = gtk_selection_data_get_length(data); - const guchar* bytes = gtk_selection_data_get_data(data); - FlValue* result = NULL; - if (bytes != NULL && length > 0) { - result = fl_value_new_uint8_list(bytes, (size_t)length); - } - - gtk_selection_data_free(data); - if (result != NULL) { - return result; - } - } - - return NULL; -} - -static gchar* build_clipboard_signature(GtkClipboard* clipboard) { - GString* signature = g_string_new(""); - - gchar** uris = gtk_clipboard_wait_for_uris(clipboard); - if (uris != NULL && uris[0] != NULL) { - for (guint i = 0; uris[i] != NULL; i++) { - g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); - if (path != NULL) { - g_string_append_printf(signature, "F:%s|", path); - } else { - g_string_append_printf(signature, "U:%s|", uris[i]); - } - } - g_strfreev(uris); - return g_string_free(signature, FALSE); - } - - if (uris != NULL) { - g_strfreev(uris); - } - - gchar* text = gtk_clipboard_wait_for_text(clipboard); - if (text != NULL && *text != '\0') { - gsize length = strlen(text); - gsize sample_length = length > 100 ? 100 : length; - g_string_append(signature, "T:"); - g_string_append_len(signature, text, sample_length); - g_free(text); - return g_string_free(signature, FALSE); - } - g_free(text); - - GdkPixbuf* image = gtk_clipboard_wait_for_image(clipboard); - if (image != NULL) { - const guchar* pixels = gdk_pixbuf_read_pixels(image); - gsize rowstride = (gsize)gdk_pixbuf_get_rowstride(image); - gint height = gdk_pixbuf_get_height(image); - gsize total = rowstride * (gsize)height; - gsize sample_len = total > 256 ? 256 : total; - g_string_append(signature, "I:"); - g_string_append_printf(signature, "%" G_GSIZE_FORMAT ":", total); - for (gsize i = 0; i < sample_len; i++) { - g_string_append_printf(signature, "%02x", pixels[i]); - } - g_object_unref(image); - return g_string_free(signature, FALSE); - } - - return g_string_free(signature, FALSE); -} - -static gboolean is_duplicate_change(ListenerPlugin* self, const gchar* hash) { - guint64 now = now_ms(); - if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0 && - (now - self->last_change_tick_ms) < kClipboardDebounceMs) { - return TRUE; - } - - g_free(self->last_content_hash); - self->last_content_hash = g_strdup(hash); - self->last_change_tick_ms = now; - return FALSE; -} - -static gboolean should_ignore_recent_write(ListenerPlugin* self) { - guint64 now = now_ms(); - return self->last_write_tick_ms != 0 && - (now - self->last_write_tick_ms) < kClipboardWriteIgnoreMs; -} - -static gboolean send_clipboard_event(ListenerPlugin* self, FlValue* event) { - if (!self->is_listening || self->event_channel == NULL || event == NULL) { - return FALSE; - } - - g_autoptr(GError) error = NULL; - gboolean success = fl_event_channel_send(self->event_channel, event, NULL, &error); - if (!success && error != NULL) { - g_warning("Failed to send clipboard event: %s", error->message); - } - return success; -} - -static FlValue* build_file_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - gchar** uris = gtk_clipboard_wait_for_uris(clipboard); - if (uris == NULL || uris[0] == NULL) { - g_strfreev(uris); - return NULL; - } - - g_autoptr(FlValue) files = fl_value_new_list(); - guint count = 0; - gint event_type = CLIP_TYPE_FILE; - gchar* first_path = NULL; - - for (guint i = 0; uris[i] != NULL; i++) { - g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); - if (path == NULL || *path == '\0') { - continue; - } - if (first_path == NULL) { - first_path = g_strdup(path); - } - fl_value_append_take(files, fl_value_new_string(path)); - count++; - } - - g_strfreev(uris); - - if (count == 0) { - g_free(first_path); - return NULL; - } - - if (count == 1 && first_path != NULL) { - event_type = detect_file_type(first_path); - } - g_free(first_path); - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(event_type)); - fl_value_set_string_take(event, "files", fl_value_ref(files)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - return fl_value_ref(event); -} - -static FlValue* build_text_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - gchar* text = gtk_clipboard_wait_for_text(clipboard); - if (text == NULL || *text == '\0') { - g_free(text); - return NULL; - } - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", - fl_value_new_int(is_url_text(text) ? CLIP_TYPE_LINK : CLIP_TYPE_TEXT)); - fl_value_set_string_take(event, "text", fl_value_new_string(text)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - - const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", - "Rich Text Format", NULL}; - const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; - - FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); - if (rtf != NULL) { - fl_value_set_string_take(event, "rtf", rtf); - } - FlValue* html = get_selection_data_value(clipboard, html_targets); - if (html != NULL) { - fl_value_set_string_take(event, "html", html); - } - - g_free(text); - return fl_value_ref(event); -} - -static FlValue* build_image_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard); - if (pixbuf == NULL) { - return NULL; - } - - gchar* buffer = NULL; - gsize buffer_size = 0; - g_autoptr(GError) error = NULL; - gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "bmp", - &error, NULL); - g_object_unref(pixbuf); - if (!ok || buffer == NULL || buffer_size == 0) { - if (error != NULL) { - g_warning("Failed to serialize clipboard image: %s", error->message); - } - g_free(buffer); - return NULL; - } - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(CLIP_TYPE_IMAGE)); - fl_value_set_string_take(event, "bytes", - fl_value_new_uint8_list((const uint8_t*)buffer, - (size_t)buffer_size)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - - g_free(buffer); - return fl_value_ref(event); -} - -static void process_clipboard(ListenerPlugin* self) { - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return; - } - - if (should_ignore_recent_write(self)) { - return; - } - - g_autofree gchar* signature = build_clipboard_signature(clipboard); - if (signature == NULL || *signature == '\0') { - return; - } - - g_autofree gchar* hash = compute_fnv1a_hash(signature); - if (hash == NULL || *hash == '\0' || is_duplicate_change(self, hash)) { - return; - } - - g_autofree gchar* source = get_clipboard_source(); - - g_autoptr(FlValue) event = build_file_event(clipboard, source, hash); - if (event == NULL) { - event = build_text_event(clipboard, source, hash); - } - if (event == NULL) { - event = build_image_event(clipboard, source, hash); - } - - if (event != NULL) { - send_clipboard_event(self, event); - } -} - -static gboolean clipboard_poll_cb(gpointer user_data) { - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - if (!self->is_listening) { - self->poll_timer_id = 0; - return G_SOURCE_REMOVE; - } - - process_clipboard(self); - return G_SOURCE_CONTINUE; -} - -static void ensure_polling(ListenerPlugin* self) { - if (self->poll_timer_id == 0) { - self->poll_timer_id = g_timeout_add(kClipboardPollIntervalMs, - clipboard_poll_cb, self); - } -} - -static void stop_polling(ListenerPlugin* self) { - if (self->poll_timer_id != 0) { - g_source_remove(self->poll_timer_id); - self->poll_timer_id = 0; - } -} - -static FlValue* get_cursor_and_screen_info(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL) { - return NULL; - } - - GdkSeat* seat = gdk_display_get_default_seat(display); - if (seat == NULL) { - return NULL; - } - - GdkDevice* pointer = gdk_seat_get_pointer(seat); - if (pointer == NULL) { - return NULL; - } - - gint cursor_x = 0; - gint cursor_y = 0; - gdk_device_get_position(pointer, NULL, &cursor_x, &cursor_y); - - GdkMonitor* monitor = gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); - if (monitor == NULL) { - return NULL; - } - - GdkRectangle workarea; - memset(&workarea, 0, sizeof(workarea)); - gdk_monitor_get_workarea(monitor, &workarea); - - g_autoptr(FlValue) info = fl_value_new_map(); - fl_value_set_string_take(info, "cursorX", fl_value_new_float((double)cursor_x)); - fl_value_set_string_take(info, "cursorY", fl_value_new_float((double)cursor_y)); - fl_value_set_string_take(info, "waLeft", fl_value_new_float((double)workarea.x)); - fl_value_set_string_take(info, "waTop", fl_value_new_float((double)workarea.y)); - fl_value_set_string_take(info, "waRight", - fl_value_new_float((double)(workarea.x + workarea.width))); - fl_value_set_string_take(info, "waBottom", - fl_value_new_float((double)(workarea.y + workarea.height))); - return fl_value_ref(info); -} - -static gboolean set_text_to_clipboard(const gchar* text) { - if (text == NULL || *text == '\0') { - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return FALSE; - } - - gtk_clipboard_set_text(clipboard, text, -1); - gtk_clipboard_store(clipboard); - return TRUE; -} - -typedef struct { - GdkPixbuf* pixbuf; - gchar* uri; -} ImageClipData; - -static void image_clip_get_cb(GtkClipboard* clipboard, - GtkSelectionData* selection_data, - guint info, - gpointer user_data) { - (void)clipboard; - ImageClipData* d = (ImageClipData*)user_data; - - if (info == 0) { - GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); - gtk_selection_data_set(selection_data, target, 8, - (const guchar*)d->uri, (gint)strlen(d->uri)); - } else { - gtk_selection_data_set_pixbuf(selection_data, d->pixbuf); - } -} - -static void image_clip_clear_cb(GtkClipboard* clipboard, gpointer user_data) { - (void)clipboard; - ImageClipData* d = (ImageClipData*)user_data; - if (d->pixbuf) g_object_unref(d->pixbuf); - g_free(d->uri); - g_free(d); -} - -static gboolean set_image_to_clipboard(const gchar* image_path) { - if (image_path == NULL || *image_path == '\0') { - return FALSE; - } - - g_autoptr(GError) error = NULL; - GdkPixbuf* pixbuf = gdk_pixbuf_new_from_file(image_path, &error); - if (pixbuf == NULL) { - if (error != NULL) { - g_warning("Failed to load image for clipboard: %s", error->message); - } - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - g_object_unref(pixbuf); - return FALSE; - } - - GtkTargetList* tl = gtk_target_list_new(NULL, 0); - gtk_target_list_add(tl, gdk_atom_intern_static_string("text/uri-list"), 0, 0); - gtk_target_list_add_image_targets(tl, 1, TRUE); - - gint n_targets = 0; - GtkTargetEntry* targets = gtk_target_table_new_from_list(tl, &n_targets); - gtk_target_list_unref(tl); - - gchar* uri = g_filename_to_uri(image_path, NULL, NULL); - if (uri == NULL) { - g_object_unref(pixbuf); - gtk_target_table_free(targets, n_targets); - return FALSE; - } - - gchar* uri_line = g_strdup_printf("%s\r\n", uri); - g_free(uri); - - ImageClipData* data = g_new0(ImageClipData, 1); - data->pixbuf = pixbuf; - data->uri = uri_line; - - gboolean ok = gtk_clipboard_set_with_data( - clipboard, targets, n_targets, - image_clip_get_cb, image_clip_clear_cb, data); - gtk_target_table_free(targets, n_targets); - - if (!ok) { - g_object_unref(pixbuf); - g_free(uri_line); - g_free(data); - return FALSE; - } - - gtk_clipboard_store(clipboard); - return TRUE; -} - -static void clipboard_uri_list_get_cb(GtkClipboard* clipboard, - GtkSelectionData* selection_data, - guint info, - gpointer user_data) { - (void)clipboard; - (void)info; - - const gchar* uri_list = (const gchar*)user_data; - if (uri_list == NULL || *uri_list == '\0') { - return; - } - - GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); - gtk_selection_data_set(selection_data, target, 8, (const guchar*)uri_list, - (gint)strlen(uri_list)); -} - -static void clipboard_uri_list_clear_cb(GtkClipboard* clipboard, gpointer user_data) { - (void)clipboard; - g_free(user_data); -} - -static gboolean set_files_to_clipboard(const gchar* content) { - if (content == NULL || *content == '\0') { - return FALSE; - } - - gchar** parts = g_strsplit(content, "\n", -1); - g_autoptr(GString) uri_list = g_string_new(NULL); - for (guint i = 0; parts[i] != NULL; i++) { - if (parts[i][0] == '\0' || !g_file_test(parts[i], G_FILE_TEST_EXISTS)) { - continue; - } - gchar* uri = g_filename_to_uri(parts[i], NULL, NULL); - if (uri != NULL) { - g_string_append(uri_list, uri); - g_string_append(uri_list, "\r\n"); - g_free(uri); - } - } - g_strfreev(parts); - - if (uri_list->len == 0) { - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return FALSE; - } - - static GtkTargetEntry targets[] = { - {(gchar*)"text/uri-list", 0, 0}, - }; - - gchar* uri_payload = g_string_free(g_steal_pointer(&uri_list), FALSE); - gboolean set_ok = gtk_clipboard_set_with_data( - clipboard, targets, G_N_ELEMENTS(targets), clipboard_uri_list_get_cb, - clipboard_uri_list_clear_cb, uri_payload); - if (!set_ok) { - g_free(uri_payload); - return FALSE; - } - - gtk_clipboard_store(clipboard); - return TRUE; -} - -static FlValue* get_media_info(void) { - return NULL; -} - -static void respond_success(FlMethodCall* method_call, FlValue* result) { - g_autoptr(GError) error = NULL; - if (!fl_method_call_respond_success(method_call, result, &error) && error != NULL) { - g_warning("Failed to respond to method call: %s", error->message); - } -} - -#ifdef GDK_WINDOWING_X11 -static gboolean paste_after_delay_cb(gpointer data) { - FlMethodCall* mc = FL_METHOD_CALL(data); - simulate_paste_x11(); - respond_success(mc, fl_value_new_bool(TRUE)); - g_object_unref(mc); - return G_SOURCE_REMOVE; -} -#endif - -static void listener_plugin_handle_method_call(ListenerPlugin* self, - FlMethodCall* method_call) { - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "setClipboardContent") == 0) { - FlValue* type_value = args != NULL ? fl_value_lookup_string(args, "type") : NULL; - gint64 type = type_value != NULL ? fl_value_get_int(type_value) : -1; - FlValue* content_value = args != NULL ? fl_value_lookup_string(args, "content") : NULL; - const gchar* content = content_value != NULL && - fl_value_get_type(content_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(content_value) - : ""; - gboolean success = FALSE; - - switch (type) { - case CLIP_TYPE_TEXT: - case CLIP_TYPE_LINK: - success = set_text_to_clipboard(content); - break; - case CLIP_TYPE_IMAGE: - success = set_image_to_clipboard(content); - break; - case CLIP_TYPE_FILE: - case CLIP_TYPE_FOLDER: - case CLIP_TYPE_AUDIO: - case CLIP_TYPE_VIDEO: - success = set_files_to_clipboard(content); - break; - default: - success = FALSE; - break; - } - - if (success) { - self->last_write_tick_ms = now_ms(); - } - respond_success(method_call, fl_value_new_bool(success)); - return; - } - - if (strcmp(method, "getMediaInfo") == 0) { - respond_success(method_call, get_media_info()); - return; - } - - if (strcmp(method, "captureFrontmostApp") == 0) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - gchar* id = capture_frontmost_x11_identifier(); - FlValue* value = id != NULL ? fl_value_new_string(id) : NULL; - g_free(id); - respond_success(method_call, value); - return; - } -#endif - respond_success(method_call, NULL); - return; - } - - if (strcmp(method, "activateAndPaste") == 0) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "bundleId") : NULL; - FlValue* delay_value = args != NULL ? fl_value_lookup_string(args, "delayMs") : NULL; - const gchar* identifier = id_value != NULL && - fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(id_value) - : NULL; - gint64 delay_ms = delay_value != NULL ? fl_value_get_int(delay_value) : 0; - gboolean activated = FALSE; - - if (identifier != NULL && g_str_has_prefix(identifier, "x11:0x")) { - Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); - activated = request_activate_x11_window(window); - } - - if (activated && delay_ms > 0) { - FlMethodCall* held_call = FL_METHOD_CALL(g_object_ref(method_call)); - guint timer_id = g_timeout_add((guint)delay_ms, paste_after_delay_cb, held_call); - if (timer_id != 0) { - return; // held_call will be released by paste_after_delay_cb - } - // Timer registration failed — release the ref and fall through to immediate paste. - g_object_unref(held_call); - g_warning("activateAndPaste: g_timeout_add failed, pasting immediately"); - } - - if (activated) { - simulate_paste_x11(); - } - respond_success(method_call, fl_value_new_bool(activated)); - return; - } -#endif - respond_success(method_call, fl_value_new_bool(FALSE)); - return; - } - - if (strcmp(method, "getCursorAndScreenInfo") == 0) { - respond_success(method_call, get_cursor_and_screen_info()); - return; - } - - if (strcmp(method, "checkAccessibility") == 0 || - strcmp(method, "requestAccessibility") == 0 || - strcmp(method, "openAccessibilitySettings") == 0) { - respond_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - g_autoptr(FlMethodResponse) response = - FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); - fl_method_call_respond(method_call, response, NULL); -} - -static FlMethodErrorResponse* stream_listen_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->is_listening = TRUE; - ensure_polling(self); - return NULL; -} - -static FlMethodErrorResponse* stream_cancel_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->is_listening = FALSE; - stop_polling(self); - return NULL; -} - -static void method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - (void)channel; - listener_plugin_handle_method_call(LISTENER_PLUGIN(user_data), method_call); -} - -FlMethodResponse* get_platform_version(void) { - struct utsname uname_data = {}; - uname(&uname_data); - g_autofree gchar* version = g_strdup_printf("Linux %s", uname_data.version); - g_autoptr(FlValue) result = fl_value_new_string(version); - return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); -} - -static void listener_plugin_dispose(GObject* object) { - ListenerPlugin* self = LISTENER_PLUGIN(object); - stop_polling(self); - self->is_listening = FALSE; - - g_clear_object(&self->event_channel); - g_clear_object(&self->method_channel); - g_free(self->last_content_hash); - self->last_content_hash = NULL; - - G_OBJECT_CLASS(listener_plugin_parent_class)->dispose(object); -} - -static void listener_plugin_class_init(ListenerPluginClass* klass) { - G_OBJECT_CLASS(klass)->dispose = listener_plugin_dispose; -} - -static void listener_plugin_init(ListenerPlugin* self) { - self->last_content_hash = NULL; - self->last_change_tick_ms = 0; - self->last_write_tick_ms = 0; - self->is_listening = FALSE; - self->poll_timer_id = 0; -} - -void listener_plugin_register_with_registrar(FlPluginRegistrar* registrar) { - ListenerPlugin* plugin = LISTENER_PLUGIN( - g_object_new(listener_plugin_get_type(), NULL)); - - FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - - plugin->event_channel = fl_event_channel_new(messenger, kClipboardChannelName, - FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers(plugin->event_channel, stream_listen_cb, - stream_cancel_cb, g_object_ref(plugin), - g_object_unref); - - plugin->method_channel = fl_method_channel_new( - messenger, kClipboardWriterChannelName, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler(plugin->method_channel, - method_call_cb, - g_object_ref(plugin), - g_object_unref); - - g_object_unref(plugin); -} +#include "include/listener/listener_plugin.h" + +#include +#include +#include +#include +#include + +#ifdef GDK_WINDOWING_X11 +#include +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include + +#include "listener_plugin_private.h" + +// Clipboard content type codes — must match Dart ClipboardDataType enum order. +#define CLIP_TYPE_TEXT 0 +#define CLIP_TYPE_IMAGE 1 +#define CLIP_TYPE_FILE 2 +#define CLIP_TYPE_FOLDER 3 +#define CLIP_TYPE_LINK 4 +#define CLIP_TYPE_AUDIO 5 +#define CLIP_TYPE_VIDEO 6 + +#define LISTENER_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), listener_plugin_get_type(), ListenerPlugin)) + +static const gchar* kClipboardChannelName = "copypaste/clipboard"; +static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; +static const guint kClipboardPollIntervalMs = 1500; +static const guint kClipboardOwnerDebounceMs = 80; +static const guint64 kClipboardWriteIgnoreMs = 700; + +typedef struct { +#ifdef GDK_WINDOWING_X11 + Window window; +#else + unsigned long window; +#endif + gboolean valid; +} ActiveX11Window; + +struct _ListenerPlugin { + GObject parent_instance; + + FlEventChannel* event_channel; + FlMethodChannel* method_channel; + + gboolean is_listening; + guint poll_timer_id; + gulong owner_change_handler_id; + guint owner_debounce_timer_id; + gchar* last_content_hash; + guint64 last_change_tick_ms; + guint64 last_write_tick_ms; +}; + +G_DEFINE_TYPE(ListenerPlugin, listener_plugin, g_object_get_type()) + +static guint64 now_ms(void) { + return (guint64)(g_get_monotonic_time() / 1000); +} + +static gchar* compute_fnv1a_hash(const gchar* text) { + uint64_t hash = 14695981039346656037ULL; + const guchar* bytes = (const guchar*)text; + for (gsize i = 0; bytes[i] != 0; i++) { + hash ^= bytes[i]; + hash *= 1099511628211ULL; + } + return g_strdup_printf("%" G_GINT64_MODIFIER "x", (guint64)hash); +} + +static gboolean is_url_text(const gchar* text) { + if (text == NULL || *text == '\0') { + return FALSE; + } + + const gchar* prefixes[] = { + "https://", "http://", "ftp://", "file:///", "mailto:", NULL, + }; + + gchar* lower = g_ascii_strdown(text, -1); + gboolean matches = FALSE; + for (guint i = 0; prefixes[i] != NULL; i++) { + if (g_str_has_prefix(lower, prefixes[i])) { + matches = TRUE; + break; + } + } + g_free(lower); + + return matches && strchr(text, ' ') == NULL && strchr(text, '\n') == NULL; +} + +static int detect_file_type(const gchar* path) { + if (path == NULL || *path == '\0') { + return CLIP_TYPE_FILE; + } + + if (g_file_test(path, G_FILE_TEST_IS_DIR)) { + return CLIP_TYPE_FOLDER; + } + + gchar* lower = g_ascii_strdown(path, -1); + const gchar* ext = strrchr(lower, '.'); + int type = CLIP_TYPE_FILE; + + if (ext != NULL) { + if (g_strcmp0(ext, ".png") == 0 || g_strcmp0(ext, ".jpg") == 0 || + g_strcmp0(ext, ".jpeg") == 0 || g_strcmp0(ext, ".gif") == 0 || + g_strcmp0(ext, ".bmp") == 0 || g_strcmp0(ext, ".webp") == 0 || + g_strcmp0(ext, ".svg") == 0 || g_strcmp0(ext, ".ico") == 0 || + g_strcmp0(ext, ".tiff") == 0 || g_strcmp0(ext, ".heic") == 0) { + type = CLIP_TYPE_IMAGE; + } else if (g_strcmp0(ext, ".mp3") == 0 || g_strcmp0(ext, ".wav") == 0 || + g_strcmp0(ext, ".flac") == 0 || g_strcmp0(ext, ".aac") == 0 || + g_strcmp0(ext, ".ogg") == 0 || g_strcmp0(ext, ".m4a") == 0) { + type = CLIP_TYPE_AUDIO; + } else if (g_strcmp0(ext, ".mp4") == 0 || g_strcmp0(ext, ".avi") == 0 || + g_strcmp0(ext, ".mkv") == 0 || g_strcmp0(ext, ".mov") == 0 || + g_strcmp0(ext, ".wmv") == 0 || g_strcmp0(ext, ".flv") == 0 || + g_strcmp0(ext, ".webm") == 0) { + type = CLIP_TYPE_VIDEO; + } + } + + g_free(lower); + return type; +} + +static gboolean plugin_is_x11(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* display = gdk_display_get_default(); + return display != NULL && GDK_IS_X11_DISPLAY(display); +#else + return FALSE; +#endif +} + +#ifdef GDK_WINDOWING_X11 +// Cached X11 atoms — interned once per process. +static Atom s_atom_net_active_window = None; +static Atom s_atom_net_wm_pid = None; + +static Atom atom_net_active_window(Display* display) { + if (s_atom_net_active_window == None) { + s_atom_net_active_window = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + } + return s_atom_net_active_window; +} + +static Atom atom_net_wm_pid(Display* display) { + if (s_atom_net_wm_pid == None) { + s_atom_net_wm_pid = XInternAtom(display, "_NET_WM_PID", False); + } + return s_atom_net_wm_pid; +} + +// XTest extension availability — checked once per process. +static gboolean s_xtest_checked = FALSE; +static gboolean s_xtest_available = FALSE; + +static gboolean ensure_xtest(Display* display) { + if (s_xtest_checked) { + return s_xtest_available; + } + s_xtest_checked = TRUE; + int event_base, error_base, major, minor; + s_xtest_available = XTestQueryExtension(display, &event_base, &error_base, + &major, &minor) != 0; + if (!s_xtest_available) { + g_warning("XTest extension not available — paste simulation disabled"); + } + return s_xtest_available; +} + +static Display* get_xdisplay(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL || !GDK_IS_X11_DISPLAY(display)) { + return NULL; + } + + return gdk_x11_display_get_xdisplay(display); +} + +static ActiveX11Window get_active_x11_window(void) { + ActiveX11Window result = {0}; + Display* display = get_xdisplay(); + if (display == NULL) { + return result; + } + + Atom property = atom_net_active_window(display); + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + + if (XGetWindowProperty(display, DefaultRootWindow(display), property, 0, 1, + False, AnyPropertyType, &actual_type, &actual_format, + &item_count, &bytes_after, &data) == Success && + data != NULL && item_count == 1) { + result.window = *(Window*)data; + result.valid = result.window != 0; + } + + (void)actual_type; + (void)actual_format; + (void)bytes_after; + + if (data != NULL) { + XFree(data); + } + + return result; +} + +static gchar* read_proc_comm(unsigned long pid) { + gchar path[64]; + g_snprintf(path, sizeof(path), "/proc/%lu/comm", pid); + gchar* content = NULL; + gsize length = 0; + if (!g_file_get_contents(path, &content, &length, NULL) || content == NULL) { + return NULL; + } + + g_strchomp(content); + return content; +} + +static gchar* prettify_app_id(gchar* value) { + if (value == NULL || *value == '\0') { + return value; + } + guint dots = 0; + for (const gchar* p = value; *p != '\0'; p++) { + if (*p == '.') { + dots++; + } + } + if (dots < 2) { + return value; + } + const gchar* last = strrchr(value, '.'); + if (last == NULL || *(last + 1) == '\0') { + return value; + } + gchar* trimmed = g_strdup(last + 1); + g_free(value); + return trimmed; +} + +static gchar* get_x11_window_source(Window window) { + Display* display = get_xdisplay(); + if (display == NULL || window == 0) { + return g_strdup(""); + } + + XClassHint class_hint; + if (XGetClassHint(display, window, &class_hint) != 0) { + gchar* value = g_strdup(class_hint.res_class != NULL ? class_hint.res_class + : class_hint.res_name); + if (class_hint.res_name != NULL) { + XFree(class_hint.res_name); + } + if (class_hint.res_class != NULL) { + XFree(class_hint.res_class); + } + if (value != NULL && *value != '\0') { + return prettify_app_id(value); + } + g_free(value); + } + + Atom pid_atom = atom_net_wm_pid(display); + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + + if (XGetWindowProperty(display, window, pid_atom, 0, 1, False, + XA_CARDINAL, &actual_type, &actual_format, + &item_count, &bytes_after, &data) == Success && + data != NULL && item_count == 1) { + unsigned long pid = *(unsigned long*)data; + XFree(data); + data = NULL; + gchar* comm = read_proc_comm(pid); + if (comm != NULL) { + return prettify_app_id(comm); + } + } + + (void)actual_type; + (void)actual_format; + (void)bytes_after; + + if (data != NULL) { + XFree(data); + } + + return g_strdup(""); +} + +static gchar* capture_frontmost_x11_identifier(void) { + ActiveX11Window active = get_active_x11_window(); + if (!active.valid) { + return NULL; + } + + return g_strdup_printf("x11:0x%lx", (unsigned long)active.window); +} + +static int activate_noop_error_handler(Display* display, XErrorEvent* event) { + (void)display; + (void)event; + return 0; +} + +static FlValue* make_paste_result(gboolean success, const gchar* error_code) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "success", fl_value_new_bool(success)); + if (error_code != NULL) { + fl_value_set_string_take(result, "errorCode", + fl_value_new_string(error_code)); + } + return result; +} + +static gboolean inject_paste_keystroke_x11(Display* display) { + if (!ensure_xtest(display)) { + return FALSE; + } + KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); + KeyCode v = XKeysymToKeycode(display, XK_v); + if (ctrl == 0 || v == 0) { + return FALSE; + } + XTestFakeKeyEvent(display, ctrl, True, CurrentTime); + XTestFakeKeyEvent(display, v, True, CurrentTime); + XTestFakeKeyEvent(display, v, False, CurrentTime); + XTestFakeKeyEvent(display, ctrl, False, CurrentTime); + XFlush(display); + return TRUE; +} + +static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { + Display* display = get_xdisplay(); + if (display == NULL) { + return make_paste_result(FALSE, "noX11"); + } + if (window == 0) { + return make_paste_result(FALSE, "invalidWindow"); + } + if (!ensure_xtest(display)) { + return make_paste_result(FALSE, "noXTest"); + } + + XWindowAttributes prev_attrs; + long prev_event_mask = 0; + gboolean restored_mask = FALSE; + int (*prev_handler)(Display*, XErrorEvent*) = + XSetErrorHandler(activate_noop_error_handler); + + if (XGetWindowAttributes(display, window, &prev_attrs) != 0) { + prev_event_mask = prev_attrs.your_event_mask; + XSelectInput(display, window, prev_event_mask | FocusChangeMask); + restored_mask = TRUE; + } + + XEvent event; + memset(&event, 0, sizeof(event)); + event.xclient.type = ClientMessage; + event.xclient.window = window; + event.xclient.message_type = atom_net_active_window(display); + event.xclient.format = 32; + event.xclient.data.l[0] = 2; + event.xclient.data.l[1] = CurrentTime; + XSendEvent(display, DefaultRootWindow(display), False, + SubstructureNotifyMask | SubstructureRedirectMask, &event); + + XRaiseWindow(display, window); + XSetInputFocus(display, window, RevertToParent, CurrentTime); + XSync(display, False); + + gint64 deadline_us = g_get_monotonic_time() + ((gint64)timeout_ms * 1000); + XEvent received; + gboolean focus_in_received = FALSE; + while (g_get_monotonic_time() < deadline_us) { + if (XCheckTypedWindowEvent(display, window, FocusIn, &received)) { + focus_in_received = TRUE; + break; + } + g_usleep(5000); + } + + Window focused = None; + int revert_to = 0; + XGetInputFocus(display, &focused, &revert_to); + gboolean focus_ok = focus_in_received || focused == window; + + if (restored_mask) { + XSelectInput(display, window, prev_event_mask); + } + XSetErrorHandler(prev_handler); + + if (!focus_ok) { + return make_paste_result(FALSE, "focusTimeout"); + } + + if (!inject_paste_keystroke_x11(display)) { + return make_paste_result(FALSE, "noXTest"); + } + + return make_paste_result(TRUE, NULL); +} +#endif + +static gchar* get_clipboard_source(void) { +#ifdef GDK_WINDOWING_X11 + if (plugin_is_x11()) { + ActiveX11Window active = get_active_x11_window(); + if (active.valid) { + return get_x11_window_source(active.window); + } + } +#endif + return g_strdup(""); +} + +static GtkSelectionData* get_target_contents(GtkClipboard* clipboard, + const gchar* target_name) { + GdkAtom atom = gdk_atom_intern(target_name, FALSE); + return gtk_clipboard_wait_for_contents(clipboard, atom); +} + +static FlValue* get_selection_data_value(GtkClipboard* clipboard, + const gchar* const* targets) { + for (guint i = 0; targets[i] != NULL; i++) { + GtkSelectionData* data = get_target_contents(clipboard, targets[i]); + if (data == NULL) { + continue; + } + + gint length = gtk_selection_data_get_length(data); + const guchar* bytes = gtk_selection_data_get_data(data); + FlValue* result = NULL; + if (bytes != NULL && length > 0) { + result = fl_value_new_uint8_list(bytes, (size_t)length); + } + + gtk_selection_data_free(data); + if (result != NULL) { + return result; + } + } + + return NULL; +} + +static gchar* build_clipboard_signature(GtkClipboard* clipboard) { + GString* signature = g_string_new(""); + + gchar** uris = gtk_clipboard_wait_for_uris(clipboard); + if (uris != NULL && uris[0] != NULL) { + for (guint i = 0; uris[i] != NULL; i++) { + g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); + if (path != NULL) { + g_string_append_printf(signature, "F:%s|", path); + } else { + g_string_append_printf(signature, "U:%s|", uris[i]); + } + } + g_strfreev(uris); + return g_string_free(signature, FALSE); + } + + if (uris != NULL) { + g_strfreev(uris); + } + + gchar* text = gtk_clipboard_wait_for_text(clipboard); + if (text != NULL && *text != '\0') { + gsize length = strlen(text); + gsize sample_length = length > 100 ? 100 : length; + g_string_append(signature, "T:"); + g_string_append_len(signature, text, sample_length); + g_free(text); + return g_string_free(signature, FALSE); + } + g_free(text); + + GdkPixbuf* image = gtk_clipboard_wait_for_image(clipboard); + if (image != NULL) { + const guchar* pixels = gdk_pixbuf_read_pixels(image); + gsize rowstride = (gsize)gdk_pixbuf_get_rowstride(image); + gint height = gdk_pixbuf_get_height(image); + gsize total = rowstride * (gsize)height; + gsize sample_len = total > 256 ? 256 : total; + g_string_append(signature, "I:"); + g_string_append_printf(signature, "%" G_GSIZE_FORMAT ":", total); + for (gsize i = 0; i < sample_len; i++) { + g_string_append_printf(signature, "%02x", pixels[i]); + } + g_object_unref(image); + return g_string_free(signature, FALSE); + } + + return g_string_free(signature, FALSE); +} + +static gboolean is_duplicate_change(ListenerPlugin* self, const gchar* hash) { + if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0) { + return TRUE; + } + + g_free(self->last_content_hash); + self->last_content_hash = g_strdup(hash); + self->last_change_tick_ms = now_ms(); + return FALSE; +} + +static gboolean should_ignore_recent_write(ListenerPlugin* self) { + guint64 now = now_ms(); + return self->last_write_tick_ms != 0 && + (now - self->last_write_tick_ms) < kClipboardWriteIgnoreMs; +} + +static gboolean send_clipboard_event(ListenerPlugin* self, FlValue* event) { + if (!self->is_listening || self->event_channel == NULL || event == NULL) { + return FALSE; + } + + g_autoptr(GError) error = NULL; + gboolean success = fl_event_channel_send(self->event_channel, event, NULL, &error); + if (!success && error != NULL) { + g_warning("Failed to send clipboard event: %s", error->message); + } + return success; +} + +static FlValue* build_file_event(GtkClipboard* clipboard, + const gchar* source, + const gchar* hash) { + gchar** uris = gtk_clipboard_wait_for_uris(clipboard); + if (uris == NULL || uris[0] == NULL) { + g_strfreev(uris); + return NULL; + } + + g_autoptr(FlValue) files = fl_value_new_list(); + guint count = 0; + gint event_type = CLIP_TYPE_FILE; + gchar* first_path = NULL; + + for (guint i = 0; uris[i] != NULL; i++) { + g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); + if (path == NULL || *path == '\0') { + continue; + } + if (first_path == NULL) { + first_path = g_strdup(path); + } + fl_value_append_take(files, fl_value_new_string(path)); + count++; + } + + g_strfreev(uris); + + if (count == 0) { + g_free(first_path); + return NULL; + } + + if (count == 1 && first_path != NULL) { + event_type = detect_file_type(first_path); + } + g_free(first_path); + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_int(event_type)); + fl_value_set_string_take(event, "files", fl_value_ref(files)); + fl_value_set_string_take(event, "source", fl_value_new_string(source)); + fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); + return fl_value_ref(event); +} + +static FlValue* build_text_event(GtkClipboard* clipboard, + const gchar* source, + const gchar* hash) { + gchar* text = gtk_clipboard_wait_for_text(clipboard); + if (text == NULL || *text == '\0') { + g_free(text); + return NULL; + } + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", + fl_value_new_int(is_url_text(text) ? CLIP_TYPE_LINK : CLIP_TYPE_TEXT)); + fl_value_set_string_take(event, "text", fl_value_new_string(text)); + fl_value_set_string_take(event, "source", fl_value_new_string(source)); + fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); + + if (!is_url_text(text)) { + const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", + "Rich Text Format", NULL}; + const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; + + FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); + if (rtf != NULL) { + fl_value_set_string_take(event, "rtf", rtf); + } + FlValue* html = get_selection_data_value(clipboard, html_targets); + if (html != NULL) { + fl_value_set_string_take(event, "html", html); + } + } + + g_free(text); + return fl_value_ref(event); +} + +static FlValue* build_image_event(GtkClipboard* clipboard, + const gchar* source, + const gchar* hash) { + GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard); + if (pixbuf == NULL) { + return NULL; + } + + gchar* buffer = NULL; + gsize buffer_size = 0; + g_autoptr(GError) error = NULL; + gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", + &error, NULL); + g_object_unref(pixbuf); + if (!ok || buffer == NULL || buffer_size == 0) { + if (error != NULL) { + g_warning("Failed to serialize clipboard image: %s", error->message); + } + g_free(buffer); + return NULL; + } + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_int(CLIP_TYPE_IMAGE)); + fl_value_set_string_take(event, "bytes", + fl_value_new_uint8_list((const uint8_t*)buffer, + (size_t)buffer_size)); + fl_value_set_string_take(event, "source", fl_value_new_string(source)); + fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); + + g_free(buffer); + return fl_value_ref(event); +} + +static void process_clipboard(ListenerPlugin* self) { + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + return; + } + + if (should_ignore_recent_write(self)) { + return; + } + + g_autofree gchar* signature = build_clipboard_signature(clipboard); + if (signature == NULL || *signature == '\0') { + if (self->last_content_hash != NULL) { + g_free(self->last_content_hash); + self->last_content_hash = NULL; + } + return; + } + + g_autofree gchar* hash = compute_fnv1a_hash(signature); + if (hash == NULL || *hash == '\0' || is_duplicate_change(self, hash)) { + return; + } + + g_autofree gchar* source = get_clipboard_source(); + + g_autoptr(FlValue) event = build_file_event(clipboard, source, hash); + if (event == NULL) { + event = build_text_event(clipboard, source, hash); + } + if (event == NULL) { + event = build_image_event(clipboard, source, hash); + } + + if (event != NULL) { + send_clipboard_event(self, event); + } +} + +static gboolean clipboard_poll_cb(gpointer user_data) { + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + if (!self->is_listening) { + self->poll_timer_id = 0; + return G_SOURCE_REMOVE; + } + + process_clipboard(self); + return G_SOURCE_CONTINUE; +} + +static gboolean owner_debounce_cb(gpointer user_data) { + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + self->owner_debounce_timer_id = 0; + if (self->is_listening) { + process_clipboard(self); + } + return G_SOURCE_REMOVE; +} + +static void on_owner_change(GtkClipboard* clipboard, + GdkEvent* event, + gpointer user_data) { + (void)clipboard; + (void)event; + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + if (!self->is_listening) { + return; + } + if (self->last_content_hash != NULL) { + g_free(self->last_content_hash); + self->last_content_hash = NULL; + } + if (self->owner_debounce_timer_id != 0) { + g_source_remove(self->owner_debounce_timer_id); + self->owner_debounce_timer_id = 0; + } + self->owner_debounce_timer_id = g_timeout_add( + kClipboardOwnerDebounceMs, owner_debounce_cb, self); +} + +static void ensure_polling(ListenerPlugin* self) { + if (self->poll_timer_id == 0) { + self->poll_timer_id = g_timeout_add(kClipboardPollIntervalMs, + clipboard_poll_cb, self); + } + if (self->owner_change_handler_id == 0) { + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard != NULL) { + self->owner_change_handler_id = g_signal_connect( + clipboard, "owner-change", G_CALLBACK(on_owner_change), self); + } + } +} + +static void stop_polling(ListenerPlugin* self) { + if (self->poll_timer_id != 0) { + g_source_remove(self->poll_timer_id); + self->poll_timer_id = 0; + } + if (self->owner_debounce_timer_id != 0) { + g_source_remove(self->owner_debounce_timer_id); + self->owner_debounce_timer_id = 0; + } + if (self->owner_change_handler_id != 0) { + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard != NULL) { + g_signal_handler_disconnect(clipboard, self->owner_change_handler_id); + } + self->owner_change_handler_id = 0; + } +} + +static FlValue* get_cursor_and_screen_info(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL) { + return NULL; + } + + GdkSeat* seat = gdk_display_get_default_seat(display); + if (seat == NULL) { + return NULL; + } + + GdkDevice* pointer = gdk_seat_get_pointer(seat); + if (pointer == NULL) { + return NULL; + } + + gint cursor_x = 0; + gint cursor_y = 0; + gdk_device_get_position(pointer, NULL, &cursor_x, &cursor_y); + + GdkMonitor* monitor = gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); + if (monitor == NULL) { + return NULL; + } + + GdkRectangle workarea; + memset(&workarea, 0, sizeof(workarea)); + gdk_monitor_get_workarea(monitor, &workarea); + + g_autoptr(FlValue) info = fl_value_new_map(); + fl_value_set_string_take(info, "cursorX", fl_value_new_float((double)cursor_x)); + fl_value_set_string_take(info, "cursorY", fl_value_new_float((double)cursor_y)); + fl_value_set_string_take(info, "waLeft", fl_value_new_float((double)workarea.x)); + fl_value_set_string_take(info, "waTop", fl_value_new_float((double)workarea.y)); + fl_value_set_string_take(info, "waRight", + fl_value_new_float((double)(workarea.x + workarea.width))); + fl_value_set_string_take(info, "waBottom", + fl_value_new_float((double)(workarea.y + workarea.height))); + return fl_value_ref(info); +} + +static gboolean set_text_to_clipboard(const gchar* text) { + if (text == NULL || *text == '\0') { + return FALSE; + } + + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + return FALSE; + } + + gtk_clipboard_set_text(clipboard, text, -1); + gtk_clipboard_store(clipboard); + return TRUE; +} + +typedef struct { + GdkPixbuf* pixbuf; + gchar* uri; +} ImageClipData; + +static void image_clip_get_cb(GtkClipboard* clipboard, + GtkSelectionData* selection_data, + guint info, + gpointer user_data) { + (void)clipboard; + ImageClipData* d = (ImageClipData*)user_data; + + if (info == 0) { + GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); + gtk_selection_data_set(selection_data, target, 8, + (const guchar*)d->uri, (gint)strlen(d->uri)); + } else { + gtk_selection_data_set_pixbuf(selection_data, d->pixbuf); + } +} + +static void image_clip_clear_cb(GtkClipboard* clipboard, gpointer user_data) { + (void)clipboard; + ImageClipData* d = (ImageClipData*)user_data; + if (d->pixbuf) g_object_unref(d->pixbuf); + g_free(d->uri); + g_free(d); +} + +static gboolean set_image_to_clipboard(const gchar* image_path) { + if (image_path == NULL || *image_path == '\0') { + return FALSE; + } + + g_autoptr(GError) error = NULL; + GdkPixbuf* pixbuf = gdk_pixbuf_new_from_file(image_path, &error); + if (pixbuf == NULL) { + if (error != NULL) { + g_warning("Failed to load image for clipboard: %s", error->message); + } + return FALSE; + } + + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + g_object_unref(pixbuf); + return FALSE; + } + + GtkTargetList* tl = gtk_target_list_new(NULL, 0); + gtk_target_list_add(tl, gdk_atom_intern_static_string("text/uri-list"), 0, 0); + gtk_target_list_add_image_targets(tl, 1, TRUE); + + gint n_targets = 0; + GtkTargetEntry* targets = gtk_target_table_new_from_list(tl, &n_targets); + gtk_target_list_unref(tl); + + gchar* uri = g_filename_to_uri(image_path, NULL, NULL); + if (uri == NULL) { + g_object_unref(pixbuf); + gtk_target_table_free(targets, n_targets); + return FALSE; + } + + gchar* uri_line = g_strdup_printf("%s\r\n", uri); + g_free(uri); + + ImageClipData* data = g_new0(ImageClipData, 1); + data->pixbuf = pixbuf; + data->uri = uri_line; + + gboolean ok = gtk_clipboard_set_with_data( + clipboard, targets, n_targets, + image_clip_get_cb, image_clip_clear_cb, data); + gtk_target_table_free(targets, n_targets); + + if (!ok) { + g_object_unref(pixbuf); + g_free(uri_line); + g_free(data); + return FALSE; + } + + gtk_clipboard_store(clipboard); + return TRUE; +} + +static void clipboard_uri_list_get_cb(GtkClipboard* clipboard, + GtkSelectionData* selection_data, + guint info, + gpointer user_data) { + (void)clipboard; + (void)info; + + const gchar* uri_list = (const gchar*)user_data; + if (uri_list == NULL || *uri_list == '\0') { + return; + } + + GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); + gtk_selection_data_set(selection_data, target, 8, (const guchar*)uri_list, + (gint)strlen(uri_list)); +} + +static void clipboard_uri_list_clear_cb(GtkClipboard* clipboard, gpointer user_data) { + (void)clipboard; + g_free(user_data); +} + +static gboolean set_files_to_clipboard(const gchar* content) { + if (content == NULL || *content == '\0') { + return FALSE; + } + + gchar** parts = g_strsplit(content, "\n", -1); + g_autoptr(GString) uri_list = g_string_new(NULL); + for (guint i = 0; parts[i] != NULL; i++) { + if (parts[i][0] == '\0' || !g_file_test(parts[i], G_FILE_TEST_EXISTS)) { + continue; + } + gchar* uri = g_filename_to_uri(parts[i], NULL, NULL); + if (uri != NULL) { + g_string_append(uri_list, uri); + g_string_append(uri_list, "\r\n"); + g_free(uri); + } + } + g_strfreev(parts); + + if (uri_list->len == 0) { + return FALSE; + } + + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + return FALSE; + } + + static GtkTargetEntry targets[] = { + {(gchar*)"text/uri-list", 0, 0}, + }; + + gchar* uri_payload = g_string_free(g_steal_pointer(&uri_list), FALSE); + gboolean set_ok = gtk_clipboard_set_with_data( + clipboard, targets, G_N_ELEMENTS(targets), clipboard_uri_list_get_cb, + clipboard_uri_list_clear_cb, uri_payload); + if (!set_ok) { + g_free(uri_payload); + return FALSE; + } + + gtk_clipboard_store(clipboard); + return TRUE; +} + +static FlValue* get_media_info(void) { + return NULL; +} + +// Generates a native PNG thumbnail for `path`, scaled so the longest side +// is `size_px`. Returns a Uint8 list FlValue with PNG bytes, or NULL when +// the file cannot be decoded by GdkPixbuf (e.g. video/audio without a +// loader). Caller takes ownership of the returned FlValue. +// +// Uses gdk_pixbuf_new_from_file_at_size() which preserves aspect ratio. +// Rejects results smaller than 64 px on the longest side (icon fallback). +static FlValue* get_native_thumbnail(const gchar* path, gint size_px) { + if (path == NULL || *path == '\0' || size_px <= 0) return NULL; + + GError* error = NULL; + GdkPixbuf* pixbuf = + gdk_pixbuf_new_from_file_at_size(path, size_px, size_px, &error); + if (pixbuf == NULL) { + if (error != NULL) { + g_warning("get_native_thumbnail: %s", error->message); + g_error_free(error); + } + return NULL; + } + + gint w = gdk_pixbuf_get_width(pixbuf); + gint h = gdk_pixbuf_get_height(pixbuf); + gint longest = w > h ? w : h; + if (longest < 64) { + g_object_unref(pixbuf); + return NULL; + } + + gchar* buffer = NULL; + gsize buffer_size = 0; + gboolean ok = gdk_pixbuf_save_to_buffer( + pixbuf, &buffer, &buffer_size, "png", &error, NULL); + g_object_unref(pixbuf); + if (!ok || buffer == NULL || buffer_size == 0) { + if (error != NULL) { + g_warning("get_native_thumbnail save: %s", error->message); + g_error_free(error); + } + g_free(buffer); + return NULL; + } + + FlValue* value = + fl_value_new_uint8_list((const uint8_t*)buffer, buffer_size); + g_free(buffer); + return value; +} + +static void respond_success(FlMethodCall* method_call, FlValue* result) { + g_autoptr(GError) error = NULL; + if (!fl_method_call_respond_success(method_call, result, &error) && error != NULL) { + g_warning("Failed to respond to method call: %s", error->message); + } +} + +static void listener_plugin_handle_method_call(ListenerPlugin* self, + FlMethodCall* method_call) { + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "getCapabilities") == 0) { + g_autoptr(FlValue) caps = fl_value_new_map(); + fl_value_set_string_take(caps, "isX11", fl_value_new_bool(plugin_is_x11())); +#ifdef GDK_WINDOWING_X11 + Display* display = get_xdisplay(); + gboolean has_xtest = display != NULL && ensure_xtest(display); +#else + gboolean has_xtest = FALSE; +#endif + fl_value_set_string_take(caps, "hasXTest", fl_value_new_bool(has_xtest)); + respond_success(method_call, fl_value_ref(caps)); + return; + } + + if (strcmp(method, "setClipboardContent") == 0) { + FlValue* type_value = args != NULL ? fl_value_lookup_string(args, "type") : NULL; + gint64 type = type_value != NULL ? fl_value_get_int(type_value) : -1; + FlValue* content_value = args != NULL ? fl_value_lookup_string(args, "content") : NULL; + const gchar* content = content_value != NULL && + fl_value_get_type(content_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(content_value) + : ""; + gboolean success = FALSE; + + switch (type) { + case CLIP_TYPE_TEXT: + case CLIP_TYPE_LINK: + success = set_text_to_clipboard(content); + break; + case CLIP_TYPE_IMAGE: + success = set_image_to_clipboard(content); + break; + case CLIP_TYPE_FILE: + case CLIP_TYPE_FOLDER: + case CLIP_TYPE_AUDIO: + case CLIP_TYPE_VIDEO: + success = set_files_to_clipboard(content); + break; + default: + success = FALSE; + break; + } + + if (success) { + self->last_write_tick_ms = now_ms(); + } + respond_success(method_call, fl_value_new_bool(success)); + return; + } + + if (strcmp(method, "getMediaInfo") == 0) { + respond_success(method_call, get_media_info()); + return; + } + + if (strcmp(method, "getNativeThumbnail") == 0) { + FlValue* path_value = + args != NULL ? fl_value_lookup_string(args, "path") : NULL; + FlValue* size_value = + args != NULL ? fl_value_lookup_string(args, "sizePx") : NULL; + const gchar* path = + path_value != NULL && fl_value_get_type(path_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(path_value) + : NULL; + gint size_px = + size_value != NULL && fl_value_get_type(size_value) == FL_VALUE_TYPE_INT + ? (gint)fl_value_get_int(size_value) + : 256; + respond_success(method_call, get_native_thumbnail(path, size_px)); + return; + } + + if (strcmp(method, "captureFrontmostApp") == 0) { +#ifdef GDK_WINDOWING_X11 + if (plugin_is_x11()) { + gchar* id = capture_frontmost_x11_identifier(); + FlValue* value = id != NULL ? fl_value_new_string(id) : NULL; + g_free(id); + respond_success(method_call, value); + return; + } +#endif + respond_success(method_call, NULL); + return; + } + + if (strcmp(method, "activateAndPaste") == 0) { +#ifdef GDK_WINDOWING_X11 + if (plugin_is_x11()) { + FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "bundleId") : NULL; + FlValue* timeout_value = + args != NULL ? fl_value_lookup_string(args, "focusTimeoutMs") : NULL; + const gchar* identifier = id_value != NULL && + fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(id_value) + : NULL; + gint timeout_ms = + timeout_value != NULL && fl_value_get_type(timeout_value) == FL_VALUE_TYPE_INT + ? (gint)fl_value_get_int(timeout_value) + : 250; + if (timeout_ms < 50) timeout_ms = 50; + if (timeout_ms > 2000) timeout_ms = 2000; + + if (identifier == NULL || !g_str_has_prefix(identifier, "x11:0x")) { + respond_success(method_call, make_paste_result(FALSE, "invalidWindow")); + return; + } + + Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); + respond_success(method_call, activate_and_paste_x11(window, timeout_ms)); + return; + } +#endif + respond_success(method_call, make_paste_result(FALSE, "noX11")); + return; + } + + if (strcmp(method, "getCursorAndScreenInfo") == 0) { + respond_success(method_call, get_cursor_and_screen_info()); + return; + } + + if (strcmp(method, "checkAccessibility") == 0 || + strcmp(method, "requestAccessibility") == 0 || + strcmp(method, "openAccessibilitySettings") == 0) { + respond_success(method_call, fl_value_new_bool(TRUE)); + return; + } + + g_autoptr(FlMethodResponse) response = + FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + fl_method_call_respond(method_call, response, NULL); +} + +static FlMethodErrorResponse* stream_listen_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + self->is_listening = TRUE; + ensure_polling(self); + process_clipboard(self); + return NULL; +} + +static FlMethodErrorResponse* stream_cancel_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + self->is_listening = FALSE; + stop_polling(self); + return NULL; +} + +static void method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + (void)channel; + listener_plugin_handle_method_call(LISTENER_PLUGIN(user_data), method_call); +} + +FlMethodResponse* get_platform_version(void) { + struct utsname uname_data = {}; + uname(&uname_data); + g_autofree gchar* version = g_strdup_printf("Linux %s", uname_data.version); + g_autoptr(FlValue) result = fl_value_new_string(version); + return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); +} + +static void listener_plugin_dispose(GObject* object) { + ListenerPlugin* self = LISTENER_PLUGIN(object); + stop_polling(self); + self->is_listening = FALSE; + + g_clear_object(&self->event_channel); + g_clear_object(&self->method_channel); + g_free(self->last_content_hash); + self->last_content_hash = NULL; + + G_OBJECT_CLASS(listener_plugin_parent_class)->dispose(object); +} + +static void listener_plugin_class_init(ListenerPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = listener_plugin_dispose; +} + +static void listener_plugin_init(ListenerPlugin* self) { + self->last_content_hash = NULL; + self->last_change_tick_ms = 0; + self->last_write_tick_ms = 0; + self->is_listening = FALSE; + self->poll_timer_id = 0; + self->owner_change_handler_id = 0; + self->owner_debounce_timer_id = 0; +} + +void listener_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + ListenerPlugin* plugin = LISTENER_PLUGIN( + g_object_new(listener_plugin_get_type(), NULL)); + + FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + + plugin->event_channel = fl_event_channel_new(messenger, kClipboardChannelName, + FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(plugin->event_channel, stream_listen_cb, + stream_cancel_cb, g_object_ref(plugin), + g_object_unref); + + plugin->method_channel = fl_method_channel_new( + messenger, kClipboardWriterChannelName, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(plugin->method_channel, + method_call_cb, + g_object_ref(plugin), + g_object_unref); + + g_object_unref(plugin); +} diff --git a/listener/test/clipboard_writer_test.dart b/listener/test/clipboard_writer_test.dart index d30952bf..a1bfef5f 100644 --- a/listener/test/clipboard_writer_test.dart +++ b/listener/test/clipboard_writer_test.dart @@ -1,513 +1,532 @@ -import 'dart:convert'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/clipboard_writer.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('copypaste/clipboard_writer'); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - switch (call.method) { - case 'setClipboardContent': - return true; - case 'getMediaInfo': - return {'width': 1920, 'height': 1080}; - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('ClipboardWriter.setText', () { - test('returns true on success', () async { - final result = await ClipboardWriter.setText('hello'); - expect(result, isTrue); - }); - - test('sends plain text flag', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setText('hi', plainText: true); - expect(captured!.arguments['plainText'], isTrue); - }); - - test('sends rtf decoded from base64 in metadata', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final rtfBytes = utf8.encode('{\\rtf1 hello}'); - final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); - await ClipboardWriter.setText('hello', metadata: meta); - expect(captured!.arguments['rtf'], isNotNull); - }); - - test('sends html decoded from base64 in metadata', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final htmlBytes = utf8.encode('hello'); - final meta = jsonEncode({'html': base64Encode(htmlBytes)}); - await ClipboardWriter.setText('hello', metadata: meta); - expect(captured!.arguments['html'], isNotNull); - }); - - test('handles invalid metadata JSON gracefully', () async { - final result = await ClipboardWriter.setText( - 'test', - metadata: 'not valid json {{{', - ); - expect(result, isTrue); - }); - - test('skips rtf/html when plainText is true', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final rtfBytes = utf8.encode('{\\rtf1 hello}'); - final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); - await ClipboardWriter.setText('hi', metadata: meta, plainText: true); - expect(captured!.arguments.containsKey('rtf'), isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.setText('test'); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.setImage', () { - test('returns true on success', () async { - final result = await ClipboardWriter.setImage('/path/to/image.png'); - expect(result, isTrue); - }); - - test('sends type 1 and correct path', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setImage('/img/photo.png'); - expect(captured!.arguments['type'], equals(1)); - expect(captured!.arguments['content'], equals('/img/photo.png')); - }); - }); - - group('ClipboardWriter.setFiles', () { - test('returns true on success', () async { - final result = await ClipboardWriter.setFiles('/path/to/file.txt', 2); - expect(result, isTrue); - }); - - test('sends provided typeValue', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFiles('/file.mp3', 5); - expect(captured!.arguments['type'], equals(5)); - }); - }); - - group('ClipboardWriter.setFromItem', () { - test('type 0 (text) calls setText', () async { - final result = await ClipboardWriter.setFromItem( - typeValue: 0, - content: 'text content', - ); - expect(result, isTrue); - }); - - test('type 4 (link) calls setText', () async { - final result = await ClipboardWriter.setFromItem( - typeValue: 4, - content: 'https://example.com', - ); - expect(result, isTrue); - }); - - test('type 1 (image) calls setImage', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 1, content: '/path/img.png'); - expect(captured!.arguments['type'], equals(1)); - }); - - test('type 2 (file) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 2, content: '/file.txt'); - expect(captured!.arguments['type'], equals(2)); - }); - - test('type 3 (folder) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 3, content: '/folder/'); - expect(captured!.arguments['type'], equals(3)); - }); - - test('type 5 (audio) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 5, content: '/audio.mp3'); - expect(captured!.arguments['type'], equals(5)); - }); - - test('type 6 (video) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 6, content: '/video.mp4'); - expect(captured!.arguments['type'], equals(6)); - }); - - test('unknown type defaults to plainText setText', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 99, content: 'fallback'); - expect(captured!.arguments['plainText'], isTrue); - }); - }); - - group('ClipboardWriter.getMediaInfo', () { - test('returns map on success', () async { - final result = await ClipboardWriter.getMediaInfo('/path/video.mp4'); - expect(result, isNotNull); - expect(result!['width'], equals(1920)); - }); - - test('returns null when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR', message: 'fail'); - }); - final result = await ClipboardWriter.getMediaInfo('/bad/path'); - expect(result, isNull); - }); - }); - - // ── macOS-specific methods ────────────────────────────────────────────── - - group('ClipboardWriter.captureFrontmostApp', () { - test('returns bundle id on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return 'com.apple.finder'; - } - return null; - }); - final result = await ClipboardWriter.captureFrontmostApp(); - expect(result, equals('com.apple.finder')); - }); - - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - final result = await ClipboardWriter.captureFrontmostApp(); - expect(result, isNull); - }); - - test('returns null when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'UNAVAILABLE'); - }); - final result = await ClipboardWriter.captureFrontmostApp(); - expect(result, isNull); - }); - }); - - group('ClipboardWriter.activateAndPaste', () { - test('returns true on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'activateAndPaste') return true; - return null; - }); - final result = await ClipboardWriter.activateAndPaste( - bundleId: 'com.apple.safari', - delayMs: 150, - ); - expect(result, isTrue); - }); - - test('sends bundleId and delayMs as arguments', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.activateAndPaste( - bundleId: 'com.example.app', - delayMs: 200, - ); - expect(captured!.method, equals('activateAndPaste')); - expect(captured!.arguments['bundleId'], equals('com.example.app')); - expect(captured!.arguments['delayMs'], equals(200)); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.activateAndPaste( - bundleId: 'com.test', - delayMs: 0, - ); - expect(result, isFalse); - }); - - test('rethrows when channel throws ACCESSIBILITY_DENIED', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ACCESSIBILITY_DENIED'); - }); - expect( - () => - ClipboardWriter.activateAndPaste(bundleId: 'com.test', delayMs: 0), - throwsA( - isA().having( - (e) => e.code, - 'code', - 'ACCESSIBILITY_DENIED', - ), - ), - ); - }); - - test('returns false when channel throws other error', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'UNKNOWN_ERROR'); - }); - final result = await ClipboardWriter.activateAndPaste( - bundleId: 'com.test', - delayMs: 0, - ); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.getCursorAndScreenInfo', () { - test('returns typed map on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getCursorAndScreenInfo') { - return { - 'cursorX': 100.0, - 'cursorY': 200.0, - 'waLeft': 0.0, - 'waTop': 25.0, - 'waRight': 1440.0, - 'waBottom': 900.0, - }; - } - return null; - }); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNotNull); - expect(result!['cursorX'], equals(100.0)); - expect(result['cursorY'], equals(200.0)); - expect(result['waLeft'], equals(0.0)); - expect(result['waTop'], equals(25.0)); - expect(result['waRight'], equals(1440.0)); - expect(result['waBottom'], equals(900.0)); - }); - - test('converts integer values to double', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getCursorAndScreenInfo') { - return { - 'cursorX': 50, - 'cursorY': 75, - 'waLeft': 0, - 'waTop': 0, - 'waRight': 1280, - 'waBottom': 800, - }; - } - return null; - }); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNotNull); - expect(result!['cursorX'], isA()); - expect(result['cursorX'], equals(50.0)); - }); - - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNull); - }); - - test('returns null when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNull); - }); - }); - - group('ClipboardWriter.checkAccessibility', () { - test('returns true when accessibility is granted', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return true; - return null; - }); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isTrue); - }); - - test('returns false when accessibility is denied', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return false; - return null; - }); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.requestAccessibility', () { - test('returns true when user grants permission', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return true; - return null; - }); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isTrue); - }); - - test('returns false when user denies permission', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return false; - return null; - }); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.openAccessibilitySettings', () { - test('completes without error on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'openAccessibilitySettings') return true; - return null; - }); - await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); - }); - - test('completes without error even when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); - }); - - test('invokes correct method name', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return null; - }); - await ClipboardWriter.openAccessibilitySettings(); - expect(captured!.method, equals('openAccessibilitySettings')); - }); - }); -} +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:listener/clipboard_writer.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('copypaste/clipboard_writer'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'setClipboardContent': + return true; + case 'getMediaInfo': + return {'width': 1920, 'height': 1080}; + default: + return null; + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('ClipboardWriter.setText', () { + test('returns true on success', () async { + final result = await ClipboardWriter.setText('hello'); + expect(result, isTrue); + }); + + test('sends plain text flag', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setText('hi', plainText: true); + expect(captured!.arguments['plainText'], isTrue); + }); + + test('sends rtf decoded from base64 in metadata', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + final rtfBytes = utf8.encode('{\\rtf1 hello}'); + final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); + await ClipboardWriter.setText('hello', metadata: meta); + expect(captured!.arguments['rtf'], isNotNull); + }); + + test('sends html decoded from base64 in metadata', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + final htmlBytes = utf8.encode('hello'); + final meta = jsonEncode({'html': base64Encode(htmlBytes)}); + await ClipboardWriter.setText('hello', metadata: meta); + expect(captured!.arguments['html'], isNotNull); + }); + + test('handles invalid metadata JSON gracefully', () async { + final result = await ClipboardWriter.setText( + 'test', + metadata: 'not valid json {{{', + ); + expect(result, isTrue); + }); + + test('skips rtf/html when plainText is true', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + final rtfBytes = utf8.encode('{\\rtf1 hello}'); + final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); + await ClipboardWriter.setText('hi', metadata: meta, plainText: true); + expect(captured!.arguments.containsKey('rtf'), isFalse); + }); + + test('returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.setText('test'); + expect(result, isFalse); + }); + }); + + group('ClipboardWriter.setImage', () { + test('returns true on success', () async { + final result = await ClipboardWriter.setImage('/path/to/image.png'); + expect(result, isTrue); + }); + + test('sends type 1 and correct path', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setImage('/img/photo.png'); + expect(captured!.arguments['type'], equals(1)); + expect(captured!.arguments['content'], equals('/img/photo.png')); + }); + }); + + group('ClipboardWriter.setFiles', () { + test('returns true on success', () async { + final result = await ClipboardWriter.setFiles('/path/to/file.txt', 2); + expect(result, isTrue); + }); + + test('sends provided typeValue', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFiles('/file.mp3', 5); + expect(captured!.arguments['type'], equals(5)); + }); + }); + + group('ClipboardWriter.setFromItem', () { + test('type 0 (text) calls setText', () async { + final result = await ClipboardWriter.setFromItem( + typeValue: 0, + content: 'text content', + ); + expect(result, isTrue); + }); + + test('type 4 (link) calls setText', () async { + final result = await ClipboardWriter.setFromItem( + typeValue: 4, + content: 'https://example.com', + ); + expect(result, isTrue); + }); + + test('type 1 (image) calls setImage', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 1, content: '/path/img.png'); + expect(captured!.arguments['type'], equals(1)); + }); + + test('type 2 (file) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 2, content: '/file.txt'); + expect(captured!.arguments['type'], equals(2)); + }); + + test('type 3 (folder) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 3, content: '/folder/'); + expect(captured!.arguments['type'], equals(3)); + }); + + test('type 5 (audio) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 5, content: '/audio.mp3'); + expect(captured!.arguments['type'], equals(5)); + }); + + test('type 6 (video) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 6, content: '/video.mp4'); + expect(captured!.arguments['type'], equals(6)); + }); + + test('unknown type defaults to plainText setText', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 99, content: 'fallback'); + expect(captured!.arguments['plainText'], isTrue); + }); + }); + + group('ClipboardWriter.getMediaInfo', () { + test('returns map on success', () async { + final result = await ClipboardWriter.getMediaInfo('/path/video.mp4'); + expect(result, isNotNull); + expect(result!['width'], equals(1920)); + }); + + test('returns null when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR', message: 'fail'); + }); + final result = await ClipboardWriter.getMediaInfo('/bad/path'); + expect(result, isNull); + }); + }); + + group('ClipboardWriter.captureFrontmostApp', () { + test('returns bundle id on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'captureFrontmostApp') { + return 'com.apple.finder'; + } + return null; + }); + final result = await ClipboardWriter.captureFrontmostApp(); + expect(result, equals('com.apple.finder')); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + final result = await ClipboardWriter.captureFrontmostApp(); + expect(result, isNull); + }); + + test('returns null when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'UNAVAILABLE'); + }); + final result = await ClipboardWriter.captureFrontmostApp(); + expect(result, isNull); + }); + }); + + group('ClipboardWriter.activateAndPaste', () { + test('returns success on bool true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'activateAndPaste') return true; + return null; + }); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'com.apple.safari', + delayMs: 150, + ); + expect(result.success, isTrue); + expect(result.errorCode, isNull); + }); + + test('parses Map response with success and errorCode', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + return { + 'success': false, + 'errorCode': 'focusTimeout', + }; + }); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'x11:0xabc', + delayMs: 0, + ); + expect(result.success, isFalse); + expect(result.errorCode, equals('focusTimeout')); + expect(result.isFocusTimeout, isTrue); + }); + + test('sends bundleId, delayMs and focusTimeoutMs as arguments', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.activateAndPaste( + bundleId: 'com.example.app', + delayMs: 200, + focusTimeoutMs: 350, + ); + expect(captured!.method, equals('activateAndPaste')); + expect(captured!.arguments['bundleId'], equals('com.example.app')); + expect(captured!.arguments['delayMs'], equals(200)); + expect(captured!.arguments['focusTimeoutMs'], equals(350)); + }); + + test('returns failure when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'com.test', + delayMs: 0, + ); + expect(result.success, isFalse); + }); + + test('rethrows when channel throws ACCESSIBILITY_DENIED', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ACCESSIBILITY_DENIED'); + }); + expect( + () => + ClipboardWriter.activateAndPaste(bundleId: 'com.test', delayMs: 0), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'ACCESSIBILITY_DENIED', + ), + ), + ); + }); + + test('returns platformError on other PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'UNKNOWN_ERROR'); + }); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'com.test', + delayMs: 0, + ); + expect(result.success, isFalse); + expect(result.errorCode, equals('platformError')); + }); + }); + + group('ClipboardWriter.getCursorAndScreenInfo', () { + test('returns typed map on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getCursorAndScreenInfo') { + return { + 'cursorX': 100.0, + 'cursorY': 200.0, + 'waLeft': 0.0, + 'waTop': 25.0, + 'waRight': 1440.0, + 'waBottom': 900.0, + }; + } + return null; + }); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNotNull); + expect(result!['cursorX'], equals(100.0)); + expect(result['cursorY'], equals(200.0)); + expect(result['waLeft'], equals(0.0)); + expect(result['waTop'], equals(25.0)); + expect(result['waRight'], equals(1440.0)); + expect(result['waBottom'], equals(900.0)); + }); + + test('converts integer values to double', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getCursorAndScreenInfo') { + return { + 'cursorX': 50, + 'cursorY': 75, + 'waLeft': 0, + 'waTop': 0, + 'waRight': 1280, + 'waBottom': 800, + }; + } + return null; + }); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNotNull); + expect(result!['cursorX'], isA()); + expect(result['cursorX'], equals(50.0)); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNull); + }); + + test('returns null when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNull); + }); + }); + + group('ClipboardWriter.checkAccessibility', () { + test('returns true when accessibility is granted', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'checkAccessibility') return true; + return null; + }); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isTrue); + }); + + test('returns false when accessibility is denied', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'checkAccessibility') return false; + return null; + }); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isFalse); + }); + }); + + group('ClipboardWriter.requestAccessibility', () { + test('returns true when user grants permission', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'requestAccessibility') return true; + return null; + }); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isTrue); + }); + + test('returns false when user denies permission', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'requestAccessibility') return false; + return null; + }); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isFalse); + }); + }); + + group('ClipboardWriter.openAccessibilitySettings', () { + test('completes without error on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'openAccessibilitySettings') return true; + return null; + }); + await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); + }); + + test('completes without error even when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); + }); + + test('invokes correct method name', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return null; + }); + await ClipboardWriter.openAccessibilitySettings(); + expect(captured!.method, equals('openAccessibilitySettings')); + }); + }); +} diff --git a/listener/test/linux_native_thumbnail_provider_test.dart b/listener/test/linux_native_thumbnail_provider_test.dart new file mode 100644 index 00000000..e524b97a --- /dev/null +++ b/listener/test/linux_native_thumbnail_provider_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:listener/linux_native_thumbnail_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('copypaste/clipboard_writer'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('LinuxNativeThumbnailProvider', () { + test('returns null on non-Linux hosts (or empty channel)', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/missing.png', sizePx: 256); + expect(result, isNull); + }); + + test('returns Uint8List bytes when channel succeeds', () async { + final fakeBytes = Uint8List.fromList(List.generate(64, (i) => i)); + String? receivedPath; + int? receivedSize; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getNativeThumbnail') return null; + final args = call.arguments as Map; + receivedPath = args['path'] as String?; + receivedSize = args['sizePx'] as int?; + return fakeBytes; + }); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/photo.jpg', sizePx: 128); + + // Outside the platform guard this is a no-op on non-Linux hosts. + if (receivedPath != null) { + expect(result, equals(fakeBytes)); + expect(receivedPath, equals('/tmp/photo.jpg')); + expect(receivedSize, greaterThanOrEqualTo(128)); + } else { + expect(result, isNull); + } + }); + + test('treats empty list as null (no thumbnail available)', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getNativeThumbnail') return Uint8List(0); + return null; + }); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/missing.bin'); + expect(result, isNull); + }); + + test('swallows PlatformException and returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getNativeThumbnail') { + throw PlatformException(code: 'boom', message: 'native failure'); + } + return null; + }); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/whatever.png'); + expect(result, isNull); + }); + + test( + 'rejects empty path / non-positive size before invoking channel', + () async { + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + called = true; + return null; + }); + + final provider = LinuxNativeThumbnailProvider(); + expect(await provider.request(''), isNull); + expect(await provider.request('x', sizePx: 0), isNull); + expect(await provider.request('x', sizePx: -1), isNull); + expect(called, isFalse); + }, + ); + + test('survives MissingPluginException (no listener registered)', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/anything.png'); + expect(result, isNull); + }); + }); +} diff --git a/packaging/obs/README.md b/packaging/obs/README.md new file mode 100644 index 00000000..395ff446 --- /dev/null +++ b/packaging/obs/README.md @@ -0,0 +1,48 @@ +# OBS packaging — `home:rgdevment/copypaste` + +This directory holds the source files OBS (`build.opensuse.org`) consumes +to build native `.deb` and `.rpm` packages from the prebuilt portable +tarball published on each GitHub Release +(`CopyPaste--linux-x64.tar.gz`). + +## Files + +| File | Purpose | +| ------------------------ | ---------------------------------------------------------- | +| `_service` | Tells OBS to download the upstream tarball at build time. | +| `copypaste.spec` | RPM spec used for Fedora and openSUSE Tumbleweed targets. | +| `copypaste.dsc` | Debian source description used for Debian/Ubuntu targets. | +| `debian/` | Debian packaging metadata (control, rules, changelog, …). | + +The literal `@VERSION@` token in `_service`, `copypaste.spec`, +`copypaste.dsc` and `debian/changelog` is substituted at release time by +the GitHub Actions job `publish-obs` in +`.github/workflows/release-linux.yml`, which then commits the rendered +files into the OBS package via `osc`. + +## How the build works + +1. CI publishes the GitHub Release with + `CopyPaste--linux-x64.tar.gz` containing the Flutter bundle + plus `LICENSE`, `packaging/com.rgdevment.copypaste.desktop` and + `packaging/icon_app_256.png`. +2. The `publish-obs` job renders the templates and pushes them to + `home:rgdevment/copypaste` on `build.opensuse.org`. +3. OBS downloads the tarball through `_service` and rebuilds the + package against every enabled target. Built repositories appear at + `https://download.opensuse.org/repositories/home:/rgdevment//`. + +The tarball is **not** rebuilt by OBS — it is only repackaged. This +keeps OBS workers free of the Flutter toolchain and matches the pattern +used by other Flutter/Electron desktop apps published on OBS. + +## Targets + +| Family | OBS target name | +| --------- | ---------------------------- | +| Debian | `Debian_12`, `Debian_13` | +| Ubuntu | `xUbuntu_22.04`, `xUbuntu_24.04` | +| Fedora | `Fedora_40`, `Fedora_41` | +| openSUSE | `openSUSE_Tumbleweed` | + +End-user installation instructions live in the project README. diff --git a/packaging/obs/_service b/packaging/obs/_service new file mode 100644 index 00000000..1aafbf23 --- /dev/null +++ b/packaging/obs/_service @@ -0,0 +1,11 @@ + + + https + github.com + /rgdevment/CopyPaste/releases/download/v@VERSION@/CopyPaste-@VERSION@-linux-x64.tar.gz + CopyPaste-@VERSION@-linux-x64.tar.gz + + + @VERSION@ + + diff --git a/packaging/obs/copypaste.dsc b/packaging/obs/copypaste.dsc new file mode 100644 index 00000000..143ba079 --- /dev/null +++ b/packaging/obs/copypaste.dsc @@ -0,0 +1,11 @@ +Format: 3.0 (quilt) +Source: copypaste +Binary: copypaste +Architecture: amd64 +Version: @VERSION@-1 +Maintainer: rgdevment +Homepage: https://github.com/rgdevment/CopyPaste +Standards-Version: 4.6.2 +Build-Depends: debhelper-compat (= 13) +Files: + 00000000000000000000000000000000 0 CopyPaste-@VERSION@-linux-x64.tar.gz diff --git a/packaging/obs/copypaste.spec b/packaging/obs/copypaste.spec new file mode 100644 index 00000000..e4121bf4 --- /dev/null +++ b/packaging/obs/copypaste.spec @@ -0,0 +1,57 @@ +Name: copypaste +Version: @VERSION@ +Release: 0 +Summary: Free, open source clipboard manager and clipboard history tool +License: GPL-3.0-only +Group: Productivity/Utilities +URL: https://github.com/rgdevment/CopyPaste +Source0: CopyPaste-%{version}-linux-x64.tar.gz +BuildRequires: desktop-file-utils +ExclusiveArch: x86_64 + +%if 0%{?suse_version} +Requires: libayatana-appindicator3-1 +Requires: libkeybinder-3_0-0 +Requires: libgtk-3-0 +Requires: libX11-6 +Requires: libXtst6 +%else +Requires: libayatana-appindicator-gtk3 +Requires: keybinder3 +Requires: gtk3 +Requires: libX11 +Requires: libXtst +%endif + +%description +CopyPaste is a free, open source, local-first clipboard manager and +clipboard history tool for X11 sessions on Linux. No telemetry, no +accounts, no cloud — your clipboard data never leaves your computer. + +%global debug_package %{nil} + +%prep +%setup -q -n CopyPaste-%{version}-linux-x64 + +%build + +%install +install -d %{buildroot}/opt/copypaste +cp -a bundle/. %{buildroot}/opt/copypaste/ +chmod 0755 %{buildroot}/opt/copypaste/copypaste +install -d %{buildroot}%{_bindir} +ln -s /opt/copypaste/copypaste %{buildroot}%{_bindir}/copypaste +install -Dm644 packaging/com.rgdevment.copypaste.desktop \ + %{buildroot}%{_datadir}/applications/com.rgdevment.copypaste.desktop +install -Dm644 packaging/icon_app_256.png \ + %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png +desktop-file-validate %{buildroot}%{_datadir}/applications/com.rgdevment.copypaste.desktop + +%files +%license LICENSE +/opt/copypaste +%{_bindir}/copypaste +%{_datadir}/applications/com.rgdevment.copypaste.desktop +%{_datadir}/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png + +%changelog diff --git a/packaging/obs/debian/changelog b/packaging/obs/debian/changelog new file mode 100644 index 00000000..e6e5c550 --- /dev/null +++ b/packaging/obs/debian/changelog @@ -0,0 +1,6 @@ +copypaste (@VERSION@-1) unstable; urgency=medium + + * Automated release from upstream tag v@VERSION@. + Full notes: https://github.com/rgdevment/CopyPaste/releases/tag/v@VERSION@ + + -- rgdevment Thu, 23 Apr 2026 00:00:00 +0000 diff --git a/packaging/obs/debian/compat b/packaging/obs/debian/compat new file mode 100644 index 00000000..b4ee0590 --- /dev/null +++ b/packaging/obs/debian/compat @@ -0,0 +1 @@ +13 diff --git a/packaging/obs/debian/control b/packaging/obs/debian/control new file mode 100644 index 00000000..5bf770be --- /dev/null +++ b/packaging/obs/debian/control @@ -0,0 +1,21 @@ +Source: copypaste +Section: x11 +Priority: optional +Maintainer: rgdevment +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 +Homepage: https://github.com/rgdevment/CopyPaste + +Package: copypaste +Architecture: amd64 +Depends: ${shlibs:Depends}, + ${misc:Depends}, + libayatana-appindicator3-1, + libkeybinder-3.0-0, + libgtk-3-0 | libgtk-3-0t64, + libx11-6, + libxtst6 +Description: Free, open source clipboard manager and clipboard history tool + CopyPaste is a free, open source, local-first clipboard manager and + clipboard history tool for X11 sessions on Linux. No telemetry, no + accounts, no cloud — your clipboard data never leaves your computer. diff --git a/packaging/obs/debian/copyright b/packaging/obs/debian/copyright new file mode 100644 index 00000000..ede19a1f --- /dev/null +++ b/packaging/obs/debian/copyright @@ -0,0 +1,21 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: CopyPaste +Upstream-Contact: rgdevment +Source: https://github.com/rgdevment/CopyPaste + +Files: * +Copyright: 2024-2026 rgdevment +License: GPL-3.0-only + +License: GPL-3.0-only + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3 of the License. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + . + On Debian systems, the complete text of the GNU General Public + License version 3 can be found in `/usr/share/common-licenses/GPL-3'. diff --git a/packaging/obs/debian/rules b/packaging/obs/debian/rules new file mode 100644 index 00000000..72173b93 --- /dev/null +++ b/packaging/obs/debian/rules @@ -0,0 +1,22 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + +override_dh_auto_install: + install -d debian/copypaste/opt/copypaste + cp -a bundle/. debian/copypaste/opt/copypaste/ + chmod 0755 debian/copypaste/opt/copypaste/copypaste + install -d debian/copypaste/usr/bin + ln -s /opt/copypaste/copypaste debian/copypaste/usr/bin/copypaste + install -Dm644 packaging/com.rgdevment.copypaste.desktop \ + debian/copypaste/usr/share/applications/com.rgdevment.copypaste.desktop + install -Dm644 packaging/icon_app_256.png \ + debian/copypaste/usr/share/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png + +override_dh_strip: + +override_dh_shlibdeps: + dh_shlibdeps --dpkg-shlibdeps-params=--ignore-missing-info -- -l/opt/copypaste/lib diff --git a/packaging/obs/debian/source/format b/packaging/obs/debian/source/format new file mode 100644 index 00000000..db1d7186 --- /dev/null +++ b/packaging/obs/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/pubspec.yaml b/pubspec.yaml index 1ac3d135..dd68b5c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,58 +1,58 @@ -name: copypaste_workspace -publish_to: none - -environment: - sdk: ^3.11.1 - -workspace: - - core - - listener - - app - -dev_dependencies: - melos: ^7.5.1 - -melos: - name: copypaste_workspace - - command: - bootstrap: - enforceLockfile: false - - scripts: - analyze: - exec: flutter analyze - description: Run flutter analyze in all packages - packageFilters: - orderDependents: true - - test: - exec: flutter test - description: Run tests in all packages - - test:coverage: - exec: flutter test --coverage - description: Run tests with coverage in all packages - - format: - run: dart format . - description: Format all code from workspace root - - format:check: - run: dart format --output=none --set-exit-if-changed . - description: Check formatting without modifying files - - fix: - run: dart fix --apply . - description: Apply all auto-fixable lint fixes - - fix:check: - run: | - OUTPUT=$(dart fix --dry-run . 2>&1) - echo "$OUTPUT" - echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "Auto-fixable issues found. Run 'melos run fix' and commit."; exit 1; } - description: Check for auto-fixable issues without applying - - outdated: - exec: flutter pub outdated --no-dev-dependencies - description: Check for outdated dependencies +name: copypaste_workspace +publish_to: none + +environment: + sdk: ^3.11.1 + +workspace: + - core + - listener + - app + +dev_dependencies: + melos: ^7.5.1 + +melos: + name: copypaste_workspace + + command: + bootstrap: + enforceLockfile: true + + scripts: + analyze: + exec: flutter analyze + description: Run flutter analyze in all packages + packageFilters: + orderDependents: true + + test: + exec: flutter test + description: Run tests in all packages + + test:coverage: + exec: flutter test --coverage + description: Run tests with coverage in all packages + + format: + run: dart format . + description: Format all code from workspace root + + format:check: + run: dart format --output=none --set-exit-if-changed . + description: Check formatting without modifying files + + fix: + run: dart fix --apply . + description: Apply all auto-fixable lint fixes + + fix:check: + run: | + OUTPUT=$(dart fix --dry-run . 2>&1) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "Auto-fixable issues found. Run 'melos run fix' and commit."; exit 1; } + description: Check for auto-fixable issues without applying + + outdated: + exec: flutter pub outdated --no-dev-dependencies + description: Check for outdated dependencies diff --git a/release-manifest.json b/release-manifest.json index 1fbfd217..9c17e3f8 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -20,8 +20,8 @@ "github_linux": { "url": "https://github.com/rgdevment/CopyPaste/releases/latest" }, - "snap": { - "command": "sudo snap refresh copypaste" + "appimage": { + "url": "https://github.com/rgdevment/CopyPaste/releases/latest" } }, "releaseNotes": {